瀏覽代碼

support Image Host

Le Tan 4 年之前
父節點
當前提交
f1d931c276
共有 85 個文件被更改,包括 1995 次插入175 次删除
  1. 1 1
      libs/vtextedit
  2. 9 0
      scripts/update_version.py
  3. 1 1
      src/core/buffer/buffer.h
  4. 23 12
      src/core/buffer/markdownbuffer.cpp
  5. 3 2
      src/core/buffer/markdownbuffer.h
  6. 92 0
      src/core/editorconfig.cpp
  7. 37 0
      src/core/editorconfig.h
  8. 2 0
      src/core/logger.cpp
  9. 2 2
      src/core/mainconfig.cpp
  10. 2 0
      src/core/markdowneditorconfig.h
  11. 13 0
      src/core/notebook/bundlenotebook.cpp
  12. 5 0
      src/core/notebook/bundlenotebook.h
  13. 6 0
      src/core/notebook/notebook.cpp
  14. 5 0
      src/core/notebook/notebook.h
  15. 5 0
      src/core/notebookconfigmgr/notebookconfig.cpp
  16. 4 0
      src/core/notebookconfigmgr/notebookconfig.h
  17. 4 4
      src/core/notebookmgr.h
  18. 2 2
      src/data/core/Info.plist
  19. 1 0
      src/data/core/core.qrc
  20. 1 0
      src/data/core/icons/image_host_editor.svg
  21. 7 1
      src/data/core/vnotex.json
  22. 2 1
      src/data/extra/docs/en/shortcuts.md
  23. 2 0
      src/data/extra/docs/en/welcome.md
  24. 3 2
      src/data/extra/docs/zh_CN/shortcuts.md
  25. 3 1
      src/data/extra/docs/zh_CN/welcome.md
  26. 204 0
      src/imagehost/githubimagehost.cpp
  27. 61 0
      src/imagehost/githubimagehost.h
  28. 30 0
      src/imagehost/imagehost.cpp
  29. 57 0
      src/imagehost/imagehost.h
  30. 14 0
      src/imagehost/imagehost.pri
  31. 198 0
      src/imagehost/imagehostmgr.cpp
  32. 61 0
      src/imagehost/imagehostmgr.h
  33. 29 0
      src/imagehost/imagehostutils.cpp
  34. 24 0
      src/imagehost/imagehostutils.h
  35. 2 0
      src/src.pro
  36. 16 0
      src/utils/pathutils.cpp
  37. 2 0
      src/utils/pathutils.h
  38. 13 0
      src/utils/utils.cpp
  39. 5 0
      src/utils/utils.h
  40. 2 2
      src/utils/webutils.cpp
  41. 7 7
      src/widgets/dialogs/imageinsertdialog.cpp
  42. 4 3
      src/widgets/dialogs/imageinsertdialog.h
  43. 38 33
      src/widgets/dialogs/newnotedialog.cpp
  44. 2 0
      src/widgets/dialogs/newnotedialog.h
  45. 0 3
      src/widgets/dialogs/nodeinfowidget.cpp
  46. 1 1
      src/widgets/dialogs/scrolldialog.cpp
  47. 3 1
      src/widgets/dialogs/settings/appearancepage.cpp
  48. 1 1
      src/widgets/dialogs/settings/appearancepage.h
  49. 3 1
      src/widgets/dialogs/settings/editorpage.cpp
  50. 1 1
      src/widgets/dialogs/settings/editorpage.h
  51. 3 1
      src/widgets/dialogs/settings/generalpage.cpp
  52. 1 1
      src/widgets/dialogs/settings/generalpage.h
  53. 295 0
      src/widgets/dialogs/settings/imagehostpage.cpp
  54. 60 0
      src/widgets/dialogs/settings/imagehostpage.h
  55. 3 1
      src/widgets/dialogs/settings/markdowneditorpage.cpp
  56. 1 1
      src/widgets/dialogs/settings/markdowneditorpage.h
  57. 2 2
      src/widgets/dialogs/settings/miscpage.cpp
  58. 1 1
      src/widgets/dialogs/settings/miscpage.h
  59. 88 0
      src/widgets/dialogs/settings/newimagehostdialog.cpp
  60. 39 0
      src/widgets/dialogs/settings/newimagehostdialog.h
  61. 3 1
      src/widgets/dialogs/settings/quickaccesspage.cpp
  62. 1 1
      src/widgets/dialogs/settings/quickaccesspage.h
  63. 55 12
      src/widgets/dialogs/settings/settingsdialog.cpp
  64. 10 2
      src/widgets/dialogs/settings/settingsdialog.h
  65. 18 4
      src/widgets/dialogs/settings/settingspage.cpp
  66. 8 2
      src/widgets/dialogs/settings/settingspage.h
  67. 3 1
      src/widgets/dialogs/settings/texteditorpage.cpp
  68. 1 1
      src/widgets/dialogs/settings/texteditorpage.h
  69. 3 1
      src/widgets/dialogs/settings/themepage.cpp
  70. 1 1
      src/widgets/dialogs/settings/themepage.h
  71. 1 0
      src/widgets/dialogs/snippetinfowidget.cpp
  72. 127 29
      src/widgets/editors/markdowneditor.cpp
  73. 12 0
      src/widgets/editors/markdowneditor.h
  74. 2 0
      src/widgets/editors/plantumlhelper.cpp
  75. 34 20
      src/widgets/filesystemviewer.cpp
  76. 2 0
      src/widgets/filesystemviewer.h
  77. 5 0
      src/widgets/locationlist.cpp
  78. 101 10
      src/widgets/markdownviewwindow.cpp
  79. 7 0
      src/widgets/markdownviewwindow.h
  80. 63 0
      src/widgets/viewwindow.cpp
  81. 10 0
      src/widgets/viewwindow.h
  82. 15 0
      src/widgets/viewwindowtoolbarhelper.cpp
  83. 2 1
      src/widgets/viewwindowtoolbarhelper.h
  84. 4 0
      src/widgets/widgets.pri
  85. 1 0
      tests/test_core/test_notebook/test_notebook.pro

+ 1 - 1
libs/vtextedit

@@ -1 +1 @@
-Subproject commit 922084a388e1f135e25297ba84a9d0ca0078ed06
+Subproject commit c53fc8dbf6df14b9e1327de0a4907700ffee6049

+ 9 - 0
scripts/update_version.py

@@ -18,3 +18,12 @@ for line in fileinput.input(['src/data/core/vnotex.json'], inplace = True):
 regExp = re.compile('(\\s+)VNOTE_VER: \\S+')
 for line in fileinput.input(['.github/workflows/ci-win.yml', '.github/workflows/ci-linux.yml', '.github/workflows/ci-macos.yml'], inplace = True):
     print(regExp.sub('\\1VNOTE_VER: ' + newVersion, line), end='')
+
+# Info.plist
+regExp = re.compile('(\\s+)<string>\\d\\.\\d\\.\\d</string>')
+for line in fileinput.input(['src/data/core/Info.plist'], inplace = True):
+    print(regExp.sub('\\1<string>' + newVersion + '</string>', line), end='')
+
+regExp = re.compile('(\\s+)<string>\\d\\.\\d\\.\\d\\.\\d</string>')
+for line in fileinput.input(['src/data/core/Info.plist'], inplace = True):
+    print(regExp.sub('\\1<string>' + newVersion + '.1</string>', line), end='')

+ 1 - 1
src/core/buffer/buffer.h

@@ -77,7 +77,7 @@ namespace vnotex
 
         QString getPath() const;
 
-        // In some cases, getPath() may point to a ocntainer containting all the stuffs.
+        // In some cases, getPath() may point to a container containting all the stuffs.
         // getContentPath() will return the real path to the file providing the content.
         QString getContentPath() const;
 

+ 23 - 12
src/core/buffer/markdownbuffer.cpp

@@ -35,9 +35,10 @@ QString MarkdownBuffer::insertImage(const QImage &p_image, const QString &p_imag
 void MarkdownBuffer::fetchInitialImages()
 {
     Q_ASSERT(m_initialImages.isEmpty());
+    vte::MarkdownLink::TypeFlags linkFlags = vte::MarkdownLink::TypeFlag::LocalRelativeInternal | vte::MarkdownLink::TypeFlag::Remote;
     m_initialImages = vte::MarkdownUtils::fetchImagesFromMarkdownText(getContent(),
                                                                       getResourcePath(),
-                                                                      vte::MarkdownLink::TypeFlag::LocalRelativeInternal);
+                                                                      linkFlags);
 }
 
 void MarkdownBuffer::addInsertedImage(const QString &p_imagePath, const QString &p_urlInLink)
@@ -45,41 +46,51 @@ void MarkdownBuffer::addInsertedImage(const QString &p_imagePath, const QString
     vte::MarkdownLink link;
     link.m_path = p_imagePath;
     link.m_urlInLink = p_urlInLink;
-    link.m_type = vte::MarkdownLink::TypeFlag::LocalRelativeInternal;
+    // There are two types: local internal and remote for image host.
+    link.m_type = PathUtils::isLocalFile(p_imagePath) ? vte::MarkdownLink::TypeFlag::LocalRelativeInternal : vte::MarkdownLink::TypeFlag::Remote;
     m_insertedImages.append(link);
 }
 
-QSet<QString> MarkdownBuffer::clearObsoleteImages()
+QHash<QString, bool> MarkdownBuffer::clearObsoleteImages()
 {
-    QSet<QString> obsoleteImages;
+    QHash<QString, bool> obsoleteImages;
 
     Q_ASSERT(!isModified());
     const bool discarded = state() & StateFlag::Discarded;
+    const vte::MarkdownLink::TypeFlags linkFlags = vte::MarkdownLink::TypeFlag::LocalRelativeInternal | vte::MarkdownLink::TypeFlag::Remote;
     const auto latestImages =
         vte::MarkdownUtils::fetchImagesFromMarkdownText(!discarded ? getContent() : m_provider->read(),
                                                         getResourcePath(),
-                                                        vte::MarkdownLink::TypeFlag::LocalRelativeInternal);
+                                                        linkFlags);
     QSet<QString> latestImagesPath;
     for (const auto &link : latestImages) {
-        latestImagesPath.insert(PathUtils::normalizePath(link.m_path));
+        if (link.m_type & vte::MarkdownLink::TypeFlag::Remote) {
+            latestImagesPath.insert(link.m_path);
+        } else {
+            latestImagesPath.insert(PathUtils::normalizePath(link.m_path));
+        }
     }
 
     for (const auto &link : m_insertedImages) {
-        if (!(link.m_type & vte::MarkdownLink::TypeFlag::LocalRelativeInternal)) {
+        if (!(link.m_type & linkFlags)) {
             continue;
         }
 
-        if (!latestImagesPath.contains(PathUtils::normalizePath(link.m_path))) {
-            obsoleteImages.insert(link.m_path);
+        const bool isRemote = link.m_type & vte::MarkdownLink::TypeFlag::Remote;
+        const auto linkPath = isRemote ? link.m_path : PathUtils::normalizePath(link.m_path);
+        if (!latestImagesPath.contains(linkPath)) {
+            obsoleteImages.insert(link.m_path, isRemote);
         }
     }
 
     m_insertedImages.clear();
 
     for (const auto &link : m_initialImages) {
-        Q_ASSERT(link.m_type & vte::MarkdownLink::TypeFlag::LocalRelativeInternal);
-        if (!latestImagesPath.contains(PathUtils::normalizePath(link.m_path))) {
-            obsoleteImages.insert(link.m_path);
+        Q_ASSERT(link.m_type & linkFlags);
+        const bool isRemote = link.m_type & vte::MarkdownLink::TypeFlag::Remote;
+        const auto linkPath = isRemote ? link.m_path : PathUtils::normalizePath(link.m_path);
+        if (!latestImagesPath.contains(linkPath)) {
+            obsoleteImages.insert(link.m_path, isRemote);
         }
     }
 

+ 3 - 2
src/core/buffer/markdownbuffer.h

@@ -4,7 +4,7 @@
 #include "buffer.h"
 
 #include <QVector>
-#include <QSet>
+#include <QHash>
 
 #include <vtextedit/markdownutils.h>
 
@@ -28,7 +28,8 @@ namespace vnotex
         // Clear obsolete images.
         // Won't delete images, just return a list of obsolete images path.
         // Will re-init m_initialImages and clear m_insertedImages.
-        QSet<QString> clearObsoleteImages();
+        // Return [ImagePath] -> IsRemote.
+        QHash<QString, bool> clearObsoleteImages();
 
     protected:
         ViewWindow *createViewWindowInternal(const QSharedPointer<FileOpenParameters> &p_paras, QWidget *p_parent) Q_DECL_OVERRIDE;

+ 92 - 0
src/core/editorconfig.cpp

@@ -12,6 +12,30 @@ using namespace vnotex;
 #define READSTR(key) readString(appObj, userObj, (key))
 #define READBOOL(key) readBool(appObj, userObj, (key))
 
+bool EditorConfig::ImageHostItem::operator==(const ImageHostItem &p_other) const
+{
+    return m_type == p_other.m_type
+           && m_name == p_other.m_name
+           && m_config == p_other.m_config;
+}
+
+void EditorConfig::ImageHostItem::fromJson(const QJsonObject &p_jobj)
+{
+    m_type = p_jobj[QStringLiteral("type")].toInt();
+    m_name = p_jobj[QStringLiteral("name")].toString();
+    m_config = p_jobj[QStringLiteral("config")].toObject();
+}
+
+QJsonObject EditorConfig::ImageHostItem::toJson() const
+{
+    QJsonObject obj;
+    obj[QStringLiteral("type")] = m_type;
+    obj[QStringLiteral("name")] = m_name;
+    obj[QStringLiteral("config")] = m_config;
+    return obj;
+}
+
+
 EditorConfig::EditorConfig(ConfigMgr *p_mgr, IConfig *p_topConfig)
     : IConfig(p_mgr, p_topConfig),
       m_textEditorConfig(new TextEditorConfig(p_mgr, p_topConfig)),
@@ -32,6 +56,8 @@ void EditorConfig::init(const QJsonObject &p_app,
 
     loadCore(appObj, userObj);
 
+    loadImageHost(appObj, userObj);
+
     m_textEditorConfig->init(appObj, userObj);
     m_markdownEditorConfig->init(appObj, userObj);
 }
@@ -112,6 +138,7 @@ QJsonObject EditorConfig::toJson() const
     obj[m_textEditorConfig->getSessionName()] = m_textEditorConfig->toJson();
     obj[m_markdownEditorConfig->getSessionName()] = m_markdownEditorConfig->toJson();
     obj[QStringLiteral("core")] = saveCore();
+    obj[QStringLiteral("image_host")] = saveImageHost();
     return obj;
 }
 
@@ -212,3 +239,68 @@ void EditorConfig::setSpellCheckDefaultDictionary(const QString &p_dict)
 {
     updateConfig(m_spellCheckDefaultDictionary, p_dict, this);
 }
+
+void EditorConfig::loadImageHost(const QJsonObject &p_app, const QJsonObject &p_user)
+{
+    const auto appObj = p_app.value(QStringLiteral("image_host")).toObject();
+    const auto userObj = p_user.value(QStringLiteral("image_host")).toObject();
+
+    {
+        auto arr = read(appObj, userObj, QStringLiteral("hosts")).toArray();
+        m_imageHosts.resize(arr.size());
+        for (int i = 0; i < arr.size(); ++i) {
+            m_imageHosts[i].fromJson(arr[i].toObject());
+        }
+    }
+
+    m_defaultImageHost = READSTR(QStringLiteral("default_image_host"));
+    m_clearObsoleteImageAtImageHost = READBOOL(QStringLiteral("clear_obsolete_image"));
+}
+
+QJsonObject EditorConfig::saveImageHost() const
+{
+    QJsonObject obj;
+
+    {
+        QJsonArray arr;
+        for (const auto &item : m_imageHosts) {
+            arr.append(item.toJson());
+        }
+        obj[QStringLiteral("hosts")] = arr;
+    }
+
+    obj[QStringLiteral("default_image_host")] = m_defaultImageHost;
+    obj[QStringLiteral("clear_obsolete_image")] = m_clearObsoleteImageAtImageHost;
+
+    return obj;
+}
+
+const QVector<EditorConfig::ImageHostItem> &EditorConfig::getImageHosts() const
+{
+    return m_imageHosts;
+}
+
+void EditorConfig::setImageHosts(const QVector<ImageHostItem> &p_hosts)
+{
+    updateConfig(m_imageHosts, p_hosts, this);
+}
+
+const QString &EditorConfig::getDefaultImageHost() const
+{
+    return m_defaultImageHost;
+}
+
+void EditorConfig::setDefaultImageHost(const QString &p_host)
+{
+    updateConfig(m_defaultImageHost, p_host, this);
+}
+
+bool EditorConfig::isClearObsoleteImageAtImageHostEnabled() const
+{
+    return m_clearObsoleteImageAtImageHost;
+}
+
+void EditorConfig::setClearObsoleteImageAtImageHostEnabled(bool p_enabled)
+{
+    updateConfig(m_clearObsoleteImageAtImageHost, p_enabled, this);
+}

+ 37 - 0
src/core/editorconfig.h

@@ -6,6 +6,7 @@
 #include <QScopedPointer>
 #include <QSharedPointer>
 #include <QObject>
+#include <QVector>
 
 namespace vnotex
 {
@@ -62,6 +63,23 @@ namespace vnotex
         };
         Q_ENUM(AutoSavePolicy)
 
+        struct ImageHostItem
+        {
+            ImageHostItem() = default;
+
+            bool operator==(const ImageHostItem &p_other) const;
+
+            void fromJson(const QJsonObject &p_jobj);
+
+            QJsonObject toJson() const;
+
+            int m_type = 0;
+
+            QString m_name;
+
+            QJsonObject m_config;
+        };
+
         EditorConfig(ConfigMgr *p_mgr, IConfig *p_topConfig);
 
         ~EditorConfig();
@@ -93,6 +111,15 @@ namespace vnotex
         const QString &getSpellCheckDefaultDictionary() const;
         void setSpellCheckDefaultDictionary(const QString &p_dict);
 
+        const QVector<ImageHostItem> &getImageHosts() const;
+        void setImageHosts(const QVector<ImageHostItem> &p_hosts);
+
+        const QString &getDefaultImageHost() const;
+        void setDefaultImageHost(const QString &p_host);
+
+        bool isClearObsoleteImageAtImageHostEnabled() const;
+        void setClearObsoleteImageAtImageHostEnabled(bool p_enabled);
+
     private:
         friend class MainConfig;
 
@@ -107,6 +134,10 @@ namespace vnotex
         QString autoSavePolicyToString(AutoSavePolicy p_policy) const;
         AutoSavePolicy stringToAutoSavePolicy(const QString &p_str) const;
 
+        void loadImageHost(const QJsonObject &p_app, const QJsonObject &p_user);
+
+        QJsonObject saveImageHost() const;
+
         // Icon size of editor tool bar.
         int m_toolBarIconSize = 16;
 
@@ -128,6 +159,12 @@ namespace vnotex
         bool m_spellCheckAutoDetectLanguageEnabled = false;
 
         QString m_spellCheckDefaultDictionary;
+
+        QVector<ImageHostItem> m_imageHosts;
+
+        QString m_defaultImageHost;
+
+        bool m_clearObsoleteImageAtImageHost = false;
     };
 }
 

+ 2 - 0
src/core/logger.cpp

@@ -71,6 +71,7 @@ void Logger::log(QtMsgType p_type, const QMessageLogContext &p_context, const QS
 
     case QtFatalMsg:
         header = QStringLiteral("Fatal:");
+        break;
     }
 
     QString fileName = getFileName(p_context.file);
@@ -109,6 +110,7 @@ void Logger::log(QtMsgType p_type, const QMessageLogContext &p_context, const QS
         fprintf(stderr, "%s(%s:%u) %s\n",
                 header.toStdString().c_str(), file, p_context.line, localMsg.constData());
         abort();
+        break;
     }
 
     fflush(stderr);

+ 2 - 2
src/core/mainconfig.cpp

@@ -7,6 +7,7 @@
 #include "coreconfig.h"
 #include "editorconfig.h"
 #include "widgetconfig.h"
+#include "markdowneditorconfig.h"
 
 using namespace vnotex;
 
@@ -117,6 +118,5 @@ QString MainConfig::getVersion(const QJsonObject &p_jobj)
 void MainConfig::doVersionSpecificOverride()
 {
     // In a new version, we may want to change one value by force.
-    m_coreConfig->m_shortcuts[CoreConfig::Shortcut::LocationListDock] = "Ctrl+G, C";
-    m_coreConfig->m_shortcuts[CoreConfig::Shortcut::NewWorkspace] = "";
+    m_editorConfig->getMarkdownEditorConfig().m_spellCheckEnabled = false;
 }

+ 2 - 0
src/core/markdowneditorconfig.h

@@ -128,6 +128,8 @@ namespace vnotex
         void setInplacePreviewSources(InplacePreviewSources p_src);
 
     private:
+        friend class MainConfig;
+
         QString sectionNumberModeToString(SectionNumberMode p_mode) const;
         SectionNumberMode stringToSectionNumberMode(const QString &p_str) const;
 

+ 13 - 0
src/core/notebook/bundlenotebook.cpp

@@ -17,6 +17,7 @@ BundleNotebook::BundleNotebook(const NotebookParameters &p_paras,
 {
     m_nextNodeId = p_notebookConfig->m_nextNodeId;
     m_history = p_notebookConfig->m_history;
+    m_extraConfigs = p_notebookConfig->m_extraConfigs;
 }
 
 BundleNotebookConfigMgr *BundleNotebook::getBundleNotebookConfigMgr() const
@@ -81,3 +82,15 @@ void BundleNotebook::clearHistory()
 
     updateNotebookConfig();
 }
+
+const QJsonObject &BundleNotebook::getExtraConfigs() const
+{
+    return m_extraConfigs;
+}
+
+void BundleNotebook::setExtraConfig(const QString &p_key, const QJsonObject &p_obj)
+{
+    m_extraConfigs[p_key] = p_obj;
+
+    updateNotebookConfig();
+}

+ 5 - 0
src/core/notebook/bundlenotebook.h

@@ -31,12 +31,17 @@ namespace vnotex
         void addHistory(const HistoryItem &p_item) Q_DECL_OVERRIDE;
         void clearHistory() Q_DECL_OVERRIDE;
 
+        const QJsonObject &getExtraConfigs() const Q_DECL_OVERRIDE;
+        void setExtraConfig(const QString &p_key, const QJsonObject &p_obj) Q_DECL_OVERRIDE;
+
     private:
         BundleNotebookConfigMgr *getBundleNotebookConfigMgr() const;
 
         ID m_nextNodeId = 1;
 
         QVector<HistoryItem> m_history;
+
+        QJsonObject m_extraConfigs;
     };
 } // ns vnotex
 

+ 6 - 0
src/core/notebook/notebook.cpp

@@ -355,3 +355,9 @@ void Notebook::reloadNodes()
     m_root.clear();
     getRootNode();
 }
+
+QJsonObject Notebook::getExtraConfig(const QString &p_key) const
+{
+    const auto &configs = getExtraConfigs();
+    return configs.value(p_key).toObject();
+}

+ 5 - 0
src/core/notebook/notebook.h

@@ -135,6 +135,11 @@ namespace vnotex
         virtual void addHistory(const HistoryItem &p_item) = 0;
         virtual void clearHistory() = 0;
 
+        // Hold extra 3rd party configs.
+        virtual const QJsonObject &getExtraConfigs() const = 0;
+        QJsonObject getExtraConfig(const QString &p_key) const;
+        virtual void setExtraConfig(const QString &p_key, const QJsonObject &p_obj) = 0;
+
         static const QString c_defaultAttachmentFolder;
 
         static const QString c_defaultImageFolder;

+ 5 - 0
src/core/notebookconfigmgr/notebookconfig.cpp

@@ -60,6 +60,8 @@ QJsonObject NotebookConfig::toJson() const
 
     jobj[QStringLiteral("history")] = saveHistory();
 
+    jobj[QStringLiteral("extra_configs")] = m_extraConfigs;
+
     return jobj;
 }
 
@@ -94,6 +96,8 @@ void NotebookConfig::fromJson(const QJsonObject &p_jobj)
     }
 
     loadHistory(p_jobj);
+
+    m_extraConfigs = p_jobj[QStringLiteral("extra_configs")].toObject();
 }
 
 QSharedPointer<NotebookConfig> NotebookConfig::fromNotebook(const QString &p_version,
@@ -111,6 +115,7 @@ QSharedPointer<NotebookConfig> NotebookConfig::fromNotebook(const QString &p_ver
     config->m_notebookConfigMgr = p_notebook->getConfigMgr()->getName();
     config->m_nextNodeId = p_notebook->getNextNodeId();
     config->m_history = p_notebook->getHistory();
+    config->m_extraConfigs = p_notebook->getExtraConfigs();
 
     return config;
 }

+ 4 - 0
src/core/notebookconfigmgr/notebookconfig.h

@@ -50,6 +50,10 @@ namespace vnotex
 
         QVector<HistoryItem> m_history;
 
+        // Hold all the extra configs for other components or 3rd party plugins.
+        // Use a unique name as the key and the value is a QJsonObject.
+        QJsonObject m_extraConfigs;
+
     private:
         QJsonArray saveHistory() const;
 

+ 4 - 4
src/core/notebookmgr.h

@@ -105,13 +105,13 @@ namespace vnotex
 
         void addNotebook(const QSharedPointer<Notebook> &p_notebook);
 
-        QSharedPointer<NameBasedServer<IVersionControllerFactory>> m_versionControllerServer;
+        QScopedPointer<NameBasedServer<IVersionControllerFactory>> m_versionControllerServer;
 
-        QSharedPointer<NameBasedServer<INotebookConfigMgrFactory>> m_configMgrServer;
+        QScopedPointer<NameBasedServer<INotebookConfigMgrFactory>> m_configMgrServer;
 
-        QSharedPointer<NameBasedServer<INotebookBackendFactory>> m_backendServer;
+        QScopedPointer<NameBasedServer<INotebookBackendFactory>> m_backendServer;
 
-        QSharedPointer<NameBasedServer<INotebookFactory>> m_notebookServer;
+        QScopedPointer<NameBasedServer<INotebookFactory>> m_notebookServer;
 
         QVector<QSharedPointer<Notebook>> m_notebooks;
 

+ 2 - 2
src/data/core/Info.plist

@@ -21,9 +21,9 @@
     <key>CFBundleExecutable</key>
     <string>vnote</string>
     <key>CFBundleShortVersionString</key>
-    <string>3.0.0</string>
+    <string>3.5.1</string>
     <key>CFBundleVersion</key>
-    <string>3.0.0.3</string>
+    <string>3.5.1.1</string>
     <key>NSHumanReadableCopyright</key>
     <string>Created by VNoteX</string>
     <key>CFBundleIconFile</key>

+ 1 - 0
src/data/core/core.qrc

@@ -22,6 +22,7 @@
         <file>icons/settings.svg</file>
         <file>icons/view.svg</file>
         <file>icons/inplace_preview_editor.svg</file>
+        <file>icons/image_host_editor.svg</file>
         <file>icons/settings_menu.svg</file>
         <file>icons/whatsthis.svg</file>
         <file>icons/help_menu.svg</file>

+ 1 - 0
src/data/core/icons/image_host_editor.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1627979077351" class="icon" viewBox="0 0 1127 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8220" width="563.5" height="512" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style type="text/css"></style></defs><path d="M51.2 102.4l1024 0 0 51.2-1024 0 0-51.2Z" p-id="8221" fill="#000000"></path><path d="M102.4 0l921.6 0 0 51.2-921.6 0 0-51.2Z" p-id="8222" fill="#000000"></path><path d="M763.3408 602.368c-4.4032-6.912-13.2608-8.5504-19.8656-3.8912l-141.4656 99.4816c-3.2768 2.304-7.7312 1.3824-9.7792-2.1504L425.5232 407.8592C421.5296 400.896 415.1296 400.8448 411.392 408.0128L102.2976 901.632c-3.7376 7.2192-0.3072 13.0048 7.6288 13.0048l903.9872 0c7.68 0 10.8032-5.5296 6.4512-12.3392L763.3408 602.368z" p-id="8223" fill="#000000"></path><path d="M896 384m-76.8 0a1.5 1.5 0 1 0 153.6 0 1.5 1.5 0 1 0-153.6 0Z" p-id="8224" fill="#000000"></path><path d="M0 211.4048l0 805.9904C0 1021.0304 3.2256 1024 7.2192 1024L1119.232 1024C1123.1744 1024 1126.4 1021.0304 1126.4 1017.3952L1126.4 211.4048C1126.4 207.7696 1123.1744 204.8 1119.232 204.8L7.2192 204.8C3.2256 204.8 0 207.7696 0 211.4048zM51.2 256l1024 0 0 716.8L51.2 972.8 51.2 256z" p-id="8225" fill="#000000"></path></svg>

+ 7 - 1
src/data/core/vnotex.json

@@ -333,11 +333,17 @@
             "smart_table" : true,
             "//comment" : "Time interval (milliseconds) to do smart table formatting",
             "smart_table_interval" : 1000,
-            "spell_check" : true,
+            "spell_check" : false,
             "editor_overridden_font_family" : "",
             "//comment" : "Sources to enable inplace preview, separated by ;",
             "//comment" : "imagelink/codeblock/math",
             "inplace_preview_sources" : "imagelink;codeblock;math"
+        },
+        "image_host" : {
+            "hosts" : [
+            ],
+            "default_image_host" : "",
+            "clear_obsolete_image" : false
         }
     },
     "widget" : {

+ 2 - 1
src/data/extra/docs/en/shortcuts.md

@@ -1,7 +1,8 @@
 # Shortcuts
 1. All the keys without special notice are **case insensitive**;
 2. On macOS, `Ctrl` corresponds to `Command` except in Vi mode;
-3. For a complete shortcuts list, please view the `vnotex.json` configuration file.
+3. The key sequence `Ctrl+G, I` means that first press both `Ctrl` and `G` simultaneously, release them, then press `I` and release;
+4. For a complete shortcuts list, please view the `vnotex.json` configuration file.
 
 ## General
 - `Ctrl+G E`  

+ 2 - 0
src/data/extra/docs/en/welcome.md

@@ -5,6 +5,8 @@ For more information, please visit [**VNote's Home Page**](https://vnotex.github
 
 ## FAQs
 * If VNote crashes after update, please delete the `vnotex.json` file under user configuration folder.
+* For **Windows** users, if VNote hangs frequently or behaves unexpectedly in interface, please check the **OpenGL** option. [Details here](https://github.com/vnotex/vnote/issues/853).
 * VNote has a series of powerful shortcuts. Please view the user configuration file `vnotex.json` for a complete list of shortcuts.
+    * The key sequence `Ctrl+G, I` means that first press both `Ctrl` and `G` simultaneously, release them, then press `I` and release.
 * Feedbacks are appreciated! Please [post an issue](https://github.com/vnotex/vnote/issues) on GitHub if there is any.
 

+ 3 - 2
src/data/extra/docs/zh_CN/shortcuts.md

@@ -1,7 +1,8 @@
 # 快捷键
 1. 以下按键除特别说明外,都不区分大小写;
-2. 在 macOS 下,`Ctrl`对应于`Command`,在 Vi 模式下除外;
-3. 可以通过查看配置文件 `vnotex.json` 来获取一个完整的快捷键列表。
+2. 在 macOS 下,`Ctrl` 对应于 `Command`,在 Vi 模式下除外;
+3. 按键序列 `Ctrl+G, I` 表示先同时按下 `Ctrl` 和 `G`,释放,然后按下 `I` 并释放;
+4. 可以通过查看配置文件 `vnotex.json` 来获取一个完整的快捷键列表。
 
 ## 通用
 - `Ctrl+G E`  

+ 3 - 1
src/data/extra/docs/zh_CN/welcome.md

@@ -1,10 +1,12 @@
 # 欢迎使用 VNote
 一个舒适的笔记平台。
 
-更多信息,请访问 [VNote 主页](https://tamlok.gitee.io/vnote) 或者[由 Gitee 托管的主页](https://tamlok.gitee.io/vnote) 。
+更多信息,请访问 [VNote 主页](https://vnotex.github.io/vnote) 或者[由 Gitee 托管的主页](https://tamlok.gitee.io/vnote) 。
 
 ## 常见问题
 * 如果更新后 VNote 崩溃,请删除用户配置文件夹中的 `vnotex.json` 文件。
+* 对于 **Windows** 用户,如果 VNote 经常卡顿或无响应,或者界面异常,请检查 **OpenGL** 选项。[详情](https://github.com/vnotex/vnote/issues/853) 。
 * VNote 有着一系列强大的快捷键。请查看用户配置文件 `vnotex.json` 以获取一个完整的快捷键列表。
+    * 按键序列 `Ctrl+G, I` 表示先同时按下 `Ctrl` 和 `G`,释放,然后按下 `I` 并释放。
 * 使用中有任何问题,欢迎[反馈](https://github.com/vnotex/vnote/issues) 。
 

+ 204 - 0
src/imagehost/githubimagehost.cpp

@@ -0,0 +1,204 @@
+#include "githubimagehost.h"
+
+#include <QDebug>
+#include <QFileInfo>
+#include <QByteArray>
+
+#include <utils/utils.h>
+#include <utils/webutils.h>
+
+using namespace vnotex;
+
+const QString GitHubImageHost::c_apiUrl = "https://api.github.com";
+
+GitHubImageHost::GitHubImageHost(QObject *p_parent)
+    : ImageHost(p_parent)
+{
+}
+
+bool GitHubImageHost::ready() const
+{
+    return !m_personalAccessToken.isEmpty() && !m_userName.isEmpty() && !m_repoName.isEmpty();
+}
+
+ImageHost::Type GitHubImageHost::getType() const
+{
+    return Type::GitHub;
+}
+
+QJsonObject GitHubImageHost::getConfig() const
+{
+    QJsonObject obj;
+    obj[QStringLiteral("personal_access_token")] = m_personalAccessToken;
+    obj[QStringLiteral("user_name")] = m_userName;
+    obj[QStringLiteral("repository_name")] = m_repoName;
+    return obj;
+}
+
+void GitHubImageHost::setConfig(const QJsonObject &p_jobj)
+{
+    parseConfig(p_jobj, m_personalAccessToken, m_userName, m_repoName);
+
+    m_imageUrlPrefix = QString("https://raw.githubusercontent.com/%1/%2/master/").arg(m_userName, m_repoName);
+}
+
+bool GitHubImageHost::testConfig(const QJsonObject &p_jobj, QString &p_msg)
+{
+    p_msg.clear();
+
+    QString token, userName, repoName;
+    parseConfig(p_jobj, token, userName, repoName);
+
+    if (token.isEmpty() || userName.isEmpty() || repoName.isEmpty()) {
+        p_msg = tr("PersonalAccessToken/UserName/RepositoryName should not be empty.");
+        return false;
+    }
+
+    auto reply = getRepoInfo(token, userName, repoName);
+    p_msg = QString::fromUtf8(reply.m_data);
+    return reply.m_error == QNetworkReply::NoError;
+}
+
+QPair<QByteArray, QByteArray> GitHubImageHost::authorizationHeader(const QString &p_token)
+{
+    auto token = "token " + p_token;
+    return qMakePair(QByteArray("Authorization"), token.toUtf8());
+}
+
+QPair<QByteArray, QByteArray> GitHubImageHost::acceptHeader()
+{
+    return qMakePair(QByteArray("Accept"), QByteArray("application/vnd.github.v3+json"));
+}
+
+vte::NetworkAccess::RawHeaderPairs GitHubImageHost::prepareCommonHeaders(const QString &p_token)
+{
+    vte::NetworkAccess::RawHeaderPairs rawHeader;
+    rawHeader.push_back(authorizationHeader(p_token));
+    rawHeader.push_back(acceptHeader());
+    return rawHeader;
+}
+
+vte::NetworkReply GitHubImageHost::getRepoInfo(const QString &p_token, const QString &p_userName, const QString &p_repoName) const
+{
+    auto rawHeader = prepareCommonHeaders(p_token);
+    const auto urlStr = QString("%1/repos/%2/%3").arg(c_apiUrl, p_userName, p_repoName);
+    auto reply = vte::NetworkAccess::request(QUrl(urlStr), rawHeader);
+    return reply;
+}
+
+void GitHubImageHost::parseConfig(const QJsonObject &p_jobj,
+                                  QString &p_token,
+                                  QString &p_userName,
+                                  QString &p_repoName)
+{
+    p_token = p_jobj[QStringLiteral("personal_access_token")].toString();
+    p_userName = p_jobj[QStringLiteral("user_name")].toString();
+    p_repoName = p_jobj[QStringLiteral("repository_name")].toString();
+}
+
+QString GitHubImageHost::create(const QByteArray &p_data, const QString &p_path, QString &p_msg)
+{
+    QString destUrl;
+
+    if (p_path.isEmpty()) {
+        p_msg = tr("Failed to create image with empty path.");
+        return destUrl;
+    }
+
+    destUrl = createResource(p_data, p_path, p_msg);
+    return destUrl;
+}
+
+QString GitHubImageHost::createResource(const QByteArray &p_content, const QString &p_path, QString &p_msg) const
+{
+    Q_ASSERT(!p_path.isEmpty());
+
+    if (!ready()) {
+        p_msg = tr("Invalid GitHub image host configuration.");
+        return QString();
+    }
+
+    auto rawHeader = prepareCommonHeaders(m_personalAccessToken);
+    const auto urlStr = QString("%1/repos/%2/%3/contents/%4").arg(c_apiUrl, m_userName, m_repoName, p_path);
+
+    // Check if @p_path already exists.
+    auto reply = vte::NetworkAccess::request(QUrl(urlStr), rawHeader);
+    if (reply.m_error == QNetworkReply::NoError) {
+        p_msg = tr("The resource already exists at the image host (%1).").arg(p_path);
+        return QString();
+    } else if (reply.m_error != QNetworkReply::ContentNotFoundError) {
+        p_msg = tr("Failed to query the resource at the image host (%1) (%2) (%3).").arg(urlStr, reply.errorStr(), reply.m_data);
+        return QString();
+    }
+
+    // Create the content.
+    QJsonObject requestDataObj;
+    requestDataObj[QStringLiteral("message")] = QString("VX_ADD: %1").arg(p_path);
+    requestDataObj[QStringLiteral("content")] = QString::fromUtf8(p_content.toBase64());
+    auto requestData = Utils::toJsonString(requestDataObj);
+    reply = vte::NetworkAccess::put(QUrl(urlStr), rawHeader, requestData);
+    if (reply.m_error != QNetworkReply::NoError) {
+        p_msg = tr("Failed to create resource at the image host (%1) (%2) (%3).").arg(urlStr, reply.errorStr(), reply.m_data);
+        return QString();
+    } else {
+        auto replyObj = Utils::fromJsonString(reply.m_data);
+        Q_ASSERT(!replyObj.isEmpty());
+        auto targetUrl = replyObj[QStringLiteral("content")].toObject().value(QStringLiteral("download_url")).toString();
+        if (targetUrl.isEmpty()) {
+            p_msg = tr("Failed to create resource at the image host (%1) (%2) (%3).").arg(urlStr, reply.errorStr(), reply.m_data);
+        } else {
+            qDebug() << "created resource" << targetUrl;
+        }
+        return targetUrl;
+    }
+}
+
+bool GitHubImageHost::ownsUrl(const QString &p_url) const
+{
+    return p_url.startsWith(m_imageUrlPrefix);
+}
+
+bool GitHubImageHost::remove(const QString &p_url, QString &p_msg)
+{
+    Q_ASSERT(ownsUrl(p_url));
+
+    if (!ready()) {
+        p_msg = tr("Invalid GitHub image host configuration.");
+        return false;
+    }
+
+    const QString resourcePath = WebUtils::purifyUrl(p_url.mid(m_imageUrlPrefix.size()));
+
+    auto rawHeader = prepareCommonHeaders(m_personalAccessToken);
+    const auto urlStr = QString("%1/repos/%2/%3/contents/%4").arg(c_apiUrl, m_userName, m_repoName, resourcePath);
+
+    // Get the SHA of the resource.
+    auto reply = vte::NetworkAccess::request(QUrl(urlStr), rawHeader);
+    if (reply.m_error != QNetworkReply::NoError) {
+        p_msg = tr("Failed to fetch information about the resource (%1).").arg(resourcePath);
+        return false;
+    }
+
+    auto replyObj = Utils::fromJsonString(reply.m_data);
+    Q_ASSERT(!replyObj.isEmpty());
+    const auto sha = replyObj[QStringLiteral("sha")].toString();
+    if (sha.isEmpty()) {
+        p_msg = tr("Failed to fetch SHA about the resource (%1) (%2).").arg(resourcePath, QString::fromUtf8(reply.m_data));
+        return false;
+    }
+
+    // Delete.
+    QJsonObject requestDataObj;
+    requestDataObj[QStringLiteral("message")] = QString("VX_DEL: %1").arg(resourcePath);
+    requestDataObj[QStringLiteral("sha")] = sha;
+    auto requestData = Utils::toJsonString(requestDataObj);
+    reply = vte::NetworkAccess::deleteResource(QUrl(urlStr), rawHeader, requestData);
+    if (reply.m_error != QNetworkReply::NoError) {
+        p_msg = tr("Failed to delete resource (%1) (%2).").arg(resourcePath, QString::fromUtf8(reply.m_data));
+        return false;
+    }
+
+    qDebug() << "deleted resource" << resourcePath;
+
+    return true;
+}

+ 61 - 0
src/imagehost/githubimagehost.h

@@ -0,0 +1,61 @@
+#ifndef GITHUBIMAGEHOST_H
+#define GITHUBIMAGEHOST_H
+
+#include "imagehost.h"
+
+#include <vtextedit/networkutils.h>
+
+namespace vnotex
+{
+    class GitHubImageHost : public ImageHost
+    {
+        Q_OBJECT
+    public:
+        explicit GitHubImageHost(QObject *p_parent);
+
+        bool ready() const Q_DECL_OVERRIDE;
+
+        Type getType() const Q_DECL_OVERRIDE;
+
+        QJsonObject getConfig() const Q_DECL_OVERRIDE;
+
+        void setConfig(const QJsonObject &p_jobj) Q_DECL_OVERRIDE;
+
+        bool testConfig(const QJsonObject &p_jobj, QString &p_msg) Q_DECL_OVERRIDE;
+
+        QString create(const QByteArray &p_data, const QString &p_path, QString &p_msg) Q_DECL_OVERRIDE;
+
+        bool remove(const QString &p_url, QString &p_msg) Q_DECL_OVERRIDE;
+
+        bool ownsUrl(const QString &p_url) const Q_DECL_OVERRIDE;
+
+    private:
+        // Used to test.
+        vte::NetworkReply getRepoInfo(const QString &p_token, const QString &p_userName, const QString &p_repoName) const;
+
+        QString createResource(const QByteArray &p_content, const QString &p_path, QString &p_msg) const;
+
+        static void parseConfig(const QJsonObject &p_jobj,
+                                QString &p_token,
+                                QString &p_userName,
+                                QString &p_repoName);
+
+        static QPair<QByteArray, QByteArray> authorizationHeader(const QString &p_token);
+
+        static QPair<QByteArray, QByteArray> acceptHeader();
+
+        static vte::NetworkAccess::RawHeaderPairs prepareCommonHeaders(const QString &p_token);
+
+        QString m_personalAccessToken;
+
+        QString m_userName;
+
+        QString m_repoName;
+
+        QString m_imageUrlPrefix;
+
+        static const QString c_apiUrl;
+    };
+}
+
+#endif // GITHUBIMAGEHOST_H

+ 30 - 0
src/imagehost/imagehost.cpp

@@ -0,0 +1,30 @@
+#include "imagehost.h"
+
+using namespace vnotex;
+
+ImageHost::ImageHost(QObject *p_parent)
+    : QObject(p_parent)
+{
+}
+
+const QString &ImageHost::getName() const
+{
+    return m_name;
+}
+
+void ImageHost::setName(const QString &p_name)
+{
+    m_name = p_name;
+}
+
+QString ImageHost::typeString(ImageHost::Type p_type)
+{
+    switch (p_type) {
+    case Type::GitHub:
+        return tr("GitHub");
+
+    default:
+        Q_ASSERT(false);
+        return QString("Unknown");
+    }
+}

+ 57 - 0
src/imagehost/imagehost.h

@@ -0,0 +1,57 @@
+#ifndef IMAGEHOST_H
+#define IMAGEHOST_H
+
+#include <QObject>
+#include <QJsonObject>
+
+#include <core/global.h>
+
+class QByteArray;
+
+namespace vnotex
+{
+    // Abstract class for image host.
+    class ImageHost : public QObject
+    {
+        Q_OBJECT
+    public:
+        enum Type
+        {
+            GitHub = 0,
+            MaxHost
+        };
+
+        virtual ~ImageHost() = default;
+
+        const QString &getName() const;
+        void setName(const QString &p_name);
+
+        virtual Type getType() const = 0;
+
+        // Whether it is ready to serve.
+        virtual bool ready() const = 0;
+
+        virtual QJsonObject getConfig() const = 0;
+        virtual void setConfig(const QJsonObject &p_jobj) = 0;
+
+        virtual bool testConfig(const QJsonObject &p_jobj, QString &p_msg) = 0;
+
+        // Upload @p_data to the host at path @p_path. Return the target Url string on success.
+        virtual QString create(const QByteArray &p_data, const QString &p_path, QString &p_msg) = 0;
+
+        virtual bool remove(const QString &p_url, QString &p_msg) = 0;
+
+        // Test if @p_url is owned by this image host.
+        virtual bool ownsUrl(const QString &p_url) const = 0;
+
+        static QString typeString(Type p_type);
+
+    protected:
+        explicit ImageHost(QObject *p_parent = nullptr);
+
+        // Name to identify one image host. One type of image host may have multiple instances.
+        QString m_name;
+    };
+}
+
+#endif // IMAGEHOST_H

+ 14 - 0
src/imagehost/imagehost.pri

@@ -0,0 +1,14 @@
+QT += widgets
+
+HEADERS += \
+    $$PWD/githubimagehost.h \
+    $$PWD/imagehost.h \
+    $$PWD/imagehostmgr.h \
+    $$PWD/imagehostutils.h
+
+SOURCES += \
+    $$PWD/githubimagehost.cpp \
+    $$PWD/imagehost.cpp \
+    $$PWD/imagehostmgr.cpp \
+    $$PWD/imagehostutils.cpp
+

+ 198 - 0
src/imagehost/imagehostmgr.cpp

@@ -0,0 +1,198 @@
+#include "imagehostmgr.h"
+
+#include <QDebug>
+
+#include <core/configmgr.h>
+#include <core/editorconfig.h>
+
+#include "githubimagehost.h"
+
+using namespace vnotex;
+
+ImageHostMgr::ImageHostMgr()
+{
+    loadImageHosts();
+}
+
+void ImageHostMgr::loadImageHosts()
+{
+    const auto &editorConfig = ConfigMgr::getInst().getEditorConfig();
+    for (const auto &host : editorConfig.getImageHosts()) {
+        if (host.m_type >= ImageHost::Type::MaxHost) {
+            qWarning() << "skipped unknown type image host" << host.m_type << host.m_name;
+            continue;
+        }
+
+        if (find(host.m_name)) {
+            qWarning() << "sikpped image host with name conflict" << host.m_type << host.m_name;
+            continue;
+        }
+
+        auto imageHost = createImageHost(static_cast<ImageHost::Type>(host.m_type), this);
+        if (!imageHost) {
+            qWarning() << "failed to create image host" << host.m_type << host.m_name;
+            continue;
+        }
+
+        imageHost->setName(host.m_name);
+        imageHost->setConfig(host.m_config);
+        add(imageHost);
+    }
+
+    m_defaultHost = find(editorConfig.getDefaultImageHost());
+
+    qDebug() << "loaded" << m_hosts.size() << "image hosts";
+}
+
+void ImageHostMgr::saveImageHosts()
+{
+    QVector<EditorConfig::ImageHostItem> items;
+    items.resize(m_hosts.size());
+    for (int i = 0; i < m_hosts.size(); ++i) {
+        items[i].m_type = static_cast<int>(m_hosts[i]->getType());
+        items[i].m_name = m_hosts[i]->getName();
+        items[i].m_config = m_hosts[i]->getConfig();
+    }
+
+    auto &editorConfig = ConfigMgr::getInst().getEditorConfig();
+    editorConfig.setImageHosts(items);
+}
+
+ImageHost *ImageHostMgr::createImageHost(ImageHost::Type p_type, QObject *p_parent)
+{
+    switch (p_type) {
+    case ImageHost::Type::GitHub:
+        return new GitHubImageHost(p_parent);
+
+    default:
+        return nullptr;
+    }
+}
+
+void ImageHostMgr::add(ImageHost *p_host)
+{
+    p_host->setParent(this);
+    m_hosts.append(p_host);
+}
+
+ImageHost *ImageHostMgr::find(const QString &p_name) const
+{
+    if (p_name.isEmpty()) {
+        return nullptr;
+    }
+
+    for (auto host : m_hosts) {
+        if (host->getName() == p_name) {
+            return host;
+        }
+    }
+
+    return nullptr;
+}
+
+ImageHost *ImageHostMgr::newImageHost(ImageHost::Type p_type, const QString &p_name)
+{
+    if (find(p_name)) {
+        qWarning() << "failed to new image host with existing name" << p_name;
+        return nullptr;
+    }
+
+    auto host = createImageHost(p_type, this);
+    if (!host) {
+        return nullptr;
+    }
+
+    host->setName(p_name);
+    add(host);
+
+    saveImageHosts();
+
+    emit imageHostChanged();
+
+    return host;
+}
+
+const QVector<ImageHost *> &ImageHostMgr::getImageHosts() const
+{
+    return m_hosts;
+}
+
+void ImageHostMgr::removeImageHost(ImageHost *p_host)
+{
+    m_hosts.removeOne(p_host);
+
+    saveImageHosts();
+
+    if (p_host == m_defaultHost) {
+        m_defaultHost = nullptr;
+        saveDefaultImageHost();
+    }
+
+    emit imageHostChanged();
+}
+
+bool ImageHostMgr::renameImageHost(ImageHost *p_host, const QString &p_newName)
+{
+    if (p_newName.isEmpty()) {
+        return false;
+    }
+
+    if (p_newName == p_host->getName()) {
+        return true;
+    }
+
+    if (find(p_newName)) {
+        return false;
+    }
+
+    p_host->setName(p_newName);
+
+    saveImageHosts();
+
+    if (m_defaultHost == p_host) {
+        saveDefaultImageHost();
+    }
+
+    emit imageHostChanged();
+    return true;
+}
+
+ImageHost *ImageHostMgr::getDefaultImageHost() const
+{
+    return m_defaultHost;
+}
+
+void ImageHostMgr::setDefaultImageHost(const QString &p_name)
+{
+    auto host = find(p_name);
+    if (m_defaultHost == host) {
+        return;
+    }
+
+    m_defaultHost = host;
+
+    saveDefaultImageHost();
+
+    emit imageHostChanged();
+}
+
+void ImageHostMgr::saveDefaultImageHost()
+{
+    auto &editorConfig = ConfigMgr::getInst().getEditorConfig();
+    editorConfig.setDefaultImageHost(m_defaultHost ? m_defaultHost->getName() : QString());
+}
+
+ImageHost *ImageHostMgr::findByImageUrl(const QString &p_url) const
+{
+    if (p_url.isEmpty()) {
+        return nullptr;
+    }
+
+    for (auto host : m_hosts) {
+        if (host->ownsUrl(p_url)) {
+            return host;
+        }
+    }
+
+    return nullptr;
+}

+ 61 - 0
src/imagehost/imagehostmgr.h

@@ -0,0 +1,61 @@
+#ifndef IMAGEHOSTMGR_H
+#define IMAGEHOSTMGR_H
+
+#include <QObject>
+#include <QScopedPointer>
+
+#include <core/noncopyable.h>
+
+#include "imagehost.h"
+
+namespace vnotex
+{
+    class ImageHostMgr : public QObject, private Noncopyable
+    {
+        Q_OBJECT
+    public:
+        static ImageHostMgr &getInst()
+        {
+            static ImageHostMgr inst;
+            return inst;
+        }
+
+        ImageHost *find(const QString &p_name) const;
+
+        ImageHost *findByImageUrl(const QString &p_url) const;
+
+        ImageHost *newImageHost(ImageHost::Type p_type, const QString &p_name);
+
+        const QVector<ImageHost *> &getImageHosts() const;
+
+        void removeImageHost(ImageHost *p_host);
+
+        bool renameImageHost(ImageHost *p_host, const QString &p_newName);
+
+        void saveImageHosts();
+
+        ImageHost *getDefaultImageHost() const;
+
+        void setDefaultImageHost(const QString &p_name);
+
+    signals:
+        void imageHostChanged();
+
+    private:
+        ImageHostMgr();
+
+        void loadImageHosts();
+
+        void add(ImageHost *p_host);
+
+        void saveDefaultImageHost();
+
+        static ImageHost *createImageHost(ImageHost::Type p_type, QObject *p_parent);
+
+        QVector<ImageHost *> m_hosts;
+
+        ImageHost *m_defaultHost = nullptr;
+    };
+}
+
+#endif // IMAGEHOSTMGR_H

+ 29 - 0
src/imagehost/imagehostutils.cpp

@@ -0,0 +1,29 @@
+#include "imagehostutils.h"
+
+#include <buffer/buffer.h>
+#include <notebook/node.h>
+#include <notebook/notebook.h>
+#include <notebookbackend/inotebookbackend.h>
+#include <utils/pathutils.h>
+
+using namespace vnotex;
+
+QString ImageHostUtils::generateRelativePath(const Buffer *p_buffer)
+{
+    QString relativePath;
+
+    // To avoid leaking any private information, for external files, we won't add path to it.
+    if (auto node = p_buffer->getNode()) {
+        auto notebook = node->getNotebook();
+        auto name = notebook->getName();
+        if (name.isEmpty() || !PathUtils::isLegalFileName(name)) {
+            name = QStringLiteral("vx_notebooks");
+        }
+
+        relativePath = name;
+        relativePath += "/" + notebook->getBackend()->getRelativePath(p_buffer->getPath());
+        relativePath = relativePath.toLower();
+    }
+
+    return relativePath;
+}

+ 24 - 0
src/imagehost/imagehostutils.h

@@ -0,0 +1,24 @@
+#ifndef IMAGEHOSTUTILS_H
+#define IMAGEHOSTUTILS_H
+
+#include <QString>
+
+class QImage;
+class QWidget;
+
+namespace vnotex
+{
+    class Buffer;
+
+    class ImageHostUtils
+    {
+    public:
+        ImageHostUtils() = delete;
+
+        // According to @p_buffer, generate the relative path on image host for images.
+        // Return the relative path folder.
+        static QString generateRelativePath(const Buffer *p_buffer);
+    };
+}
+
+#endif // IMAGEHOSTUTILS_H

+ 2 - 0
src/src.pro

@@ -51,6 +51,8 @@ include($$PWD/search/search.pri)
 
 include($$PWD/snippet/snippet.pri)
 
+include($$PWD/imagehost/imagehost.pri)
+
 include($$PWD/core/core.pri)
 
 include($$PWD/widgets/widgets.pri)

+ 16 - 0
src/utils/pathutils.cpp

@@ -83,6 +83,8 @@ QString PathUtils::fileNameCheap(const QString &p_path)
 
 QString PathUtils::normalizePath(const QString &p_path)
 {
+    Q_ASSERT(isLocalFile(p_path));
+
     auto absPath = QDir::cleanPath(QDir(p_path).absolutePath());
 #if defined(Q_OS_WIN)
     return absPath.toLower();
@@ -234,3 +236,17 @@ bool PathUtils::isDir(const QString &p_path)
 {
     return QFileInfo(p_path).isDir();
 }
+
+bool PathUtils::isLocalFile(const QString &p_path)
+{
+    if (p_path.isEmpty()) {
+        return false;
+    }
+
+    QRegularExpression regExp("^(?:ftp|http|https)://");
+    if (regExp.match(p_path).hasMatch()) {
+        return false;
+    }
+
+    return true;
+}

+ 2 - 0
src/utils/pathutils.h

@@ -73,6 +73,8 @@ namespace vnotex
 
         static bool isImageUrl(const QString &p_url);
 
+        static bool isLocalFile(const QString &p_path);
+
         // Regular expression string for file/folder name.
         // Forbidden chars: \/:*?"<>| and whitespaces except spaces.
         static const QString c_fileNameRegularExpression;

+ 13 - 0
src/utils/utils.cpp

@@ -9,6 +9,8 @@
 #include <QRegularExpression>
 #include <QSvgRenderer>
 #include <QPainter>
+#include <QJsonObject>
+#include <QJsonDocument>
 
 #include <cmath>
 
@@ -126,3 +128,14 @@ QString Utils::intToString(int p_val, int p_width)
     }
     return str;
 }
+
+QByteArray Utils::toJsonString(const QJsonObject &p_obj)
+{
+    QJsonDocument doc(p_obj);
+    return doc.toJson(QJsonDocument::Compact);
+}
+
+QJsonObject Utils::fromJsonString(const QByteArray &p_data)
+{
+    return QJsonDocument::fromJson(p_data).object();
+}

+ 5 - 0
src/utils/utils.h

@@ -20,6 +20,7 @@
 #endif
 
 class QWidget;
+class QJsonObject;
 
 namespace vnotex
 {
@@ -52,6 +53,10 @@ namespace vnotex
         static QString boolToString(bool p_val);
 
         static QString intToString(int p_val, int p_width = 0);
+
+        static QByteArray toJsonString(const QJsonObject &p_obj);
+
+        static QJsonObject fromJsonString(const QByteArray &p_data);
     };
 } // ns vnotex
 

+ 2 - 2
src/utils/webutils.cpp

@@ -36,7 +36,7 @@ QString WebUtils::toDataUri(const QUrl &p_url, bool p_keepTitle)
     QByteArray data;
     if (p_url.scheme() == "https" || p_url.scheme() == "http") {
         // Download it.
-        data = vte::Downloader::download(p_url);
+        data = vte::NetworkAccess::request(p_url).m_data;
     } else if (finfo.exists()) {
         data = FileUtils::readFile(filePath);
     }
@@ -86,7 +86,7 @@ QString WebUtils::copyResource(const QUrl &p_url, const QString &p_folder)
     try {
         if (p_url.scheme() == "https" || p_url.scheme() == "http") {
             // Download it.
-            auto data = vte::Downloader::download(p_url);
+            auto data = vte::NetworkAccess::request(p_url).m_data;
             if (!data.isEmpty()) {
                 FileUtils::writeFile(targetFile, data);
             }

+ 7 - 7
src/widgets/dialogs/imageinsertdialog.cpp

@@ -187,12 +187,12 @@ void ImageInsertDialog::checkImagePathInput()
         m_source = Source::ImageData;
 
         if (!m_downloader) {
-            m_downloader = new vte::Downloader(this);
-            connect(m_downloader, &vte::Downloader::downloadFinished,
+            m_downloader = new vte::NetworkAccess(this);
+            connect(m_downloader, &vte::NetworkAccess::requestFinished,
                     this, &ImageInsertDialog::handleImageDownloaded);
         }
 
-        m_downloader->downloadAsync(url);
+        m_downloader->requestAsync(url);
     }
 
     m_imageTitleEdit->setText(QFileInfo(text).baseName());
@@ -300,17 +300,17 @@ int ImageInsertDialog::getScaledWidth() const
     return val == m_image.width() ? 0 : val;
 }
 
-void ImageInsertDialog::handleImageDownloaded(const QByteArray &p_data, const QString &p_url)
+void ImageInsertDialog::handleImageDownloaded(const vte::NetworkReply &p_data, const QString &p_url)
 {
-    setImage(QImage::fromData(p_data));
+    setImage(QImage::fromData(p_data.m_data));
 
     // Save it to a temp file to avoid potential data loss via QImage.
     bool savedToFile = false;
-    if (!p_data.isEmpty()) {
+    if (!p_data.m_data.isEmpty()) {
         auto format = QFileInfo(PathUtils::removeUrlParameters(p_url)).suffix();
         m_tempFile.reset(FileUtils::createTemporaryFile(format));
         if (m_tempFile->open()) {
-            savedToFile = -1 != m_tempFile->write(p_data);
+            savedToFile = -1 != m_tempFile->write(p_data.m_data);
             m_tempFile->close();
         }
     }

+ 4 - 3
src/widgets/dialogs/imageinsertdialog.h

@@ -17,7 +17,8 @@ class QScrollArea;
 
 namespace vte
 {
-    class Downloader;
+    class NetworkAccess;
+    struct NetworkReply;
 }
 
 namespace vnotex
@@ -65,7 +66,7 @@ namespace vnotex
 
         void browseFile();
 
-        void handleImageDownloaded(const QByteArray &p_data, const QString &p_url);
+        void handleImageDownloaded(const vte::NetworkReply &p_data, const QString &p_url);
 
         void handleScaleSliderValueChanged(int p_val);
 
@@ -102,7 +103,7 @@ namespace vnotex
         QImage m_image;
 
         // Managed by QObject.
-        vte::Downloader *m_downloader = nullptr;
+        vte::NetworkAccess *m_downloader = nullptr;
 
         // Managed by QObject.
         QTimer *m_imagePathCheckTimer = nullptr;

+ 38 - 33
src/widgets/dialogs/newnotedialog.cpp

@@ -150,6 +150,16 @@ void NewNoteDialog::initDefaultValues(const Node *p_node)
         lineEdit->setText(defaultName);
         WidgetUtils::selectBaseName(lineEdit);
     }
+
+    if (!s_lastTemplate.isEmpty()) {
+        // Restore.
+        int idx = m_templateComboBox->findData(s_lastTemplate);
+        if (idx != -1) {
+            m_templateComboBox->setCurrentIndex(idx);
+        } else {
+            s_lastTemplate.clear();
+        }
+    }
 }
 
 void NewNoteDialog::setupTemplateComboBox(QWidget *p_parent)
@@ -166,41 +176,10 @@ void NewNoteDialog::setupTemplateComboBox(QWidget *p_parent)
         m_templateComboBox->setItemData(idx++, temp, Qt::ToolTipRole);
     }
 
-    if (!s_lastTemplate.isEmpty()) {
-        // Restore.
-        int idx = m_templateComboBox->findData(s_lastTemplate);
-        if (idx != -1) {
-            m_templateComboBox->setCurrentIndex(idx);
-        } else {
-            s_lastTemplate.clear();
-        }
-    }
+    m_templateComboBox->setCurrentIndex(0);
 
     connect(m_templateComboBox, QOverload<int>::of(&QComboBox::currentIndexChanged),
-            this, [this]() {
-                m_templateContent.clear();
-                m_templateTextEdit->clear();
-
-                auto temp = m_templateComboBox->currentData().toString();
-                if (temp.isEmpty()) {
-                    m_templateTextEdit->hide();
-                    return;
-                }
-
-                const auto filePath = TemplateMgr::getInst().getTemplateFilePath(temp);
-                try {
-                    m_templateContent = FileUtils::readTextFile(filePath);
-                    m_templateTextEdit->setPlainText(m_templateContent);
-                    m_templateTextEdit->show();
-                } catch (Exception &p_e) {
-                    m_templateTextEdit->hide();
-
-                    QString msg = tr("Failed to load template (%1) (%2).")
-                                    .arg(filePath, p_e.what());
-                    qCritical() << msg;
-                    setInformationText(msg, ScrollDialog::InformationLevel::Error);
-                }
-        });
+            this, &NewNoteDialog::updateCurrentTemplate);
 }
 
 QString NewNoteDialog::getTemplateContent() const
@@ -211,3 +190,29 @@ QString NewNoteDialog::getTemplateContent() const
                                                       cursorOffset,
                                                       SnippetMgr::generateOverrides(m_infoWidget->getName()));
 }
+
+void NewNoteDialog::updateCurrentTemplate()
+{
+    m_templateContent.clear();
+    m_templateTextEdit->clear();
+
+    auto temp = m_templateComboBox->currentData().toString();
+    if (temp.isEmpty()) {
+        m_templateTextEdit->hide();
+        return;
+    }
+
+    const auto filePath = TemplateMgr::getInst().getTemplateFilePath(temp);
+    try {
+        m_templateContent = FileUtils::readTextFile(filePath);
+        m_templateTextEdit->setPlainText(m_templateContent);
+        m_templateTextEdit->show();
+    } catch (Exception &p_e) {
+        m_templateTextEdit->hide();
+
+        QString msg = tr("Failed to load template (%1) (%2).")
+            .arg(filePath, p_e.what());
+        qCritical() << msg;
+        setInformationText(msg, ScrollDialog::InformationLevel::Error);
+    }
+}

+ 2 - 0
src/widgets/dialogs/newnotedialog.h

@@ -41,6 +41,8 @@ namespace vnotex
 
         QString getTemplateContent() const;
 
+        void updateCurrentTemplate();
+
         NodeInfoWidget *m_infoWidget = nullptr;
 
         QComboBox *m_templateComboBox = nullptr;

+ 0 - 3
src/widgets/dialogs/nodeinfowidget.cpp

@@ -71,9 +71,6 @@ void NodeInfoWidget::setupUI(const Node *p_parentNode, Node::Flags p_newNodeFlag
 void NodeInfoWidget::setupNameLineEdit(QWidget *p_parent)
 {
     m_nameLineEdit = WidgetsFactory::createLineEditWithSnippet(p_parent);
-    auto validator = new QRegularExpressionValidator(QRegularExpression(PathUtils::c_fileNameRegularExpression),
-                                                     m_nameLineEdit);
-    m_nameLineEdit->setValidator(validator);
     connect(m_nameLineEdit, &QLineEdit::textEdited,
             this, [this]() {
                 // Choose the correct file type.

+ 1 - 1
src/widgets/dialogs/scrolldialog.cpp

@@ -40,7 +40,7 @@ void ScrollDialog::addBottomWidget(QWidget *p_widget)
 
 void ScrollDialog::showEvent(QShowEvent *p_event)
 {
-    QDialog::showEvent(p_event);
+    Dialog::showEvent(p_event);
 
     resizeToHideScrollBarLater(false, true);
 }

+ 3 - 1
src/widgets/dialogs/settings/appearancepage.cpp

@@ -94,7 +94,7 @@ void AppearancePage::loadInternal()
     }
 }
 
-void AppearancePage::saveInternal()
+bool AppearancePage::saveInternal()
 {
     auto &sessionConfig = ConfigMgr::getInst().getSessionConfig();
     auto &coreConfig = ConfigMgr::getInst().getCoreConfig();
@@ -115,6 +115,8 @@ void AppearancePage::saveInternal()
         }
         widgetConfig.setMainWindowKeepDocksExpandingContentArea(docks);
     }
+
+    return true;
 }
 
 QString AppearancePage::title() const

+ 1 - 1
src/widgets/dialogs/settings/appearancepage.h

@@ -22,7 +22,7 @@ namespace vnotex
     protected:
         void loadInternal() Q_DECL_OVERRIDE;
 
-        void saveInternal() Q_DECL_OVERRIDE;
+        bool saveInternal() Q_DECL_OVERRIDE;
 
     private:
         void setupUI();

+ 3 - 1
src/widgets/dialogs/settings/editorpage.cpp

@@ -113,7 +113,7 @@ void EditorPage::loadInternal()
     }
 }
 
-void EditorPage::saveInternal()
+bool EditorPage::saveInternal()
 {
     auto &editorConfig = ConfigMgr::getInst().getEditorConfig();
 
@@ -129,6 +129,8 @@ void EditorPage::saveInternal()
     }
 
     notifyEditorConfigChange();
+
+    return true;
 }
 
 QString EditorPage::title() const

+ 1 - 1
src/widgets/dialogs/settings/editorpage.h

@@ -22,7 +22,7 @@ namespace vnotex
     protected:
         void loadInternal() Q_DECL_OVERRIDE;
 
-        void saveInternal() Q_DECL_OVERRIDE;
+        bool saveInternal() Q_DECL_OVERRIDE;
 
     private:
         void setupUI();

+ 3 - 1
src/widgets/dialogs/settings/generalpage.cpp

@@ -106,7 +106,7 @@ void GeneralPage::loadInternal()
     m_recoverLastSessionCheckBox->setChecked(coreConfig.isRecoverLastSessionOnStartEnabled());
 }
 
-void GeneralPage::saveInternal()
+bool GeneralPage::saveInternal()
 {
     auto &coreConfig = ConfigMgr::getInst().getCoreConfig();
     auto &sessionConfig = ConfigMgr::getInst().getSessionConfig();
@@ -127,6 +127,8 @@ void GeneralPage::saveInternal()
     }
 
     coreConfig.setRecoverLastSessionOnStartEnabled(m_recoverLastSessionCheckBox->isChecked());
+
+    return true;
 }
 
 QString GeneralPage::title() const

+ 1 - 1
src/widgets/dialogs/settings/generalpage.h

@@ -19,7 +19,7 @@ namespace vnotex
     protected:
         void loadInternal() Q_DECL_OVERRIDE;
 
-        void saveInternal() Q_DECL_OVERRIDE;
+        bool saveInternal() Q_DECL_OVERRIDE;
 
     private:
         void setupUI();

+ 295 - 0
src/widgets/dialogs/settings/imagehostpage.cpp

@@ -0,0 +1,295 @@
+#include "imagehostpage.h"
+
+#include <QFormLayout>
+#include <QVBoxLayout>
+#include <QHBoxLayout>
+#include <QGroupBox>
+#include <QLineEdit>
+#include <QPushButton>
+#include <QLabel>
+#include <QComboBox>
+#include <QCheckBox>
+
+#include <widgets/widgetsfactory.h>
+#include <core/editorconfig.h>
+#include <core/configmgr.h>
+#include <utils/widgetutils.h>
+#include <imagehost/imagehostmgr.h>
+#include <widgets/messageboxhelper.h>
+
+#include "editorpage.h"
+#include "newimagehostdialog.h"
+
+using namespace vnotex;
+
+ImageHostPage::ImageHostPage(QWidget *p_parent)
+    : SettingsPage(p_parent)
+{
+    setupUI();
+}
+
+void ImageHostPage::setupUI()
+{
+    m_mainLayout = new QVBoxLayout(this);
+
+    // New Image Host.
+    {
+        auto layout = new QHBoxLayout();
+        m_mainLayout->addLayout(layout);
+
+        auto newBtn = new QPushButton(tr("New Image Host"), this);
+        connect(newBtn, &QPushButton::clicked,
+                this, &ImageHostPage::newImageHost);
+        layout->addWidget(newBtn);
+        layout->addStretch();
+    }
+
+    auto box = setupGeneralBox(this);
+    m_mainLayout->addWidget(box);
+}
+
+QGroupBox *ImageHostPage::setupGeneralBox(QWidget *p_parent)
+{
+    auto box = new QGroupBox(tr("General"), p_parent);
+    auto layout = WidgetsFactory::createFormLayout(box);
+
+    {
+        m_defaultImageHostComboBox = WidgetsFactory::createComboBox(box);
+
+        // Add items in loadInternal().
+
+        const QString label(tr("Default image host:"));
+        layout->addRow(label, m_defaultImageHostComboBox);
+        addSearchItem(label, m_defaultImageHostComboBox);
+        connect(m_defaultImageHostComboBox, QOverload<int>::of(&QComboBox::currentIndexChanged),
+                this, &ImageHostPage::pageIsChanged);
+    }
+
+    {
+        const QString label(tr("Clear obsolete image"));
+        m_clearObsoleteImageCheckBox = WidgetsFactory::createCheckBox(label, box);
+        m_clearObsoleteImageCheckBox->setToolTip(tr("Clear unused images at image host (based on current file only)"));
+        layout->addRow(m_clearObsoleteImageCheckBox);
+        addSearchItem(label, m_clearObsoleteImageCheckBox->toolTip(), m_clearObsoleteImageCheckBox);
+        connect(m_clearObsoleteImageCheckBox, &QCheckBox::stateChanged,
+                this, &ImageHostPage::pageIsChanged);
+    }
+
+    return box;
+}
+
+void ImageHostPage::addWidgetToLayout(QWidget *p_widget)
+{
+    m_mainLayout->addWidget(p_widget);
+}
+
+void ImageHostPage::loadInternal()
+{
+    const auto &hosts = ImageHostMgr::getInst().getImageHosts();
+    const auto &editorConfig = ConfigMgr::getInst().getEditorConfig();
+
+    {
+        m_defaultImageHostComboBox->clear();
+
+        m_defaultImageHostComboBox->addItem(tr("Local"));
+        for (const auto &host : hosts) {
+            m_defaultImageHostComboBox->addItem(host->getName(), host->getName());
+        }
+
+        auto defaultHost = ImageHostMgr::getInst().getDefaultImageHost();
+        if (defaultHost) {
+            int idx = m_defaultImageHostComboBox->findData(defaultHost->getName());
+            Q_ASSERT(idx > 0);
+            m_defaultImageHostComboBox->setCurrentIndex(idx);
+        } else {
+            m_defaultImageHostComboBox->setCurrentIndex(0);
+        }
+    }
+
+    m_clearObsoleteImageCheckBox->setChecked(editorConfig.isClearObsoleteImageAtImageHostEnabled());
+
+    // Clear all the boxes before.
+    {
+        auto boxes = findChildren<QGroupBox *>(QString(), Qt::FindDirectChildrenOnly);
+        for (auto box : boxes) {
+            if (box->objectName().isEmpty()) {
+                continue;
+            }
+
+            m_mainLayout->removeWidget(box);
+            box->deleteLater();
+        }
+    }
+
+    // Setup boxes.
+    for (const auto &host : hosts) {
+        auto box = setupGroupBoxForImageHost(host, this);
+        addWidgetToLayout(box);
+    }
+}
+
+bool ImageHostPage::saveInternal()
+{
+    auto &hostMgr = ImageHostMgr::getInst();
+    auto &editorConfig = ConfigMgr::getInst().getEditorConfig();
+
+    Q_ASSERT(m_hostToFields.size() == hostMgr.getImageHosts().size());
+
+    bool hasError = false;
+
+    hostMgr.setDefaultImageHost(m_defaultImageHostComboBox->currentData().toString());
+
+    editorConfig.setClearObsoleteImageAtImageHostEnabled(m_clearObsoleteImageCheckBox->isChecked());
+
+    for (auto it = m_hostToFields.constBegin(); it != m_hostToFields.constEnd(); ++it) {
+        auto host = it.key();
+        const auto &fields = it.value();
+        Q_ASSERT(!fields.isEmpty());
+
+        // Name.
+        {
+            auto box = dynamic_cast<QGroupBox *>(fields[0]->parent());
+            Q_ASSERT(box);
+            auto nameLineEdit = box->findChild<QLineEdit *>(QStringLiteral("_name"), Qt::FindDirectChildrenOnly);
+            Q_ASSERT(nameLineEdit);
+            const auto &newName = nameLineEdit->text();
+            if (newName != host->getName()) {
+                if (!hostMgr.renameImageHost(host, newName)) {
+                    setError(tr("Failed to rename image host (%1) to (%2).").arg(host->getName(), newName));
+                    hasError = true;
+                    break;
+                }
+
+                box->setObjectName(newName);
+            }
+        }
+
+        // Configs.
+        const auto configObj = fieldsToConfig(fields);
+        host->setConfig(configObj);
+    }
+
+    hostMgr.saveImageHosts();
+
+    // No need to notify editor since ImageHostMgr will signal out.
+    // EditorPage::notifyEditorConfigChange();
+    return !hasError;
+}
+
+QString ImageHostPage::title() const
+{
+    return tr("Image Host");
+}
+
+void ImageHostPage::newImageHost()
+{
+    NewImageHostDialog dialog(this);
+    if (dialog.exec()) {
+        auto box = setupGroupBoxForImageHost(dialog.getNewImageHost(), this);
+        addWidgetToLayout(box);
+    }
+}
+
+QGroupBox *ImageHostPage::setupGroupBoxForImageHost(ImageHost *p_host, QWidget *p_parent)
+{
+    auto box = new QGroupBox(p_parent);
+    box->setObjectName(p_host->getName());
+    auto layout = WidgetsFactory::createFormLayout(box);
+
+    // Add Test and Delete button.
+    {
+        auto btnLayout = new QHBoxLayout();
+        btnLayout->addStretch();
+
+        layout->addRow(btnLayout);
+
+        auto testBtn = new QPushButton(tr("Test"), box);
+        btnLayout->addWidget(testBtn);
+        connect(testBtn, &QPushButton::clicked,
+                this, [this, box]() {
+                    const auto name = box->objectName();
+                    testImageHost(name);
+                });
+
+        auto deleteBtn = new QPushButton(tr("Delete"), box);
+        btnLayout->addWidget(deleteBtn);
+        connect(deleteBtn, &QPushButton::clicked,
+                this, [this, box]() {
+                    const auto name = box->objectName();
+                    removeImageHost(name);
+                });
+    }
+
+    layout->addRow(tr("Type:"), new QLabel(ImageHost::typeString(p_host->getType()), box));
+
+    auto nameLineEdit = WidgetsFactory::createLineEdit(p_host->getName(), box);
+    nameLineEdit->setObjectName(QStringLiteral("_name"));
+    layout->addRow(tr("Name:"), nameLineEdit);
+    m_hostToFields[p_host].append(nameLineEdit);
+    connect(nameLineEdit, &QLineEdit::textChanged,
+            this, &ImageHostPage::pageIsChanged);
+
+    const auto configObj = p_host->getConfig();
+    const auto keys = configObj.keys();
+    for (const auto &key : keys) {
+        Q_ASSERT(key != "_name");
+        auto configLineEdit = WidgetsFactory::createLineEdit(configObj[key].toString(), box);
+        configLineEdit->setObjectName(key);
+        layout->addRow(tr("%1:").arg(key), configLineEdit);
+        m_hostToFields[p_host].append(configLineEdit);
+        connect(configLineEdit, &QLineEdit::textChanged,
+                this, &ImageHostPage::pageIsChanged);
+    }
+
+    return box;
+}
+
+void ImageHostPage::removeImageHost(const QString &p_hostName)
+{
+    int ret = MessageBoxHelper::questionOkCancel(MessageBoxHelper::Type::Question,
+                                                 tr("Delete image host (%1)?").arg(p_hostName));
+    if (ret != QMessageBox::Ok) {
+        return;
+    }
+
+    auto &hostMgr = ImageHostMgr::getInst();
+    auto host = hostMgr.find(p_hostName);
+    Q_ASSERT(host);
+    hostMgr.removeImageHost(host);
+
+    // Remove the group box and related fields.
+    m_hostToFields.remove(host);
+
+    auto box = findChild<QGroupBox *>(p_hostName, Qt::FindDirectChildrenOnly);
+    Q_ASSERT(box);
+    m_mainLayout->removeWidget(box);
+    box->deleteLater();
+}
+
+QJsonObject ImageHostPage::fieldsToConfig(const QVector<QLineEdit *> &p_fields) const
+{
+    QJsonObject configObj;
+    for (auto field : p_fields) {
+        configObj[field->objectName()] = field->text();
+    }
+
+    return configObj;
+}
+
+void ImageHostPage::testImageHost(const QString &p_hostName)
+{
+    auto &hostMgr = ImageHostMgr::getInst();
+    auto host = hostMgr.find(p_hostName);
+    Q_ASSERT(host);
+
+    auto it = m_hostToFields.find(host);
+    Q_ASSERT(it != m_hostToFields.end());
+
+    const auto configObj = fieldsToConfig(it.value());
+    QString msg;
+    bool ret = host->testConfig(configObj, msg);
+    MessageBoxHelper::notify(ret ? MessageBoxHelper::Information : MessageBoxHelper::Warning,
+                             tr("Test %1.").arg(ret ? tr("succeeded") : tr("failed")),
+                             QString(),
+                             msg);
+}

+ 60 - 0
src/widgets/dialogs/settings/imagehostpage.h

@@ -0,0 +1,60 @@
+#ifndef IMAGEHOSTPAGE_H
+#define IMAGEHOSTPAGE_H
+
+#include "settingspage.h"
+
+#include <QVector>
+#include <QMap>
+
+class QGroupBox;
+class QLineEdit;
+class QVBoxLayout;
+class QComboBox;
+class QCheckBox;
+
+namespace vnotex
+{
+    class ImageHost;
+
+    class ImageHostPage : public SettingsPage
+    {
+        Q_OBJECT
+    public:
+        explicit ImageHostPage(QWidget *p_parent = nullptr);
+
+        QString title() const Q_DECL_OVERRIDE;
+
+    protected:
+        void loadInternal() Q_DECL_OVERRIDE;
+
+        bool saveInternal() Q_DECL_OVERRIDE;
+
+    private:
+        void setupUI();
+
+        void newImageHost();
+
+        QGroupBox *setupGroupBoxForImageHost(ImageHost *p_host, QWidget *p_parent);
+
+        void removeImageHost(const QString &p_hostName);
+
+        void addWidgetToLayout(QWidget *p_widget);
+
+        QJsonObject fieldsToConfig(const QVector<QLineEdit *> &p_fields) const;
+
+        void testImageHost(const QString &p_hostName);
+
+        QGroupBox *setupGeneralBox(QWidget *p_parent);
+
+        QVBoxLayout *m_mainLayout = nullptr;
+
+        // [host] -> list of related fields.
+        QMap<ImageHost *, QVector<QLineEdit *>> m_hostToFields;
+
+        QComboBox *m_defaultImageHostComboBox = nullptr;
+
+        QCheckBox *m_clearObsoleteImageCheckBox = nullptr;
+    };
+}
+
+#endif // IMAGEHOSTPAGE_H

+ 3 - 1
src/widgets/dialogs/settings/markdowneditorpage.cpp

@@ -117,7 +117,7 @@ void MarkdownEditorPage::loadInternal()
     }
 }
 
-void MarkdownEditorPage::saveInternal()
+bool MarkdownEditorPage::saveInternal()
 {
     auto &markdownConfig = ConfigMgr::getInst().getEditorConfig().getMarkdownEditorConfig();
 
@@ -186,6 +186,8 @@ void MarkdownEditorPage::saveInternal()
     }
 
     EditorPage::notifyEditorConfigChange();
+
+    return true;
 }
 
 QString MarkdownEditorPage::title() const

+ 1 - 1
src/widgets/dialogs/settings/markdowneditorpage.h

@@ -25,7 +25,7 @@ namespace vnotex
     protected:
         void loadInternal() Q_DECL_OVERRIDE;
 
-        void saveInternal() Q_DECL_OVERRIDE;
+        bool saveInternal() Q_DECL_OVERRIDE;
 
     private:
         void setupUI();

+ 2 - 2
src/widgets/dialogs/settings/miscpage.cpp

@@ -25,9 +25,9 @@ void MiscPage::loadInternal()
 
 }
 
-void MiscPage::saveInternal()
+bool MiscPage::saveInternal()
 {
-
+    return true;
 }
 
 QString MiscPage::title() const

+ 1 - 1
src/widgets/dialogs/settings/miscpage.h

@@ -16,7 +16,7 @@ namespace vnotex
     protected:
         void loadInternal() Q_DECL_OVERRIDE;
 
-        void saveInternal() Q_DECL_OVERRIDE;
+        bool saveInternal() Q_DECL_OVERRIDE;
 
     private:
         void setupUI();

+ 88 - 0
src/widgets/dialogs/settings/newimagehostdialog.cpp

@@ -0,0 +1,88 @@
+#include "newimagehostdialog.h"
+
+#include <QFormLayout>
+#include <QComboBox>
+#include <QLineEdit>
+#include <QLabel>
+
+#include <widgets/widgetsfactory.h>
+#include <imagehost/imagehostmgr.h>
+
+using namespace vnotex;
+
+NewImageHostDialog::NewImageHostDialog(QWidget *p_parent)
+    : ScrollDialog(p_parent)
+{
+    setupUI();
+}
+
+void NewImageHostDialog::setupUI()
+{
+    auto widget = new QWidget(this);
+    setCentralWidget(widget);
+
+    auto mainLayout = WidgetsFactory::createFormLayout(widget);
+
+    {
+        m_typeComboBox = WidgetsFactory::createComboBox(widget);
+        mainLayout->addRow(tr("Type:"), m_typeComboBox);
+
+        for (int type = static_cast<int>(ImageHost::GitHub); type < static_cast<int>(ImageHost::MaxHost); ++type) {
+            m_typeComboBox->addItem(ImageHost::typeString(static_cast<ImageHost::Type>(type)), type);
+        }
+    }
+
+    m_nameLineEdit = WidgetsFactory::createLineEdit(widget);
+    mainLayout->addRow(tr("Name:"), m_nameLineEdit);
+
+    setDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
+
+    setWindowTitle(tr("New Image Host"));
+}
+
+void NewImageHostDialog::acceptedButtonClicked()
+{
+    if (validateInputs() && newImageHost()) {
+        accept();
+    }
+}
+
+bool NewImageHostDialog::validateInputs()
+{
+    bool valid = true;
+    QString msg;
+
+    auto name = m_nameLineEdit->text();
+    if (name.isEmpty()) {
+        msg = tr("Please specify a valid name for the image host.");
+        valid = false;
+    } else if (ImageHostMgr::getInst().find(name)) {
+        msg = tr("Name conflicts with existing image host.");
+        valid = false;
+    }
+
+    if (!valid) {
+        setInformationText(msg, ScrollDialog::InformationLevel::Error);
+        return false;
+    }
+
+    return true;
+}
+
+bool NewImageHostDialog::newImageHost()
+{
+    m_imageHost = ImageHostMgr::getInst().newImageHost(static_cast<ImageHost::Type>(m_typeComboBox->currentData().toInt()),
+                                                       m_nameLineEdit->text());
+    if (!m_imageHost) {
+        setInformationText(tr("Failed to create image host (%1).").arg(m_nameLineEdit->text()),
+                           ScrollDialog::InformationLevel::Error);
+        return false;
+    }
+
+    return true;
+}
+
+ImageHost *NewImageHostDialog::getNewImageHost() const
+{
+    return m_imageHost;
+}

+ 39 - 0
src/widgets/dialogs/settings/newimagehostdialog.h

@@ -0,0 +1,39 @@
+#ifndef NEWIMAGEHOSTDIALOG_H
+#define NEWIMAGEHOSTDIALOG_H
+
+#include "../scrolldialog.h"
+
+class QComboBox;
+class QLineEdit;
+
+namespace vnotex
+{
+    class ImageHost;
+
+    class NewImageHostDialog : public ScrollDialog
+    {
+        Q_OBJECT
+    public:
+        explicit NewImageHostDialog(QWidget *p_parent = nullptr);
+
+        ImageHost *getNewImageHost() const;
+
+    protected:
+        void acceptedButtonClicked() Q_DECL_OVERRIDE;
+
+    private:
+        void setupUI();
+
+        bool validateInputs();
+
+        bool newImageHost();
+
+        QComboBox *m_typeComboBox = nullptr;
+
+        QLineEdit *m_nameLineEdit = nullptr;
+
+        ImageHost *m_imageHost = nullptr;
+    };
+}
+
+#endif // NEWIMAGEHOSTDIALOG_H

+ 3 - 1
src/widgets/dialogs/settings/quickaccesspage.cpp

@@ -47,7 +47,7 @@ void QuickAccessPage::loadInternal()
     }
 }
 
-void QuickAccessPage::saveInternal()
+bool QuickAccessPage::saveInternal()
 {
     auto &sessionConfig = ConfigMgr::getInst().getSessionConfig();
 
@@ -59,6 +59,8 @@ void QuickAccessPage::saveInternal()
             sessionConfig.setQuickAccessFiles(text.split(QChar('\n')));
         }
     }
+
+    return true;
 }
 
 QString QuickAccessPage::title() const

+ 1 - 1
src/widgets/dialogs/settings/quickaccesspage.h

@@ -21,7 +21,7 @@ namespace vnotex
     protected:
         void loadInternal() Q_DECL_OVERRIDE;
 
-        void saveInternal() Q_DECL_OVERRIDE;
+        bool saveInternal() Q_DECL_OVERRIDE;
 
     private:
         void setupUI();

+ 55 - 12
src/widgets/dialogs/settings/settingsdialog.cpp

@@ -17,6 +17,7 @@
 #include "appearancepage.h"
 #include "quickaccesspage.h"
 #include "themepage.h"
+#include "imagehostpage.h"
 
 using namespace vnotex;
 
@@ -38,12 +39,12 @@ void SettingsDialog::setupUI()
     setupPageExplorer(mainLayout, widget);
 
     {
-        auto scrollArea = new QScrollArea(widget);
-        scrollArea->setWidgetResizable(true);
-        mainLayout->addWidget(scrollArea, 5);
+        m_scrollArea = new QScrollArea(widget);
+        m_scrollArea->setWidgetResizable(true);
+        mainLayout->addWidget(m_scrollArea, 6);
 
-        auto scrollWidget = new QWidget(scrollArea);
-        scrollArea->setWidget(scrollWidget);
+        auto scrollWidget = new QWidget(m_scrollArea);
+        m_scrollArea->setWidget(scrollWidget);
 
         m_pageLayout = new QStackedLayout(scrollWidget);
     }
@@ -111,6 +112,12 @@ void SettingsDialog::setupPages()
         auto page = new EditorPage(this);
         auto item = addPage(page);
 
+        // Image Host.
+        {
+            auto subPage = new ImageHostPage(this);
+            addSubPage(subPage, item);
+        }
+
         // Text Editor.
         {
             auto subPage = new TextEditorPage(this);
@@ -171,7 +178,10 @@ void SettingsDialog::setChangesUnsaved(bool p_unsaved)
 void SettingsDialog::acceptedButtonClicked()
 {
     if (m_changesUnsaved) {
-        savePages();
+        if (savePages()) {
+            accept();
+        }
+        return;
     }
 
     accept();
@@ -179,9 +189,12 @@ void SettingsDialog::acceptedButtonClicked()
 
 void SettingsDialog::resetButtonClicked()
 {
+    clearInformationText();
+
     m_ready = false;
     forEachPage([](SettingsPage *p_page) {
         p_page->reset();
+        return true;
     });
     m_ready = true;
 
@@ -194,20 +207,39 @@ void SettingsDialog::appliedButtonClicked()
     savePages();
 }
 
-void SettingsDialog::savePages()
+bool SettingsDialog::savePages()
 {
-    forEachPage([](SettingsPage *p_page) {
-        p_page->save();
+    clearInformationText();
+
+    bool allSaved = true;
+    forEachPage([this, &allSaved](SettingsPage *p_page) {
+        if (!p_page->save()) {
+            allSaved = false;
+            m_pageLayout->setCurrentWidget(p_page);
+            if (!p_page->error().isEmpty()) {
+                setInformationText(p_page->error(), InformationLevel::Error);
+            }
+            return false;
+        }
+
+        return true;
     });
 
-    setChangesUnsaved(false);
+    if (allSaved) {
+        setChangesUnsaved(false);
+        return true;
+    }
+
+    return false;
 }
 
-void SettingsDialog::forEachPage(const std::function<void(SettingsPage *)> &p_func)
+void SettingsDialog::forEachPage(const std::function<bool(SettingsPage *)> &p_func)
 {
     for (int i = 0; i < m_pageLayout->count(); ++i) {
         auto page = dynamic_cast<SettingsPage *>(m_pageLayout->widget(i));
-        p_func(page);
+        if (!p_func(page)) {
+            break;
+        }
     }
 }
 
@@ -228,3 +260,14 @@ QTreeWidgetItem *SettingsDialog::addSubPage(SettingsPage *p_page, QTreeWidgetIte
     setupPage(subItem, p_page);
     return subItem;
 }
+
+void SettingsDialog::showEvent(QShowEvent *p_event)
+{
+    Dialog::showEvent(p_event);
+
+    if (m_firstShown) {
+        m_firstShown = false;
+        const auto sz = size();
+        resize(sz * 1.2);
+    }
+}

+ 10 - 2
src/widgets/dialogs/settings/settingsdialog.h

@@ -9,6 +9,7 @@ class QTreeWidget;
 class QStackedLayout;
 class QLineEdit;
 class QTreeWidgetItem;
+class QScrollArea;
 
 namespace vnotex
 {
@@ -27,6 +28,8 @@ namespace vnotex
 
         void appliedButtonClicked() Q_DECL_OVERRIDE;
 
+        void showEvent(QShowEvent *p_event) Q_DECL_OVERRIDE;
+
     private:
         void setupUI();
 
@@ -40,9 +43,10 @@ namespace vnotex
 
         void setChangesUnsaved(bool p_unsaved);
 
-        void savePages();
+        bool savePages();
 
-        void forEachPage(const std::function<void(SettingsPage *)> &p_func);
+        // @p_func: return true to continue the iteration.
+        void forEachPage(const std::function<bool(SettingsPage *)> &p_func);
 
         QTreeWidgetItem *addPage(SettingsPage *p_page);
 
@@ -52,11 +56,15 @@ namespace vnotex
 
         QTreeWidget *m_pageExplorer = nullptr;
 
+        QScrollArea *m_scrollArea = nullptr;
+
         QStackedLayout *m_pageLayout = nullptr;
 
         bool m_changesUnsaved = false;
 
         bool m_ready = false;
+
+        bool m_firstShown = true;
     };
 }
 

+ 18 - 4
src/widgets/dialogs/settings/settingspage.cpp

@@ -53,14 +53,18 @@ void SettingsPage::load()
     m_changed = false;
 }
 
-void SettingsPage::save()
+bool SettingsPage::save()
 {
     if (!m_changed) {
-        return;
+        return true;
     }
 
-    saveInternal();
-    m_changed = false;
+    if (saveInternal()) {
+        m_changed = false;
+        return true;
+    }
+
+    return false;
 }
 
 void SettingsPage::reset()
@@ -71,3 +75,13 @@ void SettingsPage::reset()
 
     load();
 }
+
+const QString &SettingsPage::error() const
+{
+    return m_error;
+}
+
+void SettingsPage::setError(const QString &p_err)
+{
+    m_error = p_err;
+}

+ 8 - 2
src/widgets/dialogs/settings/settingspage.h

@@ -15,7 +15,7 @@ namespace vnotex
 
         void load();
 
-        void save();
+        bool save();
 
         void reset();
 
@@ -23,13 +23,15 @@ namespace vnotex
 
         bool search(const QString &p_key);
 
+        const QString &error() const;
+
     signals:
         void changed();
 
     protected:
         virtual void loadInternal() = 0;
 
-        virtual void saveInternal() = 0;
+        virtual bool saveInternal() = 0;
 
         // Subclass could override this method to highlight matched target.
         virtual void searchHit(QWidget *p_target);
@@ -38,6 +40,8 @@ namespace vnotex
 
         void addSearchItem(const QString &p_name, const QString &p_tooltip, QWidget *p_target);
 
+        void setError(const QString &p_err);
+
     protected slots:
         void pageIsChanged();
 
@@ -59,6 +63,8 @@ namespace vnotex
         QVector<SearchItem> m_searchItems;
 
         bool m_changed = false;
+
+        QString m_error;
     };
 }
 

+ 3 - 1
src/widgets/dialogs/settings/texteditorpage.cpp

@@ -183,7 +183,7 @@ void TextEditorPage::loadInternal()
     m_spellCheckCheckBox->setChecked(textConfig.isSpellCheckEnabled());
 }
 
-void TextEditorPage::saveInternal()
+bool TextEditorPage::saveInternal()
 {
     auto &textConfig = ConfigMgr::getInst().getEditorConfig().getTextEditorConfig();
 
@@ -218,6 +218,8 @@ void TextEditorPage::saveInternal()
     textConfig.setSpellCheckEnabled(m_spellCheckCheckBox->isChecked());
 
     EditorPage::notifyEditorConfigChange();
+
+    return true;
 }
 
 QString TextEditorPage::title() const

+ 1 - 1
src/widgets/dialogs/settings/texteditorpage.h

@@ -20,7 +20,7 @@ namespace vnotex
     protected:
         void loadInternal() Q_DECL_OVERRIDE;
 
-        void saveInternal() Q_DECL_OVERRIDE;
+        bool saveInternal() Q_DECL_OVERRIDE;
 
     private:
         void setupUI();

+ 3 - 1
src/widgets/dialogs/settings/themepage.cpp

@@ -98,12 +98,14 @@ void ThemePage::loadInternal()
     loadThemes();
 }
 
-void ThemePage::saveInternal()
+bool ThemePage::saveInternal()
 {
     auto theme = currentTheme();
     if (!theme.isEmpty()) {
         ConfigMgr::getInst().getCoreConfig().setTheme(theme);
     }
+
+    return true;
 }
 
 QString ThemePage::title() const

+ 1 - 1
src/widgets/dialogs/settings/themepage.h

@@ -19,7 +19,7 @@ namespace vnotex
     protected:
         void loadInternal() Q_DECL_OVERRIDE;
 
-        void saveInternal() Q_DECL_OVERRIDE;
+        bool saveInternal() Q_DECL_OVERRIDE;
 
     private:
         void setupUI();

+ 1 - 0
src/widgets/dialogs/snippetinfowidget.cpp

@@ -76,6 +76,7 @@ void SnippetInfoWidget::setupUI()
     mainLayout->addRow(m_indentAsFirstLineCheckBox);
 
     m_contentTextEdit = WidgetsFactory::createPlainTextEdit(this);
+    m_contentTextEdit->setPlaceholderText(tr("Nested snippet is supported, like `%time%` to embed the snippet `time`"));
     connect(m_contentTextEdit, &QPlainTextEdit::textChanged,
             this, &SnippetInfoWidget::inputEdited);
     mainLayout->addRow(tr("Content:"), m_contentTextEdit);

+ 127 - 29
src/widgets/editors/markdowneditor.cpp

@@ -14,6 +14,7 @@
 #include <QProgressDialog>
 #include <QTemporaryFile>
 #include <QTimer>
+#include <QBuffer>
 
 #include <vtextedit/markdowneditorconfig.h>
 #include <vtextedit/previewmgr.h>
@@ -44,6 +45,9 @@
 #include <core/configmgr.h>
 #include <core/editorconfig.h>
 #include <core/vnotex.h>
+#include <imagehost/imagehostutils.h>
+#include <imagehost/imagehost.h>
+#include <imagehost/imagehostmgr.h>
 
 #include "previewhelper.h"
 #include "../outlineprovider.h"
@@ -358,22 +362,34 @@ bool MarkdownEditor::insertImageToBufferFromLocalFile(const QString &p_title,
     auto destFileName = generateImageFileNameToInsertAs(p_title, QFileInfo(p_srcImagePath).suffix());
 
     QString destFilePath;
-    try {
-        destFilePath = m_buffer->insertImage(p_srcImagePath, destFileName);
-    } catch (Exception e) {
-        MessageBoxHelper::notify(MessageBoxHelper::Warning,
-                                 QString("Failed to insert image from local file %1 (%2)").arg(p_srcImagePath, e.what()),
-                                 this);
-        return false;
+
+    if (m_imageHost) {
+        // Save to image host.
+        QByteArray ba;
+        try {
+            ba = FileUtils::readFile(p_srcImagePath);
+        } catch (Exception &e) {
+            MessageBoxHelper::notify(MessageBoxHelper::Warning,
+                                     QString("Failed to read local image file (%1) (%2).").arg(p_srcImagePath, e.what()),
+                                     this);
+            return false;
+        }
+        destFilePath = saveToImageHost(ba, destFileName);
+        if (destFilePath.isEmpty()) {
+            return false;
+        }
+    } else {
+        try {
+            destFilePath = m_buffer->insertImage(p_srcImagePath, destFileName);
+        } catch (Exception &e) {
+            MessageBoxHelper::notify(MessageBoxHelper::Warning,
+                                     QString("Failed to insert image from local file (%1) (%2).").arg(p_srcImagePath, e.what()),
+                                     this);
+            return false;
+        }
     }
 
-    insertImageLink(p_title,
-                    p_altText,
-                    destFilePath,
-                    p_scaledWidth,
-                    p_scaledHeight,
-                    p_insertText,
-                    p_urlInLink);
+    insertImageLink(p_title, p_altText, destFilePath, p_scaledWidth, p_scaledHeight, p_insertText, p_urlInLink);
     return true;
 }
 
@@ -389,16 +405,31 @@ bool MarkdownEditor::insertImageToBufferFromData(const QString &p_title,
                                                  int p_scaledHeight)
 {
     // Save as PNG by default.
-    auto destFileName = generateImageFileNameToInsertAs(p_title, QStringLiteral("png"));
+    const QString format("png");
+    const auto destFileName = generateImageFileNameToInsertAs(p_title, format);
 
     QString destFilePath;
-    try {
-        destFilePath = m_buffer->insertImage(p_image, destFileName);
-    } catch (Exception e) {
-        MessageBoxHelper::notify(MessageBoxHelper::Warning,
-                                 QString("Failed to insert image from data (%1)").arg(e.what()),
-                                 this);
-        return false;
+
+    if (m_imageHost) {
+        // Save to image host.
+        QByteArray ba;
+        QBuffer buffer(&ba);
+        buffer.open(QIODevice::WriteOnly);
+        p_image.save(&buffer, format.toStdString().c_str());
+
+        destFilePath = saveToImageHost(ba, destFileName);
+        if (destFilePath.isEmpty()) {
+            return false;
+        }
+    } else {
+        try {
+            destFilePath = m_buffer->insertImage(p_image, destFileName);
+        } catch (Exception &e) {
+            MessageBoxHelper::notify(MessageBoxHelper::Warning,
+                                     QString("Failed to insert image from data (%1).").arg(e.what()),
+                                     this);
+            return false;
+        }
     }
 
     insertImageLink(p_title, p_altText, destFilePath, p_scaledWidth, p_scaledHeight);
@@ -764,13 +795,17 @@ void MarkdownEditor::insertImageFromUrl(const QString &p_url)
 
 QString MarkdownEditor::getRelativeLink(const QString &p_path)
 {
-    auto relativePath = PathUtils::relativePath(PathUtils::parentDirPath(m_buffer->getContentPath()), p_path);
-    auto link = PathUtils::encodeSpacesInPath(QDir::fromNativeSeparators(relativePath));
-    if (m_config.getPrependDotInRelativeLink()) {
-        PathUtils::prependDotIfRelative(link);
-    }
+    if (PathUtils::isLocalFile(p_path)) {
+        auto relativePath = PathUtils::relativePath(PathUtils::parentDirPath(m_buffer->getContentPath()), p_path);
+        auto link = PathUtils::encodeSpacesInPath(QDir::fromNativeSeparators(relativePath));
+        if (m_config.getPrependDotInRelativeLink()) {
+            PathUtils::prependDotIfRelative(link);
+        }
 
-    return link;
+        return link;
+    } else {
+        return p_path;
+    }
 }
 
 const QVector<MarkdownEditor::Heading> &MarkdownEditor::getHeadings() const
@@ -957,6 +992,8 @@ void MarkdownEditor::handleContextMenuEvent(QContextMenuEvent *p_event, bool *p_
         }
     }
 
+    appendImageHostMenu(menu);
+
     appendSpellCheckMenu(p_event, menu);
 }
 
@@ -1100,7 +1137,7 @@ void MarkdownEditor::fetchImagesToLocalAndReplace(QString &p_text)
             if (imageUrl.startsWith(QStringLiteral("//"))) {
                 imageUrl.prepend(QStringLiteral("https:"));
             }
-            QByteArray data = vte::Downloader::download(QUrl(imageUrl));
+            QByteArray data = vte::NetworkAccess::request(QUrl(imageUrl)).m_data;
             if (!data.isEmpty()) {
                 // Prefer the suffix from the real data.
                 auto suffix = ImageUtils::guessImageSuffix(data);
@@ -1293,3 +1330,64 @@ QRgb MarkdownEditor::getPreviewBackground() const
     const auto &fmt = th->editorStyle(vte::Theme::EditorStyle::Preview);
     return fmt.m_backgroundColor;
 }
+
+void MarkdownEditor::setImageHost(ImageHost *p_host)
+{
+    // It may be different than the global default image host.
+    m_imageHost = p_host;
+}
+
+QString MarkdownEditor::saveToImageHost(const QByteArray &p_imageData, const QString &p_destFileName)
+{
+    Q_ASSERT(m_imageHost);
+
+    auto destPath = ImageHostUtils::generateRelativePath(m_buffer);
+    if (destPath.isEmpty()) {
+        destPath = p_destFileName;
+    } else {
+        destPath += "/" + p_destFileName;
+    }
+
+    QString errMsg;
+
+    QApplication::setOverrideCursor(QCursor(Qt::WaitCursor));
+    auto targetUrl = m_imageHost->create(p_imageData, destPath, errMsg);
+    QApplication::restoreOverrideCursor();
+
+    if (targetUrl.isEmpty()) {
+        MessageBoxHelper::notify(MessageBoxHelper::Warning,
+                                 QString("Failed to upload image to image host (%1) as (%2).").arg(m_imageHost->getName(), destPath),
+                                 QString(),
+                                 errMsg,
+                                 this);
+    }
+
+    return targetUrl;
+}
+
+void MarkdownEditor::appendImageHostMenu(QMenu *p_menu)
+{
+    p_menu->addSeparator();
+    auto subMenu = p_menu->addMenu(tr("Upload Images To Image Host"));
+
+    const auto &hosts = ImageHostMgr::getInst().getImageHosts();
+    if (hosts.isEmpty()) {
+        auto act = subMenu->addAction(tr("None"));
+        act->setEnabled(false);
+        return;
+    }
+
+    for (const auto &host : hosts) {
+        auto act = subMenu->addAction(host->getName(),
+                                      this,
+                                      &MarkdownEditor::uploadImagesToImageHost);
+        act->setData(host->getName());
+    }
+}
+
+void MarkdownEditor::uploadImagesToImageHost()
+{
+    auto act = static_cast<QAction *>(sender());
+    auto host = ImageHostMgr::getInst().find(act->data().toString());
+    Q_ASSERT(host);
+}

+ 12 - 0
src/widgets/editors/markdowneditor.h

@@ -23,6 +23,7 @@ namespace vnotex
     class Buffer;
     class MarkdownEditorConfig;
     class MarkdownTableHelper;
+    class ImageHost;
 
     class MarkdownEditor : public vte::VMarkdownEditor
     {
@@ -102,6 +103,8 @@ namespace vnotex
 
         QRgb getPreviewBackground() const;
 
+        void setImageHost(ImageHost *p_host);
+
     public slots:
         void handleHtmlToMarkdownData(quint64 p_id, TimeStamp p_timeStamp, const QString &p_text);
 
@@ -181,6 +184,13 @@ namespace vnotex
 
         void setupTableHelper();
 
+        // Return the dest file path of the image on success.
+        QString saveToImageHost(const QByteArray &p_imageData, const QString &p_destFileName);
+
+        void appendImageHostMenu(QMenu *p_menu);
+
+        void uploadImagesToImageHost();
+
         static QString generateImageFileNameToInsertAs(const QString &p_title, const QString &p_suffix);
 
         const MarkdownEditorConfig &m_config;
@@ -203,6 +213,8 @@ namespace vnotex
 
         // Managed by QObject.
         MarkdownTableHelper *m_tableHelper = nullptr;
+
+        ImageHost *m_imageHost = nullptr;
     };
 }
 

+ 2 - 0
src/widgets/editors/plantumlhelper.cpp

@@ -57,7 +57,9 @@ void PlantUmlHelper::prepareProgramAndArgs(const QString &p_plantUmlJarFile,
     p_args << "java";
 #endif
 
+#if defined(Q_OS_MACOS)
     p_args << "-Djava.awt.headless=true";
+#endif
 
     p_args << "-jar" << QDir::toNativeSeparators(p_plantUmlJarFile);
 

+ 34 - 20
src/widgets/filesystemviewer.cpp

@@ -42,23 +42,7 @@ void FileSystemViewer::setupUI()
     }
 
     connect(m_viewer, &QTreeView::customContextMenuRequested,
-            this, [this](const QPoint &p_pos) {
-                // @p_pos is the position in the coordinate of parent widget if parent is a popup.
-                auto pos = p_pos;
-                if (m_fixContextMenuPos) {
-                    pos = mapFromParent(p_pos);
-                    pos = m_viewer->mapFromParent(pos);
-                }
-                auto index = m_viewer->indexAt(pos);
-                QScopedPointer<QMenu> menu(WidgetsFactory::createMenu());
-                if (index.isValid()) {
-                    createContextMenuOnItem(menu.data());
-                }
-
-                if (!menu->isEmpty()) {
-                    menu->exec(m_viewer->mapToGlobal(pos));
-                }
-            });
+            this, &FileSystemViewer::handleContextMenuRequested);
     connect(m_viewer, &QTreeView::activated,
             this, [this](const QModelIndex &p_index) {
                 if (!this->fileModel()->isDir(p_index)) {
@@ -142,8 +126,7 @@ void FileSystemViewer::createContextMenuOnItem(QMenu *p_menu)
     act = createAction(Action::Delete, p_menu);
     p_menu->addAction(act);
 
-    const auto modelIndexList = m_viewer->selectionModel()->selectedRows();
-    if (modelIndexList.size() == 1) {
+    if (selectedCount() == 1) {
         act = createAction(Action::CopyPath, p_menu);
         p_menu->addAction(act);
 
@@ -224,7 +207,38 @@ void FileSystemViewer::scrollToAndSelect(const QStringList &p_paths)
                 m_viewer->scrollTo(index);
                 isFirst = false;
             }
-            selectionModel->select(index, QItemSelectionModel::SelectCurrent);
+            selectionModel->select(index, QItemSelectionModel::SelectCurrent | QItemSelectionModel::Rows);
         }
     }
 }
+
+void FileSystemViewer::handleContextMenuRequested(const QPoint &p_pos)
+{
+    // @p_pos is the position in the coordinate of parent widget if parent is a popup.
+    auto pos = p_pos;
+    if (m_fixContextMenuPos) {
+        pos = mapFromParent(p_pos);
+        pos = m_viewer->mapFromParent(pos);
+    }
+
+    QScopedPointer<QMenu> menu(WidgetsFactory::createMenu());
+
+    auto index = m_viewer->indexAt(pos);
+    if (index.isValid()) {
+        auto selectionModel = m_viewer->selectionModel();
+        if (!selectionModel->isSelected(index)) {
+            // Must select entire row since we use selectedRows() to count.
+            selectionModel->setCurrentIndex(index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
+        }
+
+        m_viewer->update();
+
+        createContextMenuOnItem(menu.data());
+    }
+
+    m_viewer->update();
+
+    if (!menu->isEmpty()) {
+        menu->exec(m_viewer->mapToGlobal(pos));
+    }
+}

+ 2 - 0
src/widgets/filesystemviewer.h

@@ -38,6 +38,8 @@ namespace vnotex
         // Resize the first column.
         void resizeTreeToContents();
 
+        void handleContextMenuRequested(const QPoint &p_pos);
+
     private:
         enum Action {
             Open,

+ 5 - 0
src/widgets/locationlist.cpp

@@ -45,6 +45,7 @@ void LocationList::setupUI()
     // When updated, pay attention to the Columns enum.
     m_tree->setHeaderLabels(QStringList() << tr("Path") << tr("Line") << tr("Text"));
     TreeWidget::showHorizontalScrollbar(m_tree);
+    m_tree->header()->setStretchLastSection(true);
     connect(m_tree, &QTreeWidget::itemActivated,
             this, [this](QTreeWidgetItem *p_item, int p_col) {
                 Q_UNUSED(p_col);
@@ -156,6 +157,10 @@ void LocationList::addLocation(const ComplexLocation &p_location)
         item->setExpanded(true);
     }
 
+    if (m_tree->topLevelItemCount() == 1) {
+        m_tree->setCurrentItem(item);
+    }
+
     updateItemsCountLabel();
 }
 

+ 101 - 10
src/widgets/markdownviewwindow.cpp

@@ -7,6 +7,8 @@
 #include <QCoreApplication>
 #include <QScrollBar>
 #include <QLabel>
+#include <QApplication>
+#include <QProgressDialog>
 
 #include <core/fileopenparameters.h>
 #include <core/editorconfig.h>
@@ -19,6 +21,8 @@
 #include <buffer/markdownbuffer.h>
 #include <core/vnotex.h>
 #include <core/thememgr.h>
+#include <imagehost/imagehostmgr.h>
+#include <imagehost/imagehost.h>
 #include "editors/markdowneditor.h"
 #include "textviewwindowhelper.h"
 #include "editors/markdownviewer.h"
@@ -31,6 +35,7 @@
 #include "editors/statuswidget.h"
 #include "editors/plantumlhelper.h"
 #include "editors/graphvizhelper.h"
+#include "messageboxhelper.h"
 
 using namespace vnotex;
 
@@ -277,6 +282,10 @@ void MarkdownViewWindow::setupToolBar()
                 });
     }
 
+    addAction(toolBar, ViewWindowToolBarHelper::ImageHost);
+
+    toolBar->addSeparator();
+
     addAction(toolBar, ViewWindowToolBarHelper::TypeHeading);
     addAction(toolBar, ViewWindowToolBarHelper::TypeBold);
     addAction(toolBar, ViewWindowToolBarHelper::TypeItalic);
@@ -326,6 +335,8 @@ void MarkdownViewWindow::setupTextEditor()
     m_previewHelper->setMarkdownEditor(m_editor);
     m_editor->setPreviewHelper(m_previewHelper);
 
+    m_editor->setImageHost(m_imageHost);
+
     // Connect viewer and editor.
     connect(adapter(), &MarkdownViewerAdapter::viewerReady,
             m_editor->getHighlighter(), &vte::PegMarkdownHighlighter::updateHighlight);
@@ -727,16 +738,31 @@ void MarkdownViewWindow::clearObsoleteImages()
 
     auto buffer = getBuffer();
     Q_ASSERT(buffer);
-    auto &markdownEditorConfig = ConfigMgr::getInst().getEditorConfig().getMarkdownEditorConfig();
+    auto &editorConfig = ConfigMgr::getInst().getEditorConfig();
+    auto &markdownEditorConfig = editorConfig.getMarkdownEditorConfig();
+    const bool clearRemote = editorConfig.isClearObsoleteImageAtImageHostEnabled();
+    const auto &hostMgr = ImageHostMgr::getInst();
+
+    QVector<QPair<QString, bool>> imagesToDelete;
+    imagesToDelete.reserve(obsoleteImages.size());
+
     if (markdownEditorConfig.getConfirmBeforeClearObsoleteImages()) {
         QVector<ConfirmItemInfo> items;
-        for (auto const &imgPath : obsoleteImages) {
-            items.push_back(ConfirmItemInfo(imgPath, imgPath, imgPath, nullptr));
+        for (auto it = obsoleteImages.constBegin(); it != obsoleteImages.constEnd(); ++it) {
+            if (!it.value() || (clearRemote && hostMgr.findByImageUrl(it.key()))) {
+                const auto imgPath = it.key();
+                // Use the @m_data field to denote whether it is remote.
+                items.push_back(ConfirmItemInfo(imgPath, imgPath, imgPath, it.value() ? reinterpret_cast<void *>(1ULL) : nullptr));
+            }
+        }
+
+        if (items.isEmpty()) {
+            return;
         }
 
         DeleteConfirmDialog dialog(tr("Clear Obsolete Images"),
-            tr("These images seems not in use anymore. Please confirm the deletion of them."),
-            tr("Deleted images could be found in the recycle bin of notebook if it is from a bundle notebook."),
+            tr("These images seems to be not in use anymore. Please confirm the deletion of them."),
+            tr("Deleted local images could be found in the recycle bin of notebook if it is from a bundle notebook."),
             items,
             DeleteConfirmDialog::Flag::AskAgain | DeleteConfirmDialog::Flag::Preview,
             false,
@@ -745,14 +771,49 @@ void MarkdownViewWindow::clearObsoleteImages()
             items = dialog.getConfirmedItems();
             markdownEditorConfig.setConfirmBeforeClearObsoleteImages(!dialog.isNoAskChecked());
             for (const auto &item : items) {
-                buffer->removeImage(item.m_path);
+                imagesToDelete.push_back(qMakePair(item.m_path, item.m_data != nullptr));
             }
         }
     } else {
-        for (const auto &imgPath : obsoleteImages) {
-            buffer->removeImage(imgPath);
+        for (auto it = obsoleteImages.constBegin(); it != obsoleteImages.constEnd(); ++it) {
+            if (clearRemote || !it.value()) {
+                imagesToDelete.push_back(qMakePair(it.key(), it.value()));
+            }
         }
     }
+
+    if (imagesToDelete.isEmpty()) {
+        return;
+    }
+
+    QProgressDialog proDlg(tr("Clearing obsolete images..."),
+                           tr("Abort"),
+                           0,
+                           imagesToDelete.size(),
+                           this);
+    proDlg.setWindowModality(Qt::WindowModal);
+    proDlg.setWindowTitle(tr("Clear Obsolete Images"));
+
+    int cnt = 0;
+    for (int i = 0; i < imagesToDelete.size(); ++i) {
+        proDlg.setValue(i + 1);
+        if (proDlg.wasCanceled()) {
+            break;
+        }
+
+        proDlg.setLabelText(tr("Clear image (%1)").arg(imagesToDelete[i].first));
+        if (imagesToDelete[i].second) {
+            removeFromImageHost(imagesToDelete[i].first);
+        } else {
+            buffer->removeImage(imagesToDelete[i].first);
+        }
+        ++cnt;
+    }
+
+    proDlg.setValue(imagesToDelete.size());
+
+    // It may be deleted so showMessage() is not available.
+    VNoteX::getInst().showStatusMessageShort(tr("Cleared %n obsolete images", "", cnt));
 }
 
 QSharedPointer<OutlineProvider> MarkdownViewWindow::getOutlineProvider()
@@ -897,7 +958,7 @@ void MarkdownViewWindow::handleFindNext(const QString &p_text, FindOptions p_opt
 void MarkdownViewWindow::handleReplace(const QString &p_text, FindOptions p_options, const QString &p_replaceText)
 {
     if (isReadMode()) {
-        VNoteX::getInst().showStatusMessageShort(tr("Replace is not supported in read mode"));
+        showMessage(tr("Replace is not supported in read mode"));
     } else {
         TextViewWindowHelper::handleReplace(this, p_text, p_options, p_replaceText);
     }
@@ -906,7 +967,7 @@ void MarkdownViewWindow::handleReplace(const QString &p_text, FindOptions p_opti
 void MarkdownViewWindow::handleReplaceAll(const QString &p_text, FindOptions p_options, const QString &p_replaceText)
 {
     if (isReadMode()) {
-        VNoteX::getInst().showStatusMessageShort(tr("Replace is not supported in read mode"));
+        showMessage(tr("Replace is not supported in read mode"));
     } else {
         TextViewWindowHelper::handleReplaceAll(this, p_text, p_options, p_replaceText);
     }
@@ -1035,3 +1096,33 @@ QPoint MarkdownViewWindow::getFloatingWidgetPosition()
 {
     return TextViewWindowHelper::getFloatingWidgetPosition(this);
 }
+
+void MarkdownViewWindow::handleImageHostChanged(const QString &p_hostName)
+{
+    m_imageHost = ImageHostMgr::getInst().find(p_hostName);
+
+    if (m_editor) {
+        m_editor->setImageHost(m_imageHost);
+    }
+}
+
+void MarkdownViewWindow::removeFromImageHost(const QString &p_url)
+{
+    auto host = ImageHostMgr::getInst().findByImageUrl(p_url);
+    if (!host) {
+        return;
+    }
+
+    QString errMsg;
+    QApplication::setOverrideCursor(QCursor(Qt::WaitCursor));
+    auto ret = host->remove(p_url, errMsg);
+    QApplication::restoreOverrideCursor();
+
+    if (!ret) {
+        MessageBoxHelper::notify(MessageBoxHelper::Warning,
+                                 QString("Failed to delete image (%1) from image host (%2).").arg(p_url, host->getName()),
+                                 QString(),
+                                 errMsg,
+                                 this);
+    }
+}

+ 7 - 0
src/widgets/markdownviewwindow.h

@@ -23,6 +23,7 @@ namespace vnotex
     struct Outline;
     class MarkdownEditorConfig;
     class EditorConfig;
+    class ImageHost;
 
     class MarkdownViewWindow : public ViewWindow
     {
@@ -60,6 +61,8 @@ namespace vnotex
 
         void handleSectionNumberOverride(OverrideState p_state) Q_DECL_OVERRIDE;
 
+        void handleImageHostChanged(const QString &p_hostName) Q_DECL_OVERRIDE;
+
         void handleFindTextChanged(const QString &p_text, FindOptions p_options) Q_DECL_OVERRIDE;
 
         void handleFindNext(const QString &p_text, FindOptions p_options) Q_DECL_OVERRIDE;
@@ -147,6 +150,8 @@ namespace vnotex
 
         void updatePreviewHelperFromConfig(const MarkdownEditorConfig &p_config);
 
+        void removeFromImageHost(const QString &p_url);
+
         template <class T>
         static QSharedPointer<Outline> headingsToOutline(const QVector<T> &p_headings);
 
@@ -184,6 +189,8 @@ namespace vnotex
         ViewWindowMode m_previousMode = ViewWindowMode::Invalid;
 
         QSharedPointer<OutlineProvider> m_outlineProvider;
+
+        ImageHost *m_imageHost = nullptr;
     };
 }
 

+ 63 - 0
src/widgets/viewwindow.cpp

@@ -14,6 +14,7 @@
 #include <QShortcut>
 #include <QWheelEvent>
 #include <QWidgetAction>
+#include <QActionGroup>
 
 #include <core/fileopenparameters.h>
 #include "toolbarhelper.h"
@@ -23,6 +24,7 @@
 #include <utils/widgetutils.h>
 #include <core/configmgr.h>
 #include <core/editorconfig.h>
+#include <imagehost/imagehostmgr.h>
 #include "messageboxhelper.h"
 #include "editreaddiscardaction.h"
 #include "viewsplit.h"
@@ -451,6 +453,28 @@ QAction *ViewWindow::addAction(QToolBar *p_toolBar, ViewWindowToolBarHelper::Act
         break;
     }
 
+    case ViewWindowToolBarHelper::ImageHost:
+    {
+        act = ViewWindowToolBarHelper::addAction(p_toolBar, p_action);
+        connect(this, &ViewWindow::modeChanged,
+                this, [this, act]() {
+                    act->setEnabled(inModeCanInsert() && getBuffer() && !getBuffer()->isReadOnly());
+                });
+        auto toolBtn = dynamic_cast<QToolButton *>(p_toolBar->widgetForAction(act));
+        Q_ASSERT(toolBtn);
+        m_imageHostMenu = toolBtn->menu();
+        Q_ASSERT(m_imageHostMenu);
+        updateImageHostMenu();
+        connect(m_imageHostMenu, &QMenu::triggered,
+                this, [this](QAction *p_act) {
+                    handleImageHostChanged(p_act->data().toString());
+                });
+
+        connect(&ImageHostMgr::getInst(), &ImageHostMgr::imageHostChanged,
+                this, &ViewWindow::updateImageHostMenu);
+        break;
+    }
+
     default:
         Q_ASSERT(false);
         break;
@@ -625,6 +649,12 @@ void ViewWindow::handleSectionNumberOverride(OverrideState p_state)
     Q_ASSERT(false);
 }
 
+void ViewWindow::handleImageHostChanged(const QString &p_hostName)
+{
+    Q_UNUSED(p_hostName);
+    Q_ASSERT(false);
+}
+
 ViewWindow::TypeAction ViewWindow::toolBarActionToTypeAction(ViewWindowToolBarHelper::Action p_action)
 {
     Q_ASSERT(p_action >= ViewWindowToolBarHelper::Action::TypeBold
@@ -1152,3 +1182,36 @@ QPoint ViewWindow::getFloatingWidgetPosition()
 {
     return mapToGlobal(QPoint(5, 5));
 }
+
+void ViewWindow::updateImageHostMenu()
+{
+    Q_ASSERT(m_imageHostMenu);
+    m_imageHostMenu->clear();
+
+    if (m_imageHostActionGroup) {
+        m_imageHostActionGroup->deleteLater();
+    }
+
+    m_imageHostActionGroup = new QActionGroup(m_imageHostMenu);
+
+    auto act = m_imageHostActionGroup->addAction(tr("Local"));
+    act->setCheckable(true);
+    m_imageHostMenu->addAction(act);
+    act->setChecked(true);
+
+    const auto &hosts = ImageHostMgr::getInst().getImageHosts();
+    auto curHost = ImageHostMgr::getInst().getDefaultImageHost();
+
+    for (const auto &host : hosts) {
+        auto act = m_imageHostActionGroup->addAction(host->getName());
+        act->setCheckable(true);
+        act->setData(host->getName());
+        m_imageHostMenu->addAction(act);
+
+        if (curHost == host) {
+            act->setChecked(true);
+        }
+    }
+
+    handleImageHostChanged(curHost ? curHost->getName() : nullptr);
+}

+ 10 - 0
src/widgets/viewwindow.h

@@ -14,6 +14,8 @@
 class QVBoxLayout;
 class QTimer;
 class QToolBar;
+class QMenu;
+class QActionGroup;
 
 namespace vnotex
 {
@@ -158,6 +160,8 @@ namespace vnotex
 
         virtual void handleSectionNumberOverride(OverrideState p_state);
 
+        virtual void handleImageHostChanged(const QString &p_hostName);
+
         virtual void handleFindTextChanged(const QString &p_text, FindOptions p_options);
 
         virtual void handleFindNext(const QString &p_text, FindOptions p_options);
@@ -302,6 +306,8 @@ namespace vnotex
 
         void handleBufferChanged(const QSharedPointer<FileOpenParameters> &p_paras);
 
+        void updateImageHostMenu();
+
         static ViewWindow::TypeAction toolBarActionToTypeAction(ViewWindowToolBarHelper::Action p_action);
 
         Buffer *m_buffer = nullptr;
@@ -344,6 +350,10 @@ namespace vnotex
 
         WindowFlags m_flags = WindowFlag::None;
 
+        QMenu *m_imageHostMenu = nullptr;
+
+        QActionGroup *m_imageHostActionGroup = nullptr;
+
         static QIcon s_savedIcon;
         static QIcon s_modifiedIcon;
     };

+ 15 - 0
src/widgets/viewwindowtoolbarhelper.cpp

@@ -360,6 +360,21 @@ QAction *ViewWindowToolBarHelper::addAction(QToolBar *p_tb, Action p_action)
         break;
     }
 
+    case Action::ImageHost:
+    {
+        act = p_tb->addAction(ToolBarHelper::generateIcon("image_host_editor.svg"),
+                              ViewWindow::tr("Image Host"));
+
+        auto toolBtn = dynamic_cast<QToolButton *>(p_tb->widgetForAction(act));
+        Q_ASSERT(toolBtn);
+        toolBtn->setPopupMode(QToolButton::InstantPopup);
+        toolBtn->setProperty(PropertyDefs::c_toolButtonWithoutMenuIndicator, true);
+
+        auto menu = WidgetsFactory::createMenu(p_tb);
+        toolBtn->setMenu(menu);
+        break;
+    }
+
     default:
         Q_ASSERT(false);
         break;

+ 2 - 1
src/widgets/viewwindowtoolbarhelper.h

@@ -44,7 +44,8 @@ namespace vnotex
             Outline,
             FindAndReplace,
             SectionNumber,
-            InplacePreview
+            InplacePreview,
+            ImageHost
         };
 
         static QAction *addAction(QToolBar *p_tb, Action p_action);

+ 4 - 0
src/widgets/widgets.pri

@@ -19,8 +19,10 @@ SOURCES += \
     $$PWD/dialogs/settings/appearancepage.cpp \
     $$PWD/dialogs/settings/editorpage.cpp \
     $$PWD/dialogs/settings/generalpage.cpp \
+    $$PWD/dialogs/settings/imagehostpage.cpp \
     $$PWD/dialogs/settings/markdowneditorpage.cpp \
     $$PWD/dialogs/settings/miscpage.cpp \
+    $$PWD/dialogs/settings/newimagehostdialog.cpp \
     $$PWD/dialogs/settings/quickaccesspage.cpp \
     $$PWD/dialogs/settings/settingspage.cpp \
     $$PWD/dialogs/settings/settingsdialog.cpp \
@@ -129,8 +131,10 @@ HEADERS += \
     $$PWD/dialogs/settings/appearancepage.h \
     $$PWD/dialogs/settings/editorpage.h \
     $$PWD/dialogs/settings/generalpage.h \
+    $$PWD/dialogs/settings/imagehostpage.h \
     $$PWD/dialogs/settings/markdowneditorpage.h \
     $$PWD/dialogs/settings/miscpage.h \
+    $$PWD/dialogs/settings/newimagehostdialog.h \
     $$PWD/dialogs/settings/quickaccesspage.h \
     $$PWD/dialogs/settings/settingspage.h \
     $$PWD/dialogs/settings/settingsdialog.h \

+ 1 - 0
tests/test_core/test_notebook/test_notebook.pro

@@ -20,6 +20,7 @@ include($$SRC_FOLDER/utils/utils.pri)
 include($$SRC_FOLDER/export/export.pri)
 include($$SRC_FOLDER/search/search.pri)
 include($$SRC_FOLDER/snippet/snippet.pri)
+include($$SRC_FOLDER/imagehost/imagehost.pri)
 
 SOURCES += \
     test_notebook.cpp