diff --git a/COPYING b/COPYING
index a00aaf28c..be3314f7b 100644
--- a/COPYING
+++ b/COPYING
@@ -153,8 +153,8 @@ Files: share/icons/application/scalable/actions/application-exit.svg
share/icons/application/scalable/actions/configure.svg
share/icons/application/scalable/actions/csv.svg
share/icons/application/scalable/actions/database-change-key.svg
- share/icons/application/scalable/actions/database-lock.svg
share/icons/application/scalable/actions/database-lock-all.svg
+ share/icons/application/scalable/actions/database-lock.svg
share/icons/application/scalable/actions/database-merge.svg
share/icons/application/scalable/actions/database-search.svg
share/icons/application/scalable/actions/database-settings.svg
@@ -165,34 +165,46 @@ Files: share/icons/application/scalable/actions/application-exit.svg
share/icons/application/scalable/actions/document-export.svg
share/icons/application/scalable/actions/document-import.svg
share/icons/application/scalable/actions/document-new.svg
- share/icons/application/scalable/actions/document-open.svg
share/icons/application/scalable/actions/document-open-recent.svg
+ share/icons/application/scalable/actions/document-open.svg
share/icons/application/scalable/actions/document-properties.svg
- share/icons/application/scalable/actions/document-save.svg
share/icons/application/scalable/actions/document-save-as.svg
share/icons/application/scalable/actions/document-save-copy.svg
+ share/icons/application/scalable/actions/document-save.svg
share/icons/application/scalable/actions/donate.svg
share/icons/application/scalable/actions/edit-clear-locationbar-ltr.svg
share/icons/application/scalable/actions/edit-clear-locationbar-rtl.svg
share/icons/application/scalable/actions/entry-clone.svg
share/icons/application/scalable/actions/entry-delete.svg
- share/icons/application/scalable/actions/entry-restore.svg
share/icons/application/scalable/actions/entry-edit.svg
share/icons/application/scalable/actions/entry-expire.svg
share/icons/application/scalable/actions/entry-new.svg
+ share/icons/application/scalable/actions/entry-restore.svg
share/icons/application/scalable/actions/favicon-download.svg
share/icons/application/scalable/actions/fingerprint.svg
+ share/icons/application/scalable/actions/format-code-block.svg
+ share/icons/application/scalable/actions/format-code.svg
+ share/icons/application/scalable/actions/format-header-decrease.svg
+ share/icons/application/scalable/actions/format-header-increase.svg
+ share/icons/application/scalable/actions/format-text-bold.svg
+ share/icons/application/scalable/actions/format-text-italic.svg
+ share/icons/application/scalable/actions/format-text-strikethrough.svg
+ share/icons/application/scalable/actions/format-text-underline.svg
share/icons/application/scalable/actions/getting-started.svg
+ share/icons/application/scalable/actions/group-clone.svg
share/icons/application/scalable/actions/group-delete.svg
share/icons/application/scalable/actions/group-edit.svg
- share/icons/application/scalable/actions/group-clone.svg
share/icons/application/scalable/actions/group-empty-trash.svg
share/icons/application/scalable/actions/group-new.svg
share/icons/application/scalable/actions/hammer-wrench.svg
share/icons/application/scalable/actions/health.svg
share/icons/application/scalable/actions/help-about.svg
- share/icons/application/scalable/actions/lock-question.svg
+ share/icons/application/scalable/actions/insert-link.svg
share/icons/application/scalable/actions/keyboard-shortcuts.svg
+ share/icons/application/scalable/actions/lock-open-alert.svg
+ share/icons/application/scalable/actions/lock-open.svg
+ share/icons/application/scalable/actions/lock-question.svg
+ share/icons/application/scalable/actions/lock.svg
share/icons/application/scalable/actions/message-close.svg
share/icons/application/scalable/actions/move-down.svg
share/icons/application/scalable/actions/move-up.svg
@@ -200,31 +212,33 @@ Files: share/icons/application/scalable/actions/application-exit.svg
share/icons/application/scalable/actions/object-unlocked.svg
share/icons/application/scalable/actions/onepassword.svg
share/icons/application/scalable/actions/paperclip.svg
- share/icons/application/scalable/actions/password-copy.svg
share/icons/application/scalable/actions/passkey.svg
+ share/icons/application/scalable/actions/password-copy.svg
share/icons/application/scalable/actions/password-generator.svg
share/icons/application/scalable/actions/password-show-off.svg
share/icons/application/scalable/actions/password-show-on.svg
share/icons/application/scalable/actions/qrcode.svg
+ share/icons/application/scalable/actions/redo.svg
share/icons/application/scalable/actions/refresh.svg
share/icons/application/scalable/actions/remote-sync.svg
- share/icons/application/scalable/actions/reports.svg
share/icons/application/scalable/actions/reports-exclude.svg
+ share/icons/application/scalable/actions/reports.svg
share/icons/application/scalable/actions/sort-alphabetical-ascending.svg
share/icons/application/scalable/actions/sort-alphabetical-descending.svg
share/icons/application/scalable/actions/statistics.svg
share/icons/application/scalable/actions/system-help.svg
share/icons/application/scalable/actions/system-search.svg
share/icons/application/scalable/actions/system-software-update.svg
- share/icons/application/scalable/actions/tag.svg
share/icons/application/scalable/actions/tag-multiple.svg
share/icons/application/scalable/actions/tag-search.svg
- share/icons/application/scalable/actions/totp.svg
- share/icons/application/scalable/actions/totp-copy.svg
+ share/icons/application/scalable/actions/tag.svg
share/icons/application/scalable/actions/totp-copy-password.svg
+ share/icons/application/scalable/actions/totp-copy.svg
share/icons/application/scalable/actions/totp-edit.svg
share/icons/application/scalable/actions/totp-invalid.svg
+ share/icons/application/scalable/actions/totp.svg
share/icons/application/scalable/actions/trash.svg
+ share/icons/application/scalable/actions/undo.svg
share/icons/application/scalable/actions/url-copy.svg
share/icons/application/scalable/actions/user-guide.svg
share/icons/application/scalable/actions/username-copy.svg
@@ -232,10 +246,10 @@ Files: share/icons/application/scalable/actions/application-exit.svg
share/icons/application/scalable/actions/web.svg
share/icons/application/scalable/actions/yubikey-refresh.svg
share/icons/application/scalable/apps/internet-web-browser.svg
- share/icons/application/scalable/apps/keepassxc.svg
share/icons/application/scalable/apps/keepassxc-dark.svg
share/icons/application/scalable/apps/keepassxc-locked.svg
share/icons/application/scalable/apps/keepassxc-unlocked.svg
+ share/icons/application/scalable/apps/keepassxc.svg
share/icons/application/scalable/apps/preferences-desktop-icons.svg
share/icons/application/scalable/apps/preferences-system-network-sharing.svg
share/icons/application/scalable/apps/utilities-terminal.svg
@@ -245,13 +259,17 @@ Files: share/icons/application/scalable/actions/application-exit.svg
share/icons/application/scalable/status/dialog-information.svg
share/icons/application/scalable/status/dialog-warning.svg
share/icons/application/scalable/status/security-high.svg
- share/icons/application/scalable/actions/lock-open-alert.svg
- share/icons/application/scalable/actions/lock-open.svg
- share/icons/application/scalable/actions/lock.svg
Copyright: 2023 Pictogrammers
License: Apache-2.0
Comment: Some icons are modified to fit KeePassXC design (https://pictogrammers.com/library/mdi/)
+Files: share/icons/application/scalable/actions/format-list-bulleted.svg
+ share/icons/application/scalable/actions/format-list-numbered.svg
+ share/icons/application/scalable/actions/format-list-check.svg
+Copyright: 2015–2025 IBM Corp. and the Carbon Design System contributors
+License: Apache-2.0
+Comment: Taken from https://github.com/carbon-design-system/carbon
+
Files: src/streams/qtiocompressor.*
src/streams/QtIOCompressor
tests/modeltest.*
diff --git a/share/icons/application/scalable/actions/format-code-block.svg b/share/icons/application/scalable/actions/format-code-block.svg
new file mode 100644
index 000000000..2774be71e
--- /dev/null
+++ b/share/icons/application/scalable/actions/format-code-block.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/share/icons/application/scalable/actions/format-code.svg b/share/icons/application/scalable/actions/format-code.svg
new file mode 100644
index 000000000..3ff18e8d5
--- /dev/null
+++ b/share/icons/application/scalable/actions/format-code.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/share/icons/application/scalable/actions/format-header-decrease.svg b/share/icons/application/scalable/actions/format-header-decrease.svg
new file mode 100644
index 000000000..a1401f229
--- /dev/null
+++ b/share/icons/application/scalable/actions/format-header-decrease.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/share/icons/application/scalable/actions/format-header-increase.svg b/share/icons/application/scalable/actions/format-header-increase.svg
new file mode 100644
index 000000000..bde8c004c
--- /dev/null
+++ b/share/icons/application/scalable/actions/format-header-increase.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/share/icons/application/scalable/actions/format-list-bulleted.svg b/share/icons/application/scalable/actions/format-list-bulleted.svg
new file mode 100644
index 000000000..5ae607003
--- /dev/null
+++ b/share/icons/application/scalable/actions/format-list-bulleted.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/share/icons/application/scalable/actions/format-list-check.svg b/share/icons/application/scalable/actions/format-list-check.svg
new file mode 100644
index 000000000..683410f1f
--- /dev/null
+++ b/share/icons/application/scalable/actions/format-list-check.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/share/icons/application/scalable/actions/format-list-numbered.svg b/share/icons/application/scalable/actions/format-list-numbered.svg
new file mode 100644
index 000000000..104ee9bc6
--- /dev/null
+++ b/share/icons/application/scalable/actions/format-list-numbered.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/share/icons/application/scalable/actions/format-text-bold.svg b/share/icons/application/scalable/actions/format-text-bold.svg
new file mode 100644
index 000000000..4844de937
--- /dev/null
+++ b/share/icons/application/scalable/actions/format-text-bold.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/share/icons/application/scalable/actions/format-text-italic.svg b/share/icons/application/scalable/actions/format-text-italic.svg
new file mode 100644
index 000000000..71565e678
--- /dev/null
+++ b/share/icons/application/scalable/actions/format-text-italic.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/share/icons/application/scalable/actions/format-text-strikethrough.svg b/share/icons/application/scalable/actions/format-text-strikethrough.svg
new file mode 100644
index 000000000..98e1d3c1c
--- /dev/null
+++ b/share/icons/application/scalable/actions/format-text-strikethrough.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/share/icons/application/scalable/actions/format-text-underline.svg b/share/icons/application/scalable/actions/format-text-underline.svg
new file mode 100644
index 000000000..ff13dab48
--- /dev/null
+++ b/share/icons/application/scalable/actions/format-text-underline.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/share/icons/application/scalable/actions/insert-link.svg b/share/icons/application/scalable/actions/insert-link.svg
new file mode 100644
index 000000000..38ec0372a
--- /dev/null
+++ b/share/icons/application/scalable/actions/insert-link.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/share/icons/application/scalable/actions/redo.svg b/share/icons/application/scalable/actions/redo.svg
new file mode 100644
index 000000000..892cbc744
--- /dev/null
+++ b/share/icons/application/scalable/actions/redo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/share/icons/application/scalable/actions/undo.svg b/share/icons/application/scalable/actions/undo.svg
new file mode 100644
index 000000000..40b73fc75
--- /dev/null
+++ b/share/icons/application/scalable/actions/undo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/share/icons/icons.qrc b/share/icons/icons.qrc
index 3e82e172d..9c3dc934f 100644
--- a/share/icons/icons.qrc
+++ b/share/icons/icons.qrc
@@ -46,6 +46,17 @@
application/scalable/actions/entry-new.svg
application/scalable/actions/favicon-download.svg
application/scalable/actions/fingerprint.svg
+ application/scalable/actions/format-code-block.svg
+ application/scalable/actions/format-code.svg
+ application/scalable/actions/format-header-decrease.svg
+ application/scalable/actions/format-header-increase.svg
+ application/scalable/actions/format-list-bulleted.svg
+ application/scalable/actions/format-list-check.svg
+ application/scalable/actions/format-list-numbered.svg
+ application/scalable/actions/format-text-bold.svg
+ application/scalable/actions/format-text-italic.svg
+ application/scalable/actions/format-text-strikethrough.svg
+ application/scalable/actions/format-text-underline.svg
application/scalable/actions/getting-started.svg
application/scalable/actions/group-delete.svg
application/scalable/actions/group-edit.svg
@@ -56,6 +67,7 @@
application/scalable/actions/health.svg
application/scalable/actions/help-about.svg
application/scalable/actions/hibp.svg
+ application/scalable/actions/insert-link.svg
application/scalable/actions/lock-question.svg
application/scalable/actions/keyboard-shortcuts.svg
application/scalable/actions/lock.svg
@@ -75,6 +87,7 @@
application/scalable/actions/password-show-on.svg
application/scalable/actions/proton.svg
application/scalable/actions/qrcode.svg
+ application/scalable/actions/redo.svg
application/scalable/actions/refresh.svg
application/scalable/actions/remote-sync.svg
application/scalable/actions/reports.svg
@@ -94,6 +107,7 @@
application/scalable/actions/totp-edit.svg
application/scalable/actions/totp-invalid.svg
application/scalable/actions/trash.svg
+ application/scalable/actions/undo.svg
application/scalable/actions/url-copy.svg
application/scalable/actions/user-guide.svg
application/scalable/actions/username-copy.svg
diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts
index 34a1abe40..acf687664 100644
--- a/share/translations/keepassxc_en.ts
+++ b/share/translations/keepassxc_en.ts
@@ -6421,6 +6421,89 @@ Expect some bugs and minor issues, this version is meant for testing purposes.
+
+ MarkdownToolbar
+
+ Undo
+
+
+
+ Redo
+
+
+
+ Decrease heading level
+
+
+
+ Increase heading level
+
+
+
+ Bold
+
+
+
+ Italic
+
+
+
+ Strike
+
+
+
+ Underline
+
+
+
+ Link
+
+
+
+ Bullet list
+
+
+
+ Numbered list
+
+
+
+ Task list
+
+
+
+ Inline code
+
+
+
+ Code block
+
+
+
+ Insert Link
+
+
+
+ Full link address (e.g. https://example.com or mailto:user@example.com)
+
+
+
+ Text shown instead of the URL
+
+
+
+ Link text:
+
+
+
+ Text shown on hover
+
+
+
+ Tooltip:
+
+
+
MergeDialog
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index fcc29784d..bbf4bc072 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -206,6 +206,7 @@ set(gui_SOURCES
gui/osutils/ScreenLockListenerPrivate.cpp
gui/widgets/ElidedLabel.cpp
gui/widgets/KPToolBar.cpp
+ gui/widgets/MarkdownToolbar.cpp
gui/widgets/PopupHelpWidget.cpp
gui/widgets/ShortcutWidget.cpp
gui/wizard/ImportWizard.cpp
diff --git a/src/gui/entry/attachments/TextAttachmentsEditWidget.cpp b/src/gui/entry/attachments/TextAttachmentsEditWidget.cpp
index c67404a6f..7c73a32a8 100644
--- a/src/gui/entry/attachments/TextAttachmentsEditWidget.cpp
+++ b/src/gui/entry/attachments/TextAttachmentsEditWidget.cpp
@@ -27,6 +27,8 @@ TextAttachmentsEditWidget::TextAttachmentsEditWidget(QWidget* parent)
, m_ui(new Ui::TextAttachmentsEditWidget())
{
m_ui->setupUi(this);
+ m_mdToolbar = new MarkdownToolbar(this);
+ m_mdToolbar->setTarget(m_ui->attachmentsTextEdit);
connect(m_ui->attachmentsTextEdit, &QTextEdit::textChanged, this, &TextAttachmentsEditWidget::textChanged);
connect(m_ui->previewPushButton, &QPushButton::clicked, this, &TextAttachmentsEditWidget::previewButtonClicked);
@@ -43,7 +45,6 @@ void TextAttachmentsEditWidget::openAttachment(attachments::Attachment attachmen
{
m_attachment = std::move(attachments);
m_mode = mode;
-
updateUi();
}
diff --git a/src/gui/entry/attachments/TextAttachmentsEditWidget.h b/src/gui/entry/attachments/TextAttachmentsEditWidget.h
index dfd63fb92..269385337 100644
--- a/src/gui/entry/attachments/TextAttachmentsEditWidget.h
+++ b/src/gui/entry/attachments/TextAttachmentsEditWidget.h
@@ -19,6 +19,8 @@
#include "AttachmentTypes.h"
+#include "gui/widgets/MarkdownToolbar.h"
+
#include
#include
@@ -36,6 +38,10 @@ public:
void openAttachment(attachments::Attachment attachment, attachments::OpenMode mode);
attachments::Attachment getAttachment() const;
+ QToolBar* markdownToolbar() const
+ {
+ return m_mdToolbar;
+ }
signals:
void textChanged();
@@ -46,6 +52,7 @@ private:
void updateUi();
QScopedPointer m_ui;
+ MarkdownToolbar* m_mdToolbar = nullptr;
attachments::Attachment m_attachment;
attachments::OpenMode m_mode;
diff --git a/src/gui/entry/attachments/TextAttachmentsWidget.cpp b/src/gui/entry/attachments/TextAttachmentsWidget.cpp
index b28465a46..7d162f2ff 100644
--- a/src/gui/entry/attachments/TextAttachmentsWidget.cpp
+++ b/src/gui/entry/attachments/TextAttachmentsWidget.cpp
@@ -19,8 +19,9 @@
#include "TextAttachmentsEditWidget.h"
#include "TextAttachmentsPreviewWidget.h"
+#include
+
#include
-#include
#include
#include
@@ -39,6 +40,14 @@ void TextAttachmentsWidget::openAttachment(attachments::Attachment attachment, a
m_attachment = std::move(attachment);
m_mode = mode;
+#if (QT_VERSION >= QT_VERSION_CHECK(5, 14, 0))
+ const auto mimeType = Tools::getMimeType(QFileInfo(m_attachment.name));
+ const bool isMd = (mimeType == Tools::MimeType::Markdown);
+ if (auto* tb = m_editWidget->markdownToolbar()) {
+ tb->setVisible(isMd && m_mode != attachments::OpenMode::ReadOnly);
+ }
+#endif
+
updateWidget();
}
@@ -120,6 +129,12 @@ void TextAttachmentsWidget::initWidget()
// Setup this widget with the splitter
auto layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
+
+ if (auto* tb = m_editWidget->markdownToolbar()) {
+ tb->setParent(this);
+ layout->addWidget(tb);
+ }
+
layout->addWidget(m_splitter);
setLayout(layout);
setObjectName("TextAttachmentsWidget");
diff --git a/src/gui/entry/attachments/TextAttachmentsWidget.h b/src/gui/entry/attachments/TextAttachmentsWidget.h
index 048ab7c50..2dc1fca60 100644
--- a/src/gui/entry/attachments/TextAttachmentsWidget.h
+++ b/src/gui/entry/attachments/TextAttachmentsWidget.h
@@ -20,14 +20,8 @@
#include "AttachmentTypes.h"
#include
-#include
#include
-namespace Ui
-{
- class TextAttachmentsWidget;
-}
-
class QSplitter;
class QTimer;
class TextAttachmentsPreviewWidget;
diff --git a/src/gui/widgets/MarkdownToolbar.cpp b/src/gui/widgets/MarkdownToolbar.cpp
new file mode 100644
index 000000000..2d8784ee1
--- /dev/null
+++ b/src/gui/widgets/MarkdownToolbar.cpp
@@ -0,0 +1,497 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 or (at your option)
+ * version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "MarkdownToolbar.h"
+
+#include "core/Config.h"
+#include "gui/Icons.h"
+#include "gui/styles/StateColorPalette.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+MarkdownToolbar::MarkdownToolbar(QWidget* parent)
+ : QToolBar(parent)
+{
+ setupUi();
+}
+
+void MarkdownToolbar::setTarget(QTextEdit* edit)
+{
+ m_target = edit;
+ if (!m_target)
+ return;
+
+ m_target->setContextMenuPolicy(Qt::DefaultContextMenu);
+
+ // Undo
+ if (m_undoAction) {
+ m_undoAction->disconnect();
+ connect(m_undoAction, &QAction::triggered, m_target, &QTextEdit::undo);
+
+ m_undoAction->setEnabled(m_target->document()->isUndoAvailable());
+
+ connect(m_target->document(), &QTextDocument::undoAvailable, m_undoAction, &QAction::setEnabled);
+ }
+
+ // Redo
+ if (m_redoAction) {
+ m_redoAction->disconnect();
+ connect(m_redoAction, &QAction::triggered, m_target, &QTextEdit::redo);
+
+ m_redoAction->setEnabled(m_target->document()->isRedoAvailable());
+
+ connect(m_target->document(), &QTextDocument::redoAvailable, m_redoAction, &QAction::setEnabled);
+ }
+}
+
+QAction* MarkdownToolbar::createAction(const QString& iconName,
+ const QString& tooltip,
+ const QKeySequence& shortcut,
+ const std::function& callback)
+{
+ QAction* act = addAction(icons()->icon(iconName), tooltip);
+
+ if (!shortcut.isEmpty()) {
+ act->setShortcut(shortcut);
+ QString tip = tooltip;
+ tip += " (" + shortcut.toString(QKeySequence::NativeText) + ")";
+ act->setToolTip(tip);
+ } else {
+ act->setToolTip(tooltip);
+ }
+
+ act->setShortcutVisibleInContextMenu(true);
+
+ if (auto tb = qobject_cast(widgetForAction(act)))
+ tb->setAutoRaise(true);
+
+ if (callback)
+ connect(act, &QAction::triggered, this, callback);
+
+ return act;
+}
+
+void MarkdownToolbar::setupUi()
+{
+ setMovable(false);
+ setFloatable(false);
+ setToolButtonStyle(Qt::ToolButtonIconOnly);
+
+ if (config()->get(Config::GUI_CompactMode).toBool())
+ setIconSize(QSize(20, 20));
+
+ // Undo
+ QString undoText = tr("Undo");
+ QString undoShortcut = QKeySequence(QKeySequence::Undo).toString(QKeySequence::NativeText);
+ m_undoAction = createAction("undo", undoText, QKeySequence(), [=]() {
+ if (m_target)
+ m_target->undo();
+ });
+ m_undoAction->setToolTip(QString("%1 (%2)").arg(undoText, undoShortcut));
+
+ // Redo
+ QString redoText = tr("Redo");
+ QString redoShortcut = QKeySequence(QKeySequence::Redo).toString(QKeySequence::NativeText);
+ m_redoAction = createAction("redo", redoText, QKeySequence(), [=]() {
+ if (m_target)
+ m_target->redo();
+ });
+ m_redoAction->setToolTip(QString("%1 (%2)").arg(redoText, redoShortcut));
+
+ addSeparator();
+
+ // Other groups of buttons
+ struct ButtonSpec
+ {
+ QString iconName;
+ QString tooltip;
+ QKeySequence shortcut;
+ std::function callback;
+ };
+
+ const QList> buttonGroups = {
+ {
+ {"format-header-decrease",
+ tr("Decrease heading level"),
+ QKeySequence(Qt::CTRL | Qt::Key_Down),
+ [=]() { changeHeadingLevel(-1); }},
+ {"format-header-increase",
+ tr("Increase heading level"),
+ QKeySequence(Qt::CTRL | Qt::Key_Up),
+ [=]() { changeHeadingLevel(1); }},
+ },
+ {
+ {"format-text-bold", tr("Bold"), QKeySequence::Bold, [=]() { wrapSelection("**", "**"); }},
+ {"format-text-italic", tr("Italic"), QKeySequence::Italic, [=]() { wrapSelection("*", "*"); }},
+ {"format-text-strikethrough",
+ tr("Strike"),
+ QKeySequence(Qt::CTRL | Qt::Key_S),
+ [=]() { wrapSelection("~~", "~~"); }},
+ {"format-text-underline",
+ tr("Underline"),
+ QKeySequence::Underline,
+ [=]() { wrapSelection("", ""); }},
+ },
+ {
+ {"insert-link", tr("Link"), QKeySequence(Qt::CTRL | Qt::Key_L), [=]() { insertLink(); }},
+ },
+ {
+ {"format-list-bulleted",
+ tr("Bullet list"),
+ QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_B),
+ [=]() { wrapSelection("- ", "", true); }},
+ {"format-list-numbered",
+ tr("Numbered list"),
+ QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_N),
+ [=]() { wrapSelectionWithNumbering(); }},
+ {"format-list-check",
+ tr("Task list"),
+ QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_T),
+ [=]() { wrapSelection("- [ ] ", "", true); }},
+ },
+ {
+ {"format-code", tr("Inline code"), QKeySequence(Qt::CTRL | Qt::Key_E), [=]() { wrapSelection("`", "`"); }},
+ {"format-code-block",
+ tr("Code block"),
+ QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_E),
+ [=]() { wrapSelection("```\n", "\n```"); }},
+ }};
+
+ for (int i = 0; i < buttonGroups.size(); ++i) {
+ for (const auto& btn : buttonGroups[i]) {
+ createAction(btn.iconName, btn.tooltip, btn.shortcut, btn.callback);
+ }
+ if (i < buttonGroups.size() - 1)
+ addSeparator();
+ }
+}
+
+void MarkdownToolbar::wrapSelection(const QString& prefix, const QString& suffix, bool lineWise)
+{
+ if (!m_target)
+ return;
+
+ QTextCursor cursor = m_target->textCursor();
+ QString selected = cursor.selectedText().replace(QChar::ParagraphSeparator, '\n');
+
+ QString result;
+ if (lineWise) {
+ QStringList lines = selected.split('\n');
+ for (QString& line : lines) {
+ line = prefix + line + suffix;
+ }
+ result = lines.join("\n");
+ } else {
+ result = prefix + selected + suffix;
+ }
+
+ cursor.beginEditBlock();
+ cursor.insertText(result);
+ cursor.endEditBlock();
+}
+
+void MarkdownToolbar::wrapSelectionWithNumbering()
+{
+ if (!m_target)
+ return;
+
+ QTextCursor cursor = m_target->textCursor();
+ QString selected = cursor.selectedText().replace(QChar::ParagraphSeparator, '\n');
+
+ QStringList lines = selected.split('\n');
+ QStringList numbered;
+ for (int i = 0; i < lines.size(); ++i) {
+ numbered << QString::number(i + 1) + ". " + lines[i];
+ }
+
+ cursor.beginEditBlock();
+ cursor.insertText(numbered.join("\n"));
+ cursor.endEditBlock();
+}
+
+void MarkdownToolbar::changeHeadingLevel(int delta, bool clearAll)
+{
+ if (!m_target)
+ return;
+
+ static const QRegularExpression HeadingRegex(R"rx(^(#{1,6})(?:\s+(.*))?$)rx");
+
+ QTextDocument* doc = m_target->document();
+ if (!doc)
+ return;
+
+ QTextCursor cur = m_target->textCursor();
+ const int origAnchor = cur.anchor();
+ const int origCaret = cur.position();
+ const bool collapsed = (origAnchor == origCaret);
+
+ struct CursorPos
+ {
+ int blockNumber;
+ int offsetInContent;
+ };
+
+ auto parseLine = [&](const QString& line, int& oldLevel, QString& pureContent) {
+ oldLevel = 0;
+ pureContent = line;
+
+ auto hm = HeadingRegex.match(pureContent);
+ if (hm.hasMatch()) {
+ oldLevel = hm.captured(1).size();
+ pureContent = hm.captured(2);
+ }
+ };
+
+ auto toCursorPos = [&](int pos) -> CursorPos {
+ QTextBlock b = doc->findBlock(pos);
+ if (!b.isValid()) {
+ return CursorPos{doc->lastBlock().blockNumber(), 0};
+ }
+ const QString text = b.text();
+ int ol = 0;
+ QString pure;
+ parseLine(text, ol, pure);
+ const int prefixLen = text.length() - pure.length();
+ const int offInBlock = pos - b.position();
+ const int offInContent = qBound(0, offInBlock - prefixLen, pure.length());
+ return CursorPos{b.blockNumber(), offInContent};
+ };
+
+ auto fromCursorPos = [&](const CursorPos& cp) -> int {
+ QTextBlock b = doc->findBlockByNumber(cp.blockNumber);
+ if (!b.isValid()) {
+ return qMax(0, doc->characterCount() - 1);
+ }
+ const QString text = b.text();
+ int ol = 0;
+ QString pure;
+ parseLine(text, ol, pure);
+ const int prefixLen = text.length() - pure.length();
+ const int offInContent = qBound(0, cp.offsetInContent, pure.length());
+ return b.position() + prefixLen + offInContent;
+ };
+
+ const bool anchorWasEnd = (origAnchor > origCaret);
+ CursorPos anchorPos = toCursorPos(origAnchor);
+ CursorPos caretPos = toCursorPos(origCaret);
+
+ const int selStart = qMin(origAnchor, origCaret);
+ const int selEnd = qMax(origAnchor, origCaret);
+
+ QVector blocks;
+ if (collapsed) {
+ QTextBlock b = doc->findBlock(selStart);
+ if (b.isValid())
+ blocks.push_back(b);
+ } else {
+ for (QTextBlock b = doc->findBlock(selStart); b.isValid(); b = b.next()) {
+ if (b.position() <= selEnd) {
+ blocks.push_back(b);
+ } else {
+ break;
+ }
+ }
+ }
+ if (blocks.isEmpty())
+ return;
+
+ bool hasHeadings = false;
+ for (const QTextBlock& b : blocks) {
+ int ol = 0;
+ QString pc;
+ parseLine(b.text(), ol, pc);
+ if (ol > 0) {
+ hasHeadings = true;
+ break;
+ }
+ }
+
+ int targetLevel = -1;
+ if (clearAll) {
+ targetLevel = 0;
+ } else if (!hasHeadings) {
+ targetLevel = std::clamp(delta, 0, 6);
+ }
+
+ cur.beginEditBlock();
+
+ for (const QTextBlock& b : blocks) {
+ const QString original = b.text();
+ int oldLevel = 0;
+ QString pure;
+ parseLine(original, oldLevel, pure);
+
+ const int newLevel = (targetLevel >= 0) ? targetLevel : std::clamp(oldLevel + delta, 0, 6);
+
+ const QString hashes = (newLevel == 0) ? QString() : QString(newLevel, QChar('#'));
+ const QString space = (newLevel == 0 || pure.isEmpty()) ? QString() : QStringLiteral(" ");
+ const QString newLine = (newLevel == 0) ? pure : hashes + space + pure;
+
+ if (newLine != original) {
+ QTextCursor lc(b);
+ lc.movePosition(QTextCursor::StartOfBlock);
+ lc.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
+ lc.insertText(newLine);
+ }
+ }
+
+ cur.endEditBlock();
+
+ // Restore cursor
+ QTextCursor nc = m_target->textCursor();
+ if (collapsed) {
+ nc.setPosition(fromCursorPos(caretPos));
+ } else {
+ const int start = fromCursorPos(anchorPos);
+ const int end = fromCursorPos(caretPos);
+ if (anchorWasEnd) {
+ nc.setPosition(end);
+ nc.setPosition(start, QTextCursor::KeepAnchor);
+ } else {
+ nc.setPosition(start);
+ nc.setPosition(end, QTextCursor::KeepAnchor);
+ }
+ }
+ m_target->setTextCursor(nc);
+}
+
+void MarkdownToolbar::insertLink()
+{
+ if (!m_target)
+ return;
+
+ QTextCursor cursor = m_target->textCursor();
+ QString originalSelected = cursor.selectedText().replace(QChar::ParagraphSeparator, '\n');
+ QString trimmedSelected = originalSelected.trimmed();
+
+ QString prefillText, prefillUrl, prefillTooltip;
+ bool hasSelection = !trimmedSelected.isEmpty();
+ bool matched = false;
+
+ auto tryMatch = [&](const QRegularExpression& re, auto handler) {
+ auto m = re.match(trimmedSelected);
+ if (m.hasMatch()) {
+ matched = true;
+ handler(m);
+ }
+ };
+
+ static const QRegularExpression PlainUrlRegex(R"rx(^(https?://\S+)$)rx", QRegularExpression::CaseInsensitiveOption);
+ static const QRegularExpression EmailRegex(R"rx(^([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})$)rx");
+
+ tryMatch(PlainUrlRegex, [&](const auto& m) {
+ prefillUrl = m.captured(1);
+ prefillText = prefillUrl;
+ prefillText.remove(QRegularExpression("^https?://", QRegularExpression::CaseInsensitiveOption));
+ });
+
+ if (!matched)
+ tryMatch(EmailRegex, [&](const auto& m) {
+ prefillUrl = "mailto:" + m.captured(1);
+ prefillText = m.captured(1);
+ });
+
+ if (hasSelection && !matched) {
+ prefillText = trimmedSelected;
+ cursor.removeSelectedText();
+ }
+
+ QDialog dlg(this);
+ dlg.setWindowTitle(tr("Insert Link"));
+ dlg.resize(420, 160);
+ QFormLayout layout(&dlg);
+ layout.setContentsMargins(12, 12, 12, 12);
+ layout.setVerticalSpacing(10);
+
+ QLineEdit urlEdit;
+ urlEdit.setPlaceholderText("https://example.com");
+ urlEdit.setToolTip(tr("Full link address (e.g. https://example.com or mailto:user@example.com)"));
+
+ if (!prefillUrl.isEmpty())
+ urlEdit.setText(prefillUrl);
+ layout.addRow("URL:", &urlEdit);
+
+ QLineEdit textEdit;
+ textEdit.setToolTip(tr("Text shown instead of the URL"));
+ if (!prefillText.isEmpty())
+ textEdit.setText(prefillText);
+ layout.addRow(tr("Link text:"), &textEdit);
+
+ QLineEdit tooltipEdit;
+ tooltipEdit.setToolTip(tr("Text shown on hover"));
+ if (!prefillTooltip.isEmpty())
+ tooltipEdit.setText(prefillTooltip);
+ layout.addRow(tr("Tooltip:"), &tooltipEdit);
+
+ QDialogButtonBox buttons(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
+ auto okBtn = buttons.button(QDialogButtonBox::Ok);
+ okBtn->setAutoDefault(false);
+ okBtn->setDefault(false);
+ layout.addRow(&buttons);
+
+ StateColorPalette statePalette;
+ auto errorColor = statePalette.color(StateColorPalette::ColorRole::Error);
+
+ connect(&urlEdit, &QLineEdit::textChanged, [&](const QString& text) {
+ if (!text.trimmed().isEmpty()) {
+ urlEdit.setStyleSheet("");
+ }
+ });
+
+ auto tryAccept = [&]() {
+ if (urlEdit.text().trimmed().isEmpty()) {
+ urlEdit.setStyleSheet(QString("QLineEdit { background: %1; }").arg(errorColor.name()));
+ urlEdit.setFocus();
+ return;
+ }
+ dlg.accept();
+ };
+
+ connect(&urlEdit, &QLineEdit::returnPressed, tryAccept);
+ connect(&textEdit, &QLineEdit::returnPressed, tryAccept);
+ connect(&tooltipEdit, &QLineEdit::returnPressed, tryAccept);
+ connect(okBtn, &QPushButton::clicked, tryAccept);
+ connect(buttons.button(QDialogButtonBox::Cancel), &QPushButton::clicked, &dlg, &QDialog::reject);
+
+ urlEdit.setFocus();
+
+ if (dlg.exec() != QDialog::Accepted) {
+ if (hasSelection) {
+ cursor.insertText(originalSelected);
+ }
+ return;
+ }
+
+ const QString url = urlEdit.text().trimmed();
+ const QString text = textEdit.text().trimmed().isEmpty() ? url : textEdit.text().trimmed();
+ const QString tooltip = tooltipEdit.text().trimmed();
+
+ QString link = tooltip.isEmpty() ? QStringLiteral("[%1](%2)").arg(text, url)
+ : QStringLiteral("[%1](%2 \"%3\")").arg(text, url, tooltip);
+
+ cursor.insertText(link);
+ m_target->setTextCursor(cursor);
+}
diff --git a/src/gui/widgets/MarkdownToolbar.h b/src/gui/widgets/MarkdownToolbar.h
new file mode 100644
index 000000000..b63ed0471
--- /dev/null
+++ b/src/gui/widgets/MarkdownToolbar.h
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 or (at your option)
+ * version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include
+#include
+#include
+
+class QTextEdit;
+
+class MarkdownToolbar : public QToolBar
+{
+ Q_OBJECT
+public:
+ explicit MarkdownToolbar(QWidget* parent = nullptr);
+ void setTarget(QTextEdit* edit);
+
+private:
+ void setupUi();
+ void wrapSelection(const QString& prefix, const QString& suffix, bool lineWise = false);
+ void wrapSelectionWithNumbering();
+ void insertLink();
+ void changeHeadingLevel(int delta, bool clearAll = false);
+
+ QAction* createAction(const QString& iconName,
+ const QString& tooltip,
+ const QKeySequence& shortcut,
+ const std::function& callback);
+
+private:
+ QTextEdit* m_target = nullptr;
+
+ QAction* m_undoAction = nullptr;
+ QAction* m_redoAction = nullptr;
+};