diff --git a/client/qmarkdowntextedit/.gitignore b/client/qmarkdowntextedit/.gitignore new file mode 100644 index 0000000..9fcd300 --- /dev/null +++ b/client/qmarkdowntextedit/.gitignore @@ -0,0 +1,8 @@ +*.o +*.log +*.dSYM +*.plist +*.pro.user +.directory +build-* +.idea diff --git a/client/qmarkdowntextedit/.travis.yml b/client/qmarkdowntextedit/.travis.yml new file mode 100644 index 0000000..a703d15 --- /dev/null +++ b/client/qmarkdowntextedit/.travis.yml @@ -0,0 +1,66 @@ +language: cpp + +os: + - linux + - osx + +branches: + only: + - develop + - master + - testing + +env: + matrix: + - CONFIG=release + #- CONFIG=debug + +install: + - if [ "${TRAVIS_OS_NAME}" = "linux" ]; then + lsb_release -a + && sudo apt-add-repository -y ppa:ubuntu-toolchain-r/test + && sudo apt-add-repository -y ppa:beineri/opt-qt591-trusty + && sudo apt-get -qq update + && sudo apt-get -qq install g++-4.8 libc6-i386 qt59tools qt59svg qt59quickcontrols qt59quickcontrols2 qt59webengine qt59script + && export CXX="g++-4.8" + && export CC="gcc-4.8" + ; + else + brew update > /dev/null + && brew install qt5 + && chmod -R 755 /usr/local/opt/qt5/* + ; + fi + +script: + - if [ "${TRAVIS_OS_NAME}" = "linux" ]; then + QTDIR="/opt/qt59" + && PATH="$QTDIR/bin:$PATH" + && qt59-env.sh + ; + else + QTDIR="/usr/local/opt/qt5" + && PATH="$QTDIR/bin:$PATH" + && LDFLAGS=-L$QTDIR/lib + && CPPFLAGS=-I$QTDIR/include + ; + fi + - qmake qmarkdowntextedit.pro CONFIG+=$CONFIG + - make + +notifications: + recipients: + - developer@bekerle.com + email: + on_success: change + on_failure: change + irc: + # https://docs.travis-ci.com/user/notifications/#IRC-notification + channels: + - "chat.freenode.net#qownnotes" + template: + - "[%{commit}] %{repository} (%{branch}): %{message} | Commit message: %{commit_message} | Changes: %{compare_url} | Build details: %{build_url}" + on_success: always + on_failure: always + use_notice: true + skip_join: true diff --git a/client/qmarkdowntextedit/CMakeLists.txt b/client/qmarkdowntextedit/CMakeLists.txt new file mode 100644 index 0000000..cdf5e5e --- /dev/null +++ b/client/qmarkdowntextedit/CMakeLists.txt @@ -0,0 +1,49 @@ +cmake_minimum_required(VERSION 3.3) +project(qmarkdowntextedit) + +#set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11") + +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTOUIC ON) +set(CMAKE_AUTORCC ON) +set(CMAKE_INCLUDE_CURRENT_DIR ON) + +find_package( Qt5Core REQUIRED ) +find_package( Qt5Widgets REQUIRED ) +find_package( Qt5Gui REQUIRED ) + +qt5_wrap_ui(ui_qplaintexteditsearchwidget.h qplaintexteditsearchwidget.ui) + +set(RESOURCE_FILES + media.qrc + ) + +qt5_add_resources(RESOURCE_ADDED ${RESOURCE_FILES}) + +set(SOURCE_FILES + markdownhighlighter.cpp + markdownhighlighter.h + main.cpp + mainwindow.cpp + mainwindow.h + mainwindow.ui + qmarkdowntextedit.cpp + qmarkdowntextedit.h + qplaintexteditsearchwidget.ui + qplaintexteditsearchwidget.cpp + qplaintexteditsearchwidget.h + ) + +add_executable(qmarkdowntextedit ${SOURCE_FILES} ${RESOURCE_ADDED}) + +include_directories(${Qt5Widgets_INCLUDES}) + +# We need add -DQT_WIDGETS_LIB when using QtWidgets in Qt 5. +add_definitions(${Qt5Widgets_DEFINITIONS}) + +# Executables fail to build with Qt 5 in the default configuration +# without -fPIE. We add that here. +set(CMAKE_CXX_FLAGS "${Qt5Widgets_EXECUTABLE_COMPILE_FLAGS}") + +# The Qt5Widgets_LIBRARIES variable also includes QtGui and QtCore +target_link_libraries(qmarkdowntextedit Qt5::Widgets) diff --git a/client/qmarkdowntextedit/LICENSE b/client/qmarkdowntextedit/LICENSE new file mode 100644 index 0000000..41f4fbb --- /dev/null +++ b/client/qmarkdowntextedit/LICENSE @@ -0,0 +1,8 @@ +The MIT License (MIT) +Copyright (c) 2014-2019 Patrizio Bekerle + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/client/qmarkdowntextedit/README.md b/client/qmarkdowntextedit/README.md new file mode 100644 index 0000000..b94bd77 --- /dev/null +++ b/client/qmarkdowntextedit/README.md @@ -0,0 +1,33 @@ +# [QMarkdownTextEdit](https://github.com/pbek/qmarkdowntextedit) +[![Build Status Linux/OS X](https://travis-ci.org/pbek/qmarkdowntextedit.svg?branch=develop)](https://travis-ci.org/pbek/qmarkdowntextedit) +[![Build Status Windows](https://ci.appveyor.com/api/projects/status/github/pbek/qmarkdowntextedit)](https://ci.appveyor.com/project/pbek/qmarkdowntextedit) + +QMarkdownTextEdit is a C++ Qt [QPlainTextEdit](http://doc.qt.io/qt-5/qtextplainedit.html) widget with [markdown](https://en.wikipedia.org/wiki/Markdown) highlighting and some other goodies. + +## Features +- markdown highlighting +- clickable links with `Ctrl + Click` +- block indent with `Tab` and `Shift + Tab` +- duplicate text with `Ctrl + Alt + Down` +- searching of text with `Ctrl + F` + - jump between search results with `Up` and `Down` + - close search field with `Escape` +- replacing of text with `Ctrl + R` + - you can also replace text with regular expressions or whole words +- and much more... + +## Screenshot +![Screenhot](screenshot.png) + +## How to use this widget +- include [qmarkdowntextedit.pri](https://github.com/pbek/qmarkdowntextedit/blob/develop/qmarkdowntextedit.pri) + to your project like this `include (qmarkdowntextedit/qmarkdowntextedit.pri)` +- add a normal `QPlainTextEdit` to your UI and promote it to `QMarkdownTextEdit` (base class `QPlainTextEdit`) + +## References +- [QOwnNotes - cross-platform open source plain-text file notepad](http://www.qownnotes.org) + +## Disclaimer +This SOFTWARE PRODUCT is provided by THE PROVIDER "as is" and "with all faults." THE PROVIDER makes no representations or warranties of any kind concerning the safety, suitability, lack of viruses, inaccuracies, typographical errors, or other harmful components of this SOFTWARE PRODUCT. + +There are inherent dangers in the use of any software, and you are solely responsible for determining whether this SOFTWARE PRODUCT is compatible with your equipment and other software installed on your equipment. You are also solely responsible for the protection of your equipment and backup of your data, and THE PROVIDER will not be liable for any damages you may suffer in connection with using, modifying, or distributing this SOFTWARE PRODUCT. diff --git a/client/qmarkdowntextedit/appveyor.yml b/client/qmarkdowntextedit/appveyor.yml new file mode 100644 index 0000000..3fd31cc --- /dev/null +++ b/client/qmarkdowntextedit/appveyor.yml @@ -0,0 +1,12 @@ +# AppVeyor build configuration +# http://www.appveyor.com/docs/build-configuration +os: unstable +skip_tags: true + +install: + - set QTDIR=C:\Qt\5.10.1\mingw53_32 + - set PATH=%PATH%;%QTDIR%\bin;C:\MinGW\bin + +build_script: + - qmake qmarkdowntextedit.pro -r -spec win32-g++ + - mingw32-make diff --git a/client/qmarkdowntextedit/main.cpp b/client/qmarkdowntextedit/main.cpp new file mode 100644 index 0000000..e960337 --- /dev/null +++ b/client/qmarkdowntextedit/main.cpp @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2014-2019 Patrizio Bekerle -- http://www.bekerle.com + * + * 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; version 2 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. + * + */ + +#include "mainwindow.h" +#include +#include +#include + +int main(int argc, char *argv[]) +{ + QApplication a(argc, argv); + QString filename; + if (argc > 1) { + filename = argv[1]; + } + if (!filename.isEmpty() && !QFileInfo(filename).isReadable()) { + qWarning() << filename << "is not a readable file"; + return 1; + } + + MainWindow w; + w.show(); + + if (!filename.isEmpty()) { + w.loadFile(filename); + } + + + return a.exec(); +} diff --git a/client/qmarkdowntextedit/mainwindow.cpp b/client/qmarkdowntextedit/mainwindow.cpp new file mode 100644 index 0000000..be3adde --- /dev/null +++ b/client/qmarkdowntextedit/mainwindow.cpp @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2014-2019 Patrizio Bekerle -- http://www.bekerle.com + * + * 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; version 2 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. + * + * mainwindow.cpp + * + * Example to show the QMarkdownTextEdit widget + */ + +#include "mainwindow.h" +#include "ui_mainwindow.h" +#include "qmarkdowntextedit.h" +#include +#include +#include + +MainWindow::MainWindow(QWidget *parent) : + QMainWindow(parent), + ui(new Ui::MainWindow) +{ + ui->setupUi(this); + QToolBar *toolBar = new QToolBar; + addToolBar(toolBar); + QAction *openAction = new QAction(QIcon::fromTheme("document-open"), tr("Open...")); + openAction->setShortcut(QKeySequence::Open); + connect(openAction, &QAction::triggered, this, &MainWindow::open); + + QAction *saveAction = new QAction(QIcon::fromTheme("document-save"), tr("Save")); + saveAction->setShortcut(QKeySequence::Save); + QAction *saveAsAction = new QAction(QIcon::fromTheme("document-save-as"), tr("Save as...")); + saveAsAction->setShortcut(QKeySequence::SaveAs); + QAction *quitAction = new QAction(QIcon::fromTheme("view-close"), tr("Quit")); + quitAction->setShortcut(QKeySequence::Quit); + connect(quitAction, &QAction::triggered, this, &MainWindow::onQuit); + + m_loadedContent = ui->textEdit->toPlainText(); + + toolBar->addActions({openAction, saveAction, saveAsAction, quitAction}); +} + +MainWindow::~MainWindow() +{ + delete ui; +} + +void MainWindow::loadFile(const QString &filename) +{ + QFile file(filename); + if (!file.open(QIODevice::ReadOnly)) { + qWarning() << "Failed to open" << filename; + return; + } + + m_filename = filename; + m_loadedContent = QString::fromLocal8Bit(file.readAll()); + ui->textEdit->setPlainText(m_loadedContent); +} + +void MainWindow::saveToFile(const QString &filename) +{ + QFile file(filename); + if (!file.open(QIODevice::WriteOnly)) { + qWarning() << "Failed to open" << filename; + return; + } + + m_filename = filename; + + m_loadedContent = ui->textEdit->toPlainText(); + file.write(m_loadedContent.toLocal8Bit()); +} + +void MainWindow::open() +{ + QString filename = QFileDialog::getOpenFileName(); + if (filename.isEmpty()) { + return; + } + loadFile(filename); +} + +void MainWindow::save() +{ + if (!m_filename.isEmpty()) { + saveAs(); + return; + } + + saveToFile(m_filename); +} + +void MainWindow::saveAs() +{ + QString filename = QFileDialog::getSaveFileName(); + if (filename.isEmpty()) { + return; + } + + saveToFile(filename); +} + +void MainWindow::onQuit() +{ + if (ui->textEdit->toPlainText() != m_loadedContent) { + if (QMessageBox::question(this, tr("Not saved"), tr("Document not saved, sure you want to quit?")) != QMessageBox::Yes) { + return; + } + } + close(); +} diff --git a/client/qmarkdowntextedit/mainwindow.h b/client/qmarkdowntextedit/mainwindow.h new file mode 100644 index 0000000..f3487ac --- /dev/null +++ b/client/qmarkdowntextedit/mainwindow.h @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2014-2019 Patrizio Bekerle -- http://www.bekerle.com + * + * 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; version 2 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. + * + */ + +#pragma once + +#include + +namespace Ui { +class MainWindow; +} + +class MainWindow : public QMainWindow +{ + Q_OBJECT + +public: + explicit MainWindow(QWidget *parent = nullptr); + ~MainWindow(); + + void loadFile(const QString &filename); + void saveToFile(const QString &filename); + +private slots: + void open(); + void save(); + void saveAs(); + void onQuit(); + +private: + Ui::MainWindow *ui; + QString m_loadedContent; + QString m_filename; +}; diff --git a/client/qmarkdowntextedit/mainwindow.ui b/client/qmarkdowntextedit/mainwindow.ui new file mode 100644 index 0000000..a5b9a5b --- /dev/null +++ b/client/qmarkdowntextedit/mainwindow.ui @@ -0,0 +1,82 @@ + + + MainWindow + + + + 0 + 0 + 1070 + 839 + + + + QMarkdownTextEdit + + + + + + + QMarkdownTextEdit +============== + +*QMarkdownTextEdit* is a C++ Qt [QPlainTextEdit](http://doc.qt.io/qt-5/qplaintextedit.html) widget with **markdown highlighting** and some other goodies. + +## Features + +- markdown highlighting +- clickable links with `Ctrl + Click` +- block indent with `Tab` and `Shift + Tab` +- duplicate text with `Ctrl + Alt + Down` +- searching of text with `Ctrl + F` + - jump between search results with `Up` and `Down` + - close search field with `Escape` +- and much more... + +## References + +- [QOwnNotes - cross-platform open source plain-text notepad](http://www.qownnotes.org) + +## Disclaimer + +This SOFTWARE PRODUCT is provided by THE PROVIDER "as is" and "with all faults." THE PROVIDER makes no representations or warranties of any kind concerning the safety, suitability, lack of viruses, inaccuracies, typographical errors, or other harmful components of this SOFTWARE PRODUCT. + +There are inherent dangers in the use of any software, and you are solely responsible for determining whether this SOFTWARE PRODUCT is compatible with your equipment and other software installed on your equipment. You are also solely responsible for the protection of your equipment and backup of your data, and THE PROVIDER will not be liable for any damages you may suffer in connection with using, modifying, or distributing this SOFTWARE PRODUCT. + + + + + + + + + + 0 + 0 + 1070 + 25 + + + + + + TopToolBarArea + + + false + + + + + + + + QMarkdownTextEdit + QPlainTextEdit +
qmarkdowntextedit.h
+
+
+ + +
diff --git a/client/qmarkdowntextedit/markdownhighlighter.cpp b/client/qmarkdowntextedit/markdownhighlighter.cpp new file mode 100644 index 0000000..1038631 --- /dev/null +++ b/client/qmarkdowntextedit/markdownhighlighter.cpp @@ -0,0 +1,698 @@ +/* + * Copyright (c) 2014-2019 Patrizio Bekerle -- http://www.bekerle.com + * + * 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; version 2 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. + * + * QPlainTextEdit markdown highlighter + */ + +#include +#include +#include "markdownhighlighter.h" +#include +#include +#include + + +/** + * Markdown syntax highlighting + * + * markdown syntax: + * http://daringfireball.net/projects/markdown/syntax + * + * @param parent + * @return + */ +MarkdownHighlighter::MarkdownHighlighter( + QTextDocument *parent, HighlightingOptions highlightingOptions) + : QSyntaxHighlighter(parent) { + _highlightingOptions = highlightingOptions; + _timer = new QTimer(this); + QObject::connect(_timer, SIGNAL(timeout()), + this, SLOT(timerTick())); + _timer->start(1000); + + // initialize the highlighting rules + initHighlightingRules(); + + // initialize the text formats + initTextFormats(); +} + +/** + * Does jobs every second + */ +void MarkdownHighlighter::timerTick() { + // qDebug() << "timerTick: " << this << ", " << this->parent()->parent()->parent()->objectName(); + + // re-highlight all dirty blocks + reHighlightDirtyBlocks(); + + // emit a signal every second if there was some highlighting done + if (_highlightingFinished) { + _highlightingFinished = false; + emit(highlightingFinished()); + } +} + +/** + * Re-highlights all dirty blocks + */ +void MarkdownHighlighter::reHighlightDirtyBlocks() { + while (_dirtyTextBlocks.count() > 0) { + QTextBlock block = _dirtyTextBlocks.at(0); + rehighlightBlock(block); + _dirtyTextBlocks.removeFirst(); + } +} + +/** + * Clears the dirty blocks vector + */ +void MarkdownHighlighter::clearDirtyBlocks() { + _dirtyTextBlocks.clear(); +} + +/** + * Adds a dirty block to the list if it doesn't already exist + * + * @param block + */ +void MarkdownHighlighter::addDirtyBlock(QTextBlock block) { + if (!_dirtyTextBlocks.contains(block)) { + _dirtyTextBlocks.append(block); + } +} + +/** + * Initializes the highlighting rules + * + * regexp tester: + * https://regex101.com + * + * other examples: + * /usr/share/kde4/apps/katepart/syntax/markdown.xml + */ +void MarkdownHighlighter::initHighlightingRules() { + // highlight the reference of reference links + { + HighlightingRule rule(HighlighterState::MaskedSyntax); + rule.pattern = QRegularExpression("^\\[.+?\\]: \\w+://.+$"); + _highlightingRulesPre.append(rule); + } + + // highlight unordered lists + { + HighlightingRule rule(HighlighterState::List); + rule.pattern = QRegularExpression("^\\s*[-*+]\\s"); + rule.useStateAsCurrentBlockState = true; + _highlightingRulesPre.append(rule); + + // highlight ordered lists + rule.pattern = QRegularExpression("^\\s*\\d+\\.\\s"); + _highlightingRulesPre.append(rule); + } + + // highlight block quotes + { + HighlightingRule rule(HighlighterState::BlockQuote); + rule.pattern = QRegularExpression( + _highlightingOptions.testFlag( + HighlightingOption::FullyHighlightedBlockQuote) ? + "^\\s*(>\\s*.+)" : "^\\s*(>\\s*)+"); + _highlightingRulesPre.append(rule); + } + + // highlight horizontal rulers + { + HighlightingRule rule(HighlighterState::HorizontalRuler); + rule.pattern = QRegularExpression("^([*\\-_]\\s?){3,}$"); + _highlightingRulesPre.append(rule); + } + + // highlight tables without starting | + // we drop that for now, it's far too messy to deal with +// rule = HighlightingRule(); +// rule.pattern = QRegularExpression("^.+? \\| .+? \\| .+$"); +// rule.state = HighlighterState::Table; +// _highlightingRulesPre.append(rule); + + /* + * highlight italic + * this goes before bold so that bold can overwrite italic + * + * text to test: + * **bold** normal **bold** + * *start of line* normal + * normal *end of line* + * * list item *italic* + */ + { + HighlightingRule rule(HighlighterState::Italic); + // we don't allow a space after the starting * to prevent problems with + // unordered lists starting with a * + rule.pattern = QRegularExpression( + "(?:^|[^\\*\\b])(?:\\*([^\\* ][^\\*]*?)\\*)(?:[^\\*\\b]|$)"); + rule.capturingGroup = 1; + _highlightingRulesAfter.append(rule); + + rule.pattern = QRegularExpression("\\b_([^_]+)_\\b"); + _highlightingRulesAfter.append(rule); + } + + { + HighlightingRule rule(HighlighterState::Bold); + // highlight bold + rule.pattern = QRegularExpression("\\B\\*{2}(.+?)\\*{2}\\B"); + rule.capturingGroup = 1; + _highlightingRulesAfter.append(rule); + rule.pattern = QRegularExpression("\\b__(.+?)__\\b"); + _highlightingRulesAfter.append(rule); + } + + // highlight urls + { + HighlightingRule rule(HighlighterState::Link); + + // highlight urls without any other markup + rule.pattern = QRegularExpression("\\b\\w+?:\\/\\/[^\\s]+"); + rule.capturingGroup = 1; + _highlightingRulesAfter.append(rule); + + // rule.pattern = QRegularExpression("<(.+?:\\/\\/.+?)>"); + rule.pattern = QRegularExpression("<([^\\s`][^`]*?[^\\s`])>"); + rule.capturingGroup = 1; + _highlightingRulesAfter.append(rule); + + // highlight urls with title + // rule.pattern = QRegularExpression("\\[(.+?)\\]\\(.+?://.+?\\)"); + // rule.pattern = QRegularExpression("\\[(.+?)\\]\\(.+\\)\\B"); + rule.pattern = QRegularExpression("\\[([^\\[\\]]+)\\]\\((\\S+|.+?)\\)\\B"); + _highlightingRulesAfter.append(rule); + + // highlight urls with empty title + // rule.pattern = QRegularExpression("\\[\\]\\((.+?://.+?)\\)"); + rule.pattern = QRegularExpression("\\[\\]\\((.+?)\\)"); + _highlightingRulesAfter.append(rule); + + // highlight email links + rule.pattern = QRegularExpression("<(.+?@.+?)>"); + _highlightingRulesAfter.append(rule); + + // highlight reference links + rule.pattern = QRegularExpression("\\[(.+?)\\]\\s?\\[.+?\\]"); + _highlightingRulesAfter.append(rule); + } + + // Images + { + // highlight images with text + HighlightingRule rule(HighlighterState::Image); + rule.pattern = QRegularExpression("!\\[(.+?)\\]\\(.+?\\)"); + rule.capturingGroup = 1; + _highlightingRulesAfter.append(rule); + + // highlight images without text + rule.pattern = QRegularExpression("!\\[\\]\\((.+?)\\)"); + _highlightingRulesAfter.append(rule); + } + + // highlight images links + { +// HighlightingRule rule; + HighlightingRule rule(HighlighterState::Link); + rule.pattern = QRegularExpression("\\[!\\[(.+?)\\]\\(.+?\\)\\]\\(.+?\\)"); + rule.capturingGroup = 1; + _highlightingRulesAfter.append(rule); + + // highlight images links without text + rule.pattern = QRegularExpression("\\[!\\[\\]\\(.+?\\)\\]\\((.+?)\\)"); + _highlightingRulesAfter.append(rule); + } + + // highlight inline code + { + HighlightingRule rule(HighlighterState::InlineCodeBlock); +// HighlightingRule rule; + rule.pattern = QRegularExpression("`(.+?)`"); + rule.capturingGroup = 1; + _highlightingRulesAfter.append(rule); + } + + // highlight code blocks with four spaces or tabs in front of them + // and no list character after that + { + HighlightingRule rule(HighlighterState::CodeBlock); +// HighlightingRule rule; + rule.pattern = QRegularExpression("^((\\t)|( {4,})).+$"); + rule.disableIfCurrentStateIsSet = true; + _highlightingRulesAfter.append(rule); + } + + // highlight inline comments + { + HighlightingRule rule(HighlighterState::Comment); + rule.pattern = QRegularExpression(""); + rule.capturingGroup = 1; + _highlightingRulesAfter.append(rule); + + // highlight comments for Rmarkdown for academic papers + rule.pattern = QRegularExpression("^\\[.+?\\]: # \\(.+?\\)$"); + _highlightingRulesAfter.append(rule); + } + + // highlight tables with starting | + { + HighlightingRule rule(HighlighterState::Table); + rule.pattern = QRegularExpression("^\\|.+?\\|$"); + _highlightingRulesAfter.append(rule); + } +} + +/** + * Initializes the text formats + * + * @param defaultFontSize + */ +void MarkdownHighlighter::initTextFormats(int defaultFontSize) { + QTextCharFormat format; + + // set character formats for headlines + format = QTextCharFormat(); + format.setForeground(QBrush(QColor(0, 49, 110))); + format.setFontWeight(QFont::Bold); + format.setFontPointSize(defaultFontSize * 1.6); + _formats[H1] = format; + format.setFontPointSize(defaultFontSize * 1.5); + _formats[H2] = format; + format.setFontPointSize(defaultFontSize * 1.4); + _formats[H3] = format; + format.setFontPointSize(defaultFontSize * 1.3); + _formats[H4] = format; + format.setFontPointSize(defaultFontSize * 1.2); + _formats[H5] = format; + format.setFontPointSize(defaultFontSize * 1.1); + _formats[H6] = format; + format.setFontPointSize(defaultFontSize); + + // set character format for horizontal rulers + format = QTextCharFormat(); + format.setForeground(QBrush(Qt::darkGray)); + format.setBackground(QBrush(Qt::lightGray)); + _formats[HorizontalRuler] = format; + + // set character format for lists + format = QTextCharFormat(); + format.setForeground(QBrush(QColor(163, 0, 123))); + _formats[List] = format; + + // set character format for links + format = QTextCharFormat(); + format.setForeground(QBrush(QColor(0, 128, 255))); + format.setFontUnderline(true); + _formats[Link] = format; + + // set character format for images + format = QTextCharFormat(); + format.setForeground(QBrush(QColor(0, 191, 0))); + format.setBackground(QBrush(QColor(228, 255, 228))); + _formats[Image] = format; + + // set character format for code blocks + format = QTextCharFormat(); + format.setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + format.setBackground(QColor(220, 220, 220)); + _formats[CodeBlock] = format; + _formats[InlineCodeBlock] = format; + + // set character format for italic + format = QTextCharFormat(); + format.setFontWeight(QFont::StyleItalic); + format.setFontItalic(true); + _formats[Italic] = format; + + // set character format for bold + format = QTextCharFormat(); + format.setFontWeight(QFont::Bold); + _formats[Bold] = format; + + // set character format for comments + format = QTextCharFormat(); + format.setForeground(QBrush(Qt::gray)); + _formats[Comment] = format; + + // set character format for masked syntax + format = QTextCharFormat(); + format.setForeground(QBrush("#cccccc")); + _formats[MaskedSyntax] = format; + + // set character format for tables + format = QTextCharFormat(); + format.setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + format.setForeground(QBrush(QColor("#649449"))); + _formats[Table] = format; + + // set character format for block quotes + format = QTextCharFormat(); + format.setForeground(QBrush(QColor(Qt::darkRed))); + _formats[BlockQuote] = format; + + format = QTextCharFormat(); + _formats[HeadlineEnd] = format; + + format = QTextCharFormat(); + _formats[NoState] = format; +} + +/** + * Sets the text formats + * + * @param formats + */ +void MarkdownHighlighter::setTextFormats( + QHash formats) { + _formats = formats; +} + +/** + * Sets a text format + * + * @param formats + */ +void MarkdownHighlighter::setTextFormat(HighlighterState state, + QTextCharFormat format) { + _formats[state] = format; +} + +/** + * Does the markdown highlighting + * + * @param text + */ +void MarkdownHighlighter::highlightBlock(const QString &text) { + setCurrentBlockState(HighlighterState::NoState); + currentBlock().setUserState(HighlighterState::NoState); + highlightMarkdown(text); + _highlightingFinished = true; +} + +void MarkdownHighlighter::highlightMarkdown(QString text) { + if (!text.isEmpty()) { + highlightAdditionalRules(_highlightingRulesPre, text); + + // needs to be called after the horizontal ruler highlighting + highlightHeadline(text); + + highlightAdditionalRules(_highlightingRulesAfter, text); + } + + highlightCommentBlock(text); + highlightCodeBlock(text); +} + +/** + * Highlight headlines + * + * @param text + */ +void MarkdownHighlighter::highlightHeadline(QString text) { + QRegularExpression regex("^(#+)\\s+(.+?)$"); + QRegularExpressionMatch match = regex.match(text); + QTextCharFormat &maskedFormat = _formats[HighlighterState::MaskedSyntax]; + + // check for headline blocks with # in front of them + if (match.hasMatch()) { + int count = match.captured(1).count(); + + // we just have H1 to H6 + count = qMin(count, 6); + + HighlighterState state = HighlighterState( + HighlighterState::H1 + count - 1); + + QTextCharFormat &format = _formats[state]; + QTextCharFormat currentMaskedFormat = maskedFormat; + + // set the font size from the current rule's font format + currentMaskedFormat.setFontPointSize(format.fontPointSize()); + + // first highlight everything as MaskedSyntax + setFormat(match.capturedStart(), match.capturedLength(), + currentMaskedFormat); + + // then highlight with the real format + setFormat(match.capturedStart(2), match.capturedLength(2), + _formats[state]); + + // set a margin for the current block + setCurrentBlockMargin(state); + + setCurrentBlockState(state); + currentBlock().setUserState(state); + return; + } + + // take care of ==== and ---- headlines + QRegularExpression patternH1 = QRegularExpression("^=+$"); + QRegularExpression patternH2 = QRegularExpression("^-+$"); + QTextBlock previousBlock = currentBlock().previous(); + QString previousText = previousBlock.text(); + previousText.trimmed().remove(QRegularExpression("[=-]")); + + // check for ===== after a headline text and highlight as H1 + if (patternH1.match(text).hasMatch()) { + if (((previousBlockState() == HighlighterState::H1) || + (previousBlockState() == HighlighterState::NoState)) && + (previousText.length() > 0)) { + // set the font size from the current rule's font format + QTextCharFormat currentMaskedFormat = maskedFormat; + currentMaskedFormat.setFontPointSize( + _formats[HighlighterState::H1].fontPointSize()); + + setFormat(0, text.length(), currentMaskedFormat); + setCurrentBlockState(HighlighterState::HeadlineEnd); + previousBlock.setUserState(HighlighterState::H1); + + // set a margin for the current block + setCurrentBlockMargin(HighlighterState::H1); + + // we want to re-highlight the previous block + // this must not done directly, but with a queue, otherwise it + // will crash + // setting the character format of the previous text, because this + // causes text to be formatted the same way when writing after + // the text + addDirtyBlock(previousBlock); + } + + return; + } + + // check for ----- after a headline text and highlight as H2 + if (patternH2.match(text).hasMatch()) { + if (((previousBlockState() == HighlighterState::H2) || + (previousBlockState() == HighlighterState::NoState)) && + (previousText.length() > 0)) { + // set the font size from the current rule's font format + QTextCharFormat currentMaskedFormat = maskedFormat; + currentMaskedFormat.setFontPointSize( + _formats[HighlighterState::H2].fontPointSize()); + + setFormat(0, text.length(), currentMaskedFormat); + setCurrentBlockState(HighlighterState::HeadlineEnd); + previousBlock.setUserState(HighlighterState::H2); + + // set a margin for the current block + setCurrentBlockMargin(HighlighterState::H2); + + // we want to re-highlight the previous block + addDirtyBlock(previousBlock); + } + + return; + } + + QTextBlock nextBlock = currentBlock().next(); + QString nextBlockText = nextBlock.text(); + + // highlight as H1 if next block is ===== + if (patternH1.match(nextBlockText).hasMatch() || + patternH2.match(nextBlockText).hasMatch()) { + setFormat(0, text.length(), _formats[HighlighterState::H1]); + setCurrentBlockState(HighlighterState::H1); + currentBlock().setUserState(HighlighterState::H1); + } + + // highlight as H2 if next block is ----- + if (patternH2.match(nextBlockText).hasMatch()) { + setFormat(0, text.length(), _formats[HighlighterState::H2]); + setCurrentBlockState(HighlighterState::H2); + currentBlock().setUserState(HighlighterState::H2); + } +} + +/** + * Sets a margin for the current block + * + * @param state + */ +void MarkdownHighlighter::setCurrentBlockMargin( + MarkdownHighlighter::HighlighterState state) { + // this is currently disabled because it causes multiple problems: + // - it prevents "undo" in headlines + // https://github.com/pbek/QOwnNotes/issues/520 + // - invisible lines at the end of a note + // https://github.com/pbek/QOwnNotes/issues/667 + // - a crash when reaching the invisible lines when the current line is + // highlighted + // https://github.com/pbek/QOwnNotes/issues/701 + return; + + qreal margin; + + switch (state) { + case HighlighterState::H1: + margin = 5; + break; + case HighlighterState::H2: + case HighlighterState::H3: + case HighlighterState::H4: + case HighlighterState::H5: + case HighlighterState::H6: + margin = 3; + break; + default: + return; + } + + QTextBlockFormat blockFormat = currentBlock().blockFormat(); + blockFormat.setTopMargin(2); + blockFormat.setBottomMargin(margin); + + // this prevents "undo" in headlines! + QTextCursor* myCursor = new QTextCursor(currentBlock()); + myCursor->setBlockFormat(blockFormat); +} + +/** + * Highlight multi-line code blocks + * + * @param text + */ +void MarkdownHighlighter::highlightCodeBlock(QString text) { + QRegularExpression regex("^```\\w*?$"); + QRegularExpressionMatch match = regex.match(text); + + if (match.hasMatch()) { + setCurrentBlockState( + previousBlockState() == HighlighterState::CodeBlock ? + HighlighterState::CodeBlockEnd : HighlighterState::CodeBlock); + // set the font size from the current rule's font format + QTextCharFormat &maskedFormat = + _formats[HighlighterState::MaskedSyntax]; + maskedFormat.setFontPointSize( + _formats[HighlighterState::CodeBlock].fontPointSize()); + + setFormat(0, text.length(), maskedFormat); + } else if (previousBlockState() == HighlighterState::CodeBlock) { + setCurrentBlockState(HighlighterState::CodeBlock); + setFormat(0, text.length(), _formats[HighlighterState::CodeBlock]); + } +} + +/** + * Highlight multi-line comments + * + * @param text + */ +void MarkdownHighlighter::highlightCommentBlock(QString text) { + bool highlight = false; + text = text.trimmed(); + QString startText = ""; + + // we will skip this case because that is an inline comment and causes + // troubles here + if (text.startsWith(startText) && text.contains(endText)) { + return; + } + + if (text.startsWith(startText) || + (!text.endsWith(endText) && + (previousBlockState() == HighlighterState::Comment))) { + setCurrentBlockState(HighlighterState::Comment); + highlight = true; + } else if (text.endsWith(endText)) { + highlight = true; + } + + if (highlight) { + setFormat(0, text.length(), _formats[HighlighterState::Comment]); + } +} + +/** + * Highlights the rules from the _highlightingRules list + * + * @param text + */ +void MarkdownHighlighter::highlightAdditionalRules( + QVector &rules, QString text) { + QTextCharFormat &maskedFormat = _formats[HighlighterState::MaskedSyntax]; + + for(const HighlightingRule &rule : rules) { + // continue if an other current block state was already set if + // disableIfCurrentStateIsSet is set + if (rule.disableIfCurrentStateIsSet && + (currentBlockState() != HighlighterState::NoState)) { + continue; + } + + QRegularExpression expression(rule.pattern); + QRegularExpressionMatchIterator iterator = expression.globalMatch(text); + int capturingGroup = rule.capturingGroup; + int maskedGroup = rule.maskedGroup; + QTextCharFormat &format = _formats[rule.state]; + + // store the current block state if useStateAsCurrentBlockState + // is set + if (iterator.hasNext() && rule.useStateAsCurrentBlockState) { + setCurrentBlockState(rule.state); + } + + // find and format all occurrences + while (iterator.hasNext()) { + QRegularExpressionMatch match = iterator.next(); + + // if there is a capturingGroup set then first highlight + // everything as MaskedSyntax and highlight capturingGroup + // with the real format + if (capturingGroup > 0) { + QTextCharFormat currentMaskedFormat = maskedFormat; + // set the font size from the current rule's font format + if (format.fontPointSize() > 0) { + currentMaskedFormat.setFontPointSize(format.fontPointSize()); + } + + setFormat(match.capturedStart(maskedGroup), + match.capturedLength(maskedGroup), + currentMaskedFormat); + } + + setFormat(match.capturedStart(capturingGroup), + match.capturedLength(capturingGroup), + format); + } + } +} + +void MarkdownHighlighter::setHighlightingOptions(HighlightingOptions options) { + _highlightingOptions = options; +} diff --git a/client/qmarkdowntextedit/markdownhighlighter.h b/client/qmarkdowntextedit/markdownhighlighter.h new file mode 100644 index 0000000..e66f86f --- /dev/null +++ b/client/qmarkdowntextedit/markdownhighlighter.h @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2014-2019 Patrizio Bekerle -- http://www.bekerle.com + * + * 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; version 2 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. + * + * QPlainTextEdit markdown highlighter + */ + + +#pragma once + +#include +#include +#include + +QT_BEGIN_NAMESPACE +class QTextDocument; + +QT_END_NAMESPACE + +class MarkdownHighlighter : public QSyntaxHighlighter +{ +Q_OBJECT + +public: + enum HighlightingOption{ + None = 0, + FullyHighlightedBlockQuote = 0x01 + }; + Q_DECLARE_FLAGS(HighlightingOptions, HighlightingOption) + + MarkdownHighlighter(QTextDocument *parent = nullptr, + HighlightingOptions highlightingOptions = + HighlightingOption::None); + + // we use some predefined numbers here to be compatible with + // the peg-markdown parser + enum HighlighterState { + NoState = -1, + Link = 0, + Image = 3, + CodeBlock, + Italic = 7, + Bold, + List, + Comment = 11, + H1, + H2, + H3, + H4, + H5, + H6, + BlockQuote, + HorizontalRuler = 21, + Table, + InlineCodeBlock, + MaskedSyntax, + CurrentLineBackgroundColor, + BrokenLink, + + // internal + CodeBlockEnd = 100, + HeadlineEnd + }; + Q_ENUM(HighlighterState) + +// enum BlockState { +// NoBlockState = 0, +// H1, +// H2, +// H3, +// Table, +// CodeBlock, +// CodeBlockEnd +// }; + + void setTextFormats(QHash formats); + void setTextFormat(HighlighterState state, QTextCharFormat format); + void clearDirtyBlocks(); + void setHighlightingOptions(HighlightingOptions options); + void initHighlightingRules(); + +signals: + void highlightingFinished(); + +protected slots: + void timerTick(); + +protected: + struct HighlightingRule { + HighlightingRule(const HighlighterState state_) : state(state_) {} + HighlightingRule() = default; + + QRegularExpression pattern; + HighlighterState state = NoState; + int capturingGroup = 0; + int maskedGroup = 0; + bool useStateAsCurrentBlockState = false; + bool disableIfCurrentStateIsSet = false; + }; + + void highlightBlock(const QString &text) Q_DECL_OVERRIDE; + + void initTextFormats(int defaultFontSize = 12); + + void highlightMarkdown(QString text); + + void highlightHeadline(QString text); + + void highlightAdditionalRules(QVector &rules, + QString text); + + void highlightCodeBlock(QString text); + + void highlightCommentBlock(QString text); + + void addDirtyBlock(QTextBlock block); + + void reHighlightDirtyBlocks(); + + QVector _highlightingRulesPre; + QVector _highlightingRulesAfter; + QVector _dirtyTextBlocks; + QHash _formats; + QTimer *_timer; + bool _highlightingFinished; + HighlightingOptions _highlightingOptions; + + void setCurrentBlockMargin(HighlighterState state); +}; diff --git a/client/qmarkdowntextedit/media.qrc b/client/qmarkdowntextedit/media.qrc new file mode 100644 index 0000000..0346de9 --- /dev/null +++ b/client/qmarkdowntextedit/media.qrc @@ -0,0 +1,9 @@ + + + media/window-close.svg + media/go-top.svg + media/go-bottom.svg + media/edit-find-replace.svg + media/format-text-superscript.svg + + diff --git a/client/qmarkdowntextedit/media/edit-find-replace.svg b/client/qmarkdowntextedit/media/edit-find-replace.svg new file mode 100644 index 0000000..c1c4671 --- /dev/null +++ b/client/qmarkdowntextedit/media/edit-find-replace.svg @@ -0,0 +1,15 @@ + + + + + + diff --git a/client/qmarkdowntextedit/media/format-text-superscript.svg b/client/qmarkdowntextedit/media/format-text-superscript.svg new file mode 100644 index 0000000..871845e --- /dev/null +++ b/client/qmarkdowntextedit/media/format-text-superscript.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/client/qmarkdowntextedit/media/go-bottom.svg b/client/qmarkdowntextedit/media/go-bottom.svg new file mode 100644 index 0000000..9b85210 --- /dev/null +++ b/client/qmarkdowntextedit/media/go-bottom.svg @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/client/qmarkdowntextedit/media/go-top.svg b/client/qmarkdowntextedit/media/go-top.svg new file mode 100644 index 0000000..50b4ca6 --- /dev/null +++ b/client/qmarkdowntextedit/media/go-top.svg @@ -0,0 +1,476 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/client/qmarkdowntextedit/media/window-close.svg b/client/qmarkdowntextedit/media/window-close.svg new file mode 100644 index 0000000..5d1539d --- /dev/null +++ b/client/qmarkdowntextedit/media/window-close.svg @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/client/qmarkdowntextedit/qmarkdowntextedit-headers.pri b/client/qmarkdowntextedit/qmarkdowntextedit-headers.pri new file mode 100644 index 0000000..646e593 --- /dev/null +++ b/client/qmarkdowntextedit/qmarkdowntextedit-headers.pri @@ -0,0 +1,6 @@ +INCLUDEPATH += $$PWD/ + +HEADERS += \ + $$PWD/markdownhighlighter.h \ + $$PWD/qmarkdowntextedit.h \ + $$PWD/qplaintexteditsearchwidget.h diff --git a/client/qmarkdowntextedit/qmarkdowntextedit-sources.pri b/client/qmarkdowntextedit/qmarkdowntextedit-sources.pri new file mode 100644 index 0000000..352e0bf --- /dev/null +++ b/client/qmarkdowntextedit/qmarkdowntextedit-sources.pri @@ -0,0 +1,14 @@ +INCLUDEPATH += $$PWD/ + +QT += gui +greaterThan(QT_MAJOR_VERSION, 4): QT += widgets + +SOURCES += \ + $$PWD/markdownhighlighter.cpp \ + $$PWD/qmarkdowntextedit.cpp \ + $$PWD/qplaintexteditsearchwidget.cpp + +RESOURCES += \ + $$PWD/media.qrc + +FORMS += $$PWD/qplaintexteditsearchwidget.ui diff --git a/client/qmarkdowntextedit/qmarkdowntextedit.cpp b/client/qmarkdowntextedit/qmarkdowntextedit.cpp new file mode 100644 index 0000000..a6e786d --- /dev/null +++ b/client/qmarkdowntextedit/qmarkdowntextedit.cpp @@ -0,0 +1,1099 @@ +/* + * Copyright (c) 2014-2019 Patrizio Bekerle -- http://www.bekerle.com + * + * 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; version 2 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. + * + */ + + +#include "qmarkdowntextedit.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +QMarkdownTextEdit::QMarkdownTextEdit(QWidget *parent, bool initHighlighter) + : QPlainTextEdit(parent) { + installEventFilter(this); + viewport()->installEventFilter(this); + _autoTextOptions = AutoTextOption::None; + _openingCharacters = QStringList() << "(" << "[" << "{" << "<" << "*" + << "\"" << "'" << "_" << "~"; + _closingCharacters = QStringList() << ")" << "]" << "}" << ">" << "*" + << "\"" << "'" << "_" << "~"; + + // markdown highlighting is enabled by default + _highlightingEnabled = true; + if (initHighlighter) { + _highlighter = new MarkdownHighlighter(document()); + } +// setHighlightingEnabled(true); + + QFont font = this->font(); + + // set the tab stop to the width of 4 spaces in the editor + const int tabStop = 4; + QFontMetrics metrics(font); + setTabStopWidth(tabStop * metrics.width(' ')); + + // add shortcuts for duplicating text +// new QShortcut( QKeySequence( "Ctrl+D" ), this, SLOT( duplicateText() ) ); +// new QShortcut( QKeySequence( "Ctrl+Alt+Down" ), this, SLOT( duplicateText() ) ); + + // add a layout to the widget + QVBoxLayout *layout = new QVBoxLayout; + layout->setContentsMargins(0, 0, 0, 0); + layout->setMargin(0); + layout->addStretch(); + this->setLayout(layout); + + // add the hidden search widget + _searchWidget = new QPlainTextEditSearchWidget(this); + this->layout()->addWidget(_searchWidget); + + QObject::connect(this, SIGNAL(textChanged()), + this, SLOT(adjustRightMargin())); + + // workaround for disabled signals up initialization + QTimer::singleShot(300, this, SLOT(adjustRightMargin())); +} + +/** + * Enables or disables the markdown highlighting + * + * @param enabled + */ +void QMarkdownTextEdit::setHighlightingEnabled(bool enabled) { + if (_highlightingEnabled == enabled) { + return; + } + + _highlighter->setDocument(enabled ? document() : Q_NULLPTR); + _highlightingEnabled = enabled; + + if (enabled) { + _highlighter->rehighlight(); + } +} + +/** + * Leave a little space on the right side if the document is too long, so + * that the search buttons don't get visually blocked by the scroll bar + */ +void QMarkdownTextEdit::adjustRightMargin() { + QMargins margins = layout()->contentsMargins(); + int rightMargin = document()->size().height() > + viewport()->size().height() ? 24 : 0; + margins.setRight(rightMargin); + layout()->setContentsMargins(margins); +} + +bool QMarkdownTextEdit::eventFilter(QObject *obj, QEvent *event) { + //qDebug() << event->type(); + if (event->type() == QEvent::HoverMove) { + QMouseEvent *mouseEvent = static_cast(event); + + QWidget *viewPort = this->viewport(); + // toggle cursor when control key has been pressed or released + viewPort->setCursor(mouseEvent->modifiers().testFlag( + Qt::ControlModifier) ? + Qt::PointingHandCursor : + Qt::IBeamCursor); + } else if (event->type() == QEvent::KeyPress) { + QKeyEvent *keyEvent = static_cast(event); + + // set cursor to pointing hand if control key was pressed + if (keyEvent->modifiers().testFlag(Qt::ControlModifier)) { + QWidget *viewPort = this->viewport(); + viewPort->setCursor(Qt::PointingHandCursor); + } + + // disallow keys if text edit hasn't focus + if (!this->hasFocus()) { + return true; + } + + if ((keyEvent->key() == Qt::Key_Escape) && _searchWidget->isVisible()) { + _searchWidget->deactivate(); + return true; + } else if ((keyEvent->key() == Qt::Key_Tab) || + (keyEvent->key() == Qt::Key_Backtab)) { + // handle entered tab and reverse tab keys + return handleTabEntered( + keyEvent->key() == Qt::Key_Backtab); + } else if ((keyEvent->key() == Qt::Key_F) && + keyEvent->modifiers().testFlag(Qt::ControlModifier)) { + _searchWidget->activate(); + return true; + } else if ((keyEvent->key() == Qt::Key_R) && + keyEvent->modifiers().testFlag(Qt::ControlModifier)) { + _searchWidget->activateReplace(); + return true; +// } else if (keyEvent->key() == Qt::Key_Delete) { + } else if (keyEvent->key() == Qt::Key_Backspace) { + return handleBracketRemoval(); + } else if (keyEvent->key() == Qt::Key_Asterisk) { + return handleBracketClosing("*"); + } else if (keyEvent->key() == Qt::Key_QuoteDbl) { + return quotationMarkCheck("\""); + // apostrophe bracket closing is temporary disabled because + // apostrophes are used in different contexts +// } else if (keyEvent->key() == Qt::Key_Apostrophe) { +// return handleBracketClosing("'"); + // underline bracket closing is temporary disabled because + // underlines are used in different contexts +// } else if (keyEvent->key() == Qt::Key_Underscore) { +// return handleBracketClosing("_"); + } + else if (keyEvent->key() == Qt::Key_QuoteLeft) { + return quotationMarkCheck("`"); + } else if (keyEvent->key() == Qt::Key_AsciiTilde) { + return handleBracketClosing("~"); +#ifdef Q_OS_MAC + } else if (keyEvent->modifiers().testFlag(Qt::AltModifier) && + keyEvent->key() == Qt::Key_ParenLeft) { + // bracket closing for US keyboard on macOS + return handleBracketClosing("{", "}"); +#endif + } else if (keyEvent->key() == Qt::Key_ParenLeft) { + return handleBracketClosing("(", ")"); + } else if (keyEvent->key() == Qt::Key_BraceLeft) { + return handleBracketClosing("{", "}"); + } else if (keyEvent->key() == Qt::Key_BracketLeft) { + return handleBracketClosing("[", "]"); + } else if (keyEvent->key() == Qt::Key_Less) { + return handleBracketClosing("<", ">"); +#ifdef Q_OS_MAC + } else if (keyEvent->modifiers().testFlag(Qt::AltModifier) && + keyEvent->key() == Qt::Key_ParenRight) { + // bracket closing for US keyboard on macOS + return bracketClosingCheck("{", "}"); +#endif + } else if (keyEvent->key() == Qt::Key_ParenRight) { + return bracketClosingCheck("(", ")"); + } else if (keyEvent->key() == Qt::Key_BraceRight) { + return bracketClosingCheck("{", "}"); + } else if (keyEvent->key() == Qt::Key_BracketRight) { + return bracketClosingCheck("[", "]"); + } else if (keyEvent->key() == Qt::Key_Return && + keyEvent->modifiers().testFlag(Qt::ShiftModifier)) { + QTextCursor cursor = this->textCursor(); + cursor.insertText(" \n"); + return true; + } else if (keyEvent->key() == Qt::Key_Return && + keyEvent->modifiers().testFlag(Qt::ControlModifier)) { + QTextCursor cursor = this->textCursor(); + cursor.movePosition(QTextCursor::EndOfLine); + cursor.insertText("\n"); + setTextCursor(cursor); + return true; + } else if (keyEvent == QKeySequence::Copy || keyEvent == QKeySequence::Cut) { + QTextCursor cursor = this->textCursor(); + if (!cursor.hasSelection()) { + QString text; + if (cursor.block().length() <= 1) // no content + text = "\n"; + else { + //cursor.select(QTextCursor::BlockUnderCursor); // negative, it will include the previous paragraph separator + cursor.movePosition(QTextCursor::StartOfBlock); + cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); + text = cursor.selectedText(); + if (!cursor.atEnd()) { + text += "\n"; + // this is the paragraph separator + cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, 1); + } + } + if (keyEvent == QKeySequence::Cut) { + if (!cursor.atEnd() && text == "\n") + cursor.deletePreviousChar(); + else + cursor.removeSelectedText(); + cursor.movePosition(QTextCursor::StartOfLine); + setTextCursor(cursor); + } + qApp->clipboard()->setText(text); + return true; + } + } + else if (keyEvent == QKeySequence::Paste) { + if (qApp->clipboard()->ownsClipboard() && + QRegExp("[^\n]*\n$").exactMatch(qApp->clipboard()->text())) { + QTextCursor cursor = this->textCursor(); + if (!cursor.hasSelection()) { + cursor.movePosition(QTextCursor::StartOfLine); + setTextCursor(cursor); + } + } + } else if ((keyEvent->key() == Qt::Key_Down) && + keyEvent->modifiers().testFlag(Qt::ControlModifier) && + keyEvent->modifiers().testFlag(Qt::AltModifier)) { + // duplicate text with `Ctrl + Alt + Down` + duplicateText(); + return true; +#ifndef Q_OS_MAC + } else if ((keyEvent->key() == Qt::Key_Down) && + keyEvent->modifiers().testFlag(Qt::ControlModifier)) { + // scroll the page down + auto *scrollBar = verticalScrollBar(); + scrollBar->setSliderPosition(scrollBar->sliderPosition() + 1); + return true; + } else if ((keyEvent->key() == Qt::Key_Up) && + keyEvent->modifiers().testFlag(Qt::ControlModifier)) { + // scroll the page up + auto *scrollBar = verticalScrollBar(); + scrollBar->setSliderPosition(scrollBar->sliderPosition() - 1); + return true; +#endif + } else if ((keyEvent->key() == Qt::Key_Down) && + keyEvent->modifiers().testFlag(Qt::NoModifier)) { + // if you are in the last line and press cursor down the cursor will + // jump to the end of the line + QTextCursor cursor = textCursor(); + if (cursor.position() >= document()->lastBlock().position()) { + cursor.movePosition(QTextCursor::EndOfLine); + setTextCursor(cursor); + } + return false; + } else if ((keyEvent->key() == Qt::Key_Up) && + keyEvent->modifiers().testFlag(Qt::NoModifier)) { + // if you are in the first line and press cursor up the cursor will + // jump to the start of the line + QTextCursor cursor = textCursor(); + QTextBlock block = document()->firstBlock(); + int endOfFirstLinePos = block.position() + block.length(); + + if (cursor.position() <= endOfFirstLinePos) { + cursor.movePosition(QTextCursor::StartOfLine); + setTextCursor(cursor); + } + return false; + } else if (keyEvent->key() == Qt::Key_Return) { + return handleReturnEntered(); + } else if ((keyEvent->key() == Qt::Key_F3)) { + _searchWidget->doSearch( + !keyEvent->modifiers().testFlag(Qt::ShiftModifier)); + return true; + } + + return false; + } else if (event->type() == QEvent::KeyRelease) { + QKeyEvent *keyEvent = static_cast(event); + + // reset cursor if control key was released + if (keyEvent->key() == Qt::Key_Control) { + resetMouseCursor(); + } + + return false; + } else if (event->type() == QEvent::MouseButtonRelease) { + QMouseEvent *mouseEvent = static_cast(event); + + // track `Ctrl + Click` in the text edit + if ((obj == this->viewport()) && + (mouseEvent->button() == Qt::LeftButton) && + (QGuiApplication::keyboardModifiers() == Qt::ExtraButton24)) { + // open the link (if any) at the current position + // in the noteTextEdit + openLinkAtCursorPosition(); + return true; + } + } + + return QPlainTextEdit::eventFilter(obj, event); +} + +/** + * Resets the cursor to Qt::IBeamCursor + */ +void QMarkdownTextEdit::resetMouseCursor() const { + QWidget *viewPort = viewport(); + viewPort->setCursor(Qt::IBeamCursor); +} + +/** + * Resets the cursor to Qt::IBeamCursor if the widget looses the focus + */ +void QMarkdownTextEdit::focusOutEvent(QFocusEvent *event) { + resetMouseCursor(); + QPlainTextEdit::focusOutEvent(event); +} + +/** + * Enters a closing character after an opening character if needed + * + * @param openingCharacter + * @param closingCharacter + * @return + */ +bool QMarkdownTextEdit::handleBracketClosing(QString openingCharacter, + QString closingCharacter) { + // check if bracket closing is enabled + if (!(_autoTextOptions & AutoTextOption::BracketClosing)) { + return false; + } + + QTextCursor cursor = textCursor(); + + // get the current text from the block (inserted character not included) + QString text = cursor.block().text(); + + if (closingCharacter.isEmpty()) { + closingCharacter = openingCharacter; + } + + QString selectedText = cursor.selectedText(); + + // When user currently has text selected, we prepend the openingCharacter + // and append the closingCharacter. E.g. 'text' -> '(text)'. We keep the + // current selectedText selected. + // + // TODO(sanderboom): how to make ctrl-z keep the selectedText selected? + if (selectedText != "") { + // Insert. The selectedText is overwritten. + cursor.insertText(openingCharacter); + cursor.insertText(selectedText); + cursor.insertText(closingCharacter); + + // Re-select the selectedText. + int selectionEnd = cursor.position() - 1; + int selectionStart = selectionEnd - selectedText.length(); + cursor.setPosition(selectionStart); + cursor.setPosition(selectionEnd, QTextCursor::KeepAnchor); + this->setTextCursor(cursor); + + return true; + } else { + // if not text was selected check if we are inside the text + int positionInBlock = cursor.position() - cursor.block().position(); + + // only allow the closing if the cursor was at the end of a block + // we are making a special allowance for openingCharacter == * + if ((positionInBlock != text.count()) && + !((openingCharacter == "*") && + (positionInBlock == (text.count() - 1)))) { + return false; + } + } + + + // Remove whitespace at start of string (e.g. in multilevel-lists). + text = text.remove(QRegExp("^\\s+")); + + // Default positions to move the cursor back. + int cursorSubtract = 1; + + // Special handling for `*` opening character, as this could be: + // - start of a list (or sublist); + // - start of a bold text; + if (openingCharacter == "*") { + // User wants: '*'. + // This could be the start of a list, don't autocomplete. + if (text == "") { + return false; + } + // User wants: '**'. + // Not the start of a list, probably bold text. We autocomplete with + // extra closingCharacter and cursorSubtract to 'catchup'. + else if (text == "*") { + closingCharacter = "**"; + cursorSubtract = 2; + } + // User wants: '* *'. + // We are in a list already, proceed as normal (autocomplete). + else if (text == "* ") { + // no-op. + } + } + + // Auto completion for ``` pair + if (openingCharacter == "`") { + if (QRegExp("[^`]*``").exactMatch(text)) { + cursor.insertText(openingCharacter); + cursor.insertText(openingCharacter); + cursorSubtract = 3; + } + } + + cursor.insertText(openingCharacter); + cursor.insertText(closingCharacter); + cursor.setPosition(cursor.position() - cursorSubtract); + setTextCursor(cursor); + return true; +} + +/** + * Checks if the closing character should be output or not + * + * @param openingCharacter + * @param closingCharacter + * @return + */ +bool QMarkdownTextEdit::bracketClosingCheck(QString openingCharacter, + QString closingCharacter) { + // check if bracket closing is enabled + if (!(_autoTextOptions & AutoTextOption::BracketClosing)) { + return false; + } + + if (closingCharacter.isEmpty()) { + closingCharacter = openingCharacter; + } + + QTextCursor cursor = textCursor(); + int positionInBlock = cursor.position() - cursor.block().position(); + + // get the current text from the block + QString text = cursor.block().text(); + int textLength = text.length(); + + // if we are at the end of the line we just want to enter the character + if (positionInBlock >= textLength) { + return false; + } + + QString currentChar = text.at(positionInBlock); + + if (closingCharacter == openingCharacter) { + + } + + qDebug() << __func__ << " - 'currentChar': " << currentChar; + + + // if the current character is not the closing character we just want to + // enter the character + if (currentChar != closingCharacter) { + return false; + } + + QString leftText = text.left(positionInBlock); + int openingCharacterCount = leftText.count(openingCharacter); + int closingCharacterCount = leftText.count(closingCharacter); + + // if there were enough opening characters just enter the character + if (openingCharacterCount < (closingCharacterCount + 1)) { + return false; + } + + // move the cursor to the right and don't enter the character + cursor.movePosition(QTextCursor::Right); + setTextCursor(cursor); + return true; +} + +/** + * Checks if the closing character should be output or not or if a closing + * character after an opening character if needed + * + * @param quotationCharacter + * @return + */ +bool QMarkdownTextEdit::quotationMarkCheck(QString quotationCharacter) { + // check if bracket closing is enabled + if (!(_autoTextOptions & AutoTextOption::BracketClosing)) { + return false; + } + + QTextCursor cursor = textCursor(); + int positionInBlock = cursor.position() - cursor.block().position(); + + // get the current text from the block + QString text = cursor.block().text(); + int textLength = text.length(); + + // if we are at the end of the line we just want to enter the character + if (positionInBlock >= textLength) { + return handleBracketClosing(quotationCharacter); + } + + QString currentChar = text.at(positionInBlock); + + // if the current character is not the quotation character we just want to + // enter the character + if (currentChar != quotationCharacter) { + return handleBracketClosing(quotationCharacter); + } + + // move the cursor to the right and don't enter the character + cursor.movePosition(QTextCursor::Right); + setTextCursor(cursor); + return true; +} + +/** + * Handles removing of matching brackets and other markdown characters + * Only works with backspace to remove text + * + * @return + */ +bool QMarkdownTextEdit::handleBracketRemoval() { + // check if bracket removal is enabled + if (!(_autoTextOptions & AutoTextOption::BracketRemoval)) { + return false; + } + + QTextCursor cursor = textCursor(); + + // return if some text was selected + if (!cursor.selectedText().isEmpty()) { + return false; + } + + int position = cursor.position(); + int positionInBlock = position - cursor.block().position(); + + // return if backspace was pressed at the beginning of a block + if (positionInBlock == 0) { + return false; + } + + // get the current text from the block + QString text = cursor.block().text(); + QString charInFront = text.at(positionInBlock - 1); + int openingCharacterIndex = _openingCharacters.indexOf(charInFront); + + // return if the character in front of the cursor is no opening character + if (openingCharacterIndex == -1) { + return false; + } + + QString closingCharacter = _closingCharacters.at(openingCharacterIndex); + + // remove everything in front of the cursor + text.remove(0, positionInBlock); + int closingCharacterIndex = text.indexOf(closingCharacter); + + // return if no closing character was found in the text after the cursor + if (closingCharacterIndex == -1) { + return false; + } + + // removing the closing character + cursor.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor, + closingCharacterIndex); + cursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor); + cursor.removeSelectedText(); + + // moving the cursor back to the old position so the previous character + // can be removed + cursor.setPosition(position); + setTextCursor(cursor); + return false; +} + +/** + * Increases (or decreases) the indention of the selected text + * (if there is a text selected) in the noteTextEdit + * @return + */ +bool QMarkdownTextEdit::increaseSelectedTextIndention(bool reverse) { + QTextCursor cursor = this->textCursor(); + QString selectedText = cursor.selectedText(); + + if (selectedText != "") { + // we need this strange newline character we are getting in the + // selected text for newlines + QString newLine = QString::fromUtf8(QByteArray::fromHex("e280a9")); + QString newText; + + if (reverse) { + // un-indent text + + // remove strange newline characters + newText = selectedText.replace( + QRegularExpression(newLine + "[\\t ]"), "\n"); + + // remove leading \t or space + newText.remove(QRegularExpression("^[\\t ]")); + } else { + // indent text + newText = selectedText.replace(newLine, "\n\t").prepend("\t"); + + // remove trailing \t + newText.replace(QRegularExpression("\\t$"), ""); + } + + // insert the new text + cursor.insertText(newText); + + // update the selection to the new text + cursor.setPosition(cursor.position() - newText.size(), QTextCursor::KeepAnchor); + this->setTextCursor(cursor); + + return true; + } else if (reverse) { + // if nothing was selected but we want to reverse the indention check + // if there is a \t in front or after the cursor and remove it if so + int position = cursor.position(); + + if (!cursor.atStart()) { + // get character in front of cursor + cursor.setPosition(position - 1, QTextCursor::KeepAnchor); + } + + // check for \t or space in front of cursor + QRegularExpression re("[\\t ]"); + QRegularExpressionMatch match = re.match(cursor.selectedText()); + + if (!match.hasMatch()) { + // (select to) check for \t or space after the cursor + cursor.setPosition(position); + + if (!cursor.atEnd()) { + cursor.setPosition(position + 1, QTextCursor::KeepAnchor); + } + } + + match = re.match(cursor.selectedText()); + + if (match.hasMatch()) { + cursor.removeSelectedText(); + } + + return true; + } + + return false; +} + +/** + * @brief Opens the link (if any) at the current cursor position + */ +bool QMarkdownTextEdit::openLinkAtCursorPosition() { + QTextCursor cursor = this->textCursor(); + int clickedPosition = cursor.position(); + + // select the text in the clicked block and find out on + // which position we clicked + cursor.movePosition(QTextCursor::StartOfBlock); + int positionFromStart = clickedPosition - cursor.position(); + cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); + + QString selectedText = cursor.selectedText(); + + // find out which url in the selected text was clicked + QString urlString = getMarkdownUrlAtPosition(selectedText, + positionFromStart); + QUrl url = QUrl(urlString); + bool isRelativeFileUrl = urlString.startsWith("file://.."); + + qDebug() << __func__ << " - 'emit urlClicked( urlString )': " + << urlString; + + emit urlClicked(urlString); + + if ((url.isValid() && isValidUrl(urlString)) || isRelativeFileUrl) { + // ignore some schemata + if (!(_ignoredClickUrlSchemata.contains(url.scheme()) || + isRelativeFileUrl)) { + // open the url + openUrl(urlString); + } + + return true; + } + + return false; +} + +/** + * Checks if urlString is a valid url + * + * @param urlString + * @return + */ +bool QMarkdownTextEdit::isValidUrl(QString urlString) { + QRegularExpressionMatch match = + QRegularExpression("^\\w+:\\/\\/.+").match(urlString); + return match.hasMatch(); +} + +/** + * Handles clicked urls + * + * examples: + * - opens the webpage + * - opens the file + * "/path/to/my/file/QOwnNotes.pdf" if the operating system supports that + * handler + */ +void QMarkdownTextEdit::openUrl(QString urlString) { + qDebug() << "QMarkdownTextEdit " << __func__ << " - 'urlString': " + << urlString; + + QDesktopServices::openUrl(QUrl(urlString)); +} + +/** + * @brief Returns the highlighter instance + * @return + */ +MarkdownHighlighter *QMarkdownTextEdit::highlighter() { + return _highlighter; +} + +/** + * @brief Returns the searchWidget instance + * @return + */ +QPlainTextEditSearchWidget *QMarkdownTextEdit::searchWidget() { + return _searchWidget; +} + +/** + * @brief Sets url schemata that will be ignored when clicked on + * @param urlSchemes + */ +void QMarkdownTextEdit::setIgnoredClickUrlSchemata( + QStringList ignoredUrlSchemata) { + _ignoredClickUrlSchemata = ignoredUrlSchemata; +} + +/** + * @brief Returns a map of parsed markdown urls with their link texts as key + * + * @param text + * @return parsed urls + */ +QMap QMarkdownTextEdit::parseMarkdownUrlsFromText( + QString text) { + QMap urlMap; + QRegularExpression regex; + QRegularExpressionMatchIterator iterator; + + // match urls like this: +// re = QRegularExpression("(<(.+?:\\/\\/.+?)>)"); + regex = QRegularExpression("(<(.+?)>)"); + iterator = regex.globalMatch(text); + while (iterator.hasNext()) { + QRegularExpressionMatch match = iterator.next(); + QString linkText = match.captured(1); + QString url = match.captured(2); + urlMap[linkText] = url; + } + + // match urls like this: [this url](http://mylink) +// QRegularExpression re("(\\[.*?\\]\\((.+?:\\/\\/.+?)\\))"); + regex = QRegularExpression("(\\[.*?\\]\\((.+?)\\))"); + iterator = regex.globalMatch(text); + while (iterator.hasNext()) { + QRegularExpressionMatch match = iterator.next(); + QString linkText = match.captured(1); + QString url = match.captured(2); + urlMap[linkText] = url; + } + + // match urls like this: http://mylink + regex = QRegularExpression("\\b\\w+?:\\/\\/[^\\s]+[^\\s>\\)]"); + iterator = regex.globalMatch(text); + while (iterator.hasNext()) { + QRegularExpressionMatch match = iterator.next(); + QString url = match.captured(0); + urlMap[url] = url; + } + + // match reference urls like this: [this url][1] with this later: + // [1]: http://domain + regex = QRegularExpression("\\[(.*?)\\]\\s?\\[(.+?)\\]"); + iterator = regex.globalMatch(text); + while (iterator.hasNext()) { + QRegularExpressionMatch match = iterator.next(); + QString linkText = match.captured(1); + QString referenceId = match.captured(2); + + // search for the referenced url in the whole text edit +// QRegularExpression refRegExp( +// "\\[" + QRegularExpression::escape(referenceId) + +// "\\]: (.+?:\\/\\/.+)"); + QRegularExpression refRegExp( + "\\[" + QRegularExpression::escape(referenceId) + "\\]: (.+?)"); + QRegularExpressionMatch urlMatch = refRegExp.match(toPlainText()); + + if (urlMatch.hasMatch()) { + QString url = urlMatch.captured(1); + urlMap[linkText] = url; + } + } + + return urlMap; +} + +/** + * @brief Returns the markdown url at position + * @param text + * @param position + * @return url string + */ +QString QMarkdownTextEdit::getMarkdownUrlAtPosition( + QString text, int position) { + QString url; + + // get a map of parsed markdown urls with their link texts as key + QMap urlMap = parseMarkdownUrlsFromText(text); + + QMapIterator iterator(urlMap); + while (iterator.hasNext()) { + iterator.next(); + QString linkText = iterator.key(); + QString urlString = iterator.value(); + + int foundPositionStart = text.indexOf(linkText); + + if (foundPositionStart >= 0) { + // calculate end position of found linkText + int foundPositionEnd = foundPositionStart + linkText.size(); + + // check if position is in found string range + if ((position >= foundPositionStart) && + (position <= foundPositionEnd)) { + url = urlString; + break; + } + } + } + + return url; +} + +/** + * @brief Duplicates the text in the text edit + */ +void QMarkdownTextEdit::duplicateText() { + QTextCursor cursor = this->textCursor(); + QString selectedText = cursor.selectedText(); + + // duplicate line if no text was selected + if (selectedText == "") { + int position = cursor.position(); + + // select the whole line + cursor.movePosition(QTextCursor::StartOfLine); + cursor.movePosition(QTextCursor::EndOfLine, QTextCursor::KeepAnchor); + + int positionDiff = cursor.position() - position; + selectedText = "\n" + cursor.selectedText(); + + // insert text with new line at end of the selected line + cursor.setPosition(cursor.selectionEnd()); + cursor.insertText(selectedText); + + // set the position to same position it was in the duplicated line + cursor.setPosition(cursor.position() - positionDiff); + } else { + // duplicate selected text + cursor.setPosition(cursor.selectionEnd()); + int selectionStart = cursor.position(); + + // insert selected text + cursor.insertText(selectedText); + int selectionEnd = cursor.position(); + + // select the inserted text + cursor.setPosition(selectionStart); + cursor.setPosition(selectionEnd, QTextCursor::KeepAnchor); + } + + this->setTextCursor(cursor); +} + +void QMarkdownTextEdit::setText(const QString & text) { + setPlainText(text); +} + +void QMarkdownTextEdit::setPlainText(const QString & text) { + // clear the dirty blocks vector to increase performance and prevent + // a possible crash in QSyntaxHighlighter::rehighlightBlock + _highlighter->clearDirtyBlocks(); + + QPlainTextEdit::setPlainText(text); + adjustRightMargin(); +} + +/** + * Uses an other widget as parent for the search widget + */ +void QMarkdownTextEdit::initSearchFrame(QWidget *searchFrame, bool darkMode) { + _searchFrame = searchFrame; + + // remove the search widget from our layout + layout()->removeWidget(_searchWidget); + + QLayout *layout = _searchFrame->layout(); + + // create a grid layout for the frame and add the search widget to it + if (layout == NULL) { + layout = new QVBoxLayout(); + layout->setSpacing(0); + layout->setContentsMargins(0, 0, 0, 0); + } + + _searchWidget->setDarkMode(darkMode); + layout->addWidget(_searchWidget); + _searchFrame->setLayout(layout); +} + +/** + * Hides the text edit and the search widget + */ +void QMarkdownTextEdit::hide() { + _searchWidget->hide(); + QWidget::hide(); +} + +/** + * Handles an entered return key + */ +bool QMarkdownTextEdit::handleReturnEntered() { + QTextCursor cursor = this->textCursor(); + int position = cursor.position(); + + cursor.movePosition(QTextCursor::StartOfBlock, QTextCursor::KeepAnchor); + QString currentLineText = cursor.selectedText(); + + // if return is pressed and there is just a list symbol then we want to + // remove the list symbol + // Valid listCharacters: '+ ', '-' , '* ', '+ [ ] ', '+ [x] ', '- [ ] ', '- [x] ', '* [ ] ', '* [x] '. + QRegularExpression regex("^(\\s*)([+|\\-|\\*] \\[(x| )\\]|[+\\-\\*])(\\s+)$"); + QRegularExpressionMatchIterator iterator = regex.globalMatch(currentLineText); + if (iterator.hasNext()) { + cursor.removeSelectedText(); + return true; + } + + // Check if we are in a list. + // We are in a list when we have '* ', '- ' or '+ ', possibly with preceding + // whitespace. If e.g. user has entered '**text**' and pressed enter - we + // don't want do anymore list-stuff. + QChar char0 = currentLineText.trimmed()[0]; + QChar char1 = currentLineText.trimmed()[1]; + bool inList = ((char0 == '*' || char0 == '-' || char0 == '+') && char1 == ' '); + + if (inList) { + // if the current line starts with a list character (possibly after + // whitespaces) add the whitespaces at the next line too + // Valid listCharacters: '+ ', '-' , '* ', '+ [ ] ', '+ [x] ', '- [ ] ', '- [x] ', '* [ ] ', '* [x] '. + regex = QRegularExpression("^(\\s*)([+|\\-|\\*] \\[(x| )\\]|[+\\-\\*])(\\s+)"); + iterator = regex.globalMatch(currentLineText); + if (iterator.hasNext()) { + QRegularExpressionMatch match = iterator.next(); + QString whitespaces = match.captured(1); + QString listCharacter = match.captured(2); + QString whitespaceCharacter = match.captured(4); + + cursor.setPosition(position); + cursor.insertText("\n" + whitespaces + listCharacter + whitespaceCharacter); + + // scroll to the cursor if we are at the bottom of the document + ensureCursorVisible(); + return true; + } + } + + return false; +} + +/** + * Handles entered tab or reverse tab keys + */ +bool QMarkdownTextEdit::handleTabEntered(bool reverse) { + QTextCursor cursor = this->textCursor(); + + // only check for lists if we haven't a text selected + if (cursor.selectedText().isEmpty()) { + cursor.movePosition(QTextCursor::StartOfLine, QTextCursor::KeepAnchor); + QString currentLineText = cursor.selectedText(); + + // check if we want to indent or un-indent a list + // Valid listCharacters: '+ ', '-' , '* ', '+ [ ] ', '+ [x] ', '- [ ] ', '- [x] ', '* [ ] ', '* [x] '. + QRegularExpression re("^(\\s*)([+|\\-|\\*] \\[(x| )\\]|[+\\-\\*])(\\s+)$"); + QRegularExpressionMatchIterator i = re.globalMatch(currentLineText); + + if (i.hasNext()) { + QRegularExpressionMatch match = i.next(); + QString whitespaces = match.captured(1); + QString listCharacter = match.captured(2); + QString whitespaceCharacter = match.captured(4); + + // add or remove one tabulator key + if (reverse) { + whitespaces.chop(1); + } else { + whitespaces += "\t"; + } + + cursor.insertText(whitespaces + listCharacter + whitespaceCharacter); + return true; + } + } + + // check if we want to intent the whole text + return increaseSelectedTextIndention(reverse); +} + +/** + * Sets the auto text options + */ +void QMarkdownTextEdit::setAutoTextOptions(AutoTextOptions options) { + _autoTextOptions = options; +} + +/** + * Overrides QPlainTextEdit::paintEvent to fix the RTL bug of QPlainTextEdit + * + * @param e + */ +void QMarkdownTextEdit::paintEvent(QPaintEvent *e) { + QTextBlock block = firstVisibleBlock(); + + while (block.isValid()) { + QTextLayout *layout = block.layout(); + + // this fixes the RTL bug of QPlainTextEdit + // https://bugreports.qt.io/browse/QTBUG-7516 + if (block.text().isRightToLeft()) + { + QTextOption opt = document()->defaultTextOption(); + opt = QTextOption(Qt::AlignRight); + opt.setTextDirection(Qt::RightToLeft); + layout->setTextOption(opt); + } + + block = block.next(); + } + + QPlainTextEdit::paintEvent(e); +} + +/** + * Overrides QPlainTextEdit::setReadOnly to fix a problem with Chinese and + * Japanese input methods + * + * @param ro + */ +void QMarkdownTextEdit::setReadOnly(bool ro) { + QPlainTextEdit::setReadOnly(ro); + + // attempted to fix a problem with Chinese and Japanese input methods + // @see https://github.com/pbek/QOwnNotes/issues/976 + setAttribute(Qt::WA_InputMethodEnabled, !isReadOnly()); +} diff --git a/client/qmarkdowntextedit/qmarkdowntextedit.h b/client/qmarkdowntextedit/qmarkdowntextedit.h new file mode 100644 index 0000000..5463f70 --- /dev/null +++ b/client/qmarkdowntextedit/qmarkdowntextedit.h @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2014-2019 Patrizio Bekerle -- http://www.bekerle.com + * + * 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; version 2 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. + * + */ + +#pragma once + +#include +#include +#include "markdownhighlighter.h" +#include "qplaintexteditsearchwidget.h" + + +class QMarkdownTextEdit : public QPlainTextEdit +{ + Q_OBJECT + +public: + enum AutoTextOption { + None = 0x0000, + + // inserts closing characters for brackets and markdown characters + BracketClosing = 0x0001, + + // removes matching brackets and markdown characters + BracketRemoval = 0x0002 + }; + + Q_DECLARE_FLAGS(AutoTextOptions, AutoTextOption) + + explicit QMarkdownTextEdit(QWidget *parent = 0, bool initHighlighter = true); + MarkdownHighlighter *highlighter(); + QPlainTextEditSearchWidget *searchWidget(); + void setIgnoredClickUrlSchemata(QStringList ignoredUrlSchemata); + virtual void openUrl(QString urlString); + QString getMarkdownUrlAtPosition(QString text, int position); + void initSearchFrame(QWidget *searchFrame, bool darkMode = false); + void setAutoTextOptions(AutoTextOptions options); + void setHighlightingEnabled(bool enabled); + static bool isValidUrl(QString urlString); + void resetMouseCursor() const; + void setReadOnly(bool ro); + +public slots: + void duplicateText(); + void setText(const QString & text); + void setPlainText(const QString & text); + void adjustRightMargin(); + void hide(); + bool openLinkAtCursorPosition(); + bool handleBracketRemoval(); + +protected: + MarkdownHighlighter *_highlighter; + bool _highlightingEnabled; + QStringList _ignoredClickUrlSchemata; + QPlainTextEditSearchWidget *_searchWidget; + QWidget *_searchFrame; + AutoTextOptions _autoTextOptions; + QStringList _openingCharacters; + QStringList _closingCharacters; + + bool eventFilter(QObject *obj, QEvent *event); + bool increaseSelectedTextIndention(bool reverse); + bool handleTabEntered(bool reverse); + QMap parseMarkdownUrlsFromText(QString text); + bool handleReturnEntered(); + bool handleBracketClosing(QString openingCharacter, + QString closingCharacter = ""); + bool bracketClosingCheck(QString openingCharacter, + QString closingCharacter); + bool quotationMarkCheck(QString quotationCharacter); + void focusOutEvent(QFocusEvent *event); + void paintEvent(QPaintEvent *e); + +signals: + void urlClicked(QString url); +}; diff --git a/client/qmarkdowntextedit/qmarkdowntextedit.pri b/client/qmarkdowntextedit/qmarkdowntextedit.pri new file mode 100644 index 0000000..77112d3 --- /dev/null +++ b/client/qmarkdowntextedit/qmarkdowntextedit.pri @@ -0,0 +1,4 @@ +INCLUDEPATH += $$PWD/ + +include($$PWD/qmarkdowntextedit-headers.pri) +include($$PWD/qmarkdowntextedit-sources.pri) diff --git a/client/qmarkdowntextedit/qmarkdowntextedit.pro b/client/qmarkdowntextedit/qmarkdowntextedit.pro new file mode 100644 index 0000000..518ae85 --- /dev/null +++ b/client/qmarkdowntextedit/qmarkdowntextedit.pro @@ -0,0 +1,23 @@ +#------------------------------------------------- +# +# Project created by QtCreator 2016-01-11T16:56:21 +# +#------------------------------------------------- + +QT += core gui + +greaterThan(QT_MAJOR_VERSION, 4): QT += widgets + +TARGET = QMarkdownTextedit +TEMPLATE = app +TRANSLATIONS = trans/qmarkdowntextedit_de.ts +CONFIG += c++11 + +SOURCES += main.cpp \ + mainwindow.cpp \ + +HEADERS += mainwindow.h + +FORMS += mainwindow.ui + +include(qmarkdowntextedit.pri) diff --git a/client/qmarkdowntextedit/qplaintexteditsearchwidget.cpp b/client/qmarkdowntextedit/qplaintexteditsearchwidget.cpp new file mode 100644 index 0000000..54fb975 --- /dev/null +++ b/client/qmarkdowntextedit/qplaintexteditsearchwidget.cpp @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2014-2019 Patrizio Bekerle -- http://www.bekerle.com + * + * 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; version 2 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. + * + */ + +#include "qplaintexteditsearchwidget.h" +#include "ui_qplaintexteditsearchwidget.h" +#include +#include +#include + +QPlainTextEditSearchWidget::QPlainTextEditSearchWidget(QPlainTextEdit *parent) : + QWidget(parent), + ui(new Ui::QPlainTextEditSearchWidget) +{ + ui->setupUi(this); + _textEdit = parent; + _darkMode = false; + hide(); + + QObject::connect(ui->closeButton, SIGNAL(clicked()), + this, SLOT(deactivate())); + QObject::connect(ui->searchLineEdit, SIGNAL(textChanged(const QString &)), + this, SLOT(searchLineEditTextChanged(const QString &))); + QObject::connect(ui->searchDownButton, SIGNAL(clicked()), + this, SLOT(doSearchDown())); + QObject::connect(ui->searchUpButton, SIGNAL(clicked()), + this, SLOT(doSearchUp())); + QObject::connect(ui->replaceToggleButton, SIGNAL(toggled(bool)), + this, SLOT(setReplaceMode(bool))); + QObject::connect(ui->replaceButton, SIGNAL(clicked()), + this, SLOT(doReplace())); + QObject::connect(ui->replaceAllButton, SIGNAL(clicked()), + this, SLOT(doReplaceAll())); + + installEventFilter(this); + ui->searchLineEdit->installEventFilter(this); + ui->replaceLineEdit->installEventFilter(this); + +#ifdef Q_OS_MAC + // set the spacing to 8 for OS X + layout()->setSpacing(8); + ui->buttonFrame->layout()->setSpacing(9); + + // set the margin to 0 for the top buttons for OS X + QString buttonStyle = "QPushButton {margin: 0}"; + ui->closeButton->setStyleSheet(buttonStyle); + ui->searchDownButton->setStyleSheet(buttonStyle); + ui->searchUpButton->setStyleSheet(buttonStyle); + ui->replaceToggleButton->setStyleSheet(buttonStyle); + ui->matchCaseSensitiveButton->setStyleSheet(buttonStyle); +#endif +} + +QPlainTextEditSearchWidget::~QPlainTextEditSearchWidget() { + delete ui; +} + +void QPlainTextEditSearchWidget::activate() { + setReplaceMode(false); + show(); + + // preset the selected text as search text if there is any and there is no + // other search text + QString selectedText = _textEdit->textCursor().selectedText(); + if (!selectedText.isEmpty() && ui->searchLineEdit->text().isEmpty()) { + ui->searchLineEdit->setText(selectedText); + } + + ui->searchLineEdit->setFocus(); + ui->searchLineEdit->selectAll(); + doSearchDown(); +} + +void QPlainTextEditSearchWidget::activateReplace() { + // replacing is prohibited if the text edit is readonly + if (_textEdit->isReadOnly()) { + return; + } + + ui->searchLineEdit->setText(_textEdit->textCursor().selectedText()); + ui->searchLineEdit->selectAll(); + activate(); + setReplaceMode(true); +} + +void QPlainTextEditSearchWidget::deactivate() { + hide(); + _textEdit->setFocus(); +} + +void QPlainTextEditSearchWidget::setReplaceMode(bool enabled) { + ui->replaceToggleButton->setChecked(enabled); + ui->replaceLabel->setVisible(enabled); + ui->replaceLineEdit->setVisible(enabled); + ui->modeLabel->setVisible(enabled); + ui->buttonFrame->setVisible(enabled); + ui->matchCaseSensitiveButton->setVisible(enabled); +} + +bool QPlainTextEditSearchWidget::eventFilter(QObject *obj, QEvent *event) { + if (event->type() == QEvent::KeyPress) { + QKeyEvent *keyEvent = static_cast(event); + + if (keyEvent->key() == Qt::Key_Escape) { + deactivate(); + return true; + } else if ((keyEvent->modifiers().testFlag(Qt::ShiftModifier) && + (keyEvent->key() == Qt::Key_Return)) || + (keyEvent->key() == Qt::Key_Up)) { + doSearchUp(); + return true; + } else if ((keyEvent->key() == Qt::Key_Return) || + (keyEvent->key() == Qt::Key_Down)) { + doSearchDown(); + return true; + } else if (keyEvent->key() == Qt::Key_F3) { + doSearch(!keyEvent->modifiers().testFlag(Qt::ShiftModifier)); + return true; + } + +// if ((obj == ui->replaceLineEdit) && (keyEvent->key() == Qt::Key_Tab) +// && ui->replaceToggleButton->isChecked()) { +// ui->replaceLineEdit->setFocus(); +// } + + return false; + } + + return QWidget::eventFilter(obj, event); +} + +void QPlainTextEditSearchWidget::searchLineEditTextChanged(const QString &arg1) { + Q_UNUSED(arg1); + doSearchDown(); +} + +void QPlainTextEditSearchWidget::doSearchUp() { + doSearch(false); +} + +void QPlainTextEditSearchWidget::doSearchDown() { + doSearch(true); +} + +bool QPlainTextEditSearchWidget::doReplace(bool forAll) { + if (_textEdit->isReadOnly()) { + return false; + } + + QTextCursor cursor = _textEdit->textCursor(); + + if (!forAll && cursor.selectedText().isEmpty()) { + return false; + } + + int searchMode = ui->modeComboBox->currentIndex(); + if (searchMode == RegularExpressionMode) { + QString text = cursor.selectedText(); + text.replace(QRegExp(ui->searchLineEdit->text()), + ui->replaceLineEdit->text()); + cursor.insertText(text); + } else { + cursor.insertText(ui->replaceLineEdit->text()); + } + + if (!forAll) { + int position = cursor.position(); + + if (!doSearch(true)) { + // restore the last cursor position if text wasn't found any more + cursor.setPosition(position); + _textEdit->setTextCursor(cursor); + } + } + + return true; +} + +void QPlainTextEditSearchWidget::doReplaceAll() { + if (_textEdit->isReadOnly()) { + return; + } + + // start at the top + _textEdit->moveCursor(QTextCursor::Start); + + // replace until everything to the bottom is replaced + while (doSearch(true, false) && doReplace(true)) {} +} + +/** + * @brief Searches for text in the text edit + * @returns true if found + */ +bool QPlainTextEditSearchWidget::doSearch(bool searchDown, bool allowRestartAtTop) { + QString text = ui->searchLineEdit->text(); + + if (text == "") { + ui->searchLineEdit->setStyleSheet(""); + return false; + } + + int searchMode = ui->modeComboBox->currentIndex(); + + QFlags options = searchDown ? + QTextDocument::FindFlag(0) + : QTextDocument::FindBackward; + if (searchMode == WholeWordsMode) { + options |= QTextDocument::FindWholeWords; + } + + if (ui->matchCaseSensitiveButton->isChecked()) { + options |= QTextDocument::FindCaseSensitively; + } + + bool found; + if (searchMode == RegularExpressionMode) { + found = _textEdit->find(QRegExp(text), options); + } else { + found = _textEdit->find(text, options); + } + + // start at the top (or bottom) if not found + if (!found && allowRestartAtTop) { + _textEdit->moveCursor( + searchDown ? QTextCursor::Start : QTextCursor::End); + found = _textEdit->find(text, options); + } + + QRect rect = _textEdit->cursorRect(); + QMargins margins = _textEdit->layout()->contentsMargins(); + int searchWidgetHotArea = _textEdit->height() - this->height(); + int marginBottom = (rect.y() > searchWidgetHotArea) ? (this->height() + 10) + : 0; + + // move the search box a bit up if we would block the search result + if (margins.bottom() != marginBottom) { + margins.setBottom(marginBottom); + _textEdit->layout()->setContentsMargins(margins); + } + + // add a background color according if we found the text or not + QString colorCode = found ? "#D5FAE2" : "#FAE9EB"; + + if (_darkMode) { + colorCode = found ? "#135a13" : "#8d2b36"; + } + + ui->searchLineEdit->setStyleSheet("* { background: " + colorCode + "; }"); + + return found; +} + +void QPlainTextEditSearchWidget::setDarkMode(bool enabled) { + _darkMode = enabled; +} diff --git a/client/qmarkdowntextedit/qplaintexteditsearchwidget.h b/client/qmarkdowntextedit/qplaintexteditsearchwidget.h new file mode 100644 index 0000000..aaeb8b0 --- /dev/null +++ b/client/qmarkdowntextedit/qplaintexteditsearchwidget.h @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2014-2019 Patrizio Bekerle -- http://www.bekerle.com + * + * 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; version 2 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. + * + */ + +#pragma once + +#include +#include + +namespace Ui { +class QPlainTextEditSearchWidget; +} + +class QPlainTextEditSearchWidget : public QWidget +{ + Q_OBJECT + +public: + enum SearchMode { + PlainTextMode, + WholeWordsMode, + RegularExpressionMode + }; + + explicit QPlainTextEditSearchWidget(QPlainTextEdit *parent = 0); + bool doSearch(bool searchDown = true, bool allowRestartAtTop = true); + void setDarkMode(bool enabled); + ~QPlainTextEditSearchWidget(); + +private: + Ui::QPlainTextEditSearchWidget *ui; + +protected: + QPlainTextEdit *_textEdit; + bool _darkMode; + bool eventFilter(QObject *obj, QEvent *event); + +public slots: + void activate(); + void deactivate(); + void doSearchDown(); + void doSearchUp(); + void setReplaceMode(bool enabled); + void activateReplace(); + bool doReplace(bool forAll = false); + void doReplaceAll(); + +protected slots: + void searchLineEditTextChanged(const QString &arg1); +}; diff --git a/client/qmarkdowntextedit/qplaintexteditsearchwidget.ui b/client/qmarkdowntextedit/qplaintexteditsearchwidget.ui new file mode 100644 index 0000000..efbba09 --- /dev/null +++ b/client/qmarkdowntextedit/qplaintexteditsearchwidget.ui @@ -0,0 +1,253 @@ + + + QPlainTextEditSearchWidget + + + + 0 + 0 + 836 + 142 + + + + true + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + replace text + + + + + + + :/media/edit-find-replace.svg:/media/edit-find-replace.svg + + + true + + + true + + + + + + + Find: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + close search + + + + + + + :/media/window-close.svg:/media/window-close.svg + + + true + + + + + + + find in text + + + + + + + search forward + + + + + + + :/media/go-bottom.svg:/media/go-bottom.svg + + + true + + + + + + + search backward + + + + + + + :/media/go-top.svg:/media/go-top.svg + + + true + + + + + + + replace with + + + + + + + Replace: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QFrame::NoFrame + + + + 0 + + + 0 + + + 0 + + + 9 + + + + + + Plain text + + + + + Whole words + + + + + Regular expression + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Replace + + + false + + + + + + + Replace All + + + false + + + + + + + + + + Mode: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Match case sensitive + + + + + + + :/media/format-text-superscript.svg:/media/format-text-superscript.svg + + + true + + + true + + + + + + + searchLineEdit + replaceLineEdit + replaceButton + replaceAllButton + searchDownButton + searchUpButton + replaceToggleButton + closeButton + + + + + + diff --git a/client/qmarkdowntextedit/screenshot.png b/client/qmarkdowntextedit/screenshot.png new file mode 100644 index 0000000..ca3bf87 Binary files /dev/null and b/client/qmarkdowntextedit/screenshot.png differ diff --git a/client/qmarkdowntextedit/trans/qmarkdowntextedit_de.qm b/client/qmarkdowntextedit/trans/qmarkdowntextedit_de.qm new file mode 100644 index 0000000..675cf2c Binary files /dev/null and b/client/qmarkdowntextedit/trans/qmarkdowntextedit_de.qm differ diff --git a/client/qmarkdowntextedit/trans/qmarkdowntextedit_de.ts b/client/qmarkdowntextedit/trans/qmarkdowntextedit_de.ts new file mode 100644 index 0000000..134f5aa --- /dev/null +++ b/client/qmarkdowntextedit/trans/qmarkdowntextedit_de.ts @@ -0,0 +1,57 @@ + + + + + QPlainTextEditSearchWidget + + + close search + Suche schließen + + + + Find: + Finden: + + + + replace text + Text ersetzen + + + + find in text + im Text finden + + + + search forward + vorwärts suchen + + + + search backward + rückwärts suchen + + + + replace with + ersetzen mit + + + + Replace: + Ersetzen: + + + + Replace + Ersetzen + + + + Replace All + Alle ersetzen + + +