diff --git a/linphone-app/CMakeLists.txt b/linphone-app/CMakeLists.txt index f4f03142c..4a5286389 100644 --- a/linphone-app/CMakeLists.txt +++ b/linphone-app/CMakeLists.txt @@ -329,6 +329,7 @@ set(SOURCES src/components/other/timeZone/TimeZoneListModel.cpp src/components/other/timeZone/TimeZoneProxyModel.cpp src/components/other/units/Units.cpp + src/components/other/spell-checker/SpellChecker.cpp src/components/participant/ParticipantModel.cpp src/components/participant/ParticipantListModel.cpp src/components/participant/ParticipantProxyModel.cpp @@ -479,6 +480,7 @@ set(HEADERS src/components/other/timeZone/TimeZoneListModel.hpp src/components/other/timeZone/TimeZoneProxyModel.hpp src/components/other/units/Units.hpp + src/components/other/spell-checker/SpellChecker.hpp src/components/participant/ParticipantModel.hpp src/components/participant/ParticipantListModel.hpp src/components/participant/ParticipantProxyModel.hpp @@ -554,6 +556,7 @@ if (APPLE) src/components/other/desktop-tools/DesktopToolsMacOsNative.mm src/components/other/desktop-tools/screen-saver/ScreenSaverMacOs.m src/components/other/desktop-tools/state-process/StateProcessMacOs.mm + src/components/other/spell-checker/SpellCheckerMacOsNative.mm ) list(APPEND HEADERS #src/app/single-application/SingleApplicationPrivate.hpp @@ -565,6 +568,7 @@ elseif (WIN32) #src/app/single-application/SingleApplication.cpp src/components/core/event-count-notifier/EventCountNotifierSystemTrayIcon.cpp src/components/other/desktop-tools/DesktopToolsWindows.cpp + src/components/other/spell-checker/SpellCheckerWindowsNative.cpp ) list(APPEND HEADERS #src/app/single-application/SingleApplicationPrivate.hpp @@ -578,6 +582,7 @@ else () src/components/other/desktop-tools/DesktopToolsLinux.cpp src/components/other/desktop-tools/screen-saver/ScreenSaverDBus.cpp src/components/other/desktop-tools/screen-saver/ScreenSaverXdg.cpp + src/components/other/spell-checker/SpellCheckerLinux.cpp ) list(APPEND HEADERS #src/app/single-application/SingleApplicationDBusPrivate.hpp diff --git a/linphone-app/assets/languages/en.ts b/linphone-app/assets/languages/en.ts index 265e09d83..393fbe281 100644 --- a/linphone-app/assets/languages/en.ts +++ b/linphone-app/assets/languages/en.ts @@ -5188,4 +5188,23 @@ Click here: <a href="%1">%1</a> + + SpellCheckerMenu + + spellCheckingMenuDidYouMean + Did you mean ? + + + spellCheckingMenuAddToDictionary + Add to Dictionnary + + + spellCheckingMenuIgnoreOnce + Ignore Once + + + spellCheckingMenuIgnoreAll + Ignore All + + diff --git a/linphone-app/assets/languages/fr_FR.ts b/linphone-app/assets/languages/fr_FR.ts index ab8c20d6f..a71e268e7 100644 --- a/linphone-app/assets/languages/fr_FR.ts +++ b/linphone-app/assets/languages/fr_FR.ts @@ -5163,4 +5163,23 @@ Cliquez ici : <a href="%1">%1</a> + + SpellCheckerMenu + + spellCheckingMenuDidYouMean + Voulez-vous dire ? + + + spellCheckingMenuAddToDictionary + Ajouter au Dictionnaire + + + spellCheckingMenuIgnoreOnce + Ignorer une fois + + + spellCheckingMenuIgnoreAll + Ignorer tout + + diff --git a/linphone-app/resources.qrc b/linphone-app/resources.qrc index bbc54cd79..b889a0463 100644 --- a/linphone-app/resources.qrc +++ b/linphone-app/resources.qrc @@ -199,6 +199,7 @@ ui/modules/Common/Form/ComboBox.qml ui/modules/Common/Form/CommonItemDelegate.qml ui/modules/Common/Form/DroppableTextArea.qml + ui/modules/Common/Form/SpellCheckerMenu.qml ui/modules/Common/Form/Fields/HexField.qml ui/modules/Common/Form/Fields/NumericField.qml ui/modules/Common/Form/Fields/PasswordField.qml @@ -246,6 +247,7 @@ ui/modules/Common/Menus/DropDownStaticMenuEntry.qml ui/modules/Common/Menus/DropDownStaticMenu.qml ui/modules/Common/Menus/MenuItem.qml + ui/modules/Common/Menus/MenuSeparator.qml ui/modules/Common/Menus/Menu.qml ui/modules/Common/Misc/Borders.qml ui/modules/Common/Misc/ForceScrollBar.qml @@ -300,6 +302,7 @@ ui/modules/Common/Styles/Menus/DropDownStaticMenuStyle.qml ui/modules/Common/Styles/Menus/MenuItemStyle.qml ui/modules/Common/Styles/Menus/MenuStyle.qml + ui/modules/Common/Styles/Menus/MenuSeparatorStyle.qml ui/modules/Common/Styles/Misc/ForceScrollBarStyle.qml ui/modules/Common/Styles/Misc/MessageBannerStyle.qml ui/modules/Common/Styles/Misc/PanedStyle.qml diff --git a/linphone-app/src/app/App.cpp b/linphone-app/src/app/App.cpp index 8f43b008f..e135fefec 100644 --- a/linphone-app/src/app/App.cpp +++ b/linphone-app/src/app/App.cpp @@ -55,6 +55,7 @@ #include "components/history/CallHistoryProxyModel.hpp" #include "components/other/desktop-tools/DesktopTools.hpp" #include "components/other/date/DateModel.hpp" +#include "components/other/spell-checker/SpellChecker.hpp" #include "components/settings/EmojisSettingsModel.hpp" #include "components/timeline/TimelineModel.hpp" @@ -758,6 +759,7 @@ void App::registerTypes () { registerType("ParticipantDeviceProxyModel"); registerType("SoundPlayer"); registerType("TelephoneNumbersModel"); + registerType("SpellChecker"); registerSingletonType("AudioCodecsModel"); registerSingletonType("OwnPresenceModel"); diff --git a/linphone-app/src/app/AppController.cpp b/linphone-app/src/app/AppController.cpp index d1ae309af..7367308c8 100644 --- a/linphone-app/src/app/AppController.cpp +++ b/linphone-app/src/app/AppController.cpp @@ -130,4 +130,4 @@ void AppController::initQtAppDetails(){ QCoreApplication::setApplicationName(EXECUTABLE_NAME); QApplication::setOrganizationDomain(EXECUTABLE_NAME); QCoreApplication::setApplicationVersion(APPLICATION_SEMVER); -} \ No newline at end of file +} diff --git a/linphone-app/src/components/other/clipboard/Clipboard.cpp b/linphone-app/src/components/other/clipboard/Clipboard.cpp index 297621c80..2d6b9cf05 100644 --- a/linphone-app/src/components/other/clipboard/Clipboard.cpp +++ b/linphone-app/src/components/other/clipboard/Clipboard.cpp @@ -20,6 +20,7 @@ #include #include +#include #include "Clipboard.hpp" @@ -36,3 +37,35 @@ QString Clipboard::getText () const { void Clipboard::setText (const QString &text) { QGuiApplication::clipboard()->setText(text, QClipboard::Clipboard); } + +void Clipboard::backup () { + if (QGuiApplication::clipboard() != nullptr) { + const QMimeData * clipboardData = QGuiApplication::clipboard()->mimeData(); + mMimeCopy = new QMimeData(); + foreach(const QString & format, clipboardData->formats()) + mMimeCopy->setData(format, clipboardData->data(format)); + } +} + +void Clipboard::restore () { + if (QGuiApplication::clipboard() != nullptr && mMimeCopy != nullptr) + QGuiApplication::clipboard()->setMimeData(mMimeCopy); +} + +QString Clipboard::getChatFormattedText () const { + QString text = getText(); + if (text.isEmpty()) + return text; +#ifdef linux + QString cr = "\n"; +#endif +#ifdef WIN32 + QString cr = "\n\r"; +#endif +#ifdef __APPLE__ + QString cr = "\n"; +#endif + return text.replace(cr,"\u2028"); +} + + diff --git a/linphone-app/src/components/other/clipboard/Clipboard.hpp b/linphone-app/src/components/other/clipboard/Clipboard.hpp index 04d7856cb..7da74b9f1 100644 --- a/linphone-app/src/components/other/clipboard/Clipboard.hpp +++ b/linphone-app/src/components/other/clipboard/Clipboard.hpp @@ -29,9 +29,12 @@ class Clipboard : public QObject { Q_OBJECT; Q_PROPERTY(QString text READ getText WRITE setText NOTIFY textChanged); - + public: Clipboard (QObject *parent = Q_NULLPTR); + Q_INVOKABLE void backup(); + Q_INVOKABLE void restore(); + Q_INVOKABLE QString getChatFormattedText() const; signals: void textChanged (); @@ -40,6 +43,7 @@ private: QString getText () const; void setText (const QString &text); + QMimeData *mMimeCopy; }; #endif // ifndef CLIPBOARD_H_ diff --git a/linphone-app/src/components/other/spell-checker/SpellChecker.cpp b/linphone-app/src/components/other/spell-checker/SpellChecker.cpp new file mode 100644 index 000000000..0bded389c --- /dev/null +++ b/linphone-app/src/components/other/spell-checker/SpellChecker.cpp @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2010-2024 Belledonne Communications SARL. + * + * This file is part of linphone-desktop + * (see https://www.linphone.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 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "SpellChecker.hpp" +#include +#include +#include +#include +#ifdef WIN32 +#include +#endif + +SpellChecker::SpellChecker(QObject *parent) : QSyntaxHighlighter(parent) { + errorFormater.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline); + errorFormater.setFontUnderline(true); + errorFormater.setUnderlineColor(Qt::red); // not supported before Qt6.2 + + QFontMetrics fm = QFontMetrics(QApplication::font()); + wave = QString(u8"\uFE4B"); + QRect boundingRect = fm.boundingRect(wave); + waveWidth = boundingRect.width(); + + graceTimer = new QTimer(this); + graceTimer->setSingleShot(true); + connect(graceTimer, SIGNAL(timeout()), SLOT(highlightAfterGracePeriod())); + + setLanguage(); +} + +SpellChecker::~SpellChecker () { + graceTimer->stop(); +#ifdef WIN32 + if (mNativeSpellChecker != nullptr) + mNativeSpellChecker->Release(); +#endif + +} + + +void SpellChecker::setTextDocument(QQuickTextDocument *textDocument) { + setDocument(textDocument->textDocument()); +} + +///////////////////////////////////////////////////////////////////////////////////////// +// Should be this option but TextEdit/TextArea does not support setUnderlineColor +// (although QTextEdit does) until QT 6.2 (1d44ddf576 of qtdeclarative submodule) +///////////////////////////////////////////////////////////////////////////////////////// + + +void SpellChecker::highlightBlock(const QString &text) { + // setFormat(begin, length, errorFormater); +} + +/////////////////////////////////////////////////////////////////////////////////////. +// QT5 Option using repeater/unicode inside QML with calculation of redline positions. +/////////////////////////////////////////////////////////////////////////////////////. + + +QString SpellChecker::underLine(qreal minLength) { + return wave.repeated(1+minLength/waveWidth); +} + +void SpellChecker::highlightDocument() { + + if (!fromTimer && QDateTime::currentMSecsSinceEpoch() <= mLastHightlight + GRACE_PERIOD_SECS*1000) { + scheduleHighlight(); + return; + } + + redLines.clear(); + if (document() == nullptr) { + emit redLinesChanged(); + return; + } + + mLastHightlight = QDateTime::currentMSecsSinceEpoch(); + QTextBlock::iterator blockIterator = document()->begin().begin(); + QStringList newWords; + bool hadActiveWord = false; + QRegularExpression expression(WORD_DELIMITERS_REGEXP); + while (!blockIterator.atEnd()) { + QTextFragment fragment = blockIterator.fragment(); + QFontMetrics metrics(fragment.charFormat().font()); + QString text = fragment.text(); + int position = 0; + QRegularExpressionMatchIterator blockWordsIterator = expression.globalMatch(text); + while (blockWordsIterator.hasNext()) { + QRegularExpressionMatch match = blockWordsIterator.next(); + QString word = match.captured(); + bool wordActive = !fromTimer && isWordActive(words, word, position); + int begin = match.capturedStart(); + int length = match.capturedLength(); + bool ignoreOnce = wasIgnoredOnce(word, match.capturedStart(),match.capturedEnd()); + if (!wordActive && !ignoredAllWords.contains(word) && !ignoreOnce && !isValid(word) && !fragment.glyphRuns(begin,length).empty()) { + QRectF boundingRect = fragment.glyphRuns(begin,length).first().boundingRect(); + QPointF start = boundingRect.bottomLeft(); + qreal width = boundingRect.width(); + redLines.append(QString::number(start.x())+","+QString::number(start.y())+","+QString::number(width)+","+underLine(width)); + } + if (wordActive) { + hadActiveWord = wordActive; + } + newWords.append(word); + position++; + } + blockIterator++; + } + words = newWords; + if (hadActiveWord && !fromTimer) { + scheduleHighlight(); + } + emit redLinesChanged(); +} + +void SpellChecker::clearHighlighting() { + redLines.clear(); + emit redLinesChanged(); +} + +void SpellChecker::scheduleHighlight() { + graceTimer->start(GRACE_PERIOD_SECS*1000); +} + +bool SpellChecker::isWordActive(QStringList words, QString word, int index) { + if (index >= words.length()) + return true; + return words.at(index) != word ; +} + +void SpellChecker::highlightAfterGracePeriod() { + fromTimer = true; + highlightDocument(); + fromTimer = false; +} + +int SpellChecker::wordPosition(int x, int y) { + int position = document()->documentLayout()->hitTest( QPointF( x, y ), Qt::ExactHit ); + return position; +} + +bool SpellChecker::isWordAtPositionValid(int cursorPosition) { + QTextCursor cursor(document()); + cursor.setPosition(cursorPosition); + cursor.select(QTextCursor::WordUnderCursor); + QString word = cursor.selectedText(); + return isValid(word); +} + +void SpellChecker::ignoreAll(QString word) { + if (!ignoredAllWords.contains(word)) { + ignoredAllWords.append(word); + } + highlightDocument(); +} + +void SpellChecker::ignoreOnce(QString word, int cursorPosition) { + QTextCursor cursor(document()); + cursor.setPosition(cursorPosition); + QTextBlock block = cursor.block(); + ignoredOnce[cursorPosition] = word; + highlightDocument(); +} + +bool SpellChecker::wasIgnoredOnce(QString word, int wordStartIndex, int wordEndIndex) { + for (int i = wordStartIndex; i<=wordEndIndex; i++) { + if (ignoredOnce[i] == word) + return true; + } + return false; +} + +void SpellChecker::replace(QString word, QString byWord, int cursorPosition) { + QTextCursor cursor(document()); + cursor.setPosition(cursorPosition); + cursor.clearSelection(); + cursor.movePosition(QTextCursor::StartOfWord, QTextCursor::MoveAnchor); + cursor.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor); + cursor.insertText(byWord); + highlightDocument(); +} diff --git a/linphone-app/src/components/other/spell-checker/SpellChecker.hpp b/linphone-app/src/components/other/spell-checker/SpellChecker.hpp new file mode 100644 index 000000000..b409c97ed --- /dev/null +++ b/linphone-app/src/components/other/spell-checker/SpellChecker.hpp @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2010-2024 Belledonne Communications SARL. + * + * This file is part of linphone-desktop + * (see https://www.linphone.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 3 of the License, or + * (at your option) any later version. + * + * 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 . + */ + +#ifndef SPELLCHECKER_HPP_ +#define SPELLCHECKER_HPP_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "app/App.hpp" + +#define SUGGESTIONS_LIMIT 10 +#define GRACE_PERIOD_SECS 1.0 + +#define LOG_TAG "[SpellChecker]" +#define WORD_DELIMITERS_REGEXP "[^\r\n\t\u2028 ]+" + +#ifdef WIN32 +class ISpellChecker; +#endif + +class SpellChecker : public QSyntaxHighlighter { + Q_OBJECT +public: + SpellChecker(QObject* parent = nullptr); + ~SpellChecker(); + + // Common + static QString currentLanguage() { return App::getInstance()->getLocale().name();} + Q_INVOKABLE void setTextDocument(QQuickTextDocument *textDocument); + Q_INVOKABLE int wordPosition(int x, int y); + Q_INVOKABLE bool isWordAtPositionValid(int cursorPosition); + Q_INVOKABLE void highlightDocument(); + Q_INVOKABLE void clearHighlighting(); + Q_INVOKABLE void ignoreOnce(QString word, int cursorPosition); + Q_INVOKABLE void ignoreAll(QString word); + Q_INVOKABLE void replace(QString word, QString byWord, int cursorPosition); + Q_PROPERTY(QStringList redLines MEMBER redLines NOTIFY redLinesChanged); + + // Native (Mac/Windows) or ISpell + Q_INVOKABLE void learn(QString word); + Q_INVOKABLE QStringList suggestionsForWord(QString word); + bool isValid(QString word); + +protected: + void highlightBlock(const QString &text) override; + +public slots: + void highlightAfterGracePeriod(); + +signals: + void redLinesChanged(); + +private: + QTextCharFormat errorFormater; + QTimer *graceTimer; + bool fromTimer = false; + QStringList ignoredAllWords; + QList> ignoredOnceWords; + QStringList redLines; + QStringList words; + QHash ignoredOnce; + QString wave; + qreal waveWidth; + qint64 mLastHightlight; + + void setLanguage(); + bool isWordActive(QStringList words, QString word, int index); + bool wasIgnoredOnce(QString word, int wordStartIndex, int wordEndIndex); + void scheduleHighlight(); + QString underLine(qreal minLength); +#ifdef WIN32 + ISpellChecker* mNativeSpellChecker = nullptr; +#endif +}; + + + +#endif /* SPELLCHECKER_HPP_ */ diff --git a/linphone-app/src/components/other/spell-checker/SpellCheckerLinux.cpp b/linphone-app/src/components/other/spell-checker/SpellCheckerLinux.cpp new file mode 100644 index 000000000..92f4de3ec --- /dev/null +++ b/linphone-app/src/components/other/spell-checker/SpellCheckerLinux.cpp @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2010-2024 Belledonne Communications SARL. + * + * This file is part of linphone-desktop + * (see https://www.linphone.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 3 of the License, or + * (at your option) any later version. + * + * 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 . + */ + + +#import "SpellChecker.hpp" + +void SpellChecker::setLanguage() { +} + +bool SpellChecker::isValid(QString word) { + return true; +} + +void SpellChecker::learn(QString word){ +} + +QStringList SpellChecker::suggestionsForWord(QString word) { + QStringList suggestions; + return suggestions; +} diff --git a/linphone-app/src/components/other/spell-checker/SpellCheckerMacOsNative.mm b/linphone-app/src/components/other/spell-checker/SpellCheckerMacOsNative.mm new file mode 100644 index 000000000..273072464 --- /dev/null +++ b/linphone-app/src/components/other/spell-checker/SpellCheckerMacOsNative.mm @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2010-2024 Belledonne Communications SARL. + * + * This file is part of linphone-desktop + * (see https://www.linphone.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 3 of the License, or + * (at your option) any later version. + * + * 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 . + */ + + +#import +#import "SpellChecker.hpp" + +void SpellChecker::setLanguage() { + NSSpellChecker *spellChecker = [NSSpellChecker sharedSpellChecker]; + QString locale = SpellChecker::currentLanguage(); + if ([spellChecker setLanguage:locale.toNSString()]) { + [spellChecker updatePanels]; + qDebug() << LOG_TAG << "Macos native spell checker Language set to " << locale; + } else { + qWarning() << LOG_TAG << "Macos native spell checker unable to set language to " << locale; + } +} + +bool SpellChecker::isValid(QString word) { + NSSpellChecker *spellChecker = [NSSpellChecker sharedSpellChecker]; + QString locale = SpellChecker::currentLanguage(); + bool isValid = [spellChecker checkSpellingOfString:word.toNSString() startingAt:0 language:locale.toNSString() wrap:NO inSpellDocumentWithTag:0 wordCount:nullptr].length == 0; + return isValid; +} + +void SpellChecker::learn(QString word){ + NSSpellChecker *spellChecker = [NSSpellChecker sharedSpellChecker]; + NSString *_word = word.toNSString(); + if (![spellChecker hasLearnedWord:_word]) { + [spellChecker learnWord:_word]; + [spellChecker updatePanels]; + } + highlightDocument(); +} + +QStringList SpellChecker::suggestionsForWord(QString word) { + NSSpellChecker *spellChecker = [NSSpellChecker sharedSpellChecker]; + NSString *_word = word.toNSString(); + QString locale = SpellChecker::currentLanguage(); + NSArray *_suggestions = [spellChecker guessesForWordRange:NSMakeRange(0, word.length()) inString:_word language:locale.toNSString() inSpellDocumentWithTag:0]; + QStringList suggestions; + for (NSString *_suggestion in _suggestions) { + QString suggestion = QString::fromNSString(_suggestion); + suggestions << suggestion; + if (suggestions.length() >= SUGGESTIONS_LIMIT) + return suggestions; + } + return suggestions; +} diff --git a/linphone-app/src/components/other/spell-checker/SpellCheckerWindowsNative.cpp b/linphone-app/src/components/other/spell-checker/SpellCheckerWindowsNative.cpp new file mode 100644 index 000000000..9a761216e --- /dev/null +++ b/linphone-app/src/components/other/spell-checker/SpellCheckerWindowsNative.cpp @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2010-2024 Belledonne Communications SARL. + * + * This file is part of linphone-desktop + * (see https://www.linphone.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 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include "SpellChecker.hpp" + + +void SpellChecker::setLanguage() { + ISpellCheckerFactory* spellCheckerFactory; + HRESULT hr = CoCreateInstance(__uuidof(SpellCheckerFactory), nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&spellCheckerFactory)); + if (!SUCCEEDED(hr)) { + qWarning() << LOG_TAG << "Windows native spell checker unable to create spell checker factory"; + return; + } + QString locale = SpellChecker::currentLanguage().toUpper(); + LPCWSTR _locale = (const wchar_t*) locale.utf16(); + BOOL isSupported = FALSE; + hr = spellCheckerFactory->IsSupported(_locale, &isSupported); + if (!SUCCEEDED(hr)) { + qWarning() << LOG_TAG << "Windows native spell checker unable to check if language is supported" << locale; + return; + } + if (!isSupported) { + qWarning() << LOG_TAG << "Windows native spell checker Language is not supported" << locale; + locale = locale.mid(0,2); + qWarning() << LOG_TAG << "Windows native spell checker trying with" << locale; + _locale = (const wchar_t*) locale.utf16(); + hr = spellCheckerFactory->IsSupported(_locale, &isSupported); + if (!SUCCEEDED(hr)) { + qWarning() << LOG_TAG << "Windows native spell checker unable to check if language is supported" << locale; + return; + } + if (!isSupported) { + qWarning() << LOG_TAG << "Windows native spell checker Language is not supported" << locale; + return; + } + } + + hr = spellCheckerFactory->CreateSpellChecker(_locale, &mNativeSpellChecker); + if (!SUCCEEDED(hr)) { + qWarning() << LOG_TAG << "Windows native spell checker unable to create spell checker"; + return; + } + qWarning() << LOG_TAG << "Windows native spell checker created for locale" << locale; +} + + +bool SpellChecker::isValid(QString word) { + if (mNativeSpellChecker == nullptr) + return true; + wchar_t *text = reinterpret_cast(word.data()); + IEnumSpellingError* enumSpellingError = nullptr; + ISpellingError* spellingError = nullptr; + HRESULT hr = mNativeSpellChecker->Check(text, &enumSpellingError); + if (SUCCEEDED(hr)) { + hr = enumSpellingError->Next(&spellingError); + enumSpellingError->Release(); + return hr != S_OK; + } else + return true; +} + +void SpellChecker::learn(QString word){ + if (mNativeSpellChecker == nullptr) + return; + wchar_t *text = reinterpret_cast(word.data()); + HRESULT hr = mNativeSpellChecker->Add(text); + if (!SUCCEEDED(hr)) + qWarning() << LOG_TAG << "Windows native spell checke unable to add word to dictionary" << word; + highlightDocument(); +} + +QStringList SpellChecker::suggestionsForWord(QString word) { + QStringList suggestions; + if (mNativeSpellChecker == nullptr) + return suggestions; + wchar_t *text = reinterpret_cast(word.data()); + IEnumString* enumString = nullptr; + HRESULT hr = mNativeSpellChecker->Suggest(text, &enumString); + if (SUCCEEDED(hr)) { + while (S_OK == hr) { + LPOLESTR string = nullptr; + hr = enumString->Next(1, &string, nullptr); + if (S_OK == hr) { + suggestions << QString::fromWCharArray(string); + CoTaskMemFree(string); + if (suggestions.length() >= SUGGESTIONS_LIMIT) { + enumString->Release(); + return suggestions; + } + } + } + enumString->Release(); + } + return suggestions; +} diff --git a/linphone-app/ui/modules/Common/Form/DroppableTextArea.qml b/linphone-app/ui/modules/Common/Form/DroppableTextArea.qml index 98154cc55..597aaf6e3 100644 --- a/linphone-app/ui/modules/Common/Form/DroppableTextArea.qml +++ b/linphone-app/ui/modules/Common/Form/DroppableTextArea.qml @@ -9,6 +9,7 @@ import Common.Styles 1.0 import Units 1.0 import Utils 1.0 import UtilsCpp 1.0 +import Clipboard 1.0 import 'qrc:/ui/scripts/Utils/utils.js' as Utils @@ -184,6 +185,7 @@ Item { clearDelay.restart() } } + spellChecker.highlightDocument() } function handleValidation () { var plainText = getText(0, text.length) @@ -213,7 +215,46 @@ Item { height:flickableArea.height //onHeightChanged: height=flickableArea.height//TextArea change its height from content text. Force it to parent - Component.onCompleted: forceActiveFocus() + property int previousFlickableAreaWidth + onWidthChanged: { + if (previousFlickableAreaWidth != flickableArea.width) { + spellChecker.clearHighlighting() + spellChecker.highlightDocument() + } + previousFlickableAreaWidth = flickableArea.width + } + + SpellChecker { + id: spellChecker + } + + SpellCheckerMenu { + id: spellCheckerMenu + } + + Repeater { + id: spellCheckerUnderliner + model: spellChecker.redLines + Rectangle { + clip: true + height: 5 + x: textArea.leftPadding + parseFloat(modelData.split(',')[0]) + y: textArea.topPadding + parseFloat(modelData.split(',')[1])-3 + width: parseFloat(modelData.split(',')[2]) + color: 'transparent' + Text { + anchors.top: parent.top + text: modelData.split(',')[3] + color: DroppableTextAreaStyle.spellChecker.underlineWave.colorModel.color + } + } + } + + Component.onCompleted: { + forceActiveFocus() + spellChecker.setTextDocument(textDocument) + spellCheckerMenu.spellChecker = spellChecker + } property var isAutoRepeating : false // shutdown repeating key feature to let optional menu appears and do normal stuff (like accents menu) Keys.onReleased: { @@ -222,7 +263,9 @@ Item { if(event.key > Qt.Key_Any && event.key <= Qt.Key_ydiaeresis)// Remove the previous character if it is a printable character textArea.remove(cursorPosition-1, cursorPosition) } - }else + } else if (event.matches(StandardKey.Paste)) { + Clipboard.restore() + } else isAutoRepeating = false// We are no more repeating. Final decision is done on Releasing } Keys.onPressed: { @@ -231,10 +274,27 @@ Item { if(event.key > Qt.Key_Any && event.key <= Qt.Key_ydiaeresis){// Ignore character if it is repeating and printable character event.accepted = true } - }else if (event.matches(StandardKey.InsertLineSeparator)) { + } else if (event.matches(StandardKey.InsertLineSeparator)) { } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { handleValidation() event.accepted = true + } else if (event.matches(StandardKey.Paste)) { + Clipboard.backup() // Restored on Keys.onReleased + Clipboard.text = Clipboard.getChatFormattedText() + event.accepted = false + } + } + onPressed: { + if (event.button == Qt.RightButton) { + var wordPosition = spellChecker.wordPosition(event.x,event.y) + if (wordPosition != -1) { + var wordValid = spellChecker.isWordAtPositionValid(wordPosition) + if (!wordValid) { + cursorPosition = wordPosition + selectWord() + spellCheckerMenu.open(selectedText,wordPosition) + } + } } } } diff --git a/linphone-app/ui/modules/Common/Form/SpellCheckerMenu.qml b/linphone-app/ui/modules/Common/Form/SpellCheckerMenu.qml new file mode 100644 index 000000000..625106c0b --- /dev/null +++ b/linphone-app/ui/modules/Common/Form/SpellCheckerMenu.qml @@ -0,0 +1,87 @@ +import QtQuick 2.7 +import QtQuick.Layouts 1.3 +import QtQuick.Controls 2.7 + +import Clipboard 1.0 +import Common 1.0 +import Linphone 1.0 + +import Common.Styles 1.0 +import Linphone.Styles 1.0 +import Utils 1.0 +import Units 1.0 +import ConstantsCpp 1.0 +import UtilsCpp 1.0 +import LinphoneEnums 1.0 + + +// ============================================================================= +// SpellCheckerMenu + +Item { + + property string _word + property int _position + property SpellChecker spellChecker + property var suggestions: [] + + function open(word, position){ + _word = word + _position = position + suggestions = spellChecker.suggestionsForWord(word) + spellCheckerMenu.popup() + } + + Menu { + id: spellCheckerMenu + menuStyle : MenuStyle.aux + + MenuItem { + //: 'Did you mean ?' : Suggest new words + text: qsTr('spellCheckingMenuDidYouMean') + menuItemStyle : MenuItemStyle.aux + visible:suggestions.length != 0 + enabled: false + } + + Repeater { + model: suggestions + MenuItem { + text:modelData + menuItemStyle : MenuItemStyle.aux + onTriggered: spellChecker.replace(_word,modelData,_position) + fontItalic: true + } + } + + MenuItem { // Work around to anchor separator below. + visible:false + } + + MenuSeparator { + visible:suggestions.length != 0 + } + + MenuItem { + //: 'Add to dictionary' : Add word to dictionary + text: qsTr('spellCheckingMenuAddToDictionary') + menuItemStyle : MenuItemStyle.aux + onTriggered: spellChecker.learn(_word) + } + + MenuItem { + //: 'Ignore Once' : Ignore spell checking only for this occurences + text: qsTr('spellCheckingMenuIgnoreOnce') + menuItemStyle : MenuItemStyle.aux + onTriggered: spellChecker.ignoreOnce(_word, _position) + } + + MenuItem { + //: 'Ignore All' : Ignore spell checking for all occurences + text: qsTr('spellCheckingMenuIgnoreAll') + menuItemStyle : MenuItemStyle.aux + onTriggered: spellChecker.ignoreAll(_word) + } + + } +} diff --git a/linphone-app/ui/modules/Common/Menus/MenuItem.qml b/linphone-app/ui/modules/Common/Menus/MenuItem.qml index 91e301dad..8dd9eba28 100644 --- a/linphone-app/ui/modules/Common/Menus/MenuItem.qml +++ b/linphone-app/ui/modules/Common/Menus/MenuItem.qml @@ -26,7 +26,8 @@ Controls.MenuItem { property int offsetBottomMargin : 0 property bool displaySelection: true property bool isTabBar: false - + property bool fontItalic: false + height:visible?undefined:0 Component.onCompleted: if(!isTabBar) menu.width = Math.max(menu.width, implicitWidth) @@ -87,6 +88,7 @@ Controls.MenuItem { font { weight: menuItemStyle.text.weight pointSize: menuItemStyle.text.pointSize + italic: fontItalic } text: button.text diff --git a/linphone-app/ui/modules/Common/Menus/MenuSeparator.qml b/linphone-app/ui/modules/Common/Menus/MenuSeparator.qml new file mode 100644 index 000000000..989daab6a --- /dev/null +++ b/linphone-app/ui/modules/Common/Menus/MenuSeparator.qml @@ -0,0 +1,17 @@ +import QtQuick 2.7 +import QtQuick.Controls 2.3 as Controls + +import Common 1.0 +import Common.Styles 1.0 + +// ============================================================================= + +Controls.MenuSeparator { + padding: 0 + topPadding: MenuSeparatorStyle.topPadding + bottomPadding: MenuSeparatorStyle.bottomPadding + contentItem: Rectangle { + implicitHeight: MenuSeparatorStyle.height + color: MenuSeparatorStyle.colorModel.color + } +} diff --git a/linphone-app/ui/modules/Common/Styles/Form/DroppableTextAreaStyle.qml b/linphone-app/ui/modules/Common/Styles/Form/DroppableTextAreaStyle.qml index 6fff7fd76..6246c0c53 100644 --- a/linphone-app/ui/modules/Common/Styles/Form/DroppableTextAreaStyle.qml +++ b/linphone-app/ui/modules/Common/Styles/Form/DroppableTextAreaStyle.qml @@ -3,6 +3,8 @@ import QtQml 2.2 import Units 1.0 import ColorsList 1.0 +import Clipboard 1.0 + // ============================================================================= @@ -92,4 +94,10 @@ QtObject { property var colorModel: ColorsList.add(sectionName+'_Chat_text', 'd') property int pointSize: Units.dp * 10 } + + property QtObject spellChecker: QtObject { + property QtObject underlineWave: QtObject { + property var colorModel: ColorsList.add(sectionName+'_Chat_spell', 'error') + } + } } diff --git a/linphone-app/ui/modules/Common/Styles/Menus/MenuSeparatorStyle.qml b/linphone-app/ui/modules/Common/Styles/Menus/MenuSeparatorStyle.qml new file mode 100644 index 000000000..ee37c674c --- /dev/null +++ b/linphone-app/ui/modules/Common/Styles/Menus/MenuSeparatorStyle.qml @@ -0,0 +1,14 @@ +pragma Singleton +import QtQml 2.2 + +import ColorsList 1.0 + +// ============================================================================= + +QtObject { + property string sectionName: 'MenuSeparator' + property var colorModel: ColorsList.add(sectionName+'_n', 'u') + property int topPadding: 0 + property int bottomPadding: 0 + property int height : 1 +} diff --git a/linphone-app/ui/modules/Common/Styles/qmldir b/linphone-app/ui/modules/Common/Styles/qmldir index c8381758b..9effa4eec 100644 --- a/linphone-app/ui/modules/Common/Styles/qmldir +++ b/linphone-app/ui/modules/Common/Styles/qmldir @@ -54,6 +54,8 @@ singleton ApplicationMenuStyle 1.0 Menus/ApplicationMenuStyle.qml singleton DropDownStaticMenuStyle 1.0 Menus/DropDownStaticMenuStyle.qml singleton MenuItemStyle 1.0 Menus/MenuItemStyle.qml singleton MenuStyle 1.0 Menus/MenuStyle.qml +singleton MenuSeparatorStyle 1.0 Menus/MenuSeparatorStyle.qml + singleton ForceScrollBarStyle 1.0 Misc/ForceScrollBarStyle.qml singleton MessageBannerStyle 1.0 Misc/MessageBannerStyle.qml diff --git a/linphone-app/ui/modules/Common/qmldir b/linphone-app/ui/modules/Common/qmldir index 1b6b6ce99..269e34232 100644 --- a/linphone-app/ui/modules/Common/qmldir +++ b/linphone-app/ui/modules/Common/qmldir @@ -86,6 +86,8 @@ DropDownStaticMenu 1.0 Menus/DropDownStaticMenu.qml DropDownStaticMenuEntry 1.0 Menus/DropDownStaticMenuEntry.qml Menu 1.0 Menus/Menu.qml MenuItem 1.0 Menus/MenuItem.qml +MenuSeparator 1.0 Menus/MenuSeparator.qml + Borders 1.0 Misc/Borders.qml ForceScrollBar 1.0 Misc/ForceScrollBar.qml