Add spellchecking suggestions
This commit is contained in:
@@ -68,7 +68,7 @@ if(ANDROID)
|
||||
)
|
||||
else()
|
||||
find_package(Qt5 ${QT_MIN_VERSION} COMPONENTS Widgets)
|
||||
find_package(KF5 ${KF5_MIN_VERSION} REQUIRED COMPONENTS QQC2DesktopStyle ConfigWidgets KIO Sonnet WindowSystem)
|
||||
find_package(KF5 ${KF5_MIN_VERSION} REQUIRED COMPONENTS QQC2DesktopStyle ConfigWidgets KIO WindowSystem Sonnet)
|
||||
set_package_properties(KF5QQC2DesktopStyle PROPERTIES
|
||||
TYPE RUNTIME
|
||||
)
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Layouts 1.15
|
||||
import QtQuick.Controls 2.15
|
||||
import QtQuick.Templates 2.15 as T
|
||||
import Qt.labs.platform 1.1 as Platform
|
||||
import QtQuick.Window 2.15
|
||||
|
||||
import org.kde.kirigami 2.15 as Kirigami
|
||||
|
||||
@@ -66,7 +68,8 @@ ToolBar {
|
||||
id: fontMetrics
|
||||
font: inputField.font
|
||||
}
|
||||
TextArea {
|
||||
|
||||
T.TextArea {
|
||||
id: inputField
|
||||
focus: true
|
||||
/* Some QQC2 styles will have their own predefined backgrounds for TextAreas.
|
||||
@@ -81,6 +84,7 @@ ToolBar {
|
||||
cursorShape: Qt.IBeamCursor
|
||||
z: 1
|
||||
}
|
||||
|
||||
leftPadding: mirrored ? 0 : Kirigami.Units.largeSpacing
|
||||
rightPadding: !mirrored ? 0 : Kirigami.Units.largeSpacing
|
||||
topPadding: 0
|
||||
@@ -97,6 +101,86 @@ ToolBar {
|
||||
wrapMode: Text.Wrap
|
||||
readOnly: currentRoom.usesEncryption
|
||||
|
||||
palette: Kirigami.Theme.palette
|
||||
Kirigami.Theme.colorSet: Kirigami.Theme.View
|
||||
Kirigami.Theme.inherit: false
|
||||
|
||||
implicitWidth: Math.max(contentWidth + leftPadding + rightPadding,
|
||||
implicitBackgroundWidth + leftInset + rightInset,
|
||||
placeholder.implicitWidth + leftPadding + rightPadding)
|
||||
implicitHeight: Math.max(contentHeight + topPadding + bottomPadding,
|
||||
implicitBackgroundHeight + topInset + bottomInset,
|
||||
placeholder.implicitHeight + topPadding + bottomPadding)
|
||||
|
||||
color: Kirigami.Theme.textColor
|
||||
selectionColor: Kirigami.Theme.highlightColor
|
||||
selectedTextColor: Kirigami.Theme.highlightedTextColor
|
||||
hoverEnabled: !Kirigami.Settings.tabletMode
|
||||
|
||||
// Work around Qt bug where NativeRendering breaks for non-integer scale factors
|
||||
// https://bugreports.qt.io/browse/QTBUG-67007
|
||||
renderType: Screen.devicePixelRatio % 1 !== 0 ? Text.QtRendering : Text.NativeRendering
|
||||
|
||||
selectByMouse: !Kirigami.Settings.tabletMode
|
||||
|
||||
cursorDelegate: Loader {
|
||||
visible: inputField.activeFocus && !inputField.readOnly && inputField.selectionStart === inputField.selectionEnd
|
||||
active: visible
|
||||
sourceComponent: CursorDelegate { target: inputField }
|
||||
}
|
||||
|
||||
CursorHandle {
|
||||
id: selectionStartHandle
|
||||
target: inputField
|
||||
}
|
||||
|
||||
CursorHandle {
|
||||
id: selectionEndHandle
|
||||
target: inputField
|
||||
isSelectionEnd: true
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
|
||||
// unfortunately, taphandler's pressed event only triggers when the press is lifted
|
||||
// we need to use the longpress signal since it triggers when the button is first pressed
|
||||
longPressThreshold: 0
|
||||
onLongPressed: TextFieldContextMenu.targetClick(point, inputField, spellcheckhighlighter, inputField.positionAt(point.position.x, point.position.y));
|
||||
}
|
||||
|
||||
onPressAndHold: {
|
||||
if (!Kirigami.Settings.tabletMode) {
|
||||
return;
|
||||
}
|
||||
forceActiveFocus();
|
||||
cursorPosition = positionAt(event.x, event.y);
|
||||
selectWord();
|
||||
}
|
||||
|
||||
onFocusChanged: {
|
||||
if (focus) {
|
||||
MobileTextActionsToolBar.controlRoot = inputField;
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
id: placeholder
|
||||
x: inputField.leftPadding
|
||||
y: inputField.topPadding
|
||||
width: inputField.width - (inputField.leftPadding + inputField.rightPadding)
|
||||
height: inputField.height - (inputField.topPadding + inputField.bottomPadding)
|
||||
|
||||
text: inputField.placeholderText
|
||||
font: inputField.font
|
||||
color: Kirigami.Theme.disabledTextColor
|
||||
horizontalAlignment: inputField.horizontalAlignment
|
||||
verticalAlignment: inputField.verticalAlignment
|
||||
visible: !inputField.length && !inputField.preeditText && (!inputField.activeFocus || inputField.horizontalAlignment !== Qt.AlignHCenter)
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
|
||||
ChatDocumentHandler {
|
||||
id: documentHandler
|
||||
@@ -107,6 +191,18 @@ ToolBar {
|
||||
room: currentRoom ?? null
|
||||
}
|
||||
|
||||
SpellcheckHighlighter {
|
||||
id: spellcheckhighlighter
|
||||
document: inputField.textDocument
|
||||
cursorPosition: inputField.cursorPosition
|
||||
selectionStart: inputField.selectionStart
|
||||
selectionEnd: inputField.selectionEnd
|
||||
onChangeCursorPosition: {
|
||||
inputField.cursorPosition = start;
|
||||
inputField.moveCursorSelection(end, TextEdit.SelectCharacters);
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: timeoutTimer
|
||||
repeat: false
|
||||
@@ -147,6 +243,9 @@ ToolBar {
|
||||
}
|
||||
|
||||
Keys.onPressed: {
|
||||
// trigger if context menu button is pressed
|
||||
TextFieldContextMenu.targetKeyPressed(event, inputField)
|
||||
|
||||
if (event.key === Qt.Key_PageDown) {
|
||||
switchRoomDown();
|
||||
} else if (event.key === Qt.Key_PageUp) {
|
||||
@@ -211,7 +310,10 @@ ToolBar {
|
||||
chatBar.complete();
|
||||
}
|
||||
|
||||
onPressed: MobileTextActionsToolBar.shouldBeVisible = true;
|
||||
|
||||
onTextChanged: {
|
||||
MobileTextActionsToolBar.shouldBeVisible = false;
|
||||
timeoutTimer.restart()
|
||||
repeatTimer.start()
|
||||
currentRoom.cachedInput = text
|
||||
|
||||
65
imports/NeoChat/Component/ChatBox/CursorDelegate.qml
Normal file
65
imports/NeoChat/Component/ChatBox/CursorDelegate.qml
Normal file
@@ -0,0 +1,65 @@
|
||||
/* SPDX-FileCopyrightText: 2018 Marco Martin <mart@kde.org>
|
||||
* SPDX-FileCopyrightText: 2021 Noah Davis <noahadvs@gmail.com>
|
||||
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Window 2.15
|
||||
import QtQuick.Templates 2.15
|
||||
import org.kde.kirigami 2.14 as Kirigami
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property alias target: root.parent
|
||||
|
||||
Rectangle {
|
||||
id: cursorLine
|
||||
property real previousX: 0
|
||||
property real previousY: 0
|
||||
parent: target
|
||||
implicitWidth: target.cursorRectangle.width
|
||||
implicitHeight: target.cursorRectangle.height
|
||||
x: Math.floor(target.cursorRectangle.x)
|
||||
y: Math.floor(target.cursorRectangle.y)
|
||||
|
||||
color: target.color
|
||||
SequentialAnimation {
|
||||
id: blinkAnimation
|
||||
running: root.visible && Qt.styleHints.cursorFlashTime != 0 && target.selectionStart === target.selectionEnd
|
||||
PropertyAction {
|
||||
target: cursorLine
|
||||
property: "opacity"
|
||||
value: 1
|
||||
}
|
||||
PauseAnimation {
|
||||
duration: Qt.styleHints.cursorFlashTime/2
|
||||
}
|
||||
SequentialAnimation {
|
||||
loops: Animation.Infinite
|
||||
OpacityAnimator {
|
||||
target: cursorLine
|
||||
from: 1
|
||||
to: 0
|
||||
duration: Qt.styleHints.cursorFlashTime/2
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
OpacityAnimator {
|
||||
target: cursorLine
|
||||
from: 0
|
||||
to: 1
|
||||
duration: Qt.styleHints.cursorFlashTime/2
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root.target
|
||||
function onCursorPositionChanged() {
|
||||
blinkAnimation.restart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
98
imports/NeoChat/Component/ChatBox/CursorHandle.qml
Normal file
98
imports/NeoChat/Component/ChatBox/CursorHandle.qml
Normal file
@@ -0,0 +1,98 @@
|
||||
/* SPDX-FileCopyrightText: 2018 Marco Martin <mart@kde.org>
|
||||
* SPDX-FileCopyrightText: 2021 Noah Davis <noahadvs@gmail.com>
|
||||
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Window 2.15
|
||||
import QtQuick.Templates 2.15
|
||||
import org.kde.kirigami 2.14 as Kirigami
|
||||
|
||||
Loader {
|
||||
id: root
|
||||
property Item target
|
||||
property bool isSelectionEnd: false
|
||||
visible: Kirigami.Settings.tabletMode && target.activeFocus && (isSelectionEnd ? target.selectionStart !== target.selectionEnd : true)
|
||||
active: visible
|
||||
sourceComponent: Kirigami.ShadowedRectangle {
|
||||
id: handle
|
||||
property real selectionStartX: Math.floor(Qt.inputMethod.anchorRectangle.x + (Qt.inputMethod.cursorRectangle.width - width)/2)
|
||||
property real selectionStartY: Math.floor(Qt.inputMethod.anchorRectangle.y + Qt.inputMethod.cursorRectangle.height + pointyBitVerticalOffset)
|
||||
property real selectionEndX: Math.floor(Qt.inputMethod.cursorRectangle.x + (Qt.inputMethod.cursorRectangle.width - width)/2)
|
||||
property real selectionEndY: Math.floor(Qt.inputMethod.cursorRectangle.y + Qt.inputMethod.cursorRectangle.height + pointyBitVerticalOffset)
|
||||
property real pointyBitVerticalOffset: Math.abs(pointyBit.y*2)
|
||||
parent: Overlay.overlay
|
||||
x: isSelectionEnd ? selectionEndX : selectionStartX
|
||||
y: isSelectionEnd ? selectionEndY : selectionStartY
|
||||
|
||||
// HACK: make it appear above most popups that show up in the
|
||||
// overlay in case any of them use TextField or TextArea
|
||||
z: 999
|
||||
|
||||
//opacity: target.activeFocus ? 1 : 0
|
||||
implicitHeight: {
|
||||
let h = Kirigami.Units.gridUnit
|
||||
return h - (h % 2 == 0 ? 1 : 0)
|
||||
}
|
||||
implicitWidth: implicitHeight
|
||||
radius: width/2
|
||||
|
||||
color: target.selectionColor
|
||||
|
||||
shadow {
|
||||
color: Qt.rgba(0,0,0,0.2)
|
||||
size: 3
|
||||
yOffset: 1
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: pointyBit
|
||||
x: (parent.width - width)/2
|
||||
y: -height/4 + 0.2 // magic number to get it to line up with the edge of the circle
|
||||
implicitHeight: parent.implicitHeight/2
|
||||
implicitWidth: implicitHeight
|
||||
antialiasing: true
|
||||
rotation: 45
|
||||
color: parent.color
|
||||
}
|
||||
|
||||
Kirigami.ShadowedRectangle {
|
||||
id: inner
|
||||
visible: target.selectionStart !== target.selectionEnd && (handle.y < selectionStartY || handle.y < selectionEndY)
|
||||
anchors.fill: parent
|
||||
anchors.margins: Kirigami.Units.smallBorder
|
||||
color: target.selectedTextColor
|
||||
radius: height/2
|
||||
Rectangle {
|
||||
id: innerPointyBit
|
||||
x: (parent.width - width)/2
|
||||
y: -height/4 + 0.8 // magic number to get it to line up with the edge of the circle
|
||||
implicitHeight: pointyBit.implicitHeight
|
||||
implicitWidth: implicitHeight
|
||||
antialiasing: true
|
||||
rotation: 45
|
||||
color: parent.color
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
enabled: handle.visible
|
||||
anchors.fill: parent
|
||||
// preventStealing: true
|
||||
onPositionChanged: {
|
||||
let pos = mapToItem(root.target, mouse.x, mouse.y);
|
||||
pos = root.target.positionAt(pos.x, pos.y - handle.height - handle.pointyBitVerticalOffset);
|
||||
|
||||
if (target.selectionStart !== target.selectionEnd) {
|
||||
if (!isSelectionEnd) {
|
||||
root.target.select(Math.min(pos, root.target.selectionEnd - 1), root.target.selectionEnd);
|
||||
} else {
|
||||
root.target.select(root.target.selectionStart, Math.max(pos, root.target.selectionStart + 1));
|
||||
}
|
||||
} else {
|
||||
root.target.cursorPosition = pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2018 Marco Martin <mart@kde.org>
|
||||
|
||||
SPDX-License-Identifier: LGPL-2.0-or-later
|
||||
*/
|
||||
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick 2.1
|
||||
import QtQuick.Layouts 1.2
|
||||
import QtQuick.Window 2.2
|
||||
import QtQuick.Controls 2.15
|
||||
import org.kde.kirigami 2.5 as Kirigami
|
||||
|
||||
Popup {
|
||||
id: root
|
||||
|
||||
property Item controlRoot
|
||||
parent: controlRoot ? controlRoot.Window.contentItem : undefined
|
||||
modal: false
|
||||
focus: false
|
||||
closePolicy: Popup.NoAutoClose
|
||||
property bool shouldBeVisible: false
|
||||
|
||||
x: {
|
||||
if (!controlRoot || !controlRoot.Window.contentItem) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(Math.max(0, controlRoot.mapToItem(root.parent, controlRoot.positionToRectangle(controlRoot.selectionStart).x, 0).x - root.width/2), controlRoot.Window.contentItem.width - root.width);
|
||||
}
|
||||
|
||||
y: {
|
||||
if (!controlRoot || !controlRoot.Window.contentItem) {
|
||||
return 0;
|
||||
}
|
||||
var desiredY = controlRoot.mapToItem(root.parent, 0, controlRoot.positionToRectangle(controlRoot.selectionStart).y).y - root.height;
|
||||
|
||||
if (desiredY >= 0) {
|
||||
return Math.min(desiredY, controlRoot.Window.contentItem.height - root.height);
|
||||
} else {
|
||||
return Math.min(Math.max(0, controlRoot.mapToItem(root.parent, 0, controlRoot.positionToRectangle(controlRoot.selectionEnd).y + Math.round(Kirigami.Units.gridUnit*1.5)).y), controlRoot.Window.contentItem.height - root.height);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
visible: controlRoot ? shouldBeVisible && Qt.platform.os !== "android" && Kirigami.Settings.tabletMode && (controlRoot.selectedText.length > 0 || controlRoot.canPaste) : false
|
||||
|
||||
width: contentItem.implicitWidth + leftPadding + rightPadding
|
||||
|
||||
contentItem: RowLayout {
|
||||
ToolButton {
|
||||
focusPolicy: Qt.NoFocus
|
||||
icon.name: "edit-cut"
|
||||
visible: controlRoot && controlRoot.selectedText.length > 0 && (!controlRoot.hasOwnProperty("echoMode") || controlRoot.echoMode === TextInput.Normal)
|
||||
onClicked: {
|
||||
controlRoot.cut();
|
||||
}
|
||||
}
|
||||
ToolButton {
|
||||
focusPolicy: Qt.NoFocus
|
||||
icon.name: "edit-copy"
|
||||
visible: controlRoot && controlRoot.selectedText.length > 0 && (!controlRoot.hasOwnProperty("echoMode") || controlRoot.echoMode === TextInput.Normal)
|
||||
onClicked: {
|
||||
controlRoot.copy();
|
||||
}
|
||||
}
|
||||
ToolButton {
|
||||
focusPolicy: Qt.NoFocus
|
||||
icon.name: "edit-paste"
|
||||
visible: controlRoot && controlRoot.canPaste
|
||||
onClicked: {
|
||||
controlRoot.paste();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
259
imports/NeoChat/Component/ChatBox/TextFieldContextMenu.qml
Normal file
259
imports/NeoChat/Component/ChatBox/TextFieldContextMenu.qml
Normal file
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2020 Devin Lin <espidev@gmail.com>
|
||||
SPDX-FileCopyrightText: 2021 Carl Schwan <carlschwan@kde.org>
|
||||
|
||||
SPDX-License-Identifier: LGPL-2.0-or-later
|
||||
*/
|
||||
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick 2.6
|
||||
import QtQml 2.2
|
||||
import QtQuick.Controls 2.15
|
||||
import org.kde.kirigami 2.5 as Kirigami
|
||||
|
||||
Menu {
|
||||
id: contextMenu
|
||||
|
||||
property Item target
|
||||
property bool deselectWhenMenuClosed: true
|
||||
property int restoredCursorPosition: 0
|
||||
property int restoredSelectionStart
|
||||
property int restoredSelectionEnd
|
||||
property bool persistentSelectionSetting
|
||||
property var spellcheckhighlighter: null
|
||||
property var suggestions: ([])
|
||||
Component.onCompleted: persistentSelectionSetting = persistentSelectionSetting // break binding
|
||||
|
||||
property var runOnMenuClose
|
||||
|
||||
parent: Overlay.overlay
|
||||
|
||||
function storeCursorAndSelection() {
|
||||
contextMenu.restoredCursorPosition = target.cursorPosition;
|
||||
contextMenu.restoredSelectionStart = target.selectionStart;
|
||||
contextMenu.restoredSelectionEnd = target.selectionEnd;
|
||||
}
|
||||
|
||||
// target is pressed with mouse
|
||||
function targetClick(handlerPoint, newTarget, spellcheckhighlighter, mousePosition) {
|
||||
if (handlerPoint.pressedButtons === Qt.RightButton) { // only accept just right click
|
||||
if (contextMenu.visible) {
|
||||
deselectWhenMenuClosed = false; // don't deselect text if menu closed by right click on textfield
|
||||
dismiss();
|
||||
} else {
|
||||
contextMenu.target = newTarget;
|
||||
contextMenu.target.persistentSelection = true; // persist selection when menu is opened
|
||||
contextMenu.spellcheckhighlighter = spellcheckhighlighter
|
||||
contextMenu.suggestions = spellcheckhighlighter.suggestions(mousePosition);
|
||||
storeCursorAndSelection();
|
||||
popup(contextMenu.target);
|
||||
// slightly locate context menu away from mouse so no item is selected when menu is opened
|
||||
x += 1
|
||||
y += 1
|
||||
}
|
||||
} else {
|
||||
dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
// context menu keyboard key
|
||||
function targetKeyPressed(event, newTarget) {
|
||||
if (event.modifiers === Qt.NoModifier && event.key === Qt.Key_Menu) {
|
||||
contextMenu.target = newTarget;
|
||||
target.persistentSelection = true; // persist selection when menu is opened
|
||||
storeCursorAndSelection();
|
||||
popup(contextMenu.target);
|
||||
}
|
||||
}
|
||||
|
||||
readonly property bool targetIsPassword: target !== null && (target.echoMode === TextInput.PasswordEchoOnEdit || target.echoMode === TextInput.Password)
|
||||
|
||||
onAboutToShow: {
|
||||
if (Overlay.overlay) {
|
||||
let tempZ = 0
|
||||
for (let i in Overlay.overlay.visibleChildren) {
|
||||
tempZ = Math.max(tempZ, Overlay.overlay.visibleChildren[i].z)
|
||||
}
|
||||
z = tempZ + 1
|
||||
}
|
||||
}
|
||||
|
||||
// deal with whether or not text should be deselected
|
||||
onClosed: {
|
||||
// restore text field's original persistent selection setting
|
||||
target.persistentSelection = persistentSelectionSetting
|
||||
// deselect text field text if menu is closed not because of a right click on the text field
|
||||
if (deselectWhenMenuClosed) {
|
||||
target.deselect();
|
||||
}
|
||||
deselectWhenMenuClosed = true;
|
||||
|
||||
// restore cursor position
|
||||
target.forceActiveFocus();
|
||||
target.cursorPosition = restoredCursorPosition;
|
||||
target.select(restoredSelectionStart, restoredSelectionEnd);
|
||||
|
||||
// run action
|
||||
runOnMenuClose();
|
||||
}
|
||||
|
||||
onOpened: {
|
||||
runOnMenuClose = function() {};
|
||||
}
|
||||
|
||||
Instantiator {
|
||||
active: target !== null && !target.readOnly && spellcheckhighlighter !== null && spellcheckhighlighter.wordIsMisspelled
|
||||
model: suggestions
|
||||
delegate: MenuItem {
|
||||
text: modelData
|
||||
onClicked: {
|
||||
deselectWhenMenuClosed = false;
|
||||
runOnMenuClose = function() {
|
||||
spellcheckhighlighter.replaceWord(modelData);
|
||||
};
|
||||
}
|
||||
}
|
||||
onObjectAdded: contextMenu.insertItem(0, object)
|
||||
onObjectRemoved: contextMenu.removeItem(0)
|
||||
}
|
||||
|
||||
MenuItem {
|
||||
visible: target !== null && !target.readOnly && spellcheckhighlighter !== null && spellcheckhighlighter.wordIsMisspelled && suggestions.length === 0
|
||||
action: Action {
|
||||
text: spellcheckhighlighter ? i18nc("@action:inmenu", "No suggestions for %1", spellcheckhighlighter.wordUnderMouse) : ""
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
|
||||
MenuSeparator {
|
||||
visible: target !== null && !target.readOnly && spellcheckhighlighter !== null && spellcheckhighlighter.wordIsMisspelled
|
||||
}
|
||||
|
||||
MenuItem {
|
||||
visible: target !== null && !target.readOnly && spellcheckhighlighter !== null && spellcheckhighlighter.wordIsMisspelled
|
||||
action: Action {
|
||||
text: i18n("Add to dictionary")
|
||||
onTriggered: {
|
||||
deselectWhenMenuClosed = false;
|
||||
runOnMenuClose = function() {
|
||||
spellcheckhighlighter.addWordToDictionary(spellcheckhighlighter.wordUnderMouse)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MenuItem {
|
||||
visible: target !== null && !target.readOnly && spellcheckhighlighter !== null && spellcheckhighlighter.wordIsMisspelled
|
||||
action: Action {
|
||||
text: i18n("Ignore")
|
||||
onTriggered: {
|
||||
deselectWhenMenuClosed = false;
|
||||
runOnMenuClose = function() {
|
||||
spellcheckhighlighter.ignoreWord(spellcheckhighlighter.wordUnderMouse)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MenuSeparator {
|
||||
visible: target !== null && !target.readOnly && spellcheckhighlighter !== null && spellcheckhighlighter.wordIsMisspelled
|
||||
}
|
||||
|
||||
MenuItem {
|
||||
visible: target !== null && !target.readOnly
|
||||
action: Action {
|
||||
icon.name: "edit-undo-symbolic"
|
||||
text: i18nc("@action:inmenu", "Undo")
|
||||
shortcut: StandardKey.Undo
|
||||
}
|
||||
enabled: target !== null && target.canUndo
|
||||
onTriggered: {
|
||||
deselectWhenMenuClosed = false;
|
||||
runOnMenuClose = function() {target.undo()};
|
||||
}
|
||||
}
|
||||
MenuItem {
|
||||
visible: target !== null && !target.readOnly
|
||||
action: Action {
|
||||
icon.name: "edit-redo-symbolic"
|
||||
text: i18nc("@action:inmenu", "Redo")
|
||||
shortcut: StandardKey.Redo
|
||||
}
|
||||
enabled: target !== null && target.canRedo
|
||||
onTriggered: {
|
||||
deselectWhenMenuClosed = false;
|
||||
runOnMenuClose = function() {target.redo()};
|
||||
}
|
||||
}
|
||||
MenuSeparator {
|
||||
visible: target !== null && !target.readOnly
|
||||
}
|
||||
MenuItem {
|
||||
visible: target !== null && !target.readOnly && !targetIsPassword
|
||||
action: Action {
|
||||
icon.name: "edit-cut-symbolic"
|
||||
text: i18nc("@action:inmenu", "Cut")
|
||||
shortcut: StandardKey.Cut
|
||||
}
|
||||
enabled: target !== null && target.selectedText
|
||||
onTriggered: {
|
||||
deselectWhenMenuClosed = false;
|
||||
runOnMenuClose = function() {target.cut()}
|
||||
}
|
||||
}
|
||||
MenuItem {
|
||||
action: Action {
|
||||
icon.name: "edit-copy-symbolic"
|
||||
text: i18nc("@action:inmenu", "Copy")
|
||||
shortcut: StandardKey.Copy
|
||||
}
|
||||
enabled: target !== null && target.selectedText
|
||||
visible: !targetIsPassword
|
||||
onTriggered: {
|
||||
deselectWhenMenuClosed = false;
|
||||
runOnMenuClose = function() {target.copy()}
|
||||
}
|
||||
}
|
||||
MenuItem {
|
||||
visible: target !== null && !target.readOnly
|
||||
action: Action {
|
||||
icon.name: "edit-paste-symbolic"
|
||||
text: i18nc("@action:inmenu", "Paste")
|
||||
shortcut: StandardKey.Paste
|
||||
}
|
||||
enabled: target !== null && target.canPaste
|
||||
onTriggered: {
|
||||
deselectWhenMenuClosed = false;
|
||||
runOnMenuClose = function() {target.paste()};
|
||||
}
|
||||
}
|
||||
MenuItem {
|
||||
visible: target !== null && !target.readOnly
|
||||
action: Action {
|
||||
icon.name: "edit-delete-symbolic"
|
||||
text: i18nc("@action:inmenu", "Delete")
|
||||
shortcut: StandardKey.Delete
|
||||
}
|
||||
enabled: target !== null && target.selectedText
|
||||
onTriggered: {
|
||||
deselectWhenMenuClosed = false;
|
||||
runOnMenuClose = function() {target.remove(target.selectionStart, target.selectionEnd)};
|
||||
}
|
||||
}
|
||||
MenuSeparator {
|
||||
visible: !targetIsPassword
|
||||
}
|
||||
MenuItem {
|
||||
action: Action {
|
||||
icon.name: "edit-select-all-symbolic"
|
||||
text: i18nc("@action:inmenu", "Select All")
|
||||
shortcut: StandardKey.SelectAll
|
||||
}
|
||||
visible: !targetIsPassword
|
||||
onTriggered: {
|
||||
deselectWhenMenuClosed = false;
|
||||
runOnMenuClose = function() {target.selectAll()};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,3 +5,7 @@ ReplyPane 1.0 ReplyPane.qml
|
||||
AttachmentPane 1.0 AttachmentPane.qml
|
||||
CompletionMenu 1.0 CompletionMenu.qml
|
||||
EmojiPickerPane 1.0 EmojiPickerPane.qml
|
||||
singleton TextFieldContextMenu 1.0 TextFieldContextMenu.qml
|
||||
CursorDelegate 1.0 CursorDelegate.qml
|
||||
CursorHandle 1.0 CursorHandle.qml
|
||||
singleton MobileTextActionsToolBar 1.0 MobileTextActionsToolBar.qml
|
||||
|
||||
25
imports/NeoChat/Menu/TextContextMenu.qml
Normal file
25
imports/NeoChat/Menu/TextContextMenu.qml
Normal file
@@ -0,0 +1,25 @@
|
||||
// SPDX-FileCopyrightText: 2021 Carl Schwan <carl@carlschwan.eu>
|
||||
// SPDX-License-Identifier: LGPL-2.0-or-later
|
||||
|
||||
|
||||
/**
|
||||
* Context menu when clicking on a room in the room list
|
||||
*/
|
||||
Menu {
|
||||
id: root
|
||||
property var selectedText
|
||||
|
||||
Repeater {
|
||||
model: WebShortcutModel {
|
||||
selectedText: root.selectedText
|
||||
}
|
||||
delegate: MenuItem {
|
||||
text: model.display
|
||||
icon.name: model.decoration
|
||||
}
|
||||
}
|
||||
|
||||
MenuSeparator {}
|
||||
|
||||
onClosed: destroy()
|
||||
}
|
||||
4
res.qrc
4
res.qrc
@@ -25,6 +25,10 @@
|
||||
<file>imports/NeoChat/Component/ChatBox/AttachmentPane.qml</file>
|
||||
<file>imports/NeoChat/Component/ChatBox/ReplyPane.qml</file>
|
||||
<file>imports/NeoChat/Component/ChatBox/CompletionMenu.qml</file>
|
||||
<file>imports/NeoChat/Component/ChatBox/CursorHandle.qml</file>
|
||||
<file>imports/NeoChat/Component/ChatBox/CursorDelegate.qml</file>
|
||||
<file>imports/NeoChat/Component/ChatBox/MobileTextActionsToolBar.qml</file>
|
||||
<file>imports/NeoChat/Component/ChatBox/TextFieldContextMenu.qml</file>
|
||||
<file>imports/NeoChat/Component/ChatBox/qmldir</file>
|
||||
<file>imports/NeoChat/Component/Emoji/EmojiPicker.qml</file>
|
||||
<file>imports/NeoChat/Component/Emoji/qmldir</file>
|
||||
|
||||
@@ -44,7 +44,7 @@ target_sources(neochat PRIVATE ${NEOCHAT_ICON})
|
||||
|
||||
if(NOT ANDROID)
|
||||
target_sources(neochat PRIVATE trayicon.cpp colorschemer.cpp spellcheckhighlighter.cpp)
|
||||
target_link_libraries(neochat PRIVATE KF5::ConfigWidgets KF5::SonnetCore KF5::WindowSystem)
|
||||
target_link_libraries(neochat PRIVATE KF5::ConfigWidgets KF5::WindowSystem KF5::SonnetCore)
|
||||
target_compile_definitions(neochat PRIVATE -DHAVE_COLORSCHEME)
|
||||
target_compile_definitions(neochat PRIVATE -DHAVE_WINDOWSYSTEM)
|
||||
endif()
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
#include <QTextDocument>
|
||||
|
||||
#include "neochatroom.h"
|
||||
#ifndef Q_OS_ANDROID
|
||||
#include "spellcheckhighlighter.h"
|
||||
#endif
|
||||
|
||||
ChatDocumentHandler::ChatDocumentHandler(QObject *parent)
|
||||
: QObject(parent)
|
||||
@@ -37,9 +34,6 @@ void ChatDocumentHandler::setDocument(QQuickTextDocument *document)
|
||||
m_document->textDocument()->disconnect(this);
|
||||
}
|
||||
m_document = document;
|
||||
#ifndef Q_OS_ANDROID
|
||||
new SpellcheckHighlighter(m_document->textDocument());
|
||||
#endif
|
||||
Q_EMIT documentChanged();
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
#include "userdirectorylistmodel.h"
|
||||
#include "userlistmodel.h"
|
||||
#include "webshortcutmodel.h"
|
||||
#include "spellcheckhighlighter.h"
|
||||
#ifdef HAVE_COLORSCHEME
|
||||
#include "colorschemer.h"
|
||||
#endif
|
||||
@@ -158,6 +159,7 @@ int main(int argc, char *argv[])
|
||||
qmlRegisterType<AccountListModel>("org.kde.neochat", 1, 0, "AccountListModel");
|
||||
qmlRegisterType<ActionsHandler>("org.kde.neochat", 1, 0, "ActionsHandler");
|
||||
qmlRegisterType<ChatDocumentHandler>("org.kde.neochat", 1, 0, "ChatDocumentHandler");
|
||||
qmlRegisterType<SpellcheckHighlighter>("org.kde.neochat", 1, 0, "SpellcheckHighlighter");
|
||||
qmlRegisterType<RoomListModel>("org.kde.neochat", 1, 0, "RoomListModel");
|
||||
qmlRegisterType<KWebShortcutModel>("org.kde.neochat", 1, 0, "WebShortcutModel");
|
||||
qmlRegisterType<UserListModel>("org.kde.neochat", 1, 0, "UserListModel");
|
||||
|
||||
@@ -1,11 +1,51 @@
|
||||
// Copyright (c) 2020 Christian Mollekopf <mollekopf@kolabsystems.com>
|
||||
// SPDX-License-Identifier: LGPL-2.0-or-later
|
||||
// SPDX-FileCopyrightText: 2013 Aurélien Gâteau <agateau@kde.org>
|
||||
// SPDX-FileCopyrightText: 2020 Christian Mollekopf <mollekopf@kolabsystems.com>
|
||||
// SPDX-FileCopyrightText: 2021 Carl Schwan <carlschwan@kde.org>
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
#include "spellcheckhighlighter.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QHash>
|
||||
#include <QTextBoundaryFinder>
|
||||
|
||||
// Cache of previously-determined languages (when using AutoDetectLanguage)
|
||||
// There is one such cache per block (paragraph)
|
||||
class LanguageCache : public QTextBlockUserData
|
||||
{
|
||||
public:
|
||||
// Key: QPair<start, length>
|
||||
// Value: language name
|
||||
QMap<QPair<int, int>, QString> languages;
|
||||
|
||||
// Remove all cached language information after @p pos
|
||||
void invalidate(int pos)
|
||||
{
|
||||
QMutableMapIterator<QPair<int, int>, QString> it(languages);
|
||||
it.toBack();
|
||||
while (it.hasPrevious()) {
|
||||
it.previous();
|
||||
if (it.key().first + it.key().second >= pos) {
|
||||
it.remove();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QString languageAtPos(int pos) const
|
||||
{
|
||||
// The data structure isn't really great for such lookups...
|
||||
QMapIterator<QPair<int, int>, QString> it(languages);
|
||||
while (it.hasNext()) {
|
||||
it.next();
|
||||
if (it.key().first <= pos && it.key().first + it.key().second >= pos) {
|
||||
return it.value();
|
||||
}
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
};
|
||||
|
||||
QVector<QStringRef> split(QTextBoundaryFinder::BoundaryType boundary, const QString &text, int reasonMask = 0)
|
||||
{
|
||||
QVector<QStringRef> parts;
|
||||
@@ -14,7 +54,7 @@ QVector<QStringRef> split(QTextBoundaryFinder::BoundaryType boundary, const QStr
|
||||
while (boundaryFinder.position() < text.length()) {
|
||||
const int start = boundaryFinder.position();
|
||||
|
||||
//Advance until we find a break that matches the mask or are at the end
|
||||
// Advance until we find a break that matches the mask or are at the end
|
||||
for (;;) {
|
||||
if (boundaryFinder.toNextBoundary() == -1) {
|
||||
boundaryFinder.toEnd();
|
||||
@@ -35,31 +75,36 @@ QVector<QStringRef> split(QTextBoundaryFinder::BoundaryType boundary, const QStr
|
||||
return parts;
|
||||
}
|
||||
|
||||
|
||||
SpellcheckHighlighter::SpellcheckHighlighter(QTextDocument *parent)
|
||||
: QSyntaxHighlighter(parent),
|
||||
mSpellchecker{new Sonnet::Speller()},
|
||||
mLanguageGuesser{new Sonnet::GuessLanguage()}
|
||||
SpellcheckHighlighter::SpellcheckHighlighter(QObject *parent)
|
||||
: QSyntaxHighlighter(parent)
|
||||
#ifndef Q_OS_ANDROID
|
||||
, mSpellchecker{new Sonnet::Speller()}
|
||||
, mLanguageGuesser{new Sonnet::GuessLanguage()}
|
||||
#endif
|
||||
, m_document(nullptr)
|
||||
, m_cursorPosition(-1)
|
||||
{
|
||||
//Danger red from our color scheme
|
||||
// Danger red from our color scheme
|
||||
mErrorFormat.setForeground(QColor{"#ed1515"});
|
||||
mErrorFormat.setUnderlineColor(QColor{"#ed1515"});
|
||||
mErrorFormat.setUnderlineStyle(QTextCharFormat::SingleUnderline);
|
||||
mQuoteFormat.setForeground(QColor{"#7f8c8d"});
|
||||
|
||||
|
||||
#ifndef Q_OS_ANDROID
|
||||
if (!mSpellchecker->isValid()) {
|
||||
qWarning() << "Spellchecker is invalid";
|
||||
}
|
||||
qDebug() << "Available dictionaries: " << mSpellchecker->availableDictionaries();
|
||||
#endif
|
||||
}
|
||||
|
||||
void SpellcheckHighlighter::autodetectLanguage(const QString &sentence)
|
||||
{
|
||||
#ifndef Q_OS_ANDROID
|
||||
const auto lang = mLanguageGuesser->identify(sentence, mSpellchecker->availableLanguages());
|
||||
if (lang.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
mSpellchecker->setLanguage(lang);
|
||||
#endif
|
||||
}
|
||||
|
||||
static bool isSpellcheckable(const QStringRef &token)
|
||||
@@ -70,20 +115,21 @@ static bool isSpellcheckable(const QStringRef &token)
|
||||
if (!token.at(0).isLetter() || token.at(0).isUpper() || token.startsWith(QStringLiteral("http"))) {
|
||||
return false;
|
||||
}
|
||||
//TODO ignore urls and uppercase?
|
||||
// TODO ignore urls and uppercase?
|
||||
return true;
|
||||
}
|
||||
|
||||
void SpellcheckHighlighter::highlightBlock(const QString &text)
|
||||
{
|
||||
//Avoid spellchecking quotes
|
||||
if (text.isEmpty() || text.at(0) == QChar{'>'}) {
|
||||
// Avoid spellchecking quotes
|
||||
if (text.isEmpty() || text.at(0) == QLatin1Char('>')) {
|
||||
setFormat(0, text.length(), mQuoteFormat);
|
||||
return;
|
||||
}
|
||||
#ifndef Q_OS_ANDROID
|
||||
for (const auto &sentenceRef : split(QTextBoundaryFinder::Sentence, text)) {
|
||||
//Avoid spellchecking quotes
|
||||
if (sentenceRef.isEmpty() || sentenceRef.at(0) == QChar{'>'}) {
|
||||
// Avoid spellchecking quotes
|
||||
if (sentenceRef.isEmpty() || sentenceRef.at(0) == QLatin1Char('>')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -93,8 +139,8 @@ void SpellcheckHighlighter::highlightBlock(const QString &text)
|
||||
|
||||
const int offset = sentenceRef.position();
|
||||
for (const auto &wordRef : split(QTextBoundaryFinder::Word, sentence)) {
|
||||
//Avoid spellchecking words in progress
|
||||
//FIXME this will also prevent spellchecking a single word on a line.
|
||||
// Avoid spellchecking words in progress
|
||||
// FIXME this will also prevent spellchecking a single word on a line.
|
||||
if (offset + wordRef.position() + wordRef.length() >= text.length()) {
|
||||
continue;
|
||||
}
|
||||
@@ -105,4 +151,202 @@ void SpellcheckHighlighter::highlightBlock(const QString &text)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
QStringList SpellcheckHighlighter::suggestions(int mousePosition, int max)
|
||||
{
|
||||
#ifndef Q_OS_ANDROID
|
||||
QTextCursor cursor = textCursor();
|
||||
|
||||
QTextCursor cursorAtMouse(textDocument());
|
||||
cursorAtMouse.setPosition(mousePosition);
|
||||
|
||||
// Check if the user clicked a selected word
|
||||
/* clang-format off */
|
||||
const bool selectedWordClicked = cursor.hasSelection()
|
||||
&& mousePosition >= cursor.selectionStart()
|
||||
&& mousePosition <= cursor.selectionEnd();
|
||||
/* clang-format on */
|
||||
|
||||
// Get the word under the (mouse-)cursor and see if it is misspelled.
|
||||
// Don't include apostrophes at the start/end of the word in the selection.
|
||||
QTextCursor wordSelectCursor(cursorAtMouse);
|
||||
wordSelectCursor.clearSelection();
|
||||
wordSelectCursor.select(QTextCursor::WordUnderCursor);
|
||||
m_selectedWord = wordSelectCursor.selectedText();
|
||||
|
||||
// Clear the selection again, we re-select it below (without the apostrophes).
|
||||
wordSelectCursor.setPosition(wordSelectCursor.position() - m_selectedWord.size());
|
||||
if (m_selectedWord.startsWith(QLatin1Char('\'')) || m_selectedWord.startsWith(QLatin1Char('\"'))) {
|
||||
m_selectedWord = m_selectedWord.right(m_selectedWord.size() - 1);
|
||||
wordSelectCursor.movePosition(QTextCursor::NextCharacter, QTextCursor::MoveAnchor);
|
||||
}
|
||||
if (m_selectedWord.endsWith(QLatin1Char('\'')) || m_selectedWord.endsWith(QLatin1Char('\"'))) {
|
||||
m_selectedWord.chop(1);
|
||||
}
|
||||
|
||||
wordSelectCursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, m_selectedWord.size());
|
||||
|
||||
int endSelection = wordSelectCursor.selectionEnd();
|
||||
Q_EMIT wordUnderMouseChanged();
|
||||
|
||||
bool isMouseCursorInsideWord = true;
|
||||
if ((mousePosition < wordSelectCursor.selectionStart() || mousePosition >= wordSelectCursor.selectionEnd()) //
|
||||
&& (m_selectedWord.length() > 1)) {
|
||||
isMouseCursorInsideWord = false;
|
||||
}
|
||||
|
||||
wordSelectCursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, m_selectedWord.size());
|
||||
|
||||
m_wordIsMisspelled = isMouseCursorInsideWord && !m_selectedWord.isEmpty() && mSpellchecker->isMisspelled(m_selectedWord);
|
||||
Q_EMIT wordIsMisspelledChanged();
|
||||
|
||||
if (!m_wordIsMisspelled || selectedWordClicked) {
|
||||
return QStringList{};
|
||||
}
|
||||
|
||||
if (!selectedWordClicked) {
|
||||
Q_EMIT changeCursorPosition(wordSelectCursor.selectionStart(), endSelection);
|
||||
}
|
||||
|
||||
LanguageCache *cache = dynamic_cast<LanguageCache *>(cursor.block().userData());
|
||||
if (cache) {
|
||||
const QString cachedLanguage = cache->languageAtPos(cursor.positionInBlock());
|
||||
if (!cachedLanguage.isEmpty()) {
|
||||
mSpellchecker->setLanguage(cachedLanguage);
|
||||
}
|
||||
}
|
||||
QStringList suggestions = mSpellchecker->suggest(m_selectedWord);
|
||||
if (max >= 0 && suggestions.count() > max) {
|
||||
suggestions = suggestions.mid(0, max);
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
#else
|
||||
return QStringList();
|
||||
#endif
|
||||
}
|
||||
|
||||
void SpellcheckHighlighter::addWordToDictionary(const QString &word)
|
||||
{
|
||||
#ifndef Q_OS_ANDROID
|
||||
mSpellchecker->addToPersonal(word);
|
||||
rehighlight();
|
||||
#endif
|
||||
}
|
||||
|
||||
void SpellcheckHighlighter::ignoreWord(const QString &word)
|
||||
{
|
||||
#ifndef Q_OS_ANDROID
|
||||
mSpellchecker->addToSession(word);
|
||||
rehighlight();
|
||||
#endif
|
||||
}
|
||||
|
||||
void SpellcheckHighlighter::replaceWord(const QString &replacement)
|
||||
{
|
||||
#ifndef Q_OS_ANDROID
|
||||
textCursor().insertText(replacement);
|
||||
#endif
|
||||
}
|
||||
|
||||
QQuickTextDocument *SpellcheckHighlighter::quickDocument() const
|
||||
{
|
||||
return m_document;
|
||||
}
|
||||
|
||||
void SpellcheckHighlighter::setQuickDocument(QQuickTextDocument *document)
|
||||
{
|
||||
if (document == m_document) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_document) {
|
||||
m_document->textDocument()->disconnect(this);
|
||||
}
|
||||
m_document = document;
|
||||
setDocument(document->textDocument());
|
||||
Q_EMIT documentChanged();
|
||||
}
|
||||
|
||||
int SpellcheckHighlighter::cursorPosition() const
|
||||
{
|
||||
return m_cursorPosition;
|
||||
}
|
||||
|
||||
void SpellcheckHighlighter::setCursorPosition(int position)
|
||||
{
|
||||
if (position == m_cursorPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_cursorPosition = position;
|
||||
Q_EMIT cursorPositionChanged();
|
||||
}
|
||||
|
||||
int SpellcheckHighlighter::selectionStart() const
|
||||
{
|
||||
return m_selectionStart;
|
||||
}
|
||||
|
||||
void SpellcheckHighlighter::setSelectionStart(int position)
|
||||
{
|
||||
if (position == m_selectionStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_selectionStart = position;
|
||||
Q_EMIT selectionStartChanged();
|
||||
}
|
||||
|
||||
int SpellcheckHighlighter::selectionEnd() const
|
||||
{
|
||||
return m_selectionEnd;
|
||||
}
|
||||
|
||||
void SpellcheckHighlighter::setSelectionEnd(int position)
|
||||
{
|
||||
if (position == m_selectionEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_selectionEnd = position;
|
||||
Q_EMIT selectionEndChanged();
|
||||
}
|
||||
|
||||
QTextCursor SpellcheckHighlighter::textCursor() const
|
||||
{
|
||||
QTextDocument *doc = textDocument();
|
||||
if (!doc) {
|
||||
return QTextCursor();
|
||||
}
|
||||
|
||||
QTextCursor cursor(doc);
|
||||
if (m_selectionStart != m_selectionEnd) {
|
||||
cursor.setPosition(m_selectionStart);
|
||||
cursor.setPosition(m_selectionEnd, QTextCursor::KeepAnchor);
|
||||
} else {
|
||||
cursor.setPosition(m_cursorPosition);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
QTextDocument *SpellcheckHighlighter::textDocument() const
|
||||
{
|
||||
if (!m_document) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return m_document->textDocument();
|
||||
}
|
||||
|
||||
bool SpellcheckHighlighter::wordIsMisspelled() const
|
||||
{
|
||||
return m_wordIsMisspelled;
|
||||
}
|
||||
|
||||
QString SpellcheckHighlighter::wordUnderMouse() const
|
||||
{
|
||||
return m_selectedWord;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,81 @@
|
||||
// Copyright (c) 2020 Christian Mollekopf <mollekopf@kolabsystems.com>
|
||||
// SPDX-License-Identifier: LGPL-2.0-or-later
|
||||
// SPDX-FileCopyrightText: 2013 Aurélien Gâteau <agateau@kde.org>
|
||||
// SPDX-FileCopyrightText: 2020 Christian Mollekopf <mollekopf@kolabsystems.com>
|
||||
// SPDX-FileCopyrightText: 2021 Carl Schwan <carlschwan@kde.org>
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QTextDocument>
|
||||
#include <QQuickTextDocument>
|
||||
#include <QSyntaxHighlighter>
|
||||
#include <Sonnet/Speller>
|
||||
#include <QTextDocument>
|
||||
#ifndef Q_OS_ANDROID
|
||||
#include <Sonnet/GuessLanguage>
|
||||
#include <Sonnet/Speller>
|
||||
#endif
|
||||
|
||||
class SpellcheckHighlighter: public QSyntaxHighlighter
|
||||
class SpellcheckHighlighter : public QSyntaxHighlighter
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(QQuickTextDocument *document READ quickDocument WRITE setQuickDocument NOTIFY documentChanged)
|
||||
Q_PROPERTY(int cursorPosition READ cursorPosition WRITE setCursorPosition NOTIFY cursorPositionChanged)
|
||||
Q_PROPERTY(int selectionStart READ selectionStart WRITE setSelectionStart NOTIFY selectionStartChanged)
|
||||
Q_PROPERTY(int selectionEnd READ selectionEnd WRITE setSelectionEnd NOTIFY selectionEndChanged)
|
||||
Q_PROPERTY(bool wordIsMisspelled READ wordIsMisspelled NOTIFY wordIsMisspelledChanged)
|
||||
Q_PROPERTY(QString wordUnderMouse READ wordUnderMouse NOTIFY wordUnderMouseChanged)
|
||||
|
||||
public:
|
||||
SpellcheckHighlighter(QTextDocument *parent);
|
||||
SpellcheckHighlighter(QObject *parent = nullptr);
|
||||
|
||||
Q_INVOKABLE QStringList suggestions(int position, int max = 5);
|
||||
Q_INVOKABLE void ignoreWord(const QString &word);
|
||||
Q_INVOKABLE void addWordToDictionary(const QString &word);
|
||||
Q_INVOKABLE void replaceWord(const QString &word);
|
||||
|
||||
[[nodiscard]] QQuickTextDocument *quickDocument() const;
|
||||
void setQuickDocument(QQuickTextDocument *document);
|
||||
|
||||
[[nodiscard]] int cursorPosition() const;
|
||||
void setCursorPosition(int position);
|
||||
|
||||
[[nodiscard]] int selectionStart() const;
|
||||
void setSelectionStart(int position);
|
||||
|
||||
[[nodiscard]] int selectionEnd() const;
|
||||
void setSelectionEnd(int position);
|
||||
|
||||
[[nodiscard]] bool wordIsMisspelled() const;
|
||||
[[nodiscard]] QString wordUnderMouse() const;
|
||||
|
||||
protected:
|
||||
void highlightBlock(const QString &text) override;
|
||||
|
||||
Q_SIGNALS:
|
||||
void documentChanged();
|
||||
void cursorPositionChanged();
|
||||
void selectionStartChanged();
|
||||
void selectionEndChanged();
|
||||
void wordIsMisspelledChanged();
|
||||
void wordUnderMouseChanged();
|
||||
void changeCursorPosition(int start, int end);
|
||||
|
||||
private:
|
||||
[[nodiscard]] QTextCursor textCursor() const;
|
||||
[[nodiscard]] QTextDocument *textDocument() const;
|
||||
|
||||
void autodetectLanguage(const QString &sentence);
|
||||
QTextCharFormat mErrorFormat;
|
||||
QTextCharFormat mQuoteFormat;
|
||||
#ifndef Q_OS_ANDROID
|
||||
QScopedPointer<Sonnet::Speller> mSpellchecker;
|
||||
QScopedPointer<Sonnet::GuessLanguage> mLanguageGuesser;
|
||||
};
|
||||
#endif
|
||||
QString m_selectedWord;
|
||||
QQuickTextDocument *m_document;
|
||||
int m_cursorPosition;
|
||||
int m_selectionStart;
|
||||
int m_selectionEnd;
|
||||
|
||||
int m_autoCompleteBeginPosition = -1;
|
||||
int m_autoCompleteEndPosition = -1;
|
||||
int m_wordIsMisspelled = false;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user