diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 429fb47..9991560 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,9 @@ on: - 'LICENSE' - '.gitignore' +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build: strategy: @@ -31,7 +34,7 @@ jobs: name: Build (${{ matrix.name }}) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install dependencies (Linux) if: runner.os == 'Linux' diff --git a/.github/workflows/msstore.yml b/.github/workflows/msstore.yml index 549fb8b..08abdf0 100644 --- a/.github/workflows/msstore.yml +++ b/.github/workflows/msstore.yml @@ -12,13 +12,16 @@ on: permissions: contents: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: msix: runs-on: windows-latest name: Build MSIX steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Qt6 uses: jurplel/install-qt-action@v4 @@ -44,7 +47,7 @@ jobs: echo "Version: ${VER}, MSIX: ${MSIX_VER}" - name: Configure - run: cmake -B build -DCMAKE_BUILD_TYPE=Release + run: cmake -B build -DCMAKE_BUILD_TYPE=Release -DAPP_VERSION=${{ steps.version.outputs.version }} - name: Build run: cmake --build build --config Release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 122a00a..8730168 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,9 @@ on: permissions: contents: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build: strategy: @@ -24,7 +27,14 @@ jobs: name: Build (${{ matrix.name }}) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + + - name: Determine version + id: version + shell: bash + run: | + VER="${GITHUB_REF#refs/tags/v}" + echo "version=${VER}" >> "$GITHUB_OUTPUT" - name: Install dependencies (Linux) if: runner.os == 'Linux' @@ -36,7 +46,7 @@ jobs: version: '6.7.*' - name: Configure - run: cmake -B build -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Release + run: cmake -B build -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Release -DAPP_VERSION=${{ steps.version.outputs.version }} - name: Build run: cmake --build build --config Release diff --git a/CMakeLists.txt b/CMakeLists.txt index 778af22..f959eb6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,7 +26,7 @@ endif() # ---- Dependencies ------------------------------------------------------------ -find_package(Qt6 REQUIRED COMPONENTS Widgets Svg PrintSupport) +find_package(Qt6 REQUIRED COMPONENTS Widgets Svg PrintSupport Network LinguistTools) # ---- Library (all sources except main.cpp) ----------------------------------- @@ -40,6 +40,7 @@ add_library(ymind_lib OBJECT src/core/SettingsDialog.h src/core/SettingsDialog.cpp src/core/TemplateDescriptor.h src/core/TemplateDescriptor.cpp src/core/TemplateRegistry.h src/core/TemplateRegistry.cpp + src/core/UpdateChecker.h src/core/UpdateChecker.cpp # Scene – graphics-scene items src/scene/EdgeItem.h src/scene/EdgeItem.cpp @@ -68,12 +69,25 @@ add_library(ymind_lib OBJECT target_include_directories(ymind_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src) -target_compile_definitions(ymind_lib PUBLIC YMIND_VERSION="${PROJECT_VERSION}") +set(APP_VERSION "${PROJECT_VERSION}" CACHE STRING "Application version string") +target_compile_definitions(ymind_lib PUBLIC YMIND_VERSION="${APP_VERSION}") target_link_libraries(ymind_lib PUBLIC Qt6::Widgets Qt6::Svg Qt6::PrintSupport + Qt6::Network +) + +# ---- Translations ------------------------------------------------------------ + +set(TS_FILES + translations/ymind_zh_CN.ts +) + +qt_add_translations(ymind_lib + TS_FILES ${TS_FILES} + RESOURCE_PREFIX "/translations" ) # ---- Executable -------------------------------------------------------------- diff --git a/src/core/AboutDialog.cpp b/src/core/AboutDialog.cpp index 986fd6e..37d805d 100644 --- a/src/core/AboutDialog.cpp +++ b/src/core/AboutDialog.cpp @@ -7,7 +7,7 @@ #include AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent) { - setWindowTitle("About YMind"); + setWindowTitle(tr("About YMind")); setFixedWidth(420); QString linkColor = ThemeManager::isDark() ? "#5cacee" : "#0563C1"; @@ -26,18 +26,20 @@ AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent) { appLabel->setWordWrap(true); appLabel->setText( QString("

YMind

" - "

Version %1

" - "

A desktop mind map editor.

") - .arg(QCoreApplication::applicationVersion())); + "

%1

" + "

%2

") + .arg(tr("Version %1").arg(QCoreApplication::applicationVersion()), + tr("A desktop mind map editor."))); layout->addWidget(appLabel); // License auto* licenseLabel = new QLabel(this); licenseLabel->setTextFormat(Qt::RichText); licenseLabel->setWordWrap(true); - licenseLabel->setText(QString("

Licensed under the %1.

") - .arg(makeLink("https://www.apache.org/licenses/LICENSE-2.0", - "Apache License 2.0"))); + licenseLabel->setText(QString("

%1

") + .arg(tr("Licensed under the %1.") + .arg(makeLink("https://www.apache.org/licenses/LICENSE-2.0", + "Apache License 2.0")))); licenseLabel->setOpenExternalLinks(true); layout->addWidget(licenseLabel); @@ -47,18 +49,19 @@ AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent) { qtLabel->setWordWrap(true); QString qtOpenSource = "https://www.qt.io/download/open-source"; qtLabel->setText( - QString("

This application uses Qt %1, licensed under the %2. " - "Qt source code and re-linking instructions are available at: %3.

") - .arg(qVersion(), - makeLink("https://www.gnu.org/licenses/lgpl-3.0.html", "GNU LGPL v3"), - makeLink(qtOpenSource, qtOpenSource))); + QString("

%1

") + .arg(tr("This application uses Qt %1, licensed under the %2. " + "Qt source code and re-linking instructions are available at: %3.") + .arg(qVersion(), + makeLink("https://www.gnu.org/licenses/lgpl-3.0.html", "GNU LGPL v3"), + makeLink(qtOpenSource, qtOpenSource)))); qtLabel->setOpenExternalLinks(true); layout->addWidget(qtLabel); layout->addStretch(); // Close button - auto* closeBtn = new QPushButton("Close", this); + auto* closeBtn = new QPushButton(tr("Close"), this); closeBtn->setDefault(true); connect(closeBtn, &QPushButton::clicked, this, &QDialog::accept); diff --git a/src/core/AppSettings.cpp b/src/core/AppSettings.cpp index 406770d..12c3168 100644 --- a/src/core/AppSettings.cpp +++ b/src/core/AppSettings.cpp @@ -92,3 +92,19 @@ QByteArray AppSettings::windowState() const { void AppSettings::setWindowState(const QByteArray& state) { m_settings->setValue("window/state", state); } + +bool AppSettings::checkForUpdatesEnabled() const { + return m_settings->value("updates/checkOnStartup", false).toBool(); +} + +void AppSettings::setCheckForUpdatesEnabled(bool enabled) { + m_settings->setValue("updates/checkOnStartup", enabled); +} + +QString AppSettings::language() const { + return m_settings->value("appearance/language", "en").toString(); +} + +void AppSettings::setLanguage(const QString& lang) { + m_settings->setValue("appearance/language", lang); +} diff --git a/src/core/AppSettings.h b/src/core/AppSettings.h index 55feaab..b112b3f 100644 --- a/src/core/AppSettings.h +++ b/src/core/AppSettings.h @@ -35,6 +35,12 @@ class AppSettings : public QObject { QByteArray windowState() const; void setWindowState(const QByteArray& state); + bool checkForUpdatesEnabled() const; + void setCheckForUpdatesEnabled(bool enabled); + + QString language() const; + void setLanguage(const QString& lang); + signals: void themeChanged(AppTheme theme); void autoSaveSettingsChanged(); diff --git a/src/core/FileManager.cpp b/src/core/FileManager.cpp index 61349d1..2f38efb 100644 --- a/src/core/FileManager.cpp +++ b/src/core/FileManager.cpp @@ -23,8 +23,8 @@ void FileManager::newFile() { void FileManager::openFile() { QString filePath = - QFileDialog::getOpenFileName(m_window, "Open Mind Map", QString(), - "YMind Files (*.ymind);;JSON Files (*.json);;All Files (*)"); + QFileDialog::getOpenFileName(m_window, tr("Open Mind Map"), QString(), + tr("YMind Files (*.ymind);;JSON Files (*.json);;All Files (*)")); if (filePath.isEmpty()) return; @@ -39,7 +39,7 @@ void FileManager::openFile() { auto* scene = m_tabManager->currentScene(); auto* view = m_tabManager->currentView(); if (!scene->loadFromFile(filePath)) { - QMessageBox::warning(m_window, "YMind", "Could not open file:\n" + filePath); + QMessageBox::warning(m_window, "YMind", tr("Could not open file:\n%1").arg(filePath)); return; } m_tabManager->setCurrentFilePath(filePath); @@ -55,7 +55,7 @@ void FileManager::openFile() { view->setScene(scene); if (!scene->loadFromFile(filePath)) { - QMessageBox::warning(m_window, "YMind", "Could not open file:\n" + filePath); + QMessageBox::warning(m_window, "YMind", tr("Could not open file:\n%1").arg(filePath)); delete scene; delete view; return; @@ -80,7 +80,7 @@ void FileManager::saveFile() { QString path = m_tabManager->currentFilePath(); if (!scene->saveToFile(path)) { - QMessageBox::warning(m_window, "YMind", "Could not save file:\n" + path); + QMessageBox::warning(m_window, "YMind", tr("Could not save file:\n%1").arg(path)); } int cur = m_tabManager->currentIndex(); @@ -92,8 +92,8 @@ void FileManager::saveFile() { void FileManager::saveFileAs() { QString filePath = - QFileDialog::getSaveFileName(m_window, "Save Mind Map", QString(), - "YMind Files (*.ymind);;JSON Files (*.json);;All Files (*)"); + QFileDialog::getSaveFileName(m_window, tr("Save Mind Map"), QString(), + tr("YMind Files (*.ymind);;JSON Files (*.json);;All Files (*)")); if (filePath.isEmpty()) return; @@ -102,7 +102,7 @@ void FileManager::saveFileAs() { auto* scene = m_tabManager->currentScene(); if (!scene->saveToFile(filePath)) { - QMessageBox::warning(m_window, "YMind", "Could not save file:\n" + filePath); + QMessageBox::warning(m_window, "YMind", tr("Could not save file:\n%1").arg(filePath)); return; } @@ -130,15 +130,15 @@ void FileManager::doExport(const QString& dialogTitle, const QString& filter, if (!exporter(filePath)) { QMessageBox::warning(m_window, "YMind", - QString("Could not export %1:\n%2").arg(errorLabel, filePath)); + tr("Could not export %1:\n%2").arg(errorLabel, filePath)); return; } if (auto* mw = qobject_cast(m_window)) - mw->statusBar()->showMessage("Exported to " + filePath, 3000); + mw->statusBar()->showMessage(tr("Exported to %1").arg(filePath), 3000); } void FileManager::exportAsText() { - doExport("Export as Text", "Text Files (*.txt);;All Files (*)", ".txt", + doExport(tr("Export as Text"), tr("Text Files (*.txt);;All Files (*)"), ".txt", [this](const QString& path) { QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) @@ -147,11 +147,11 @@ void FileManager::exportAsText() { file.close(); return true; }, - "file"); + tr("file")); } void FileManager::exportAsMarkdown() { - doExport("Export as Markdown", "Markdown Files (*.md);;All Files (*)", ".md", + doExport(tr("Export as Markdown"), tr("Markdown Files (*.md);;All Files (*)"), ".md", [this](const QString& path) { QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) @@ -160,11 +160,11 @@ void FileManager::exportAsMarkdown() { file.close(); return true; }, - "file"); + tr("file")); } void FileManager::exportAsPng() { - doExport("Export as PNG", "PNG Images (*.png);;All Files (*)", ".png", + doExport(tr("Export as PNG"), tr("PNG Images (*.png);;All Files (*)"), ".png", [this](const QString& path) { return m_tabManager->currentScene()->exportToPng(path); }, @@ -172,7 +172,7 @@ void FileManager::exportAsPng() { } void FileManager::exportAsSvg() { - doExport("Export as SVG", "SVG Files (*.svg);;All Files (*)", ".svg", + doExport(tr("Export as SVG"), tr("SVG Files (*.svg);;All Files (*)"), ".svg", [this](const QString& path) { return m_tabManager->currentScene()->exportToSvg(path); }, @@ -180,7 +180,7 @@ void FileManager::exportAsSvg() { } void FileManager::exportAsPdf() { - doExport("Export as PDF", "PDF Files (*.pdf);;All Files (*)", ".pdf", + doExport(tr("Export as PDF"), tr("PDF Files (*.pdf);;All Files (*)"), ".pdf", [this](const QString& path) { return m_tabManager->currentScene()->exportToPdf(path); }, @@ -188,14 +188,14 @@ void FileManager::exportAsPdf() { } void FileManager::importFromText() { - QString filePath = QFileDialog::getOpenFileName(m_window, "Import from Text", QString(), - "Text Files (*.txt);;All Files (*)"); + QString filePath = QFileDialog::getOpenFileName(m_window, tr("Import from Text"), QString(), + tr("Text Files (*.txt);;All Files (*)")); if (filePath.isEmpty()) return; QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - QMessageBox::warning(m_window, "YMind", "Could not read file:\n" + filePath); + QMessageBox::warning(m_window, "YMind", tr("Could not read file:\n%1").arg(filePath)); return; } QString text = QString::fromUtf8(file.readAll()); @@ -206,7 +206,8 @@ void FileManager::importFromText() { auto* scene = m_tabManager->currentScene(); auto* view = m_tabManager->currentView(); if (!scene->importFromText(text)) { - QMessageBox::warning(m_window, "YMind", "Could not parse text file:\n" + filePath); + QMessageBox::warning(m_window, "YMind", + tr("Could not parse text file:\n%1").arg(filePath)); return; } m_tabManager->setCurrentFilePath(QString()); @@ -221,7 +222,8 @@ void FileManager::importFromText() { view->setScene(scene); if (!scene->importFromText(text)) { - QMessageBox::warning(m_window, "YMind", "Could not parse text file:\n" + filePath); + QMessageBox::warning(m_window, "YMind", + tr("Could not parse text file:\n%1").arg(filePath)); delete scene; delete view; return; @@ -236,5 +238,5 @@ void FileManager::importFromText() { } if (auto* mw = qobject_cast(m_window)) - mw->statusBar()->showMessage("Imported from " + filePath, 3000); + mw->statusBar()->showMessage(tr("Imported from %1").arg(filePath), 3000); } diff --git a/src/core/MainWindow.cpp b/src/core/MainWindow.cpp index 769355d..13ffe84 100644 --- a/src/core/MainWindow.cpp +++ b/src/core/MainWindow.cpp @@ -9,12 +9,14 @@ #include "ui/OutlineWidget.h" #include "core/AboutDialog.h" #include "core/SettingsDialog.h" +#include "core/UpdateChecker.h" #include "ui/TabManager.h" #include "ui/ThemeManager.h" #include #include #include +#include #include #include #include @@ -22,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -98,10 +101,25 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { restoreWindowState(); + // Update checker + m_updateChecker = new UpdateChecker(this); + connect(m_updateChecker, &UpdateChecker::updateAvailable, this, + &MainWindow::showUpdateDialog); + connect(m_updateChecker, &UpdateChecker::upToDate, this, [this]() { + QMessageBox::information(this, tr("Check for Updates"), + tr("You are running the latest version of YMind.")); + }); + connect(m_updateChecker, &UpdateChecker::checkFailed, this, [this](const QString& msg) { + QMessageBox::warning(this, tr("Check for Updates"), + tr("Could not check for updates:\n%1").arg(msg)); + }); + if (AppSettings::instance().checkForUpdatesEnabled()) + QTimer::singleShot(3000, m_updateChecker, [this]() { m_updateChecker->checkForUpdates(false); }); + m_statusHelpLabel = new QLabel( - "Enter: Add Child | Ctrl+Enter: Add Sibling | Del: Delete | " - "F2/Double-click: Edit | Ctrl+L: Auto Layout | Scroll: Zoom | " - "Middle/Right-drag: Pan", + tr("Enter: Add Child | Ctrl+Enter: Add Sibling | Del: Delete | " + "F2/Double-click: Edit | Ctrl+L: Auto Layout | Scroll: Zoom | " + "Middle/Right-drag: Pan"), this); m_statusHelpLabel->setAlignment(Qt::AlignCenter); statusBar()->addWidget(m_statusHelpLabel, 1); @@ -150,7 +168,7 @@ void MainWindow::setupCentralLayout() { m_toggleOutlineBtn = new QToolButton(this); m_toggleOutlineBtn->setIcon(IconFactory::makeToolIcon("sidebar")); m_toggleOutlineBtn->setProperty("iconName", "sidebar"); - m_toggleOutlineBtn->setToolTip("Toggle Outline Panel"); + m_toggleOutlineBtn->setToolTip(tr("Toggle Outline Panel")); m_toggleOutlineBtn->setCheckable(true); m_toggleOutlineBtn->setChecked(true); m_toggleOutlineBtn->setAutoRaise(true); @@ -162,7 +180,7 @@ void MainWindow::setupCentralLayout() { m_toggleToolbarBtn = new QToolButton(this); m_toggleToolbarBtn->setIcon(IconFactory::makeToolIcon("toolbar")); m_toggleToolbarBtn->setProperty("iconName", "toolbar"); - m_toggleToolbarBtn->setToolTip("Toggle Toolbar"); + m_toggleToolbarBtn->setToolTip(tr("Toggle Toolbar")); m_toggleToolbarBtn->setCheckable(true); m_toggleToolbarBtn->setChecked(true); m_toggleToolbarBtn->setAutoRaise(true); @@ -212,7 +230,7 @@ void MainWindow::setupCentralLayout() { // Actions (undo/redo) // --------------------------------------------------------------------------- void MainWindow::setupActions() { - m_undoAct = new QAction("&Undo", this); + m_undoAct = new QAction(tr("&Undo"), this); m_undoAct->setShortcut(QKeySequence::Undo); m_undoAct->setEnabled(false); connect(m_undoAct, &QAction::triggered, this, [this]() { @@ -221,7 +239,7 @@ void MainWindow::setupActions() { scene->undoStack()->undo(); }); - m_redoAct = new QAction("&Redo", this); + m_redoAct = new QAction(tr("&Redo"), this); m_redoAct->setShortcuts({QKeySequence::Redo, QKeySequence("Ctrl+Y")}); m_redoAct->setEnabled(false); connect(m_redoAct, &QAction::triggered, this, [this]() { @@ -268,7 +286,7 @@ void MainWindow::setupToolBar() { }; // Undo/Redo at the very left - m_undoBtn = addButton("undo", "Undo", "Undo last action (Ctrl+Z)"); + m_undoBtn = addButton("undo", tr("Undo"), tr("Undo last action (Ctrl+Z)")); m_undoBtn->setEnabled(false); connect(m_undoBtn, &QToolButton::clicked, this, [this]() { auto* scene = m_tabManager->currentScene(); @@ -279,7 +297,7 @@ void MainWindow::setupToolBar() { m_undoBtn->setEnabled(m_undoAct->isEnabled()); }); - m_redoBtn = addButton("redo", "Redo", "Redo last action (Ctrl+Y)"); + m_redoBtn = addButton("redo", tr("Redo"), tr("Redo last action (Ctrl+Y)")); m_redoBtn->setEnabled(false); connect(m_redoBtn, &QToolButton::clicked, this, [this]() { auto* scene = m_tabManager->currentScene(); @@ -292,37 +310,37 @@ void MainWindow::setupToolBar() { addSeparator(); - auto* addChildBtn = addButton("add-child", "Add Child", "Add a child node (Enter)"); + auto* addChildBtn = addButton("add-child", tr("Add Child"), tr("Add a child node (Enter)")); connect(addChildBtn, &QToolButton::clicked, this, [this]() { if (auto* s = m_tabManager->currentScene()) s->addChildToSelected(); }); auto* addSiblingBtn = - addButton("add-sibling", "Add Sibling", "Add a sibling node (Ctrl+Enter)"); + addButton("add-sibling", tr("Add Sibling"), tr("Add a sibling node (Ctrl+Enter)")); connect(addSiblingBtn, &QToolButton::clicked, this, [this]() { if (auto* s = m_tabManager->currentScene()) s->addSiblingToSelected(); }); - auto* deleteBtn = addButton("delete", "Delete", "Delete selected node (Del)"); + auto* deleteBtn = addButton("delete", tr("Delete"), tr("Delete selected node (Del)")); connect(deleteBtn, &QToolButton::clicked, this, [this]() { if (auto* s = m_tabManager->currentScene()) s->deleteSelected(); }); addSeparator(); auto* layoutBtn = - addButton("auto-layout", "Auto Layout", "Automatically arrange all nodes (Ctrl+L)"); + addButton("auto-layout", tr("Auto Layout"), tr("Automatically arrange all nodes (Ctrl+L)")); connect(layoutBtn, &QToolButton::clicked, this, [this]() { if (auto* s = m_tabManager->currentScene()) s->autoLayout(); }); addSeparator(); - auto* zoomInBtn = addButton("zoom-in", "Zoom In", "Zoom in (Ctrl++)"); + auto* zoomInBtn = addButton("zoom-in", tr("Zoom In"), tr("Zoom in (Ctrl++)")); connect(zoomInBtn, &QToolButton::clicked, this, [this]() { if (auto* v = m_tabManager->currentView()) v->zoomIn(); }); - auto* zoomOutBtn = addButton("zoom-out", "Zoom Out", "Zoom out (Ctrl+-)"); + auto* zoomOutBtn = addButton("zoom-out", tr("Zoom Out"), tr("Zoom out (Ctrl+-)")); connect(zoomOutBtn, &QToolButton::clicked, this, [this]() { if (auto* v = m_tabManager->currentView()) v->zoomOut(); }); - auto* fitBtn = addButton("fit-view", "Fit View", "Fit all nodes in view (Ctrl+0)"); + auto* fitBtn = addButton("fit-view", tr("Fit View"), tr("Fit all nodes in view (Ctrl+0)")); connect(fitBtn, &QToolButton::clicked, this, [this]() { if (auto* v = m_tabManager->currentView()) v->zoomToFit(); }); @@ -331,19 +349,19 @@ void MainWindow::setupToolBar() { auto* exportBtn = new QToolButton(m_toolbarWidget); exportBtn->setProperty("iconName", "export"); exportBtn->setIcon(IconFactory::makeToolIcon("export")); - exportBtn->setText("Export"); - exportBtn->setToolTip("Export mind map"); + exportBtn->setText(tr("Export")); + exportBtn->setToolTip(tr("Export mind map")); exportBtn->setPopupMode(QToolButton::InstantPopup); exportBtn->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); exportBtn->setAutoRaise(true); exportBtn->setIconSize(QSize(24, 24)); auto* exportBtnMenu = new QMenu(exportBtn); - exportBtnMenu->addAction("As Text...", m_fileManager, &FileManager::exportAsText); - exportBtnMenu->addAction("As Markdown...", m_fileManager, &FileManager::exportAsMarkdown); + exportBtnMenu->addAction(tr("As Text..."), m_fileManager, &FileManager::exportAsText); + exportBtnMenu->addAction(tr("As Markdown..."), m_fileManager, &FileManager::exportAsMarkdown); exportBtnMenu->addSeparator(); - exportBtnMenu->addAction("As PNG...", m_fileManager, &FileManager::exportAsPng); - exportBtnMenu->addAction("As SVG...", m_fileManager, &FileManager::exportAsSvg); - exportBtnMenu->addAction("As PDF...", m_fileManager, &FileManager::exportAsPdf); + exportBtnMenu->addAction(tr("As PNG..."), m_fileManager, &FileManager::exportAsPng); + exportBtnMenu->addAction(tr("As SVG..."), m_fileManager, &FileManager::exportAsSvg); + exportBtnMenu->addAction(tr("As PDF..."), m_fileManager, &FileManager::exportAsPdf); exportBtn->setMenu(exportBtnMenu); layout->addWidget(exportBtn); @@ -354,7 +372,7 @@ void MainWindow::setupToolBar() { auto* closeBtn = new QToolButton(m_toolbarWidget); closeBtn->setIcon(IconFactory::makeToolIcon("close-panel")); closeBtn->setProperty("iconName", "close-panel"); - closeBtn->setToolTip("Hide Toolbar"); + closeBtn->setToolTip(tr("Hide Toolbar")); closeBtn->setAutoRaise(true); closeBtn->setFixedSize(20, 20); closeBtn->setIconSize(QSize(14, 14)); @@ -371,100 +389,100 @@ void MainWindow::setupToolBar() { // --------------------------------------------------------------------------- void MainWindow::setupMenuBar() { // ---- File menu ---- - auto* fileMenu = menuBar()->addMenu("&File"); + auto* fileMenu = menuBar()->addMenu(tr("&File")); - auto* newAct = fileMenu->addAction("&New"); + auto* newAct = fileMenu->addAction(tr("&New")); newAct->setShortcut(QKeySequence::New); connect(newAct, &QAction::triggered, m_fileManager, &FileManager::newFile); - auto* newTabAct = fileMenu->addAction("New &Tab"); + auto* newTabAct = fileMenu->addAction(tr("New &Tab")); newTabAct->setShortcut(QKeySequence("Ctrl+T")); connect(newTabAct, &QAction::triggered, m_tabManager, &TabManager::addNewTab); - auto* openAct = fileMenu->addAction("&Open..."); + auto* openAct = fileMenu->addAction(tr("&Open...")); openAct->setShortcut(QKeySequence::Open); connect(openAct, &QAction::triggered, m_fileManager, &FileManager::openFile); fileMenu->addSeparator(); - auto* saveAct = fileMenu->addAction("&Save"); + auto* saveAct = fileMenu->addAction(tr("&Save")); saveAct->setShortcut(QKeySequence::Save); connect(saveAct, &QAction::triggered, m_fileManager, &FileManager::saveFile); - auto* saveAsAct = fileMenu->addAction("Save &As..."); + auto* saveAsAct = fileMenu->addAction(tr("Save &As...")); saveAsAct->setShortcut(QKeySequence::SaveAs); connect(saveAsAct, &QAction::triggered, m_fileManager, &FileManager::saveFileAs); fileMenu->addSeparator(); - auto* closeTabAct = fileMenu->addAction("&Close Tab"); + auto* closeTabAct = fileMenu->addAction(tr("&Close Tab")); closeTabAct->setShortcut(QKeySequence("Ctrl+W")); connect(closeTabAct, &QAction::triggered, this, [this]() { m_tabManager->closeTab(m_tabManager->currentIndex()); }); fileMenu->addSeparator(); - auto* exportMenu = fileMenu->addMenu("&Export"); + auto* exportMenu = fileMenu->addMenu(tr("&Export")); - auto* exportTextAct = exportMenu->addAction("As &Text..."); + auto* exportTextAct = exportMenu->addAction(tr("As &Text...")); connect(exportTextAct, &QAction::triggered, m_fileManager, &FileManager::exportAsText); - auto* exportMdAct = exportMenu->addAction("As &Markdown..."); + auto* exportMdAct = exportMenu->addAction(tr("As &Markdown...")); connect(exportMdAct, &QAction::triggered, m_fileManager, &FileManager::exportAsMarkdown); exportMenu->addSeparator(); - auto* exportPngAct = exportMenu->addAction("As &PNG..."); + auto* exportPngAct = exportMenu->addAction(tr("As &PNG...")); connect(exportPngAct, &QAction::triggered, m_fileManager, &FileManager::exportAsPng); - auto* exportSvgAct = exportMenu->addAction("As &SVG..."); + auto* exportSvgAct = exportMenu->addAction(tr("As &SVG...")); connect(exportSvgAct, &QAction::triggered, m_fileManager, &FileManager::exportAsSvg); - auto* exportPdfAct = exportMenu->addAction("As P&DF..."); + auto* exportPdfAct = exportMenu->addAction(tr("As P&DF...")); connect(exportPdfAct, &QAction::triggered, m_fileManager, &FileManager::exportAsPdf); - auto* importAct = fileMenu->addAction("&Import from Text..."); + auto* importAct = fileMenu->addAction(tr("&Import from Text...")); connect(importAct, &QAction::triggered, m_fileManager, &FileManager::importFromText); fileMenu->addSeparator(); - auto* exitAct = fileMenu->addAction("E&xit"); + auto* exitAct = fileMenu->addAction(tr("E&xit")); exitAct->setShortcut(QKeySequence::Quit); connect(exitAct, &QAction::triggered, this, &QWidget::close); // ---- Edit menu ---- - auto* editMenu = menuBar()->addMenu("&Edit"); + auto* editMenu = menuBar()->addMenu(tr("&Edit")); editMenu->addAction(m_undoAct); editMenu->addAction(m_redoAct); editMenu->addSeparator(); - auto* deleteAct = editMenu->addAction("&Delete"); - deleteAct->setToolTip("Delete selected node (Del)"); + auto* deleteAct = editMenu->addAction(tr("&Delete")); + deleteAct->setToolTip(tr("Delete selected node (Del)")); connect(deleteAct, &QAction::triggered, this, [this]() { if (auto* s = m_tabManager->currentScene()) s->deleteSelected(); }); // ---- View menu ---- - auto* viewMenu = menuBar()->addMenu("&View"); + auto* viewMenu = menuBar()->addMenu(tr("&View")); - auto* zoomInAct = viewMenu->addAction("Zoom &In"); + auto* zoomInAct = viewMenu->addAction(tr("Zoom &In")); zoomInAct->setShortcut(QKeySequence::ZoomIn); connect(zoomInAct, &QAction::triggered, this, [this]() { if (auto* v = m_tabManager->currentView()) v->zoomIn(); }); - auto* zoomOutAct = viewMenu->addAction("Zoom &Out"); + auto* zoomOutAct = viewMenu->addAction(tr("Zoom &Out")); zoomOutAct->setShortcut(QKeySequence::ZoomOut); connect(zoomOutAct, &QAction::triggered, this, [this]() { if (auto* v = m_tabManager->currentView()) v->zoomOut(); }); - auto* fitAct = viewMenu->addAction("&Fit to View"); + auto* fitAct = viewMenu->addAction(tr("&Fit to View")); fitAct->setShortcut(QKeySequence("Ctrl+0")); connect(fitAct, &QAction::triggered, this, [this]() { if (auto* v = m_tabManager->currentView()) v->zoomToFit(); }); viewMenu->addSeparator(); - m_toggleToolbarAct = viewMenu->addAction("&Toolbar"); + m_toggleToolbarAct = viewMenu->addAction(tr("&Toolbar")); m_toggleToolbarAct->setCheckable(true); m_toggleToolbarAct->setChecked(true); connect(m_toggleToolbarAct, &QAction::toggled, this, [this](bool checked) { @@ -475,7 +493,7 @@ void MainWindow::setupMenuBar() { updateContentVisibility(); }); - m_toggleOutlineAct = viewMenu->addAction("&Outline"); + m_toggleOutlineAct = viewMenu->addAction(tr("&Outline")); m_toggleOutlineAct->setCheckable(true); m_toggleOutlineAct->setChecked(true); connect(m_toggleOutlineAct, &QAction::toggled, this, [this](bool checked) { @@ -499,40 +517,46 @@ void MainWindow::setupMenuBar() { }); // ---- Layout menu ---- - auto* layoutMenu = menuBar()->addMenu("&Layout"); + auto* layoutMenu = menuBar()->addMenu(tr("&Layout")); - auto* autoLayoutAct = layoutMenu->addAction("&Auto Layout"); + auto* autoLayoutAct = layoutMenu->addAction(tr("&Auto Layout")); autoLayoutAct->setShortcut(QKeySequence("Ctrl+L")); connect(autoLayoutAct, &QAction::triggered, this, [this]() { if (auto* s = m_tabManager->currentScene()) s->autoLayout(); }); // ---- Insert menu ---- - auto* insertMenu = menuBar()->addMenu("&Insert"); + auto* insertMenu = menuBar()->addMenu(tr("&Insert")); - m_addChildAct = insertMenu->addAction("Add &Child"); - m_addChildAct->setToolTip("Add a child node (Enter)"); + m_addChildAct = insertMenu->addAction(tr("Add &Child")); + m_addChildAct->setToolTip(tr("Add a child node (Enter)")); connect(m_addChildAct, &QAction::triggered, this, [this]() { if (auto* s = m_tabManager->currentScene()) s->addChildToSelected(); }); - m_addSiblingAct = insertMenu->addAction("Add &Sibling"); - m_addSiblingAct->setToolTip("Add a sibling node (Ctrl+Enter)"); + m_addSiblingAct = insertMenu->addAction(tr("Add &Sibling")); + m_addSiblingAct->setToolTip(tr("Add a sibling node (Ctrl+Enter)")); connect(m_addSiblingAct, &QAction::triggered, this, [this]() { if (auto* s = m_tabManager->currentScene()) s->addSiblingToSelected(); }); // ---- Style menu ---- - auto* styleMenu = menuBar()->addMenu("&Style"); + auto* styleMenu = menuBar()->addMenu(tr("&Style")); - auto* settingsAct = styleMenu->addAction("&Settings..."); + auto* settingsAct = styleMenu->addAction(tr("&Settings...")); settingsAct->setShortcut(QKeySequence("Ctrl+,")); connect(settingsAct, &QAction::triggered, this, &MainWindow::openSettings); // ---- Help menu ---- - auto* helpMenu = menuBar()->addMenu("&Help"); + auto* helpMenu = menuBar()->addMenu(tr("&Help")); + + auto* checkUpdatesAct = helpMenu->addAction(tr("Check for &Updates...")); + connect(checkUpdatesAct, &QAction::triggered, this, + [this]() { m_updateChecker->checkForUpdates(true); }); + + helpMenu->addSeparator(); - auto* aboutAct = helpMenu->addAction("About &YMind..."); + auto* aboutAct = helpMenu->addAction(tr("About &YMind...")); connect(aboutAct, &QAction::triggered, this, &MainWindow::openAbout); - auto* aboutQtAct = helpMenu->addAction("About &Qt..."); + auto* aboutQtAct = helpMenu->addAction(tr("About &Qt...")); connect(aboutQtAct, &QAction::triggered, qApp, &QApplication::aboutQt); } @@ -552,7 +576,7 @@ void MainWindow::closeEvent(QCloseEvent* event) { // Window title // --------------------------------------------------------------------------- void MainWindow::updateWindowTitle() { - QString title = "YMind - Mind Map Editor"; + QString title = tr("YMind - Mind Map Editor"); QString filePath = m_tabManager->currentFilePath(); if (!filePath.isEmpty()) { title = QFileInfo(filePath).fileName() + " - YMind"; @@ -651,7 +675,7 @@ void MainWindow::onAutoSaveTimeout() { } } updateWindowTitle(); - statusBar()->showMessage("Auto-saved", 3000); + statusBar()->showMessage(tr("Auto-saved"), 3000); } void MainWindow::onAutoSaveSettingsChanged() { @@ -695,3 +719,22 @@ void MainWindow::applyTheme() { } } } + +// --------------------------------------------------------------------------- +// Update dialog +// --------------------------------------------------------------------------- +void MainWindow::showUpdateDialog(const QString& latestVersion, const QString& releaseUrl) { + QMessageBox box(this); + box.setWindowTitle(tr("Update Available")); + box.setIcon(QMessageBox::Information); + box.setText(tr("A new version of YMind is available.\n\n" + "Current version: %1\n" + "Latest version: %2") + .arg(QCoreApplication::applicationVersion(), latestVersion)); + box.setInformativeText(tr("Would you like to open the download page?")); + box.setStandardButtons(QMessageBox::Yes | QMessageBox::No); + box.setDefaultButton(QMessageBox::Yes); + + if (box.exec() == QMessageBox::Yes) + QDesktopServices::openUrl(QUrl(releaseUrl)); +} diff --git a/src/core/MainWindow.h b/src/core/MainWindow.h index 8ebb324..34f3768 100644 --- a/src/core/MainWindow.h +++ b/src/core/MainWindow.h @@ -5,6 +5,7 @@ class TabManager; class FileManager; class OutlineWidget; +class UpdateChecker; class QLabel; class QTimer; class QSplitter; @@ -38,10 +39,12 @@ class MainWindow : public QMainWindow { void onAutoSaveSettingsChanged(); void applyTheme(); void refreshOutline(); + void showUpdateDialog(const QString& latestVersion, const QString& releaseUrl); // Managers TabManager* m_tabManager = nullptr; FileManager* m_fileManager = nullptr; + UpdateChecker* m_updateChecker = nullptr; // Widgets OutlineWidget* m_outlineWidget = nullptr; diff --git a/src/core/SettingsDialog.cpp b/src/core/SettingsDialog.cpp index 06e6b6f..c764e02 100644 --- a/src/core/SettingsDialog.cpp +++ b/src/core/SettingsDialog.cpp @@ -14,51 +14,67 @@ #include SettingsDialog::SettingsDialog(QWidget* parent) : QDialog(parent) { - setWindowTitle("Settings"); + setWindowTitle(tr("Settings")); setMinimumWidth(360); auto* mainLayout = new QVBoxLayout(this); // Appearance group - auto* appearanceGroup = new QGroupBox("Appearance"); + auto* appearanceGroup = new QGroupBox(tr("Appearance")); auto* appearanceLayout = new QFormLayout(appearanceGroup); m_themeCombo = new QComboBox; - m_themeCombo->addItem("Light", 0); - m_themeCombo->addItem("Dark", 1); - appearanceLayout->addRow("Theme:", m_themeCombo); + m_themeCombo->addItem(tr("Light"), 0); + m_themeCombo->addItem(tr("Dark"), 1); + appearanceLayout->addRow(tr("Theme:"), m_themeCombo); - m_syncSystemThemeBtn = new QPushButton("Sync with System Theme"); + m_syncSystemThemeBtn = new QPushButton(tr("Sync with System Theme")); connect(m_syncSystemThemeBtn, &QPushButton::clicked, this, &SettingsDialog::onSyncSystemTheme); appearanceLayout->addRow("", m_syncSystemThemeBtn); + m_languageCombo = new QComboBox; + m_languageCombo->addItem("English", "en"); + m_languageCombo->addItem(QString::fromUtf8("简体中文"), "zh_CN"); + appearanceLayout->addRow(tr("Language:"), m_languageCombo); + + auto* langHint = new QLabel(tr("Restart required to apply language change")); + langHint->setObjectName("settingsHint"); + appearanceLayout->addRow(langHint); + mainLayout->addWidget(appearanceGroup); // Auto-save group - auto* autoSaveGroup = new QGroupBox("Auto-save"); + auto* autoSaveGroup = new QGroupBox(tr("Auto-save")); auto* autoSaveLayout = new QFormLayout(autoSaveGroup); - m_autoSaveCheck = new QCheckBox("Enable auto-save"); + m_autoSaveCheck = new QCheckBox(tr("Enable auto-save")); autoSaveLayout->addRow(m_autoSaveCheck); m_autoSaveIntervalSpin = new QSpinBox; m_autoSaveIntervalSpin->setRange(1, 5); - m_autoSaveIntervalSpin->setSuffix(" min"); - autoSaveLayout->addRow("Interval:", m_autoSaveIntervalSpin); + m_autoSaveIntervalSpin->setSuffix(tr(" min")); + autoSaveLayout->addRow(tr("Interval:"), m_autoSaveIntervalSpin); connect(m_autoSaveCheck, &QCheckBox::toggled, m_autoSaveIntervalSpin, &QWidget::setEnabled); mainLayout->addWidget(autoSaveGroup); // Editor group - auto* editorGroup = new QGroupBox("Editor"); + auto* editorGroup = new QGroupBox(tr("Editor")); auto* editorLayout = new QFormLayout(editorGroup); m_fontFamilyCombo = new QFontComboBox; - editorLayout->addRow("Default font:", m_fontFamilyCombo); + editorLayout->addRow(tr("Default font:"), m_fontFamilyCombo); m_fontSizeSpin = new QSpinBox; m_fontSizeSpin->setRange(8, 24); m_fontSizeSpin->setSuffix(" pt"); - editorLayout->addRow("Default font size:", m_fontSizeSpin); - auto* hint = new QLabel("Applies to newly created nodes only"); + editorLayout->addRow(tr("Default font size:"), m_fontSizeSpin); + auto* hint = new QLabel(tr("Applies to newly created nodes only")); hint->setObjectName("settingsHint"); editorLayout->addRow(hint); mainLayout->addWidget(editorGroup); + // Updates group + auto* updatesGroup = new QGroupBox(tr("Updates")); + auto* updatesLayout = new QFormLayout(updatesGroup); + m_checkUpdatesCheck = new QCheckBox(tr("Check for updates on startup")); + updatesLayout->addRow(m_checkUpdatesCheck); + mainLayout->addWidget(updatesGroup); + mainLayout->addStretch(); // Button box @@ -81,6 +97,11 @@ void SettingsDialog::loadCurrentSettings() { m_autoSaveIntervalSpin->setEnabled(s.autoSaveEnabled()); m_fontSizeSpin->setValue(s.defaultFontSize()); m_fontFamilyCombo->setCurrentFont(QFont(s.defaultFontFamily())); + m_checkUpdatesCheck->setChecked(s.checkForUpdatesEnabled()); + + int langIdx = m_languageCombo->findData(s.language()); + if (langIdx >= 0) + m_languageCombo->setCurrentIndex(langIdx); } void SettingsDialog::onSyncSystemTheme() { @@ -96,4 +117,6 @@ void SettingsDialog::apply() { s.setAutoSaveIntervalMinutes(m_autoSaveIntervalSpin->value()); s.setDefaultFontSize(m_fontSizeSpin->value()); s.setDefaultFontFamily(m_fontFamilyCombo->currentFont().family()); + s.setCheckForUpdatesEnabled(m_checkUpdatesCheck->isChecked()); + s.setLanguage(m_languageCombo->currentData().toString()); } diff --git a/src/core/SettingsDialog.h b/src/core/SettingsDialog.h index 6dd9df9..7988925 100644 --- a/src/core/SettingsDialog.h +++ b/src/core/SettingsDialog.h @@ -22,9 +22,11 @@ private slots: void apply(); QComboBox* m_themeCombo; + QComboBox* m_languageCombo; QPushButton* m_syncSystemThemeBtn; QCheckBox* m_autoSaveCheck; QSpinBox* m_autoSaveIntervalSpin; QFontComboBox* m_fontFamilyCombo; QSpinBox* m_fontSizeSpin; + QCheckBox* m_checkUpdatesCheck; }; diff --git a/src/core/UpdateChecker.cpp b/src/core/UpdateChecker.cpp new file mode 100644 index 0000000..e83015b --- /dev/null +++ b/src/core/UpdateChecker.cpp @@ -0,0 +1,84 @@ +#include "core/UpdateChecker.h" + +#include +#include +#include +#include +#include +#include +#include + +static const char* kReleasesUrl = + "https://api.github.com/repos/broccoli-97/xmind/releases/latest"; + +UpdateChecker::UpdateChecker(QObject* parent) + : QObject(parent), m_nam(new QNetworkAccessManager(this)) {} + +void UpdateChecker::checkForUpdates(bool manual) { + m_manual = manual; + + QNetworkRequest req{QUrl(kReleasesUrl)}; + req.setHeader(QNetworkRequest::UserAgentHeader, "YMind-UpdateChecker"); + req.setRawHeader("Accept", "application/vnd.github+json"); + + QNetworkReply* reply = m_nam->get(req); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + onReplyFinished(reply); + reply->deleteLater(); + }); +} + +void UpdateChecker::onReplyFinished(QNetworkReply* reply) { + if (reply->error() != QNetworkReply::NoError) { + if (m_manual) + emit checkFailed(reply->errorString()); + return; + } + + QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + if (!doc.isObject()) { + if (m_manual) + emit checkFailed("Invalid response from GitHub."); + return; + } + + QJsonObject obj = doc.object(); + QString tagName = obj.value("tag_name").toString(); + QString htmlUrl = obj.value("html_url").toString(); + + if (tagName.isEmpty()) { + if (m_manual) + emit checkFailed("No release tag found."); + return; + } + + // Strip leading 'v' or 'V' + QString latestVersion = tagName; + if (latestVersion.startsWith('v') || latestVersion.startsWith('V')) + latestVersion = latestVersion.mid(1); + + QString currentVersion = QCoreApplication::applicationVersion(); + + if (isNewerVersion(currentVersion, latestVersion)) { + emit updateAvailable(latestVersion, htmlUrl); + } else { + if (m_manual) + emit upToDate(); + } +} + +bool UpdateChecker::isNewerVersion(const QString& current, const QString& latest) { + QStringList curParts = current.split('.'); + QStringList latParts = latest.split('.'); + + int count = qMax(curParts.size(), latParts.size()); + for (int i = 0; i < count; ++i) { + int c = (i < curParts.size()) ? curParts[i].toInt() : 0; + int l = (i < latParts.size()) ? latParts[i].toInt() : 0; + if (l > c) + return true; + if (l < c) + return false; + } + return false; +} diff --git a/src/core/UpdateChecker.h b/src/core/UpdateChecker.h new file mode 100644 index 0000000..84cc938 --- /dev/null +++ b/src/core/UpdateChecker.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +class QNetworkAccessManager; +class QNetworkReply; + +class UpdateChecker : public QObject { + Q_OBJECT + +public: + explicit UpdateChecker(QObject* parent = nullptr); + + void checkForUpdates(bool manual); + + static bool isNewerVersion(const QString& current, const QString& latest); + +signals: + void updateAvailable(const QString& version, const QString& url); + void upToDate(); + void checkFailed(const QString& errorMessage); + +private: + void onReplyFinished(QNetworkReply* reply); + + QNetworkAccessManager* m_nam; + bool m_manual = false; +}; diff --git a/src/main.cpp b/src/main.cpp index fffa57b..3857398 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,7 +1,10 @@ +#include "core/AppSettings.h" #include "core/MainWindow.h" #include +#include #include +#include int main(int argc, char* argv[]) { QApplication app(argc, argv); @@ -10,6 +13,23 @@ int main(int argc, char* argv[]) { app.setApplicationName("YMind"); app.setApplicationVersion(YMIND_VERSION); + // Load translations based on language setting + QString lang = AppSettings::instance().language(); + if (lang != "en") { + // Load Qt's own translations (standard dialogs, buttons, etc.) + auto* qtTranslator = new QTranslator(&app); + if (qtTranslator->load("qt_" + lang, + QLibraryInfo::path(QLibraryInfo::TranslationsPath))) { + app.installTranslator(qtTranslator); + } + + // Load application translations + auto* appTranslator = new QTranslator(&app); + if (appTranslator->load(":/translations/ymind_" + lang + ".qm")) { + app.installTranslator(appTranslator); + } + } + MainWindow window; window.show(); diff --git a/src/scene/MindMapScene.cpp b/src/scene/MindMapScene.cpp index 090a2ba..5639a10 100644 --- a/src/scene/MindMapScene.cpp +++ b/src/scene/MindMapScene.cpp @@ -4,6 +4,7 @@ #include "core/TemplateRegistry.h" #include "scene/EdgeItem.h" #include "layout/LayoutEngine.h" +#include "scene/MindMapView.h" #include "scene/NodeItem.h" #include "ui/ThemeManager.h" @@ -33,7 +34,7 @@ MindMapScene::MindMapScene(QObject* parent) : QGraphicsScene(parent) { connect(m_undoStack, &QUndoStack::cleanChanged, this, [this](bool clean) { setModified(!clean); }); - m_rootNode = createRootNode("Central Topic"); + m_rootNode = createRootNode(tr("Central Topic")); } NodeItem* MindMapScene::rootNode() const { @@ -194,8 +195,14 @@ void MindMapScene::addChildToSelected() { if (!node) node = m_rootNode; - auto* cmd = new AddNodeCommand(this, node, "New Topic"); + auto* cmd = new AddNodeCommand(this, node, tr("New Topic")); m_undoStack->push(cmd); + + for (auto* view : views()) { + if (auto* mv = qobject_cast(view)) + mv->ensureNodeVisible(cmd->createdNode()); + } + startEditing(cmd->createdNode()); } @@ -208,8 +215,14 @@ void MindMapScene::addSiblingToSelected() { return; } - auto* cmd = new AddNodeCommand(this, node->parentNode(), "New Topic"); + auto* cmd = new AddNodeCommand(this, node->parentNode(), tr("New Topic")); m_undoStack->push(cmd); + + for (auto* view : views()) { + if (auto* mv = qobject_cast(view)) + mv->ensureNodeVisible(cmd->createdNode()); + } + startEditing(cmd->createdNode()); } @@ -442,7 +455,7 @@ bool MindMapScene::fromJson(const QJsonObject& json) { m_rootNode = nodeFromJson(rootObj, nullptr); if (!m_rootNode) { // Fallback: create default root - m_rootNode = createRootNode("Central Topic"); + m_rootNode = createRootNode(tr("Central Topic")); } m_undoStack->clear(); @@ -643,7 +656,7 @@ bool MindMapScene::importFromText(const QString& text) { } if (!m_rootNode) { - m_rootNode = createRootNode("Central Topic"); + m_rootNode = createRootNode(tr("Central Topic")); } autoLayout(); @@ -677,6 +690,12 @@ void MindMapScene::autoLayout() { anim->setEasingCurve(QEasingCurve::OutCubic); group->addAnimation(anim); } - connect(group, &QAbstractAnimation::finished, group, &QObject::deleteLater); + connect(group, &QAbstractAnimation::finished, this, [this, group]() { + group->deleteLater(); + for (auto* view : views()) { + if (auto* mv = qobject_cast(view)) + mv->zoomToFit(); + } + }); group->start(); } diff --git a/src/scene/MindMapView.cpp b/src/scene/MindMapView.cpp index f0c5aa3..cb5e411 100644 --- a/src/scene/MindMapView.cpp +++ b/src/scene/MindMapView.cpp @@ -5,7 +5,10 @@ #include #include +#include +#include #include +#include #include #include @@ -73,8 +76,116 @@ void MindMapView::zoomOut() { void MindMapView::zoomToFit() { if (!scene()) return; + + stopAnimations(); + QRectF bounds = scene()->itemsBoundingRect().adjusted(-80, -80, 80, 80); + + // Snapshot current state + QTransform oldTransform = transform(); + QPointF oldCenter = mapToScene(viewport()->rect().center()); + + // Let Qt compute the target fitInView(bounds, Qt::KeepAspectRatio); + QTransform newTransform = transform(); + QPointF newCenter = mapToScene(viewport()->rect().center()); + + qreal oldScale = oldTransform.m11(); + qreal newScale = newTransform.m11(); + + // Already at target — nothing to animate + if (qFuzzyCompare(oldScale, newScale) && + (oldCenter - newCenter).manhattanLength() < 0.5) { + return; + } + + // Restore old state, then animate + setTransform(oldTransform); + centerOn(oldCenter); + + m_zoomAnimation = new QVariantAnimation(this); + m_zoomAnimation->setDuration(400); + m_zoomAnimation->setStartValue(0.0); + m_zoomAnimation->setEndValue(1.0); + m_zoomAnimation->setEasingCurve(QEasingCurve::OutCubic); + + connect(m_zoomAnimation, &QVariantAnimation::valueChanged, this, + [this, oldScale, newScale, oldCenter, newCenter](const QVariant& value) { + qreal t = value.toReal(); + qreal s = oldScale + (newScale - oldScale) * t; + QPointF c = oldCenter + (newCenter - oldCenter) * t; + setTransform(QTransform::fromScale(s, s)); + centerOn(c); + }); + + connect(m_zoomAnimation, &QAbstractAnimation::finished, this, [this]() { + m_zoomAnimation->deleteLater(); + m_zoomAnimation = nullptr; + }); + + m_zoomAnimation->start(); +} + +void MindMapView::ensureNodeVisible(QGraphicsItem* item) { + if (!item) + return; + + stopAnimations(); + + // Snapshot current scrollbar positions + int oldH = horizontalScrollBar()->value(); + int oldV = verticalScrollBar()->value(); + + // Let Qt compute the target scroll position + ensureVisible(item, 80, 80); + + // Capture the target positions + int newH = horizontalScrollBar()->value(); + int newV = verticalScrollBar()->value(); + + // Already visible — nothing to animate + if (oldH == newH && oldV == newV) + return; + + // Restore original positions before animating + horizontalScrollBar()->setValue(oldH); + verticalScrollBar()->setValue(oldV); + + // Animate horizontal scrollbar + auto* hAnim = new QPropertyAnimation(horizontalScrollBar(), "value"); + hAnim->setDuration(300); + hAnim->setStartValue(oldH); + hAnim->setEndValue(newH); + hAnim->setEasingCurve(QEasingCurve::OutCubic); + + // Animate vertical scrollbar + auto* vAnim = new QPropertyAnimation(verticalScrollBar(), "value"); + vAnim->setDuration(300); + vAnim->setStartValue(oldV); + vAnim->setEndValue(newV); + vAnim->setEasingCurve(QEasingCurve::OutCubic); + + m_scrollAnimation = new QParallelAnimationGroup(this); + m_scrollAnimation->addAnimation(hAnim); + m_scrollAnimation->addAnimation(vAnim); + connect(m_scrollAnimation, &QAbstractAnimation::finished, this, [this]() { + m_scrollAnimation->deleteLater(); + m_scrollAnimation = nullptr; + }); + m_scrollAnimation->start(); +} + +void MindMapView::stopAnimations() { + if (m_scrollAnimation) { + m_scrollAnimation->stop(); + m_scrollAnimation->deleteLater(); + m_scrollAnimation = nullptr; + } + if (m_zoomAnimation) { + m_zoomAnimation->stop(); + m_zoomAnimation->deleteLater(); + m_zoomAnimation = nullptr; + } } void MindMapView::drawBackground(QPainter* painter, const QRectF& rect) { diff --git a/src/scene/MindMapView.h b/src/scene/MindMapView.h index 8928cbf..10aeee8 100644 --- a/src/scene/MindMapView.h +++ b/src/scene/MindMapView.h @@ -2,6 +2,9 @@ #include +class QParallelAnimationGroup; +class QVariantAnimation; + class MindMapView : public QGraphicsView { Q_OBJECT @@ -12,6 +15,7 @@ public slots: void zoomIn(); void zoomOut(); void zoomToFit(); + void ensureNodeVisible(QGraphicsItem* item); protected: void wheelEvent(QWheelEvent* event) override; @@ -21,6 +25,10 @@ public slots: void drawBackground(QPainter* painter, const QRectF& rect) override; private: + void stopAnimations(); + bool m_panning = false; QPoint m_lastPanPoint; + QParallelAnimationGroup* m_scrollAnimation = nullptr; + QVariantAnimation* m_zoomAnimation = nullptr; }; diff --git a/src/ui/OutlineWidget.cpp b/src/ui/OutlineWidget.cpp index fbf5373..b5ffe56 100644 --- a/src/ui/OutlineWidget.cpp +++ b/src/ui/OutlineWidget.cpp @@ -23,14 +23,14 @@ OutlineWidget::OutlineWidget(QWidget* parent) : QWidget(parent) { auto* titleRow = new QHBoxLayout(); titleRow->setContentsMargins(0, 0, 0, 0); titleRow->setSpacing(0); - auto* titleLabel = new QLabel("Outline"); + auto* titleLabel = new QLabel(tr("Outline")); titleLabel->setObjectName("outlineTitle"); titleRow->addWidget(titleLabel); titleRow->addStretch(); auto* closeBtn = new QToolButton(this); closeBtn->setIcon(IconFactory::makeToolIcon("close-panel")); closeBtn->setProperty("iconName", "close-panel"); - closeBtn->setToolTip("Hide Outline"); + closeBtn->setToolTip(tr("Hide Outline")); closeBtn->setAutoRaise(true); closeBtn->setFixedSize(20, 20); closeBtn->setIconSize(QSize(14, 14)); diff --git a/src/ui/StartPage.cpp b/src/ui/StartPage.cpp index 5ebd4ac..a7bd6bf 100644 --- a/src/ui/StartPage.cpp +++ b/src/ui/StartPage.cpp @@ -5,6 +5,7 @@ #include "scene/MindMapScene.h" #include "scene/NodeItem.h" +#include #include #include #include @@ -24,13 +25,14 @@ QWidget* StartPage::create(QObject* /*receiver*/, std::functionsetAlignment(Qt::AlignCenter); // Title - auto* title = new QLabel("Create a New Mind Map"); + auto* title = new QLabel(QCoreApplication::translate("StartPage", "Create a New Mind Map")); title->setObjectName("startPageTitle"); title->setAlignment(Qt::AlignCenter); outer->addWidget(title); // Subtitle - auto* subtitle = new QLabel("Choose a template to get started"); + auto* subtitle = + new QLabel(QCoreApplication::translate("StartPage", "Choose a template to get started")); subtitle->setObjectName("startPageSubtitle"); subtitle->setAlignment(Qt::AlignCenter); outer->addWidget(subtitle); @@ -64,7 +66,8 @@ QWidget* StartPage::create(QObject* /*receiver*/, std::functionaddSpacing(16); // Blank Canvas button + Load Template link - auto* blankBtn = new QPushButton("Blank Canvas"); + auto* blankBtn = + new QPushButton(QCoreApplication::translate("StartPage", "Blank Canvas")); blankBtn->setObjectName("blankCanvasBtn"); blankBtn->setFixedSize(160, 36); QObject::connect(blankBtn, &QPushButton::clicked, page, [onBlankCanvas]() { onBlankCanvas(); }); @@ -76,13 +79,18 @@ QWidget* StartPage::create(QObject* /*receiver*/, std::functionaddSpacing(8); - auto* loadLink = new QLabel("Load Template..."); + auto* loadLink = new QLabel( + QString("%1") + .arg(QCoreApplication::translate("StartPage", "Load Template..."))); loadLink->setObjectName("loadTemplateLink"); loadLink->setAlignment(Qt::AlignCenter); loadLink->setCursor(Qt::PointingHandCursor); QObject::connect(loadLink, &QLabel::linkActivated, page, [page, onTemplate]() { QString filePath = QFileDialog::getOpenFileName( - page, "Load Template", QString(), "Template Files (*.json)"); + page, + QCoreApplication::translate("StartPage", "Load Template"), + QString(), + QCoreApplication::translate("StartPage", "Template Files (*.json)")); if (filePath.isEmpty()) return; diff --git a/src/ui/TabManager.cpp b/src/ui/TabManager.cpp index 91c24b7..c3702dd 100644 --- a/src/ui/TabManager.cpp +++ b/src/ui/TabManager.cpp @@ -37,7 +37,7 @@ void TabManager::init(QAction* undoAct, QAction* redoAct) { // Create "+" button m_newTabBtn = new QToolButton(m_parentWidget); m_newTabBtn->setText("+"); - m_newTabBtn->setToolTip("New Tab (Ctrl+T)"); + m_newTabBtn->setToolTip(tr("New Tab (Ctrl+T)")); m_newTabBtn->setAutoRaise(true); m_newTabBtn->setFixedSize(28, 28); m_newTabBtn->setObjectName("newTabBtn"); @@ -133,7 +133,7 @@ void TabManager::addTab(MindMapScene* scene, MindMapView* view, QStackedWidget* { QSignalBlocker blocker(m_tabBar); m_tabs.append(tab); - QString label = filePath.isEmpty() ? "Untitled" : QFileInfo(filePath).fileName(); + QString label = filePath.isEmpty() ? tr("Untitled") : QFileInfo(filePath).fileName(); m_tabBar->addTab(label); m_contentStack->addWidget(stack); updateTabIcon(m_tabs.size() - 1); @@ -214,10 +214,10 @@ void TabManager::connectUndoStack() { connect(stack, &QUndoStack::canUndoChanged, m_undoAct, &QAction::setEnabled); connect(stack, &QUndoStack::canRedoChanged, m_redoAct, &QAction::setEnabled); connect(stack, &QUndoStack::undoTextChanged, this, [this](const QString& text) { - m_undoAct->setText(text.isEmpty() ? "&Undo" : "&Undo " + text); + m_undoAct->setText(text.isEmpty() ? tr("&Undo") : tr("&Undo %1").arg(text)); }); connect(stack, &QUndoStack::redoTextChanged, this, [this](const QString& text) { - m_redoAct->setText(text.isEmpty() ? "&Redo" : "&Redo " + text); + m_redoAct->setText(text.isEmpty() ? tr("&Redo") : tr("&Redo %1").arg(text)); }); m_undoAct->setEnabled(stack->canUndo()); m_redoAct->setEnabled(stack->canRedo()); @@ -227,7 +227,7 @@ void TabManager::updateTabText(int index) { if (index < 0 || index >= m_tabs.size()) return; const auto& tab = m_tabs[index]; - QString label = tab.filePath.isEmpty() ? "Untitled" : QFileInfo(tab.filePath).fileName(); + QString label = tab.filePath.isEmpty() ? tr("Untitled") : QFileInfo(tab.filePath).fileName(); if (tab.scene->isModified()) label.prepend("* "); m_tabBar->setTabText(index, label); @@ -278,12 +278,12 @@ bool TabManager::maybeSaveTab(int index) { if (!scene->isModified()) return true; - QString name = m_tabs[index].filePath.isEmpty() ? "Untitled" + QString name = m_tabs[index].filePath.isEmpty() ? tr("Untitled") : QFileInfo(m_tabs[index].filePath).fileName(); auto ret = QMessageBox::warning(m_parentWidget, "YMind", - QString("The mind map \"%1\" has been modified.\n" - "Do you want to save your changes?") + tr("The mind map \"%1\" has been modified.\n" + "Do you want to save your changes?") .arg(name), QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel); @@ -343,15 +343,15 @@ void TabManager::onTabBarContextMenu(const QPoint& pos) { QMenu menu(m_parentWidget); - auto* newTabAct = menu.addAction("New Tab"); + auto* newTabAct = menu.addAction(tr("New Tab")); connect(newTabAct, &QAction::triggered, this, &TabManager::addNewTab); if (index >= 0) { menu.addSeparator(); - auto* closeAct = menu.addAction("Close"); + auto* closeAct = menu.addAction(tr("Close")); connect(closeAct, &QAction::triggered, this, [this, index]() { closeTab(index); }); - auto* closeOthersAct = menu.addAction("Close Others"); + auto* closeOthersAct = menu.addAction(tr("Close Others")); connect(closeOthersAct, &QAction::triggered, this, [this, index]() { for (int i = m_tabs.size() - 1; i > index; --i) closeTab(i); diff --git a/translations/ymind_zh_CN.ts b/translations/ymind_zh_CN.ts new file mode 100644 index 0000000..eccae4b --- /dev/null +++ b/translations/ymind_zh_CN.ts @@ -0,0 +1,604 @@ + + + + + MainWindow + + Check for Updates + 检查更新 + + + You are running the latest version of YMind. + 您正在运行最新版本的 YMind。 + + + Could not check for updates: +%1 + 无法检查更新: +%1 + + + Enter: Add Child | Ctrl+Enter: Add Sibling | Del: Delete | F2/Double-click: Edit | Ctrl+L: Auto Layout | Scroll: Zoom | Middle/Right-drag: Pan + Enter:添加子节点 | Ctrl+Enter:添加同级节点 | Del:删除 | F2/双击:编辑 | Ctrl+L:自动布局 | 滚轮:缩放 | 中键/右键拖动:平移 + + + Toggle Outline Panel + 切换大纲面板 + + + Toggle Toolbar + 切换工具栏 + + + &Undo + 撤销(&U) + + + &Redo + 重做(&R) + + + Undo + 撤销 + + + Undo last action (Ctrl+Z) + 撤销上一步操作 (Ctrl+Z) + + + Redo + 重做 + + + Redo last action (Ctrl+Y) + 重做上一步操作 (Ctrl+Y) + + + Add Child + 添加子节点 + + + Add a child node (Enter) + 添加子节点 (Enter) + + + Add Sibling + 添加同级节点 + + + Add a sibling node (Ctrl+Enter) + 添加同级节点 (Ctrl+Enter) + + + Delete + 删除 + + + Delete selected node (Del) + 删除选中节点 (Del) + + + Auto Layout + 自动布局 + + + Automatically arrange all nodes (Ctrl+L) + 自动排列所有节点 (Ctrl+L) + + + Zoom In + 放大 + + + Zoom in (Ctrl++) + 放大 (Ctrl++) + + + Zoom Out + 缩小 + + + Zoom out (Ctrl+-) + 缩小 (Ctrl+-) + + + Fit View + 适合视图 + + + Fit all nodes in view (Ctrl+0) + 将所有节点适合视图 (Ctrl+0) + + + Export + 导出 + + + Export mind map + 导出思维导图 + + + As Text... + 导出为文本... + + + As Markdown... + 导出为 Markdown... + + + As PNG... + 导出为 PNG... + + + As SVG... + 导出为 SVG... + + + As PDF... + 导出为 PDF... + + + Hide Toolbar + 隐藏工具栏 + + + &File + 文件(&F) + + + &New + 新建(&N) + + + New &Tab + 新建标签页(&T) + + + &Open... + 打开(&O)... + + + &Save + 保存(&S) + + + Save &As... + 另存为(&A)... + + + &Close Tab + 关闭标签页(&C) + + + &Export + 导出(&E) + + + As &Text... + 导出为文本(&T)... + + + As &Markdown... + 导出为 Markdown(&M)... + + + As &PNG... + 导出为 PNG(&P)... + + + As &SVG... + 导出为 SVG(&S)... + + + As P&DF... + 导出为 PDF(&D)... + + + &Import from Text... + 从文本导入(&I)... + + + E&xit + 退出(&x) + + + &Edit + 编辑(&E) + + + &Delete + 删除(&D) + + + &View + 视图(&V) + + + Zoom &In + 放大(&I) + + + Zoom &Out + 缩小(&O) + + + &Fit to View + 适合视图(&F) + + + &Toolbar + 工具栏(&T) + + + &Outline + 大纲(&O) + + + &Layout + 布局(&L) + + + &Auto Layout + 自动布局(&A) + + + &Insert + 插入(&I) + + + Add &Child + 添加子节点(&C) + + + Add &Sibling + 添加同级节点(&S) + + + &Style + 样式(&S) + + + &Settings... + 设置(&S)... + + + &Help + 帮助(&H) + + + Check for &Updates... + 检查更新(&U)... + + + About &YMind... + 关于 YMind(&Y)... + + + About &Qt... + 关于 Qt(&Q)... + + + YMind - Mind Map Editor + YMind - 思维导图编辑器 + + + Auto-saved + 已自动保存 + + + Update Available + 有可用更新 + + + A new version of YMind is available. + +Current version: %1 +Latest version: %2 + YMind 有新版本可用。 + +当前版本:%1 +最新版本:%2 + + + Would you like to open the download page? + 是否打开下载页面? + + + + SettingsDialog + + Settings + 设置 + + + Appearance + 外观 + + + Light + 浅色 + + + Dark + 深色 + + + Theme: + 主题: + + + Sync with System Theme + 同步系统主题 + + + Language: + 语言: + + + Restart required to apply language change + 需要重启应用以使语言更改生效 + + + Auto-save + 自动保存 + + + Enable auto-save + 启用自动保存 + + + min + 分钟 + + + Interval: + 间隔: + + + Editor + 编辑器 + + + Default font: + 默认字体: + + + Default font size: + 默认字号: + + + Applies to newly created nodes only + 仅适用于新创建的节点 + + + Updates + 更新 + + + Check for updates on startup + 启动时检查更新 + + + + FileManager + + Open Mind Map + 打开思维导图 + + + YMind Files (*.ymind);;JSON Files (*.json);;All Files (*) + YMind 文件 (*.ymind);;JSON 文件 (*.json);;所有文件 (*) + + + Could not open file: +%1 + 无法打开文件: +%1 + + + Could not save file: +%1 + 无法保存文件: +%1 + + + Save Mind Map + 保存思维导图 + + + Could not export %1: +%2 + 无法导出 %1: +%2 + + + Exported to %1 + 已导出到 %1 + + + Export as Text + 导出为文本 + + + Text Files (*.txt);;All Files (*) + 文本文件 (*.txt);;所有文件 (*) + + + file + 文件 + + + Export as Markdown + 导出为 Markdown + + + Markdown Files (*.md);;All Files (*) + Markdown 文件 (*.md);;所有文件 (*) + + + Export as PNG + 导出为 PNG + + + PNG Images (*.png);;All Files (*) + PNG 图片 (*.png);;所有文件 (*) + + + Export as SVG + 导出为 SVG + + + SVG Files (*.svg);;All Files (*) + SVG 文件 (*.svg);;所有文件 (*) + + + Export as PDF + 导出为 PDF + + + PDF Files (*.pdf);;All Files (*) + PDF 文件 (*.pdf);;所有文件 (*) + + + Import from Text + 从文本导入 + + + Could not read file: +%1 + 无法读取文件: +%1 + + + Could not parse text file: +%1 + 无法解析文本文件: +%1 + + + Imported from %1 + 已从 %1 导入 + + + + AboutDialog + + About YMind + 关于 YMind + + + Version %1 + 版本 %1 + + + A desktop mind map editor. + 一款桌面思维导图编辑器。 + + + Licensed under the %1. + 基于 %1 许可。 + + + This application uses Qt %1, licensed under the %2. Qt source code and re-linking instructions are available at: %3. + 本应用使用 Qt %1,基于 %2 许可。Qt 源代码和重新链接说明请访问:%3。 + + + Close + 关闭 + + + + MindMapScene + + Central Topic + 中心主题 + + + New Topic + 新主题 + + + + StartPage + + Create a New Mind Map + 创建新的思维导图 + + + Choose a template to get started + 选择模板以开始 + + + Blank Canvas + 空白画布 + + + Load Template... + 加载模板... + + + Load Template + 加载模板 + + + Template Files (*.json) + 模板文件 (*.json) + + + + TabManager + + New Tab (Ctrl+T) + 新建标签页 (Ctrl+T) + + + Untitled + 未命名 + + + &Undo + 撤销(&U) + + + &Undo %1 + 撤销 %1(&U) + + + &Redo + 重做(&R) + + + &Redo %1 + 重做 %1(&R) + + + The mind map "%1" has been modified. +Do you want to save your changes? + 思维导图"%1"已被修改。 +是否保存更改? + + + New Tab + 新建标签页 + + + Close + 关闭 + + + Close Others + 关闭其他 + + + + OutlineWidget + + Outline + 大纲 + + + Hide Outline + 隐藏大纲 + + +