diff --git a/ElectronClient/gui/NoteText.jsx b/ElectronClient/gui/NoteText.jsx index 04d3516df..0d5e2f2c3 100644 --- a/ElectronClient/gui/NoteText.jsx +++ b/ElectronClient/gui/NoteText.jsx @@ -427,6 +427,9 @@ class NoteTextComponent extends React.Component { if (this.props.noteId) { note = await Note.load(this.props.noteId); noteTags = this.props.noteTags || []; + await this.handleResourceDownloadMode(note); + } else { + console.warn('Trying to load a note with no ID - should no longer be possible'); } const folder = note ? Folder.byId(this.props.folders, note.parent_id) : null; @@ -612,10 +615,7 @@ class NoteTextComponent extends React.Component { }, 10); } - if (note && note.body && Setting.value('sync.resourceDownloadMode') === 'auto') { - const resourceIds = await Note.linkedResourceIds(note.body); - await ResourceFetcher.instance().markForDownload(resourceIds); - } + await this.handleResourceDownloadMode(note); } if (note) { @@ -655,6 +655,13 @@ class NoteTextComponent extends React.Component { defer(); } + async handleResourceDownloadMode(note) { + if (note && note.body && Setting.value('sync.resourceDownloadMode') === 'auto') { + const resourceIds = await Note.linkedResourceIds(note.body); + await ResourceFetcher.instance().markForDownload(resourceIds); + } + } + async UNSAFE_componentWillReceiveProps(nextProps) { if ('noteId' in nextProps && nextProps.noteId !== this.props.noteId) { await this.scheduleReloadNote(nextProps); diff --git a/ElectronClient/gui/NoteText2.tsx b/ElectronClient/gui/NoteText2.tsx index 9d292c7c7..fee2cffb0 100644 --- a/ElectronClient/gui/NoteText2.tsx +++ b/ElectronClient/gui/NoteText2.tsx @@ -20,11 +20,14 @@ const { MarkupToHtml } = require('lib/joplin-renderer'); const HtmlToMd = require('lib/HtmlToMd'); const { _ } = require('lib/locale'); const Note = require('lib/models/Note.js'); +const BaseModel = require('lib/BaseModel.js'); const Resource = require('lib/models/Resource.js'); const { shim } = require('lib/shim'); const TemplateUtils = require('lib/TemplateUtils'); const { bridge } = require('electron').remote.require('./bridge'); const { urlDecode } = require('lib/string-utils'); +const ResourceFetcher = require('lib/services/ResourceFetcher.js'); +const DecryptionWorker = require('lib/services/DecryptionWorker.js'); interface NoteTextProps { style: any, @@ -139,7 +142,7 @@ function usePrevious(value:any):any { return ref.current; } -function initNoteState(n:any, setFormNote:Function, setDefaultEditorState:Function) { +async function initNoteState(n:any, setFormNote:Function, setDefaultEditorState:Function) { let originalCss = ''; if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) { const htmlToHtml = new HtmlToHtml(); @@ -163,7 +166,17 @@ function initNoteState(n:any, setFormNote:Function, setDefaultEditorState:Functi setDefaultEditorState({ value: n.body, markupLanguage: n.markup_language, + resourceInfos: await attachedResources(n.body), }); + + await handleResourceDownloadMode(n.body); +} + +async function handleResourceDownloadMode(noteBody:string) { + if (noteBody && Setting.value('sync.resourceDownloadMode') === 'auto') { + const resourceIds = await Note.linkedResourceIds(noteBody); + await ResourceFetcher.instance().markForDownload(resourceIds); + } } async function htmlToMarkdown(html:string):Promise { @@ -192,6 +205,52 @@ async function formNoteToNote(formNote:FormNote):Promise { return newNote; } +let resourceCache_:any = {}; + +function clearResourceCache() { + resourceCache_ = {}; +} + +async function attachedResources(noteBody:string):Promise { + if (!noteBody) return {}; + const resourceIds = await Note.linkedItemIdsByType(BaseModel.TYPE_RESOURCE, noteBody); + + const output:any = {}; + for (let i = 0; i < resourceIds.length; i++) { + const id = resourceIds[i]; + + if (resourceCache_[id]) { + output[id] = resourceCache_[id]; + } else { + const resource = await Resource.load(id); + const localState = await Resource.localState(resource); + + const o = { + item: resource, + localState: localState, + }; + + // eslint-disable-next-line require-atomic-updates + resourceCache_[id] = o; + output[id] = o; + } + } + + return output; +} + +function installResourceHandling(refreshResourceHandler:Function) { + ResourceFetcher.instance().on('downloadComplete', refreshResourceHandler); + ResourceFetcher.instance().on('downloadStarted', refreshResourceHandler); + DecryptionWorker.instance().on('resourceDecrypted', refreshResourceHandler); +} + +function uninstallResourceHandling(refreshResourceHandler:Function) { + ResourceFetcher.instance().off('downloadComplete', refreshResourceHandler); + ResourceFetcher.instance().off('downloadStarted', refreshResourceHandler); + DecryptionWorker.instance().off('resourceDecrypted', refreshResourceHandler); +} + async function attachResources() { const filePaths = bridge().showOpenDialog({ properties: ['openFile', 'createDirectory', 'multiSelections'], @@ -309,7 +368,7 @@ function useWindowCommand(windowCommand:any, dispatch:Function, formNote:FormNot function NoteText2(props:NoteTextProps) { const [formNote, setFormNote] = useState(defaultNote()); - const [defaultEditorState, setDefaultEditorState] = useState({ value: '', markupLanguage: MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN }); + const [defaultEditorState, setDefaultEditorState] = useState({ value: '', markupLanguage: MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, resourceInfos: {} }); const prevSyncStarted = usePrevious(props.syncStarted); const editorRef = useRef(); @@ -384,6 +443,28 @@ function NoteText2(props:NoteTextProps) { } }, [props.isProvisional, formNote.id]); + const refreshResource = useCallback(async function(event) { + if (!defaultEditorState.value) return; + + const resourceIds = await Note.linkedResourceIds(defaultEditorState.value); + if (resourceIds.indexOf(event.id) >= 0) { + clearResourceCache(); + const e = { + ...defaultEditorState, + resourceInfos: await attachedResources(defaultEditorState.value), + }; + setDefaultEditorState(e); + } + }, [defaultEditorState]); + + useEffect(() => { + installResourceHandling(refreshResource); + + return () => { + uninstallResourceHandling(refreshResource); + }; + }, [defaultEditorState]); + useEffect(() => { // This is not exactly a hack but a bit ugly. If the note was changed (willChangeId > 0) but not // yet saved, we need to save it now before the component is unmounted. However, we can't put @@ -420,7 +501,7 @@ function NoteText2(props:NoteTextProps) { return; } - initNoteState(n, setFormNote, setDefaultEditorState); + await initNoteState(n, setFormNote, setDefaultEditorState); }; loadNote(); @@ -462,7 +543,7 @@ function NoteText2(props:NoteTextProps) { if (cancelled) return; if (!n) throw new Error(`Cannot find note with ID: ${props.noteId}`); reg.logger().debug('Loaded note:', n); - initNoteState(n, setFormNote, setDefaultEditorState); + await initNoteState(n, setFormNote, setDefaultEditorState); handleAutoFocus(!!n.is_todo); } @@ -675,6 +756,7 @@ function NoteText2(props:NoteTextProps) { attachResources: attachResources, disabled: waitingToSaveNote, joplinHtml: joplinHtml, + theme: props.theme, }; let editor = null; diff --git a/ElectronClient/gui/editors/TinyMCE.tsx b/ElectronClient/gui/editors/TinyMCE.tsx index ce7e8d021..a61364b2e 100644 --- a/ElectronClient/gui/editors/TinyMCE.tsx +++ b/ElectronClient/gui/editors/TinyMCE.tsx @@ -2,14 +2,17 @@ import * as React from 'react'; import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react'; // eslint-disable-next-line no-unused-vars -import { DefaultEditorState, OnChangeEvent, TextEditorUtils, EditorCommand } from '../utils/NoteText'; +import { DefaultEditorState, OnChangeEvent, TextEditorUtils, EditorCommand, resourcesStatus } from '../utils/NoteText'; const { MarkupToHtml } = require('lib/joplin-renderer'); const taboverride = require('taboverride'); const { reg } = require('lib/registry.js'); +const { _ } = require('lib/locale'); +const { themeStyle, buildStyle } = require('../../theme.js'); interface TinyMCEProps { style: any, + theme: number, onChange(event: OnChangeEvent): void, onWillChange(event:any): void, onMessage(event:any): void, @@ -95,6 +98,30 @@ const joplinCommandToTinyMceCommands:JoplinCommandToTinyMceCommands = { 'search': { name: 'SearchReplace' }, }; +function styles_(props:TinyMCEProps) { + return buildStyle('TinyMCE', props.theme, (/* theme:any */) => { + return { + disabledOverlay: { + zIndex: 10, + position: 'absolute', + backgroundColor: 'white', + opacity: 0.7, + height: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: 20, + paddingTop: 50, + textAlign: 'center', + }, + rootStyle: { + position: 'relative', + ...props.style, + }, + }; + }); +} + let loadedAssetFiles_:string[] = []; let dispatchDidUpdateIID_:any = null; let changeId_:number = 1; @@ -114,6 +141,9 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { const rootIdRef = useRef(`tinymce-${Date.now()}${Math.round(Math.random() * 10000)}`); + const styles = styles_(props); + const theme = themeStyle(props.theme); + const dispatchDidUpdate = (editor:any) => { if (dispatchDidUpdateIID_) clearTimeout(dispatchDidUpdateIID_); dispatchDidUpdateIID_ = setTimeout(() => { @@ -425,6 +455,11 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { useEffect(() => { if (!editor) return () => {}; + if (resourcesStatus(props.defaultEditorState.resourceInfos) !== 'ready') { + editor.setContent(''); + return () => {}; + } + let cancelled = false; const loadContent = async () => { @@ -561,7 +596,25 @@ const TinyMCE = (props:TinyMCEProps, ref:any) => { }; }, [props.onWillChange, props.onChange, editor]); - return
; + function renderDisabledOverlay() { + const status = resourcesStatus(props.defaultEditorState.resourceInfos); + if (status === 'ready') return null; + + const message = _('Please wait for all attachments to be downloaded and decrypted. You may also switch the layout and edit the note in Markdown mode.'); + return ( +
+

{message}

+

{`Status: ${status}`}

+
+ ); + } + + return ( +
+ {renderDisabledOverlay()} +
+
+ ); }; export default forwardRef(TinyMCE); diff --git a/ElectronClient/gui/utils/NoteText.ts b/ElectronClient/gui/utils/NoteText.ts index b4f14452d..53ebd95a6 100644 --- a/ElectronClient/gui/utils/NoteText.ts +++ b/ElectronClient/gui/utils/NoteText.ts @@ -1,6 +1,10 @@ +const joplinRendererUtils = require('lib/joplin-renderer').utils; +const Resource = require('lib/models/Resource'); + export interface DefaultEditorState { value: string, markupLanguage: number, // MarkupToHtml.MARKUP_LANGUAGE_XXX + resourceInfos: any, } export interface OnChangeEvent { @@ -16,3 +20,13 @@ export interface EditorCommand { name: string, value: any, } + +export function resourcesStatus(resourceInfos:any) { + let lowestIndex = joplinRendererUtils.resourceStatusIndex('ready'); + for (const id in resourceInfos) { + const s = joplinRendererUtils.resourceStatus(Resource, resourceInfos[id]); + const idx = joplinRendererUtils.resourceStatusIndex(s); + if (idx < lowestIndex) lowestIndex = idx; + } + return joplinRendererUtils.resourceStatusName(lowestIndex); +} diff --git a/ElectronClient/theme.js b/ElectronClient/theme.js index a9ba54caf..f59f4f902 100644 --- a/ElectronClient/theme.js +++ b/ElectronClient/theme.js @@ -304,6 +304,13 @@ function addExtraStyles(style) { { color: style.color2 } ); + style.textStyleMinor = Object.assign({}, style.textStyle, + { + color: style.colorFaded, + fontSize: style.fontSize * 0.8, + }, + ); + style.urlStyle = Object.assign({}, style.textStyle, { textDecoration: 'underline', diff --git a/ReactNativeClient/lib/joplin-renderer/utils.js b/ReactNativeClient/lib/joplin-renderer/utils.js index 69eba9ac2..1460bc2d0 100644 --- a/ReactNativeClient/lib/joplin-renderer/utils.js +++ b/ReactNativeClient/lib/joplin-renderer/utils.js @@ -63,18 +63,38 @@ utils.loaderImage = function() { `; }; -utils.resourceStatusImage = function(state) { - if (state === 'notDownloaded') return utils.notDownloadedResource(); - return utils.resourceStatusFile(state); +utils.resourceStatusImage = function(status) { + if (status === 'notDownloaded') return utils.notDownloadedResource(); + return utils.resourceStatusFile(status); }; -utils.resourceStatusFile = function(state) { - if (state === 'notDownloaded') return utils.notDownloadedResource(); - if (state === 'downloading') return utils.loaderImage(); - if (state === 'encrypted') return utils.loaderImage(); - if (state === 'error') return utils.errorImage(); +utils.resourceStatusFile = function(status) { + if (status === 'notDownloaded') return utils.notDownloadedResource(); + if (status === 'downloading') return utils.loaderImage(); + if (status === 'encrypted') return utils.loaderImage(); + if (status === 'error') return utils.errorImage(); - throw new Error(`Unknown state: ${state}`); + throw new Error(`Unknown status: ${status}`); +}; + +utils.resourceStatusIndex = function(status) { + if (status === 'error') return -1; + if (status === 'notDownloaded') return 0; + if (status === 'downloading') return 1; + if (status === 'encrypted') return 2; + if (status === 'ready') return 10; + + throw new Error(`Unknown status: ${status}`); +}; + +utils.resourceStatusName = function(index) { + if (index === -1) return 'error'; + if (index === 0) return 'notDownloaded'; + if (index === 1) return 'downloading'; + if (index === 2) return 'encrypted'; + if (index === 10) return 'ready'; + + throw new Error(`Unknown index: ${index}`); }; utils.resourceStatus = function(ResourceModel, resourceInfo) {