Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Spell Checking #428

Open
wants to merge 86 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 76 commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
74727aa
Added textChanges SharedFlow to follow text changes
Wavesonics Nov 17, 2024
2254b20
API
Wavesonics Nov 17, 2024
ad23f9d
Clean up
Wavesonics Nov 19, 2024
b47e25f
Handle link clicks in BasicRichTextEditor
Wavesonics Jan 28, 2024
3392198
Re move arg
Wavesonics Feb 1, 2024
e4fc462
Add callback for handling click on RichSpans
Wavesonics Feb 2, 2024
32a7430
Add some code for testing span clicks
Wavesonics Feb 2, 2024
b57f070
Spellcheck test
Wavesonics Feb 2, 2024
f6b926f
Make explicitly public
Wavesonics Nov 13, 2024
707110d
It compiles again
Wavesonics Nov 13, 2024
61a75f5
API
Wavesonics Nov 13, 2024
d04676f
Add click position to RichSpanClickListener
Wavesonics Nov 14, 2024
2bda581
API
Wavesonics Nov 14, 2024
a348e8b
Mocking up spellcheck
Wavesonics Nov 14, 2024
add5521
Fix null
Wavesonics Nov 14, 2024
9c3a529
Provide access to the rich spans
Wavesonics Nov 14, 2024
8c5ad1b
Added spellcheck screen
Wavesonics Nov 14, 2024
c57dd80
Added spellcheck
Wavesonics Nov 14, 2024
9dc71df
Don't need this
Wavesonics Nov 14, 2024
9ce658c
API
Wavesonics Nov 14, 2024
52e1b42
utils
Wavesonics Nov 15, 2024
2b03d3a
SymSpell version bump (contains other targets)
Wavesonics Nov 16, 2024
1e85ffc
Add getAllRichSpans()
Wavesonics Nov 17, 2024
fcab6f9
Added textChanges SharedFlow to follow text changes
Wavesonics Nov 17, 2024
8661764
More realistic Spell Checker
Wavesonics Nov 17, 2024
0cb0f14
API
Wavesonics Nov 17, 2024
0b010a1
dont convert to string every time
Wavesonics Nov 17, 2024
9ed977f
Solve the no-suggestions case
Wavesonics Nov 19, 2024
3ae282c
Spellcheck now appears to be bug free
Wavesonics Nov 22, 2024
5e6adf8
Refactored so we dont need to expose things
Wavesonics Nov 22, 2024
3efc2ce
Reduce public API surface
Wavesonics Nov 22, 2024
cbde9c5
Fix adding of original term
Wavesonics Nov 22, 2024
b0e8087
API Dump
Wavesonics Nov 22, 2024
363ef42
Fix multi-line spell check ranges
Wavesonics Nov 22, 2024
b357bfa
Better spellcheck options
Wavesonics Nov 22, 2024
56c7cf6
re-organized into a more production ready structure
Wavesonics Nov 23, 2024
661f4c4
API dump
Wavesonics Nov 23, 2024
e202ab2
Damn KorgeIO doesn't have all of the KMP targets
Wavesonics Nov 23, 2024
2ea9bfa
Revert "Damn KorgeIO doesn't have all of the KMP targets"
Wavesonics Nov 23, 2024
402b791
this version maybe?
Wavesonics Nov 23, 2024
08da237
Fix merge
Wavesonics Nov 25, 2024
781a381
Handle link clicks in BasicRichTextEditor
Wavesonics Jan 28, 2024
5025119
Re move arg
Wavesonics Feb 1, 2024
270609a
Add callback for handling click on RichSpans
Wavesonics Feb 2, 2024
6a86da6
Add some code for testing span clicks
Wavesonics Feb 2, 2024
65ffeec
Spellcheck test
Wavesonics Feb 2, 2024
1eadc30
Make explicitly public
Wavesonics Nov 13, 2024
dde84e5
It compiles again
Wavesonics Nov 13, 2024
b9f89ff
API
Wavesonics Nov 13, 2024
e12da22
Add click position to RichSpanClickListener
Wavesonics Nov 14, 2024
3a7a4f6
API
Wavesonics Nov 14, 2024
d63b539
Refactored so we dont need to expose things
Wavesonics Nov 22, 2024
0940b42
Add onRichSpanClick to Outline
Wavesonics Nov 25, 2024
e5d525d
Correct touch position for contentPadding
Wavesonics Nov 25, 2024
702948b
Will CI build this?
Wavesonics Nov 25, 2024
12a8d64
Ooohhh this fixes it
Wavesonics Nov 25, 2024
f786215
clean up
Wavesonics Nov 25, 2024
ab9bddf
more cleanup
Wavesonics Nov 26, 2024
bae05e8
Provide a more robust way of handling different kinds of interactions…
Wavesonics Nov 27, 2024
cf1edd6
API Dump
Wavesonics Nov 27, 2024
61f1247
Merge branch 'handle-link-click-in-editor' into spell-check-test
Wavesonics Nov 27, 2024
d471a4a
Simplified
Wavesonics Nov 27, 2024
1d6990a
Filter for click type
Wavesonics Nov 27, 2024
e9c0cdc
Right click on desktop, tap on mobile
Wavesonics Nov 28, 2024
3e26ba2
Fixed Touch VS Mouse input
Wavesonics Nov 28, 2024
65a76d4
Clear menu when not handled
Wavesonics Nov 29, 2024
9e4319f
Not needed
Wavesonics Nov 29, 2024
f7301f0
New min required for running on SDK 35 devices
Wavesonics Nov 29, 2024
ab11ee0
Not needed anymore
Wavesonics Nov 29, 2024
5505a9c
Not needed anymore
Wavesonics Nov 29, 2024
793b3d4
Split up the file
Wavesonics Nov 29, 2024
16966e7
trivial cleanup
Wavesonics Nov 29, 2024
29c5438
Re-organized into a spellcheck module
Wavesonics Nov 29, 2024
0891bf1
Change SpellCheck to draw the traditional red squiggle
Wavesonics Nov 29, 2024
47a9c60
Merge branch 'handle-link-click-in-editor' into spell-check-test
Wavesonics Nov 29, 2024
8bf8154
Merge branch 'textchangeflow' into spell-check-test
Wavesonics Nov 29, 2024
2287833
API Dump
Wavesonics Nov 29, 2024
e3cf052
Make SymSpell an API dependency
Wavesonics Nov 29, 2024
4b9bc2e
Sample now uses the binary .fdic format for it's sample dictionary
Wavesonics Dec 5, 2024
e568d7b
Removed Korge, no longer needed
Wavesonics Dec 5, 2024
9f6ab27
symspell version bump
Wavesonics Dec 5, 2024
e6328aa
Moved spell check utils to library
Wavesonics Dec 5, 2024
c057947
Merge branch 'main' into spell-check-test
Wavesonics Dec 5, 2024
9f018f1
Remove the textChanges Flow
Wavesonics Dec 9, 2024
87905ce
Moby Dick as spell check text
Wavesonics Dec 9, 2024
4776c1e
Merge branch 'main' into spell-check-test
Wavesonics Dec 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ nexus-publish = "2.0.0"
# For sample
androidx-appcompat = "1.7.0"
activity-compose = "1.9.3"
symspellkt = "2.1.1"
korge-io = "5.4.0"
voyager = "1.1.0-beta03"
richeditor = "1.0.0-rc10"
coroutines = "1.9.0"
ktor = "3.0.1"
android-minSdk = "21"
android-minSdk = "26"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't install APKs that have min SDK set below 26 on devices running SDK 35+

android-compileSdk = "34"

[libraries]
Expand All @@ -32,6 +34,8 @@ nexus-publish = { module = "io.github.gradle-nexus.publish-plugin:io.github.grad
# For sample
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" }
symspellkt = { module = "com.darkrockstudios:symspellkt", version.ref = "symspellkt" }
korge-io = { module = "com.soywiz.korge:korge-core", version.ref = "korge-io" }
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
richeditor-compose = { module = "com.mohamedrejeb.richeditor:richeditor-compose", version.ref = "richeditor" }

Expand Down
1 change: 1 addition & 0 deletions richeditor-compose-spellcheck/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
84 changes: 84 additions & 0 deletions richeditor-compose-spellcheck/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.bcv)
id("module.publication")
}

kotlin {
explicitApi()
applyDefaultHierarchyTemplate()

androidTarget {
publishLibraryVariants("release")
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_1_8)
}
}

jvm("desktop") {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}

js(IR) {
browser()
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser {
testTask {
enabled = false
}
}
}

iosX64()
iosArm64()
iosSimulatorArm64()

sourceSets.commonMain.dependencies {
implementation(projects.richeditorCompose)

implementation(compose.ui)
implementation(compose.foundation)
implementation(compose.material3)

// Spell Check
implementation(libs.symspellkt)
}

sourceSets.commonTest.dependencies {
implementation(kotlin("test"))
}
}

android {
namespace = "com.mohamedrejeb.richeditor.compose.spellcheck"
compileSdk = libs.versions.android.compileSdk.get().toInt()

defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
}

apiValidation {
@OptIn(kotlinx.validation.ExperimentalBCVApi::class)
klib {
enabled = true
}
}
21 changes: 21 additions & 0 deletions richeditor-compose-spellcheck/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html

# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}

# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable

# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.mohamedrejeb.richeditor.sample.common.richeditor
package com.mohamedrejeb.richeditor.compose.spellcheck

import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.SpanStyle
Expand All @@ -13,9 +15,15 @@ import com.mohamedrejeb.richeditor.annotation.ExperimentalRichTextApi
import com.mohamedrejeb.richeditor.model.RichSpanStyle
import com.mohamedrejeb.richeditor.model.RichTextConfig
import com.mohamedrejeb.richeditor.utils.getBoundingBoxes
import kotlin.math.PI
import kotlin.math.sin

/**
* RichSpanStyle that draws a Spell Check style red squiggle below the Spanned text.
*/
@OptIn(ExperimentalRichTextApi::class)
object SpellCheck: RichSpanStyle {
public object SpellCheck: RichSpanStyle {

override val spanStyle: (RichTextConfig) -> SpanStyle = {
SpanStyle()
}
Expand All @@ -35,19 +43,36 @@ object SpellCheck: RichSpanStyle {
flattenForFullParagraphs = true,
)

val amplitude = 1.5.dp.toPx() // Height of the wave
val frequency = 0.15f // Controls how many waves appear

boxes.fastForEach { box ->
path.moveTo(box.left + startPadding, box.bottom + topPadding)
path.lineTo(box.right + startPadding, box.bottom + topPadding)

// Create the sine wave path
for (x in 0..box.width.toInt()) {
val xPos = box.left + startPadding + x
val yPos = box.bottom + topPadding +
(amplitude * sin(x * frequency * 2 * PI)).toFloat()

if (x == 0) {
path.moveTo(xPos, yPos)
} else {
path.lineTo(xPos, yPos)
}
}

drawPath(
path = path,
color = strokeColor,
style = Stroke(
width = 2.dp.toPx(),
width = 1.dp.toPx(),
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)
}
}

override val acceptNewTextInTheEdges: Boolean = false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.mohamedrejeb.richeditor.compose.spellcheck

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.util.fastForEach
import com.darkrockstudios.symspellkt.api.SpellChecker
import com.darkrockstudios.symspellkt.common.SuggestionItem
import com.darkrockstudios.symspellkt.common.Verbosity
import com.mohamedrejeb.richeditor.compose.spellcheck.utils.applyCapitalizationStrategy
import com.mohamedrejeb.richeditor.compose.spellcheck.utils.isSpelledCorrectly
import com.mohamedrejeb.richeditor.compose.spellcheck.utils.spellingIsCorrect
import com.mohamedrejeb.richeditor.model.RichSpanStyle
import com.mohamedrejeb.richeditor.model.RichTextState
import com.mohamedrejeb.richeditor.utils.WordSegment

public class SpellCheckState(
public val richTextState: RichTextState,
public var spellChecker: SpellChecker?
) {
private var lastTextHash = -1
private val misspelledWords = mutableListOf<WordSegment>()

public fun handleSpanClick(span: RichSpanStyle, range: TextRange, click: Offset): WordSegment? {
return if (span is SpellCheck) {
findWordSegmentContainingRange(
misspelledWords,
range
)
} else {
null
}
}

public fun correctSpelling(segment: WordSegment, correction: String) {
val currentStyle = richTextState.getSpanStyle(segment.range)
richTextState.replaceTextRange(segment.range, correction)

val correctionRange =
TextRange(start = segment.range.start, end = segment.range.start + correction.length)
richTextState.addSpanStyle(currentStyle, correctionRange)
}

/**
* This is a very naive algorithm that just removes all spell check spans and
* reruns the entire spell check again.
*/
public fun runSpellCheck() {
val sp = spellChecker ?: return

richTextState.apply {
// Remove all existing spell checks
getAllRichSpans()
.filter { it.first is SpellCheck }
.forEach { span ->
removeRichSpan(SpellCheck, span.second)
}

misspelledWords.clear()
getWords().mapNotNullTo(misspelledWords) { segment ->
val suggestions = sp.lookup(segment.text)
if (suggestions.spellingIsCorrect(segment.text)) {
null
} else {
segment
}
}

misspelledWords.fastForEach { wordSegment ->
addRichSpan(SpellCheck, wordSegment.range)
}
}
}

public fun onTextChange(richTextState: RichTextState) {
val newTextHash = richTextState.annotatedString.hashCode()
if (lastTextHash != newTextHash) {
runSpellCheck()
lastTextHash = newTextHash
}
}

private fun findWordSegmentContainingRange(
segments: List<WordSegment>,
range: TextRange
): WordSegment? {
return segments.find { wordSegment ->
val segmentRange = wordSegment.range
range.start >= segmentRange.start && range.end <= segmentRange.end
}
}

public fun getSuggestions(word: String): List<SuggestionItem> {
val sp = spellChecker ?: return emptyList()

val suggestions = sp.lookup(word, verbosity = Verbosity.All)
val proposedSuggestions = if (word.isSpelledCorrectly(suggestions).not()) {
// If things are misspelled, see if it just needs to be broken up
val composition = sp.wordBreakSegmentation(word)
val segmentedWord = composition.segmentedString
if (segmentedWord != null
&& segmentedWord.equals(word, ignoreCase = true).not()
&& suggestions.find { it.term.equals(segmentedWord, ignoreCase = true) } == null
) {
// Add the segmented suggest as first item if it didn't already exist
listOf(SuggestionItem(segmentedWord, 1.0, 0.1)) + suggestions
} else {
suggestions
}
} else {
emptyList()
}

return proposedSuggestions.map { suggestionItem ->
suggestionItem.copy(
term = applyCapitalizationStrategy(
source = word,
target = suggestionItem.term
)
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.mohamedrejeb.richeditor.compose.spellcheck

import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import com.mohamedrejeb.richeditor.utils.WordSegment

@Composable
public expect fun SpellCheckTextContextMenuProvider(
modifier: Modifier,
spellCheckMenuState: SpellCheckMenuState,
content: @Composable () -> Unit
)

public data class SpellCheckMenuState(
val spellCheckState: SpellCheckState,
) {
val missSpelling: MutableState<MissSpelling?> = mutableStateOf(null)

public fun clearSpellCheck() {
missSpelling.value = null
}

public fun performCorrection(toReplace: WordSegment, correction: String) {
spellCheckState.correctSpelling(toReplace, correction)
clearSpellCheck()
}

public data class MissSpelling(val wordSegment: WordSegment, val menuPosition: Offset)
}
Loading
Loading