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; +};