Add spellchecking suggestions
This commit is contained in:
@@ -68,7 +68,7 @@ if(ANDROID)
|
|||||||
)
|
)
|
||||||
else()
|
else()
|
||||||
find_package(Qt5 ${QT_MIN_VERSION} COMPONENTS Widgets)
|
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
|
set_package_properties(KF5QQC2DesktopStyle PROPERTIES
|
||||||
TYPE RUNTIME
|
TYPE RUNTIME
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
import QtQuick 2.15
|
import QtQuick 2.15
|
||||||
import QtQuick.Layouts 1.15
|
import QtQuick.Layouts 1.15
|
||||||
import QtQuick.Controls 2.15
|
import QtQuick.Controls 2.15
|
||||||
|
import QtQuick.Templates 2.15 as T
|
||||||
import Qt.labs.platform 1.1 as Platform
|
import Qt.labs.platform 1.1 as Platform
|
||||||
|
import QtQuick.Window 2.15
|
||||||
|
|
||||||
import org.kde.kirigami 2.15 as Kirigami
|
import org.kde.kirigami 2.15 as Kirigami
|
||||||
|
|
||||||
@@ -66,7 +68,8 @@ ToolBar {
|
|||||||
id: fontMetrics
|
id: fontMetrics
|
||||||
font: inputField.font
|
font: inputField.font
|
||||||
}
|
}
|
||||||
TextArea {
|
|
||||||
|
T.TextArea {
|
||||||
id: inputField
|
id: inputField
|
||||||
focus: true
|
focus: true
|
||||||
/* Some QQC2 styles will have their own predefined backgrounds for TextAreas.
|
/* Some QQC2 styles will have their own predefined backgrounds for TextAreas.
|
||||||
@@ -81,6 +84,7 @@ ToolBar {
|
|||||||
cursorShape: Qt.IBeamCursor
|
cursorShape: Qt.IBeamCursor
|
||||||
z: 1
|
z: 1
|
||||||
}
|
}
|
||||||
|
|
||||||
leftPadding: mirrored ? 0 : Kirigami.Units.largeSpacing
|
leftPadding: mirrored ? 0 : Kirigami.Units.largeSpacing
|
||||||
rightPadding: !mirrored ? 0 : Kirigami.Units.largeSpacing
|
rightPadding: !mirrored ? 0 : Kirigami.Units.largeSpacing
|
||||||
topPadding: 0
|
topPadding: 0
|
||||||
@@ -97,6 +101,86 @@ ToolBar {
|
|||||||
wrapMode: Text.Wrap
|
wrapMode: Text.Wrap
|
||||||
readOnly: currentRoom.usesEncryption
|
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 {
|
ChatDocumentHandler {
|
||||||
id: documentHandler
|
id: documentHandler
|
||||||
@@ -107,6 +191,18 @@ ToolBar {
|
|||||||
room: currentRoom ?? null
|
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 {
|
Timer {
|
||||||
id: timeoutTimer
|
id: timeoutTimer
|
||||||
repeat: false
|
repeat: false
|
||||||
@@ -147,6 +243,9 @@ ToolBar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Keys.onPressed: {
|
Keys.onPressed: {
|
||||||
|
// trigger if context menu button is pressed
|
||||||
|
TextFieldContextMenu.targetKeyPressed(event, inputField)
|
||||||
|
|
||||||
if (event.key === Qt.Key_PageDown) {
|
if (event.key === Qt.Key_PageDown) {
|
||||||
switchRoomDown();
|
switchRoomDown();
|
||||||
} else if (event.key === Qt.Key_PageUp) {
|
} else if (event.key === Qt.Key_PageUp) {
|
||||||
@@ -211,7 +310,10 @@ ToolBar {
|
|||||||
chatBar.complete();
|
chatBar.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onPressed: MobileTextActionsToolBar.shouldBeVisible = true;
|
||||||
|
|
||||||
onTextChanged: {
|
onTextChanged: {
|
||||||
|
MobileTextActionsToolBar.shouldBeVisible = false;
|
||||||
timeoutTimer.restart()
|
timeoutTimer.restart()
|
||||||
repeatTimer.start()
|
repeatTimer.start()
|
||||||
currentRoom.cachedInput = text
|
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
|
AttachmentPane 1.0 AttachmentPane.qml
|
||||||
CompletionMenu 1.0 CompletionMenu.qml
|
CompletionMenu 1.0 CompletionMenu.qml
|
||||||
EmojiPickerPane 1.0 EmojiPickerPane.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/AttachmentPane.qml</file>
|
||||||
<file>imports/NeoChat/Component/ChatBox/ReplyPane.qml</file>
|
<file>imports/NeoChat/Component/ChatBox/ReplyPane.qml</file>
|
||||||
<file>imports/NeoChat/Component/ChatBox/CompletionMenu.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/ChatBox/qmldir</file>
|
||||||
<file>imports/NeoChat/Component/Emoji/EmojiPicker.qml</file>
|
<file>imports/NeoChat/Component/Emoji/EmojiPicker.qml</file>
|
||||||
<file>imports/NeoChat/Component/Emoji/qmldir</file>
|
<file>imports/NeoChat/Component/Emoji/qmldir</file>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ target_sources(neochat PRIVATE ${NEOCHAT_ICON})
|
|||||||
|
|
||||||
if(NOT ANDROID)
|
if(NOT ANDROID)
|
||||||
target_sources(neochat PRIVATE trayicon.cpp colorschemer.cpp spellcheckhighlighter.cpp)
|
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_COLORSCHEME)
|
||||||
target_compile_definitions(neochat PRIVATE -DHAVE_WINDOWSYSTEM)
|
target_compile_definitions(neochat PRIVATE -DHAVE_WINDOWSYSTEM)
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -11,9 +11,6 @@
|
|||||||
#include <QTextDocument>
|
#include <QTextDocument>
|
||||||
|
|
||||||
#include "neochatroom.h"
|
#include "neochatroom.h"
|
||||||
#ifndef Q_OS_ANDROID
|
|
||||||
#include "spellcheckhighlighter.h"
|
|
||||||
#endif
|
|
||||||
|
|
||||||
ChatDocumentHandler::ChatDocumentHandler(QObject *parent)
|
ChatDocumentHandler::ChatDocumentHandler(QObject *parent)
|
||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
@@ -37,9 +34,6 @@ void ChatDocumentHandler::setDocument(QQuickTextDocument *document)
|
|||||||
m_document->textDocument()->disconnect(this);
|
m_document->textDocument()->disconnect(this);
|
||||||
}
|
}
|
||||||
m_document = document;
|
m_document = document;
|
||||||
#ifndef Q_OS_ANDROID
|
|
||||||
new SpellcheckHighlighter(m_document->textDocument());
|
|
||||||
#endif
|
|
||||||
Q_EMIT documentChanged();
|
Q_EMIT documentChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
#include "userdirectorylistmodel.h"
|
#include "userdirectorylistmodel.h"
|
||||||
#include "userlistmodel.h"
|
#include "userlistmodel.h"
|
||||||
#include "webshortcutmodel.h"
|
#include "webshortcutmodel.h"
|
||||||
|
#include "spellcheckhighlighter.h"
|
||||||
#ifdef HAVE_COLORSCHEME
|
#ifdef HAVE_COLORSCHEME
|
||||||
#include "colorschemer.h"
|
#include "colorschemer.h"
|
||||||
#endif
|
#endif
|
||||||
@@ -158,6 +159,7 @@ int main(int argc, char *argv[])
|
|||||||
qmlRegisterType<AccountListModel>("org.kde.neochat", 1, 0, "AccountListModel");
|
qmlRegisterType<AccountListModel>("org.kde.neochat", 1, 0, "AccountListModel");
|
||||||
qmlRegisterType<ActionsHandler>("org.kde.neochat", 1, 0, "ActionsHandler");
|
qmlRegisterType<ActionsHandler>("org.kde.neochat", 1, 0, "ActionsHandler");
|
||||||
qmlRegisterType<ChatDocumentHandler>("org.kde.neochat", 1, 0, "ChatDocumentHandler");
|
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<RoomListModel>("org.kde.neochat", 1, 0, "RoomListModel");
|
||||||
qmlRegisterType<KWebShortcutModel>("org.kde.neochat", 1, 0, "WebShortcutModel");
|
qmlRegisterType<KWebShortcutModel>("org.kde.neochat", 1, 0, "WebShortcutModel");
|
||||||
qmlRegisterType<UserListModel>("org.kde.neochat", 1, 0, "UserListModel");
|
qmlRegisterType<UserListModel>("org.kde.neochat", 1, 0, "UserListModel");
|
||||||
|
|||||||
@@ -1,11 +1,51 @@
|
|||||||
// Copyright (c) 2020 Christian Mollekopf <mollekopf@kolabsystems.com>
|
// SPDX-FileCopyrightText: 2013 Aurélien Gâteau <agateau@kde.org>
|
||||||
// SPDX-License-Identifier: LGPL-2.0-or-later
|
// 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 "spellcheckhighlighter.h"
|
||||||
|
|
||||||
#include <QDebug>
|
#include <QHash>
|
||||||
#include <QTextBoundaryFinder>
|
#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> split(QTextBoundaryFinder::BoundaryType boundary, const QString &text, int reasonMask = 0)
|
||||||
{
|
{
|
||||||
QVector<QStringRef> parts;
|
QVector<QStringRef> parts;
|
||||||
@@ -14,7 +54,7 @@ QVector<QStringRef> split(QTextBoundaryFinder::BoundaryType boundary, const QStr
|
|||||||
while (boundaryFinder.position() < text.length()) {
|
while (boundaryFinder.position() < text.length()) {
|
||||||
const int start = boundaryFinder.position();
|
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 (;;) {
|
for (;;) {
|
||||||
if (boundaryFinder.toNextBoundary() == -1) {
|
if (boundaryFinder.toNextBoundary() == -1) {
|
||||||
boundaryFinder.toEnd();
|
boundaryFinder.toEnd();
|
||||||
@@ -35,31 +75,36 @@ QVector<QStringRef> split(QTextBoundaryFinder::BoundaryType boundary, const QStr
|
|||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SpellcheckHighlighter::SpellcheckHighlighter(QObject *parent)
|
||||||
SpellcheckHighlighter::SpellcheckHighlighter(QTextDocument *parent)
|
: QSyntaxHighlighter(parent)
|
||||||
: QSyntaxHighlighter(parent),
|
#ifndef Q_OS_ANDROID
|
||||||
mSpellchecker{new Sonnet::Speller()},
|
, mSpellchecker{new Sonnet::Speller()}
|
||||||
mLanguageGuesser{new Sonnet::GuessLanguage()}
|
, 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.setUnderlineColor(QColor{"#ed1515"});
|
||||||
mErrorFormat.setUnderlineStyle(QTextCharFormat::SingleUnderline);
|
mErrorFormat.setUnderlineStyle(QTextCharFormat::SingleUnderline);
|
||||||
mQuoteFormat.setForeground(QColor{"#7f8c8d"});
|
mQuoteFormat.setForeground(QColor{"#7f8c8d"});
|
||||||
|
#ifndef Q_OS_ANDROID
|
||||||
|
|
||||||
if (!mSpellchecker->isValid()) {
|
if (!mSpellchecker->isValid()) {
|
||||||
qWarning() << "Spellchecker is invalid";
|
qWarning() << "Spellchecker is invalid";
|
||||||
}
|
}
|
||||||
qDebug() << "Available dictionaries: " << mSpellchecker->availableDictionaries();
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
void SpellcheckHighlighter::autodetectLanguage(const QString &sentence)
|
void SpellcheckHighlighter::autodetectLanguage(const QString &sentence)
|
||||||
{
|
{
|
||||||
|
#ifndef Q_OS_ANDROID
|
||||||
const auto lang = mLanguageGuesser->identify(sentence, mSpellchecker->availableLanguages());
|
const auto lang = mLanguageGuesser->identify(sentence, mSpellchecker->availableLanguages());
|
||||||
if (lang.isEmpty()) {
|
if (lang.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mSpellchecker->setLanguage(lang);
|
mSpellchecker->setLanguage(lang);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool isSpellcheckable(const QStringRef &token)
|
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"))) {
|
if (!token.at(0).isLetter() || token.at(0).isUpper() || token.startsWith(QStringLiteral("http"))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
//TODO ignore urls and uppercase?
|
// TODO ignore urls and uppercase?
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void SpellcheckHighlighter::highlightBlock(const QString &text)
|
void SpellcheckHighlighter::highlightBlock(const QString &text)
|
||||||
{
|
{
|
||||||
//Avoid spellchecking quotes
|
// Avoid spellchecking quotes
|
||||||
if (text.isEmpty() || text.at(0) == QChar{'>'}) {
|
if (text.isEmpty() || text.at(0) == QLatin1Char('>')) {
|
||||||
setFormat(0, text.length(), mQuoteFormat);
|
setFormat(0, text.length(), mQuoteFormat);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
#ifndef Q_OS_ANDROID
|
||||||
for (const auto &sentenceRef : split(QTextBoundaryFinder::Sentence, text)) {
|
for (const auto &sentenceRef : split(QTextBoundaryFinder::Sentence, text)) {
|
||||||
//Avoid spellchecking quotes
|
// Avoid spellchecking quotes
|
||||||
if (sentenceRef.isEmpty() || sentenceRef.at(0) == QChar{'>'}) {
|
if (sentenceRef.isEmpty() || sentenceRef.at(0) == QLatin1Char('>')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +139,8 @@ void SpellcheckHighlighter::highlightBlock(const QString &text)
|
|||||||
|
|
||||||
const int offset = sentenceRef.position();
|
const int offset = sentenceRef.position();
|
||||||
for (const auto &wordRef : split(QTextBoundaryFinder::Word, sentence)) {
|
for (const auto &wordRef : split(QTextBoundaryFinder::Word, sentence)) {
|
||||||
//Avoid spellchecking words in progress
|
// Avoid spellchecking words in progress
|
||||||
//FIXME this will also prevent spellchecking a single word on a line.
|
// FIXME this will also prevent spellchecking a single word on a line.
|
||||||
if (offset + wordRef.position() + wordRef.length() >= text.length()) {
|
if (offset + wordRef.position() + wordRef.length() >= text.length()) {
|
||||||
continue;
|
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-FileCopyrightText: 2013 Aurélien Gâteau <agateau@kde.org>
|
||||||
// SPDX-License-Identifier: LGPL-2.0-or-later
|
// 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
|
#pragma once
|
||||||
|
|
||||||
#include <QTextDocument>
|
#include <QQuickTextDocument>
|
||||||
#include <QSyntaxHighlighter>
|
#include <QSyntaxHighlighter>
|
||||||
#include <Sonnet/Speller>
|
#include <QTextDocument>
|
||||||
|
#ifndef Q_OS_ANDROID
|
||||||
#include <Sonnet/GuessLanguage>
|
#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:
|
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:
|
protected:
|
||||||
void highlightBlock(const QString &text) override;
|
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:
|
private:
|
||||||
|
[[nodiscard]] QTextCursor textCursor() const;
|
||||||
|
[[nodiscard]] QTextDocument *textDocument() const;
|
||||||
|
|
||||||
void autodetectLanguage(const QString &sentence);
|
void autodetectLanguage(const QString &sentence);
|
||||||
QTextCharFormat mErrorFormat;
|
QTextCharFormat mErrorFormat;
|
||||||
QTextCharFormat mQuoteFormat;
|
QTextCharFormat mQuoteFormat;
|
||||||
|
#ifndef Q_OS_ANDROID
|
||||||
QScopedPointer<Sonnet::Speller> mSpellchecker;
|
QScopedPointer<Sonnet::Speller> mSpellchecker;
|
||||||
QScopedPointer<Sonnet::GuessLanguage> mLanguageGuesser;
|
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