This commit is contained in:
xboxones1 2026-03-10 09:51:12 +05:30 committed by GitHub
commit 30d3060417
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 729 additions and 27 deletions

48
COPYING
View file

@ -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 <https://pictogrammers.com/docs/general/about/>
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: 20152025 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.*

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5.59 3.41L7 4.82L3.82 8L7 11.18L5.59 12.6L1 8L5.59 3.41M11.41 3.41L16 8L11.41 12.6L10 11.18L13.18 8L10 4.82L11.41 3.41M22 6V18C22 19.11 21.11 20 20 20H4C2.9 20 2 19.11 2 18V14H4V18H20V6H17.03V4H20C21.11 4 22 4.89 22 6Z" /></svg>

After

Width:  |  Height:  |  Size: 298 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14.6,16.6L19.2,12L14.6,7.4L16,6L22,12L16,18L14.6,16.6M9.4,16.6L4.8,12L9.4,7.4L8,6L2,12L8,18L9.4,16.6Z" /></svg>

After

Width:  |  Height:  |  Size: 181 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4,4H6V10H10V4H12V18H10V12H6V18H4V4M20.42,7.41L16.83,11L20.42,14.59L19,16L14,11L19,6L20.42,7.41Z" /></svg>

After

Width:  |  Height:  |  Size: 175 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4,4H6V10H10V4H12V18H10V12H6V18H4V4M14.59,7.41L18.17,11L14.59,14.59L16,16L21,11L16,6L14.59,7.41Z" /></svg>

After

Width:  |  Height:  |  Size: 175 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="7" cy="9" r="3" fill="currentColor"/><circle cx="7" cy="23" r="3" fill="currentColor"/><path fill="currentColor" d="M16 22h14v2H16zm0-14h14v2H16z"/></svg>

After

Width:  |  Height:  |  Size: 249 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M16 8h14v2H16zm0 14h14v2H16zm-6-8H4a2.002 2.002 0 0 1-2-2V6a2.002 2.002 0 0 1 2-2h6a2.002 2.002 0 0 1 2 2v6a2.002 2.002 0 0 1-2 2M4 6v6h6.001L10 6zm6 22H4a2.002 2.002 0 0 1-2-2v-6a2.002 2.002 0 0 1 2-2h6a2.002 2.002 0 0 1 2 2v6a2.002 2.002 0 0 1-2 2m-6-8v6h6.001L10 20z"/></svg>

After

Width:  |  Height:  |  Size: 390 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M16 22h14v2H16zm0-14h14v2H16zm-8 4V4H6v1H4v2h2v5H4v2h6v-2zm2 16H4v-4a2 2 0 0 1 2-2h2v-2H4v-2h4a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6v2h4z"/></svg>

After

Width:  |  Height:  |  Size: 252 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13.5,15.5H10V12.5H13.5A1.5,1.5 0 0,1 15,14A1.5,1.5 0 0,1 13.5,15.5M10,6.5H13A1.5,1.5 0 0,1 14.5,8A1.5,1.5 0 0,1 13,9.5H10M15.6,10.79C16.57,10.11 17.25,9 17.25,8C17.25,5.74 15.5,4 13.25,4H7V18H14.04C16.14,18 17.75,16.3 17.75,14.21C17.75,12.69 16.89,11.39 15.6,10.79Z" /></svg>

After

Width:  |  Height:  |  Size: 345 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M10,4V7H12.21L8.79,15H6V18H14V15H11.79L15.21,7H18V4H10Z" /></svg>

After

Width:  |  Height:  |  Size: 134 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.2 9.8C6 7.5 7.7 4.8 10.1 4.3C13.2 3.3 17.7 4.7 17.6 8.5H14.6C14.6 8.2 14.5 7.9 14.5 7.7C14.3 7.1 13.9 6.8 13.3 6.6C12.5 6.3 11.2 6.4 10.5 6.9C9 8.2 10.4 9.5 12 10H7.4C7.3 9.9 7.3 9.8 7.2 9.8M21 13V11H3V13H12.6C12.8 13.1 13 13.1 13.2 13.2C13.8 13.5 14.3 13.7 14.5 14.3C14.6 14.7 14.7 15.2 14.5 15.6C14.3 16.1 13.9 16.3 13.4 16.5C11.6 17 9.4 16.3 9.5 14.1H6.5C6.4 16.7 8.6 18.5 11 18.8C14.8 19.6 19.3 17.2 17.3 12.9L21 13Z" /></svg>

After

Width:  |  Height:  |  Size: 502 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5,21H19V19H5V21M12,17A6,6 0 0,0 18,11V3H15.5V11A3.5,3.5 0 0,1 12,14.5A3.5,3.5 0 0,1 8.5,11V3H6V11A6,6 0 0,0 12,17Z" /></svg>

After

Width:  |  Height:  |  Size: 194 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M10.59,13.41C11,13.8 11,14.44 10.59,14.83C10.2,15.22 9.56,15.22 9.17,14.83C7.22,12.88 7.22,9.71 9.17,7.76V7.76L12.71,4.22C14.66,2.27 17.83,2.27 19.78,4.22C21.73,6.17 21.73,9.34 19.78,11.29L18.29,12.78C18.3,11.96 18.17,11.14 17.89,10.36L18.36,9.88C19.54,8.71 19.54,6.81 18.36,5.64C17.19,4.46 15.29,4.46 14.12,5.64L10.59,9.17C9.41,10.34 9.41,12.24 10.59,13.41M13.41,9.17C13.8,8.78 14.44,8.78 14.83,9.17C16.78,11.12 16.78,14.29 14.83,16.24V16.24L11.29,19.78C9.34,21.73 6.17,21.73 4.22,19.78C2.27,17.83 2.27,14.66 4.22,12.71L5.71,11.22C5.7,12.04 5.83,12.86 6.11,13.65L5.64,14.12C4.46,15.29 4.46,17.19 5.64,18.36C6.81,19.54 8.71,19.54 9.88,18.36L13.41,14.83C14.59,13.66 14.59,11.76 13.41,10.59C13,10.2 13,9.56 13.41,9.17Z" /></svg>

After

Width:  |  Height:  |  Size: 795 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 13.5C3 9.36 6.36 6 10.5 6H13V2L20 8L13 14V10H10.5C8.57 10 7 11.57 7 13.5S8.57 17 10.5 17H18V21H10.5C6.36 21 3 17.64 3 13.5Z" /></svg>

After

Width:  |  Height:  |  Size: 205 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13.5 21H6V17H13.5C15.43 17 17 15.43 17 13.5S15.43 10 13.5 10H11V14L4 8L11 2V6H13.5C17.64 6 21 9.36 21 13.5S17.64 21 13.5 21Z" /></svg>

After

Width:  |  Height:  |  Size: 204 B

View file

@ -46,6 +46,17 @@
<file>application/scalable/actions/entry-new.svg</file>
<file>application/scalable/actions/favicon-download.svg</file>
<file>application/scalable/actions/fingerprint.svg</file>
<file>application/scalable/actions/format-code-block.svg</file>
<file>application/scalable/actions/format-code.svg</file>
<file>application/scalable/actions/format-header-decrease.svg</file>
<file>application/scalable/actions/format-header-increase.svg</file>
<file>application/scalable/actions/format-list-bulleted.svg</file>
<file>application/scalable/actions/format-list-check.svg</file>
<file>application/scalable/actions/format-list-numbered.svg</file>
<file>application/scalable/actions/format-text-bold.svg</file>
<file>application/scalable/actions/format-text-italic.svg</file>
<file>application/scalable/actions/format-text-strikethrough.svg</file>
<file>application/scalable/actions/format-text-underline.svg</file>
<file>application/scalable/actions/getting-started.svg</file>
<file>application/scalable/actions/group-delete.svg</file>
<file>application/scalable/actions/group-edit.svg</file>
@ -56,6 +67,7 @@
<file>application/scalable/actions/health.svg</file>
<file>application/scalable/actions/help-about.svg</file>
<file>application/scalable/actions/hibp.svg</file>
<file>application/scalable/actions/insert-link.svg</file>
<file>application/scalable/actions/lock-question.svg</file>
<file>application/scalable/actions/keyboard-shortcuts.svg</file>
<file>application/scalable/actions/lock.svg</file>
@ -75,6 +87,7 @@
<file>application/scalable/actions/password-show-on.svg</file>
<file>application/scalable/actions/proton.svg</file>
<file>application/scalable/actions/qrcode.svg</file>
<file>application/scalable/actions/redo.svg</file>
<file>application/scalable/actions/refresh.svg</file>
<file>application/scalable/actions/remote-sync.svg</file>
<file>application/scalable/actions/reports.svg</file>
@ -94,6 +107,7 @@
<file>application/scalable/actions/totp-edit.svg</file>
<file>application/scalable/actions/totp-invalid.svg</file>
<file>application/scalable/actions/trash.svg</file>
<file>application/scalable/actions/undo.svg</file>
<file>application/scalable/actions/url-copy.svg</file>
<file>application/scalable/actions/user-guide.svg</file>
<file>application/scalable/actions/username-copy.svg</file>

View file

@ -6475,6 +6475,89 @@ Expect some bugs and minor issues, this version is meant for testing purposes.</
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>MarkdownToolbar</name>
<message>
<source>Undo</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Redo</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Decrease heading level</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Increase heading level</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Bold</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Italic</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Strike</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Underline</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Link</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Bullet list</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Numbered list</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Task list</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Inline code</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Code block</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Insert Link</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Full link address (e.g. https://example.com or mailto:user@example.com)</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Text shown instead of the URL</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Link text:</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Text shown on hover</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Tooltip:</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>MergeDialog</name>
<message>

View file

@ -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

View file

@ -6,10 +6,16 @@
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>300</height>
<width>720</width>
<height>490</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>720</width>
<height>490</height>
</size>
</property>
<property name="windowTitle">
<string/>
</property>

View file

@ -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();
}

View file

@ -19,6 +19,8 @@
#include "AttachmentTypes.h"
#include "gui/widgets/MarkdownToolbar.h"
#include <QScopedPointer>
#include <QWidget>
@ -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<Ui::TextAttachmentsEditWidget> m_ui;
MarkdownToolbar* m_mdToolbar = nullptr;
attachments::Attachment m_attachment;
attachments::OpenMode m_mode;

View file

@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>300</height>
<width>720</width>
<height>490</height>
</rect>
</property>
<property name="windowTitle">

View file

@ -19,8 +19,9 @@
#include "TextAttachmentsEditWidget.h"
#include "TextAttachmentsPreviewWidget.h"
#include <core/Tools.h>
#include <QSplitter>
#include <QTextEdit>
#include <QTimer>
#include <QVBoxLayout>
@ -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,10 +129,18 @@ 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");
setMinimumSize(720, 490);
updateWidget();
}

View file

@ -20,14 +20,8 @@
#include "AttachmentTypes.h"
#include <QPointer>
#include <QScopedPointer>
#include <QWidget>
namespace Ui
{
class TextAttachmentsWidget;
}
class QSplitter;
class QTimer;
class TextAttachmentsPreviewWidget;

View file

@ -0,0 +1,497 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "MarkdownToolbar.h"
#include "core/Config.h"
#include "gui/Icons.h"
#include "gui/styles/StateColorPalette.h"
#include <QDialog>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLineEdit>
#include <QPushButton>
#include <QRegularExpression>
#include <QTextBlock>
#include <QTextEdit>
#include <QToolButton>
#include <algorithm>
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<void()>& 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<QToolButton*>(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<void()> callback;
};
const QList<QList<ButtonSpec>> 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("<u>", "</u>"); }},
},
{
{"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<QTextBlock> 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);
}

View file

@ -0,0 +1,50 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <QKeySequence>
#include <QToolBar>
#include <functional>
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<void()>& callback);
private:
QTextEdit* m_target = nullptr;
QAction* m_undoAction = nullptr;
QAction* m_redoAction = nullptr;
};