├── sample
├── .gitignore
├── src
│ └── main
│ │ ├── res
│ │ ├── values
│ │ │ ├── strings.xml
│ │ │ └── themes.xml
│ │ ├── mipmap-hdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-mdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxxhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-anydpi-v26
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ ├── drawable-v24
│ │ │ └── ic_launcher_foreground.xml
│ │ └── drawable
│ │ │ └── ic_launcher_background.xml
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ └── com
│ │ └── github
│ │ └── badoualy
│ │ └── storyeditor
│ │ └── sample
│ │ └── MainActivity.kt
├── proguard-rules.pro
└── build.gradle
├── story-editor
├── .gitignore
├── consumer-rules.pro
├── src
│ └── main
│ │ ├── AndroidManifest.xml
│ │ ├── java
│ │ └── com
│ │ │ └── github
│ │ │ └── badoualy
│ │ │ └── storyeditor
│ │ │ ├── util
│ │ │ ├── Size.kt
│ │ │ ├── Matrix.kt
│ │ │ ├── TextLayoutResult.kt
│ │ │ ├── PaddingValues.kt
│ │ │ ├── Offset.kt
│ │ │ ├── Rect.kt
│ │ │ └── FocusManager.kt
│ │ │ ├── element
│ │ │ └── text
│ │ │ │ ├── AutoResize.kt
│ │ │ │ ├── TextElementEditorBar.kt
│ │ │ │ └── TextElement.kt
│ │ │ ├── StoryElement.kt
│ │ │ ├── component
│ │ │ ├── ColorSchemeTypeToggleRow.kt
│ │ │ └── EditorDeleteButton.kt
│ │ │ ├── Screenshot.kt
│ │ │ ├── StoryEditorState.kt
│ │ │ ├── StoryElementTransformation.kt
│ │ │ └── StoryEditor.kt
│ │ └── res
│ │ └── drawable
│ │ ├── ic_baseline_delete_24.xml
│ │ ├── ic_baseline_close_24.xml
│ │ ├── ic_outline_format_align_center_24.xml
│ │ ├── ic_outline_font_download_24.xml
│ │ ├── ic_round_font_download_24.xml
│ │ ├── ic_round_format_align_center_24.xml
│ │ ├── ic_round_format_align_left_24.xml
│ │ └── ic_round_format_align_right_24.xml
├── proguard-rules.pro
└── build.gradle
├── jitpack.yml
├── ART
└── preview.gif
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── settings.gradle
├── gradle.properties
├── .gitignore
├── gradlew.bat
├── README.md
├── gradlew
└── LICENSE
/sample/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/story-editor/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/story-editor/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/jitpack.yml:
--------------------------------------------------------------------------------
1 | jdk:
2 | - openjdk17
3 |
--------------------------------------------------------------------------------
/ART/preview.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/badoualy/story-editor/HEAD/ART/preview.gif
--------------------------------------------------------------------------------
/story-editor/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/badoualy/story-editor/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/sample/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Story Editor
3 |
--------------------------------------------------------------------------------
/sample/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/badoualy/story-editor/HEAD/sample/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/sample/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/badoualy/story-editor/HEAD/sample/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/sample/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/badoualy/story-editor/HEAD/sample/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/badoualy/story-editor/HEAD/sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/badoualy/story-editor/HEAD/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/badoualy/story-editor/HEAD/sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/badoualy/story-editor/HEAD/sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/badoualy/story-editor/HEAD/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/badoualy/story-editor/HEAD/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/badoualy/story-editor/HEAD/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/sample/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Thu Jul 03 09:05:43 CEST 2025
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/story-editor/src/main/java/com/github/badoualy/storyeditor/util/Size.kt:
--------------------------------------------------------------------------------
1 | package com.github.badoualy.storyeditor.util
2 |
3 | import androidx.compose.ui.geometry.Size
4 |
5 | internal operator fun Size.plus(size: Size): Size {
6 | return Size(
7 | width = width + size.width,
8 | height = height + size.height
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/story-editor/src/main/java/com/github/badoualy/storyeditor/util/Matrix.kt:
--------------------------------------------------------------------------------
1 | package com.github.badoualy.storyeditor.util
2 |
3 | import androidx.compose.ui.geometry.Offset
4 | import androidx.compose.ui.graphics.Matrix
5 |
6 | internal fun Matrix.rotateZ(degrees: Float, center: Offset) {
7 | translate(x = center.x, y = center.y)
8 | rotateZ(degrees)
9 | translate(x = -center.x, y = -center.y)
10 | }
11 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | gradlePluginPortal()
4 | google()
5 | mavenCentral()
6 | }
7 | }
8 | dependencyResolutionManagement {
9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10 | repositories {
11 | google()
12 | mavenCentral()
13 | }
14 | }
15 | rootProject.name = "Story Editor"
16 | include ':sample'
17 | include ':story-editor'
18 |
--------------------------------------------------------------------------------
/story-editor/src/main/res/drawable/ic_baseline_delete_24.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/story-editor/src/main/res/drawable/ic_baseline_close_24.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/story-editor/src/main/res/drawable/ic_outline_format_align_center_24.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/story-editor/src/main/res/drawable/ic_outline_font_download_24.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/story-editor/src/main/java/com/github/badoualy/storyeditor/util/TextLayoutResult.kt:
--------------------------------------------------------------------------------
1 | package com.github.badoualy.storyeditor.util
2 |
3 | import androidx.compose.ui.text.TextLayoutResult
4 |
5 | internal fun TextLayoutResult.getLines(): String {
6 | val input = layoutInput.text.toString()
7 | if (input.isEmpty()) return ""
8 |
9 | return (0 until lineCount).joinToString("\n") { line ->
10 | val lineContent = input.substring(
11 | getLineStart(line),
12 | getLineEnd(line, visibleEnd = true)
13 | )
14 | lineContent
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/story-editor/src/main/res/drawable/ic_round_font_download_24.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/story-editor/src/main/res/drawable/ic_round_format_align_center_24.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/story-editor/src/main/java/com/github/badoualy/storyeditor/util/PaddingValues.kt:
--------------------------------------------------------------------------------
1 | package com.github.badoualy.storyeditor.util
2 |
3 | import androidx.compose.foundation.layout.PaddingValues
4 | import androidx.compose.ui.unit.Dp
5 | import androidx.compose.ui.unit.DpSize
6 | import androidx.compose.ui.unit.LayoutDirection
7 |
8 | internal fun PaddingValues.verticalPadding(): Dp {
9 | return calculateTopPadding() + calculateBottomPadding()
10 | }
11 |
12 | internal fun PaddingValues.horizontalPadding(): Dp {
13 | return calculateLeftPadding(LayoutDirection.Ltr) + calculateRightPadding(LayoutDirection.Ltr)
14 | }
15 |
16 | internal fun PaddingValues.toDpSize(): DpSize {
17 | return DpSize(
18 | width = horizontalPadding(),
19 | height = verticalPadding()
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/story-editor/src/main/res/drawable/ic_round_format_align_left_24.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/story-editor/src/main/res/drawable/ic_round_format_align_right_24.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/sample/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/story-editor/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/story-editor/src/main/java/com/github/badoualy/storyeditor/util/Offset.kt:
--------------------------------------------------------------------------------
1 | package com.github.badoualy.storyeditor.util
2 |
3 | import androidx.compose.ui.geometry.Offset
4 | import kotlin.math.cos
5 | import kotlin.math.sin
6 |
7 | private const val ROTATION_CONST = (Math.PI / 180f).toFloat()
8 |
9 | /**
10 | * Rotates the given offset around the origin by the given angle in degrees.
11 | *
12 | * A positive angle indicates a counterclockwise rotation around the right-handed 2D Cartesian
13 | * coordinate system.
14 | *
15 | * See: [Rotation matrix](https://en.wikipedia.org/wiki/Rotation_matrix)
16 | */
17 | internal fun Offset.rotateBy(
18 | angle: Float
19 | ): Offset {
20 | val angleInRadians = ROTATION_CONST * angle
21 | val newX = x * cos(angleInRadians) - y * sin(angleInRadians)
22 | val newY = x * sin(angleInRadians) + y * cos(angleInRadians)
23 | return Offset(newX, newY)
24 | }
25 |
--------------------------------------------------------------------------------
/story-editor/src/main/java/com/github/badoualy/storyeditor/util/Rect.kt:
--------------------------------------------------------------------------------
1 | package com.github.badoualy.storyeditor.util
2 |
3 | import androidx.compose.ui.geometry.Offset
4 | import androidx.compose.ui.geometry.Rect
5 |
6 | /**
7 | * Coerce the rect into the given bounds.
8 | * If the rect doesn't fit the bounds on one axis, it'll be centered instead.
9 | */
10 | internal fun Rect.coerceInOrCenter(bounds: Rect): Rect {
11 | val x = try {
12 | left.coerceIn(
13 | bounds.left,
14 | bounds.right - width
15 | )
16 | } catch (e: IllegalArgumentException) {
17 | bounds.center.x - width / 2f
18 | }
19 |
20 | val y = try {
21 | top.coerceIn(
22 | bounds.top,
23 | bounds.bottom - height
24 | )
25 | } catch (e: IllegalArgumentException) {
26 | bounds.center.y - height / 2f
27 | }
28 |
29 | return Rect(Offset(x, y), size)
30 | }
31 |
--------------------------------------------------------------------------------
/story-editor/src/main/java/com/github/badoualy/storyeditor/util/FocusManager.kt:
--------------------------------------------------------------------------------
1 | package com.github.badoualy.storyeditor.util
2 |
3 | import androidx.compose.foundation.layout.ExperimentalLayoutApi
4 | import androidx.compose.foundation.layout.WindowInsets
5 | import androidx.compose.foundation.layout.isImeVisible
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.LaunchedEffect
8 | import androidx.compose.runtime.getValue
9 | import androidx.compose.runtime.rememberUpdatedState
10 | import androidx.compose.runtime.snapshotFlow
11 | import androidx.compose.ui.focus.FocusManager
12 | import kotlinx.coroutines.delay
13 | import kotlinx.coroutines.flow.dropWhile
14 | import kotlinx.coroutines.flow.filter
15 |
16 | @OptIn(ExperimentalLayoutApi::class)
17 | @Composable
18 | fun FocusManager.ClearFocusOnKeyboardCloseEffect() {
19 | val isImeVisible by rememberUpdatedState(WindowInsets.isImeVisible)
20 | LaunchedEffect(Unit) {
21 | // Weird bug where keyboard is closing when we focus an element rapidly after unselecting it
22 | // It might be because of re-composition
23 | // To make sure we don't un-focus the element because of this bug, add a slight delay...
24 | delay(300)
25 | snapshotFlow { isImeVisible }
26 | .dropWhile { !it } // Wait for a first keyboard open event
27 | .filter { !it }
28 | .collect { clearFocus() }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app"s APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
24 |
--------------------------------------------------------------------------------
/sample/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
15 |
16 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Built application files
2 | *.apk
3 | *.aar
4 | *.ap_
5 | *.aab
6 |
7 | # Files for the ART/Dalvik VM
8 | *.dex
9 |
10 | # Java class files
11 | *.class
12 |
13 | # Generated files
14 | bin/
15 | gen/
16 | out/
17 | # Uncomment the following line in case you need and you don't have the release build type files in your app
18 | # release/
19 |
20 | # Gradle files
21 | .gradle/
22 | build/
23 |
24 | # Local configuration file (sdk path, etc)
25 | local.properties
26 |
27 | # Proguard folder generated by Eclipse
28 | proguard/
29 |
30 | # Log Files
31 | *.log
32 |
33 | # Android Studio Navigation editor temp files
34 | .navigation/
35 |
36 | # Android Studio captures folder
37 | captures/
38 |
39 | # IntelliJ
40 | *.iml
41 | .idea/workspace.xml
42 | .idea/tasks.xml
43 | .idea/gradle.xml
44 | .idea/assetWizardSettings.xml
45 | .idea/dictionaries
46 | .idea/libraries
47 | # Android Studio 3 in .gitignore file.
48 | .idea/caches
49 | .idea/modules.xml
50 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you
51 | .idea/navEditor.xml
52 | .idea/
53 |
54 | # Keystore files
55 | # Uncomment the following lines if you do not want to check your keystore files in.
56 | #*.jks
57 | #*.keystore
58 |
59 | # External native build folder generated in Android Studio 2.2 and later
60 | .externalNativeBuild
61 | .cxx/
62 |
63 | # Google Services (e.g. APIs or Firebase)
64 | # google-services.json
65 |
66 | # Freeline
67 | freeline.py
68 | freeline/
69 | freeline_project_description.json
70 |
71 | # fastlane
72 | fastlane/report.xml
73 | fastlane/Preview.html
74 | fastlane/screenshots
75 | fastlane/test_output
76 | fastlane/readme.md
77 |
78 | # Version control
79 | vcs.xml
80 |
81 | # lint
82 | lint/intermediates/
83 | lint/generated/
84 | lint/outputs/
85 | lint/tmp/
86 | # lint/reports/
87 |
88 | # Android Profiling
89 | *.hprof
--------------------------------------------------------------------------------
/sample/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.application'
3 | id 'kotlin-android'
4 |
5 | id 'org.jetbrains.kotlin.plugin.compose'
6 | }
7 |
8 | android {
9 | namespace "com.github.badoualy.storyeditor.sample"
10 | compileSdk 35
11 |
12 | defaultConfig {
13 | applicationId "com.github.badoualy.storyeditor.sample"
14 | minSdkVersion 23
15 | targetSdkVersion 35
16 | versionCode 1
17 | versionName "1.0"
18 |
19 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
20 | }
21 |
22 | buildTypes {
23 | release {
24 | minifyEnabled false
25 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
26 | }
27 | }
28 |
29 | buildFeatures {
30 | compose true
31 | }
32 | compileOptions {
33 | sourceCompatibility JavaVersion.VERSION_1_8
34 | targetCompatibility JavaVersion.VERSION_1_8
35 | }
36 | kotlinOptions {
37 | jvmTarget = '1.8'
38 | }
39 | }
40 |
41 | dependencies {
42 | implementation "org.jetbrains.kotlin:kotlin-stdlib:2.1.20"
43 | implementation "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0"
44 | implementation 'androidx.core:core-ktx:1.16.0'
45 | implementation 'androidx.appcompat:appcompat:1.7.1'
46 | implementation "androidx.compose.material3:material3:1.3.2"
47 |
48 | implementation "androidx.compose.ui:ui:1.8.3"
49 | implementation "androidx.compose.material:material:1.8.3"
50 | implementation "androidx.compose.ui:ui-tooling:1.8.3"
51 | implementation "androidx.activity:activity-compose:1.10.1"
52 |
53 | implementation "io.coil-kt.coil3:coil-compose:3.2.0"
54 | implementation "io.coil-kt.coil3:coil-network-okhttp:3.2.0"
55 |
56 | implementation project(':story-editor')
57 | }
58 |
--------------------------------------------------------------------------------
/story-editor/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.library'
3 | id 'kotlin-android'
4 | id 'maven-publish'
5 |
6 | id 'org.jetbrains.kotlin.plugin.compose'
7 | }
8 |
9 | android {
10 | namespace "com.github.badoualy.storyeditor"
11 | compileSdk 34
12 |
13 | defaultConfig {
14 | minSdkVersion 21
15 | targetSdkVersion 34
16 | versionCode 1
17 | versionName "1.0"
18 |
19 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
20 | consumerProguardFiles "consumer-rules.pro"
21 | }
22 |
23 | buildTypes {
24 | release {
25 | minifyEnabled false
26 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
27 | }
28 | }
29 |
30 | buildFeatures {
31 | compose = true
32 | }
33 | compileOptions {
34 | sourceCompatibility JavaVersion.VERSION_1_8
35 | targetCompatibility JavaVersion.VERSION_1_8
36 | }
37 | kotlinOptions {
38 | jvmTarget = '1.8'
39 | }
40 | }
41 |
42 | afterEvaluate {
43 | publishing {
44 | publications {
45 | release(MavenPublication) {
46 | from components.release
47 |
48 | groupId = 'com.github.badoualy'
49 | artifactId = 'story-editor'
50 | version = '1.6.0'
51 | }
52 | }
53 | }
54 | }
55 |
56 | dependencies {
57 | implementation "org.jetbrains.kotlin:kotlin-stdlib:2.1.20"
58 | implementation "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0"
59 |
60 | implementation "androidx.compose.ui:ui:1.8.3"
61 | implementation "androidx.compose.animation:animation:1.8.3"
62 | implementation "androidx.compose.ui:ui-util:1.8.3"
63 |
64 | implementation "androidx.compose.material3:material3:1.3.2"
65 | }
66 |
--------------------------------------------------------------------------------
/story-editor/src/main/java/com/github/badoualy/storyeditor/element/text/AutoResize.kt:
--------------------------------------------------------------------------------
1 | package com.github.badoualy.storyeditor.element.text
2 |
3 | import android.content.Context
4 | import androidx.compose.ui.text.ParagraphIntrinsics
5 | import androidx.compose.ui.text.TextStyle
6 | import androidx.compose.ui.text.font.createFontFamilyResolver
7 | import androidx.compose.ui.unit.Density
8 | import androidx.compose.ui.unit.TextUnit
9 | import androidx.compose.ui.unit.sp
10 |
11 | fun resolveAutoResizeTextSize(
12 | text: String,
13 | textStyle: TextStyle,
14 | maxWidth: Float,
15 | density: Density,
16 | context: Context,
17 | interval: Float = 1f
18 | ): TextUnit {
19 | require(maxWidth > 0) { "maxWidth should be > 0" }
20 | val originalTextSize = textStyle.fontSize
21 | // Only SP unit is supported for AutoResize
22 | require(originalTextSize.isSp) { "Only SP unit is supported for AutoResize" }
23 |
24 | val fontFamilyResolver = createFontFamilyResolver(context)
25 | fun measure(text: String, size: TextUnit): ParagraphIntrinsics {
26 | return ParagraphIntrinsics(
27 | text = text,
28 | style = textStyle.copy(fontSize = size),
29 | density = density,
30 | fontFamilyResolver = fontFamilyResolver
31 | )
32 | }
33 |
34 | // We need to first measure each line, and use it for auto resize operations
35 | val largestLineInfo = text.lines()
36 | .map { it to measure(it, originalTextSize).maxIntrinsicWidth }
37 | .maxBy { it.second }
38 | val largestLine = largestLineInfo.first
39 | var largestLineWidth = largestLineInfo.second
40 |
41 | // Loop until the line fits
42 | var currentTextSizePx = originalTextSize.value
43 | while (largestLineWidth > maxWidth && currentTextSizePx > 0) {
44 | // TODO: instead of using a fixed interval, we might be able to use the textSize and compare with (largestLineWidth - maxWidth)
45 | currentTextSizePx -= interval
46 | largestLineWidth = measure(largestLine, currentTextSizePx.sp).maxIntrinsicWidth
47 | }
48 | return currentTextSizePx.sp
49 | }
50 |
--------------------------------------------------------------------------------
/story-editor/src/main/java/com/github/badoualy/storyeditor/StoryElement.kt:
--------------------------------------------------------------------------------
1 | package com.github.badoualy.storyeditor
2 |
3 | import androidx.compose.runtime.Stable
4 | import androidx.compose.ui.geometry.Rect
5 | import androidx.compose.ui.graphics.Color
6 | import androidx.compose.ui.unit.IntSize
7 | import kotlinx.collections.immutable.toImmutableList
8 |
9 | /**
10 | * Base element that can be added to a [StoryEditor]
11 | */
12 | @Stable
13 | interface StoryElement {
14 |
15 | /**
16 | * Called when the element gains focus and should update its state to an edit mode.
17 | * By default, calling startEdit will disable scale/rotation transformations.
18 | */
19 | suspend fun startEdit(editorSize: IntSize, bounds: Rect)
20 |
21 | /** @return true if the element should be kept, false otherwise */
22 | suspend fun stopEdit(editorSize: IntSize, bounds: Rect): Boolean
23 |
24 | data class ColorScheme(val primary: Color, val secondary: Color) {
25 |
26 | companion object {
27 |
28 | val White = ColorScheme(primary = Color.White, secondary = Color.Black)
29 | val Black = ColorScheme(primary = Color.Black, secondary = Color.White)
30 | val Magenta = ColorScheme(primary = Color.Magenta, secondary = Color.White)
31 | val Cyan = ColorScheme(primary = Color.Cyan, secondary = Color.White)
32 | val Blue = ColorScheme(primary = Color.Blue, secondary = Color.White)
33 | val Green = ColorScheme(primary = Color.Green, secondary = Color.White)
34 | val Yellow = ColorScheme(primary = Color.Yellow, secondary = Color.Black)
35 | val Red = ColorScheme(primary = Color.Red, secondary = Color.White)
36 |
37 | val DefaultList = listOf(
38 | White,
39 | Black,
40 | Magenta,
41 | Cyan,
42 | Blue,
43 | Green,
44 | Yellow,
45 | Red
46 | ).toImmutableList()
47 | }
48 | }
49 | }
50 |
51 | /**
52 | * A [StoryElement] that hols a [StoryElementTransformation].
53 | * Each element that can be dragged/scaled/rotated must implement this interface to be able
54 | * to apply predefined modifiers.
55 | */
56 | @Stable
57 | interface TransformableStoryElement : StoryElement {
58 |
59 | val transformation: StoryElementTransformation
60 | }
61 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/story-editor/src/main/java/com/github/badoualy/storyeditor/component/ColorSchemeTypeToggleRow.kt:
--------------------------------------------------------------------------------
1 | package com.github.badoualy.storyeditor.component
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.border
5 | import androidx.compose.foundation.clickable
6 | import androidx.compose.foundation.horizontalScroll
7 | import androidx.compose.foundation.layout.Arrangement
8 | import androidx.compose.foundation.layout.Box
9 | import androidx.compose.foundation.layout.Row
10 | import androidx.compose.foundation.layout.fillMaxWidth
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.foundation.layout.size
13 | import androidx.compose.foundation.rememberScrollState
14 | import androidx.compose.foundation.shape.CircleShape
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.runtime.key
17 | import androidx.compose.ui.Alignment
18 | import androidx.compose.ui.Modifier
19 | import androidx.compose.ui.draw.clip
20 | import androidx.compose.ui.graphics.Color
21 | import androidx.compose.ui.unit.dp
22 | import com.github.badoualy.storyeditor.StoryElement
23 | import kotlinx.collections.immutable.ImmutableList
24 |
25 | /**
26 | * A scrollable row to select one color scheme in a given list.
27 | * Each color scheme is represented by a filled circle.
28 | */
29 | @Composable
30 | fun ColorSchemeTypeToggleRow(
31 | colorSchemes: ImmutableList,
32 | currentColorScheme: () -> StoryElement.ColorScheme,
33 | onColorSchemeClick: (StoryElement.ColorScheme) -> Unit,
34 | modifier: Modifier = Modifier
35 | ) {
36 | Row(
37 | modifier = modifier
38 | .fillMaxWidth()
39 | .horizontalScroll(rememberScrollState())
40 | .padding(horizontal = 16.dp),
41 | verticalAlignment = Alignment.CenterVertically,
42 | horizontalArrangement = Arrangement.spacedBy(16.dp)
43 | ) {
44 | colorSchemes.forEach { colorScheme ->
45 | key(colorScheme) {
46 | ColorSchemeButton(
47 | colorScheme = colorScheme,
48 | isSelected = currentColorScheme() == colorScheme,
49 | onClick = { onColorSchemeClick(colorScheme) }
50 | )
51 | }
52 | }
53 | }
54 | }
55 |
56 | @Composable
57 | private fun ColorSchemeButton(
58 | colorScheme: StoryElement.ColorScheme,
59 | isSelected: Boolean,
60 | onClick: () -> Unit,
61 | modifier: Modifier = Modifier
62 | ) {
63 | Box(
64 | modifier = modifier
65 | .size(32.dp)
66 | .then(
67 | if (isSelected) {
68 | Modifier
69 | .border(2.dp, Color.White, CircleShape)
70 | } else {
71 | Modifier
72 | .padding(1.dp)
73 | .border(1.dp, Color.White, CircleShape)
74 | }
75 | )
76 | .clip(CircleShape)
77 | .background(colorScheme.primary)
78 | .clickable(onClick = onClick)
79 | )
80 | }
81 |
--------------------------------------------------------------------------------
/story-editor/src/main/java/com/github/badoualy/storyeditor/Screenshot.kt:
--------------------------------------------------------------------------------
1 | package com.github.badoualy.storyeditor
2 |
3 | import android.graphics.Bitmap
4 | import android.graphics.Canvas
5 | import android.os.Build
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.LaunchedEffect
8 | import androidx.compose.runtime.Stable
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.composed
11 | import androidx.compose.ui.draw.drawWithContent
12 | import androidx.compose.ui.graphics.asAndroidBitmap
13 | import androidx.compose.ui.graphics.layer.drawLayer
14 | import androidx.compose.ui.graphics.rememberGraphicsLayer
15 | import kotlinx.coroutines.flow.filter
16 |
17 | internal enum class ScreenshotLayer {
18 | EDITOR,
19 | BACKGROUND,
20 | ELEMENTS
21 | }
22 |
23 | @Stable
24 | internal data class ScreenshotRequest(
25 | val layer: ScreenshotLayer,
26 | val destination: Bitmap,
27 | val onSuccess: () -> Unit,
28 | val onError: (Throwable) -> Unit
29 | ) {
30 |
31 | val destinationCanvas = Canvas(destination)
32 | }
33 |
34 | @Composable
35 | internal fun Modifier.screenshotLayer(
36 | editorState: StoryEditorState,
37 | layer: ScreenshotLayer,
38 | ): Modifier = composed(
39 | "com.github.badoualy.storyeditor.screenshotLayer",
40 | editorState.screenshotMode,
41 | layer
42 | ) {
43 | if (layer in editorState.screenshotMode.layers) {
44 | val graphicsLayer = rememberGraphicsLayer()
45 |
46 | // Listen to request
47 | LaunchedEffect(editorState) {
48 | editorState.screenshotRequest
49 | .filter { it.layer == layer }
50 | .collect { request ->
51 | try {
52 | val bitmap = graphicsLayer.toImageBitmap().asAndroidBitmap()
53 | val bitmapToDraw = if (bitmap.isHardware) {
54 | bitmap.copy(Bitmap.Config.ARGB_8888, false)
55 | } else {
56 | bitmap
57 | }
58 | request.destinationCanvas.drawBitmap(
59 | bitmapToDraw,
60 | 0f,
61 | 0f,
62 | null
63 | )
64 | bitmap.recycle()
65 | if (bitmapToDraw !== bitmap) {
66 | bitmapToDraw.recycle()
67 | }
68 | request.onSuccess()
69 | } catch (e: Exception) {
70 | request.onError(e)
71 | }
72 | }
73 | }
74 |
75 | this
76 | .drawWithContent {
77 | // call record to capture the content in the graphics layer
78 | graphicsLayer.record {
79 | // draw the contents of the composable into the graphics layer
80 | this@drawWithContent.drawContent()
81 | }
82 | // draw the graphics layer on the visible canvas
83 | drawLayer(graphicsLayer)
84 | }
85 | } else {
86 | this
87 | }
88 | }
89 |
90 | private val Bitmap.isHardware
91 | get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && config == Bitmap.Config.HARDWARE
92 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://jitpack.io/#badoualy/story-editor)
2 |
3 | # Story Editor
4 |
5 | Instagram-like story editor to add content to your pictures.
6 |
7 | Note: this is still a WIP
8 |
9 |
10 |
11 | Setup
12 | ----------------
13 |
14 | First, add jitpack in your build.gradle at the end of repositories:
15 |
16 | ```gradle
17 | repositories {
18 | // ...
19 | maven { url "https://jitpack.io" }
20 | }
21 | ```
22 |
23 | Then, add the library dependency:
24 |
25 | ```gradle
26 | implementation 'com.github.badoualy:story-editor:0.1.0'
27 | ```
28 |
29 | Usage
30 | ----------------
31 |
32 | (See MainActivity sample)
33 |
34 | In your manifest:
35 |
36 | ```
37 | android:windowSoftInputMode="adjustResize"
38 | ```
39 |
40 | In your activity (important to handle keyboard correctly):
41 |
42 | ```kotlin
43 | WindowCompat.setDecorFitsSystemWindows(window, false)
44 | ```
45 |
46 | Then:
47 |
48 | ```kotlin
49 | val elements = remember { mutableStateListOf() }
50 | StoryEditor(
51 | state = rememberStoryEditorState(),
52 | modifier = modifier.fillMaxSize(),
53 | onClick = {
54 | val element = StoryTextElement()
55 | editorState.focusedElement = element
56 | elements.add(element)
57 | },
58 | onDeleteElement = {
59 | elements.remove(it)
60 | },
61 | background = {
62 | AsyncImage(
63 | "https://i.ytimg.com/vi/h78qlOYCXJQ/maxresdefault.jpg",
64 | contentDescription = null,
65 | contentScale = ContentScale.Crop,
66 | modifier = Modifier.aspectRatio(9f / 16f)
67 | )
68 | }
69 | ) {
70 | elements.forEach { element ->
71 | Element(element = element, modifier = Modifier) {
72 | TextElement(
73 | element = element,
74 | )
75 | }
76 | }
77 | }
78 |
79 | @Composable
80 | private fun rememberStoryEditorState(): StoryEditorState {
81 | return remember {
82 | StoryEditorState(
83 | elementsBoundsFraction = Rect(0.01f, 0.1f, 0.99f, 0.99f),
84 | editMode = true,
85 | debug = true, // draws hitbox red box
86 | screenshotMode = StoryEditorState.ScreenshotMode.FULL
87 | )
88 | }
89 | }
90 |
91 | ```
92 |
93 | Screenshot
94 | ----------------
95 |
96 | You can take a screenshot of the editor's content via `editorState.takeScreenshot()`.
97 |
98 | * Specify a screenshot mode when creating your `StoryEditorState`.
99 |
100 | Current restrictions:
101 |
102 | * Make sure you background doesn't have any hardware bitmap, or you'll get the exception:
103 | `Software rendering doesn't support hardware bitmaps`. If you're using Coil/Glide/... you can
104 | disable hardware bitmap when creating the request.
105 | * If your background is clipped to a given shape, the bitmap will also be clipped.
106 |
107 | Screenshot mode:
108 |
109 | * `DISABLED`: Screenshot support is disabled
110 | * `FULL`: Screenshot support is enabled, and the screenshot will contain background + content
111 | * `FULL_NOT_CLIPPED`: Same as `FULL`, but the screenshot won't be clipped to the `StoryEditor`'s
112 | shape. This is useful when you specify a shape for the background, and you don't want the
113 | screenshot to be clipped.
114 | * `CONTENT`: Screenshot support is enabled, and the screenshot will contain only the content without
115 | the background
116 |
117 | Elements
118 | ----------------
119 |
120 | By default, only a `TextElement` is provided, but you can easily add your own components to the
121 | editor. Check TextElement implementation to do so.
122 |
--------------------------------------------------------------------------------
/story-editor/src/main/java/com/github/badoualy/storyeditor/component/EditorDeleteButton.kt:
--------------------------------------------------------------------------------
1 | package com.github.badoualy.storyeditor.component
2 |
3 | import androidx.compose.animation.AnimatedVisibility
4 | import androidx.compose.animation.fadeIn
5 | import androidx.compose.animation.fadeOut
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.foundation.shape.CircleShape
8 | import androidx.compose.material.icons.Icons
9 | import androidx.compose.material.icons.filled.Delete
10 | import androidx.compose.material3.Icon
11 | import androidx.compose.material3.Surface
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.runtime.LaunchedEffect
14 | import androidx.compose.runtime.derivedStateOf
15 | import androidx.compose.runtime.getValue
16 | import androidx.compose.runtime.mutableStateOf
17 | import androidx.compose.runtime.remember
18 | import androidx.compose.runtime.rememberUpdatedState
19 | import androidx.compose.runtime.setValue
20 | import androidx.compose.runtime.snapshotFlow
21 | import androidx.compose.ui.Modifier
22 | import androidx.compose.ui.draw.scale
23 | import androidx.compose.ui.geometry.Rect
24 | import androidx.compose.ui.geometry.isSpecified
25 | import androidx.compose.ui.graphics.Color
26 | import androidx.compose.ui.hapticfeedback.HapticFeedbackType
27 | import androidx.compose.ui.layout.boundsInParent
28 | import androidx.compose.ui.layout.onPlaced
29 | import androidx.compose.ui.platform.LocalHapticFeedback
30 | import androidx.compose.ui.unit.dp
31 | import com.github.badoualy.storyeditor.StoryEditorState
32 | import com.github.badoualy.storyeditor.StoryElement
33 | import kotlinx.coroutines.flow.dropWhile
34 | import kotlinx.coroutines.flow.take
35 |
36 | /**
37 | * Delete button with a scaling effect and haptic feedback when an element enters the deletion range
38 | */
39 | @Composable
40 | internal fun EditorDeleteButton(
41 | editorState: StoryEditorState,
42 | onDelete: (StoryElement) -> Unit,
43 | modifier: Modifier = Modifier
44 | ) {
45 | var deleteBounds by remember { mutableStateOf(Rect.Zero) }
46 | val isInDeleteRange by remember(editorState) {
47 | derivedStateOf {
48 | if (editorState.pointerPosition.isSpecified) {
49 | deleteBounds.contains(editorState.pointerPosition)
50 | } else {
51 | false
52 | }
53 | }
54 | }
55 |
56 | if (isInDeleteRange) {
57 | val hapticFeedback = LocalHapticFeedback.current
58 | val rememberedOnDelete by rememberUpdatedState(onDelete)
59 | LaunchedEffect(Unit) {
60 | hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
61 |
62 | val element = editorState.draggedElement ?: return@LaunchedEffect
63 | snapshotFlow { editorState.draggedElement }
64 | .dropWhile { it != null }
65 | .take(1)
66 | .collect { rememberedOnDelete(element) }
67 | }
68 | }
69 |
70 | AnimatedVisibility(
71 | visible = editorState.draggedElement != null,
72 | enter = fadeIn(),
73 | exit = fadeOut(),
74 | modifier = modifier
75 | .onPlaced { deleteBounds = it.boundsInParent() }
76 | .scale(if (isInDeleteRange) 1.5f else 1f)
77 | .padding(24.dp)
78 | ) {
79 | val color = if (isInDeleteRange) Color.Red else Color.Black
80 | Surface(
81 | shape = CircleShape,
82 | color = color.copy(alpha = 0.5f),
83 | contentColor = Color.White
84 | ) {
85 | Icon(
86 | Icons.Default.Delete,
87 | contentDescription = null,
88 | modifier = Modifier.padding(12.dp)
89 | )
90 | }
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/sample/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/story-editor/src/main/java/com/github/badoualy/storyeditor/element/text/TextElementEditorBar.kt:
--------------------------------------------------------------------------------
1 | package com.github.badoualy.storyeditor.element.text
2 |
3 | import androidx.compose.foundation.BorderStroke
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.horizontalScroll
6 | import androidx.compose.foundation.layout.Arrangement
7 | import androidx.compose.foundation.layout.Box
8 | import androidx.compose.foundation.layout.Column
9 | import androidx.compose.foundation.layout.Row
10 | import androidx.compose.foundation.layout.fillMaxWidth
11 | import androidx.compose.foundation.layout.heightIn
12 | import androidx.compose.foundation.layout.padding
13 | import androidx.compose.foundation.layout.size
14 | import androidx.compose.foundation.rememberScrollState
15 | import androidx.compose.foundation.shape.RoundedCornerShape
16 | import androidx.compose.material3.Icon
17 | import androidx.compose.material3.IconButton
18 | import androidx.compose.material3.Surface
19 | import androidx.compose.material3.Text
20 | import androidx.compose.runtime.Composable
21 | import androidx.compose.runtime.key
22 | import androidx.compose.ui.Alignment
23 | import androidx.compose.ui.Modifier
24 | import androidx.compose.ui.graphics.Color
25 | import androidx.compose.ui.res.painterResource
26 | import androidx.compose.ui.unit.dp
27 | import androidx.compose.ui.unit.sp
28 | import com.github.badoualy.storyeditor.R
29 | import com.github.badoualy.storyeditor.StoryElement
30 | import com.github.badoualy.storyeditor.component.ColorSchemeTypeToggleRow
31 | import com.github.badoualy.storyeditor.element.text.StoryTextElement.AlignType
32 | import com.github.badoualy.storyeditor.element.text.StoryTextElement.ColorSchemeType
33 | import kotlinx.collections.immutable.ImmutableList
34 |
35 | @Composable
36 | internal fun TextElementEditorBar(
37 | colorSchemes: ImmutableList,
38 | fontStyles: ImmutableList,
39 | currentColorScheme: () -> StoryElement.ColorScheme,
40 | currentAlignType: () -> AlignType,
41 | currentFontStyle: () -> StoryTextElement.FontStyle,
42 | currentColorSchemeType: () -> ColorSchemeType,
43 | onSelectColorScheme: (StoryElement.ColorScheme) -> Unit,
44 | onAlignTypeClick: () -> Unit,
45 | onSelectFontStyle: (StoryTextElement.FontStyle) -> Unit,
46 | onColorSchemeTypeClick: () -> Unit,
47 | modifier: Modifier = Modifier
48 | ) {
49 | Column(
50 | modifier = modifier.padding(vertical = 8.dp),
51 | verticalArrangement = Arrangement.spacedBy(4.dp)
52 | ) {
53 | // Align / Colors type / Font style
54 | Row(
55 | modifier = Modifier.padding(start = 8.dp),
56 | verticalAlignment = Alignment.CenterVertically
57 | ) {
58 | IconButton(onClick = onAlignTypeClick) {
59 | val icon = when (currentAlignType()) {
60 | AlignType.START -> R.drawable.ic_round_format_align_left_24
61 | AlignType.CENTER -> R.drawable.ic_round_format_align_center_24
62 | AlignType.END -> R.drawable.ic_round_format_align_right_24
63 | }
64 |
65 | Icon(
66 | painter = painterResource(icon),
67 | contentDescription = null
68 | )
69 | }
70 |
71 | IconButton(onClick = onColorSchemeTypeClick) {
72 | val icon = when (currentColorSchemeType()) {
73 | ColorSchemeType.BACKGROUND -> R.drawable.ic_round_font_download_24
74 | ColorSchemeType.INVERTED -> R.drawable.ic_round_font_download_24
75 | ColorSchemeType.TEXT_ONLY -> R.drawable.ic_outline_font_download_24
76 | }
77 | Icon(
78 | painter = painterResource(icon),
79 | contentDescription = null
80 | )
81 | }
82 |
83 | Box(
84 | modifier = Modifier
85 | .padding(start = 8.dp)
86 | .size(width = 2.dp, height = 24.dp)
87 | .background(Color.White.copy(alpha = 0.5f))
88 | )
89 |
90 | FontStyleToggleRow(
91 | fontStyles = fontStyles,
92 | currentFontStyle = currentFontStyle,
93 | onSelectFontStyle = onSelectFontStyle
94 | )
95 | }
96 |
97 | // Color toggle
98 | ColorSchemeTypeToggleRow(
99 | colorSchemes = colorSchemes,
100 | currentColorScheme = currentColorScheme,
101 | onColorSchemeClick = onSelectColorScheme
102 | )
103 | }
104 | }
105 |
106 | @Composable
107 | private fun FontStyleToggleRow(
108 | fontStyles: ImmutableList,
109 | currentFontStyle: () -> StoryTextElement.FontStyle,
110 | onSelectFontStyle: (StoryTextElement.FontStyle) -> Unit,
111 | modifier: Modifier = Modifier
112 | ) {
113 | Row(
114 | modifier = modifier
115 | .fillMaxWidth()
116 | .horizontalScroll(rememberScrollState())
117 | .padding(horizontal = 16.dp, vertical = 4.dp),
118 | verticalAlignment = Alignment.CenterVertically,
119 | horizontalArrangement = Arrangement.spacedBy(8.dp)
120 | ) {
121 | fontStyles.forEach { fontStyle ->
122 | key(fontStyle) {
123 | FontStyleToggle(
124 | fontStyle = fontStyle,
125 | isSelected = currentFontStyle() == fontStyle,
126 | onClick = { onSelectFontStyle(fontStyle) }
127 | )
128 | }
129 | }
130 | }
131 | }
132 |
133 | @Composable
134 | private fun FontStyleToggle(
135 | fontStyle: StoryTextElement.FontStyle,
136 | isSelected: Boolean,
137 | onClick: () -> Unit,
138 | modifier: Modifier = Modifier
139 | ) {
140 | Surface(
141 | modifier = modifier.heightIn(min = 32.dp),
142 | shape = RoundedCornerShape(4.dp),
143 | color = Color.Transparent,
144 | contentColor = Color.White,
145 | border = BorderStroke(
146 | width = if (isSelected) 2.dp else 1.dp,
147 | color = Color.White.copy(alpha = if (isSelected) 1f else 0.5f)
148 | ),
149 | onClick = onClick
150 | ) {
151 | Box(contentAlignment = Alignment.Center) {
152 | Text(
153 | text = fontStyle.name,
154 | modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
155 | style = fontStyle.textStyle,
156 | fontSize = 12.sp
157 | )
158 | }
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/story-editor/src/main/java/com/github/badoualy/storyeditor/StoryEditorState.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("MemberVisibilityCanBePrivate", "unused")
2 |
3 | package com.github.badoualy.storyeditor
4 |
5 | import android.graphics.Bitmap
6 | import android.graphics.Bitmap.createBitmap
7 | import android.net.Uri
8 | import androidx.compose.runtime.Stable
9 | import androidx.compose.runtime.getValue
10 | import androidx.compose.runtime.mutableStateOf
11 | import androidx.compose.runtime.setValue
12 | import androidx.compose.ui.geometry.Offset
13 | import androidx.compose.ui.geometry.Rect
14 | import androidx.compose.ui.unit.IntSize
15 | import kotlinx.coroutines.Dispatchers
16 | import kotlinx.coroutines.flow.MutableSharedFlow
17 | import kotlinx.coroutines.flow.asSharedFlow
18 | import kotlinx.coroutines.withContext
19 | import java.io.File
20 | import kotlin.coroutines.resume
21 | import kotlin.coroutines.resumeWithException
22 | import kotlin.coroutines.suspendCoroutine
23 |
24 | /**
25 | * @param elementsBoundsFraction Bounds of allowed position in fraction for each side
26 | *
27 | * eg:
28 | * - left 0.1 means elements' offset can't be smaller than 10% of the width
29 | * - right 0.9 means elements' right position in parent can't exceed 90% of the width
30 | * @param screenshotMode screenshot support mode.
31 | * When using this, parts of the editor will be wrapped in an ComposeView inside an AndroidView.
32 | * see [ScreenshotMode]
33 | * @param debug In debug mode, the editor will draw the bounds of each element's hitbox
34 | */
35 | @Stable
36 | class StoryEditorState(
37 | private val elementsBoundsFraction: Rect = Rect(0.0f, 0.0f, 1f, 1f),
38 | editMode: Boolean = true,
39 | val screenshotMode: ScreenshotMode = ScreenshotMode.DISABLED,
40 | val debug: Boolean = false
41 | ) {
42 |
43 | /** allow editing (adding/updating/moving elements) */
44 | var editMode by mutableStateOf(editMode)
45 |
46 | /**
47 | * Size of the editor's content (without any padding).
48 | * eg: size of the *picture* composable passed to [StoryEditor]
49 | */
50 | var editorSize by mutableStateOf(IntSize.Zero)
51 | private set
52 |
53 | /**
54 | * Bounds of allowed position in px for each side.
55 | * eg:
56 | * - left 10 means elements' offset can't be smaller than 10
57 | * - right 90 means elements' right position in parent can't exceed 90
58 | */
59 | var elementsBounds by mutableStateOf(Rect.Zero)
60 | private set
61 |
62 | /**
63 | * Element currently focused for edition.
64 | * When an element is focused, other elements can't be interacted with.
65 | */
66 | var focusedElement by mutableStateOf(null)
67 |
68 | /**
69 | * Element currently dragged. Only one element can be drag/interacted with at the same time.
70 | */
71 | var draggedElement by mutableStateOf(null)
72 | internal set
73 |
74 | /**
75 | * Pointer position while [draggedElement] is not null.
76 | * Reset to [Offset.Unspecified] when drag is finished.
77 | */
78 | internal var pointerPosition: Offset by mutableStateOf(Offset.Unspecified)
79 |
80 | private val _screenshotRequest = MutableSharedFlow(extraBufferCapacity = 1)
81 | internal val screenshotRequest get() = _screenshotRequest.asSharedFlow()
82 |
83 | suspend fun takeScreenshot(destination: Bitmap) {
84 | require(screenshotMode != ScreenshotMode.DISABLED) { "screenshotMode set to DISABLED" }
85 |
86 | screenshotMode.layers.forEach { layer ->
87 | suspendCoroutine { cont ->
88 | _screenshotRequest.tryEmit(
89 | ScreenshotRequest(
90 | layer = layer,
91 | destination = destination,
92 | onError = cont::resumeWithException,
93 | onSuccess = { cont.resume(Unit) }
94 | )
95 | )
96 | }
97 | }
98 | }
99 |
100 | suspend fun takeScreenshot(config: Bitmap.Config = Bitmap.Config.RGB_565): Bitmap {
101 | require(screenshotMode != ScreenshotMode.DISABLED) { "screenshotMode set to DISABLED" }
102 | val destination = withContext(Dispatchers.Default) {
103 | createBitmap(editorSize.width, editorSize.height, config)
104 | }
105 |
106 | try {
107 | takeScreenshot(destination)
108 | return destination
109 | } catch (e: Exception) {
110 | destination.recycle()
111 | throw e
112 | }
113 | }
114 |
115 | suspend fun takeAndSaveScreenshot(
116 | directory: File,
117 | config: Bitmap.Config = Bitmap.Config.RGB_565
118 | ): Uri {
119 | val screenshot = takeScreenshot(config)
120 |
121 | return withContext(Dispatchers.IO) {
122 | val file = File(directory, "story_editor_screenshot.png").apply {
123 | if (exists()) delete()
124 | }
125 | file.outputStream().use {
126 | screenshot.compress(Bitmap.CompressFormat.PNG, 100, it)
127 | }
128 |
129 | Uri.fromFile(file)
130 | }
131 | }
132 |
133 | fun isFocusable(element: StoryElement): Boolean {
134 | return editMode && (focusedElement == null || focusedElement === element) && draggedElement == null
135 | }
136 |
137 | internal fun updateBackgroundSize(size: IntSize) {
138 | editorSize = size
139 | elementsBounds = Rect(
140 | left = size.width * elementsBoundsFraction.left,
141 | top = size.height * elementsBoundsFraction.top,
142 | right = size.width * elementsBoundsFraction.right,
143 | bottom = size.height * elementsBoundsFraction.bottom
144 | )
145 | }
146 |
147 | enum class ScreenshotMode(internal val layers: Array) {
148 | /** Screenshot support is disabled, no AndroidView used */
149 | DISABLED(layers = emptyArray()),
150 |
151 | /** Screenshot support is enabled, and the screenshot will contain background + content */
152 | FULL(layers = arrayOf(ScreenshotLayer.EDITOR)),
153 |
154 | /**
155 | * Same as [FULL], but the screenshot won't be clipped to the [StoryEditor]'s shape.
156 | * This is useful when you specify a shape for the background, and you don't want the screenshot to be clipped.
157 | */
158 | FULL_NOT_CLIPPED(layers = arrayOf(ScreenshotLayer.BACKGROUND, ScreenshotLayer.ELEMENTS)),
159 |
160 | /** Screenshot support is enabled, and the screenshot will contain only the content without the background */
161 | CONTENT(layers = arrayOf(ScreenshotLayer.ELEMENTS));
162 |
163 | companion object {
164 |
165 | val ScreenshotMode.isBackgroundDrawn get() = this == FULL || this == FULL_NOT_CLIPPED
166 | }
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/sample/src/main/java/com/github/badoualy/storyeditor/sample/MainActivity.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalMaterial3Api::class)
2 |
3 | package com.github.badoualy.storyeditor.sample
4 |
5 | import android.content.Context
6 | import android.graphics.Bitmap
7 | import android.os.Bundle
8 | import android.util.Log
9 | import androidx.activity.ComponentActivity
10 | import androidx.activity.compose.setContent
11 | import androidx.compose.foundation.background
12 | import androidx.compose.foundation.layout.Box
13 | import androidx.compose.foundation.layout.aspectRatio
14 | import androidx.compose.foundation.layout.fillMaxSize
15 | import androidx.compose.foundation.layout.fillMaxWidth
16 | import androidx.compose.foundation.layout.height
17 | import androidx.compose.foundation.layout.padding
18 | import androidx.compose.foundation.layout.systemBarsPadding
19 | import androidx.compose.foundation.shape.RoundedCornerShape
20 | import androidx.compose.material.icons.Icons
21 | import androidx.compose.material.icons.filled.Close
22 | import androidx.compose.material3.Button
23 | import androidx.compose.material3.ExperimentalMaterial3Api
24 | import androidx.compose.material3.Icon
25 | import androidx.compose.material3.IconButton
26 | import androidx.compose.material3.MaterialTheme
27 | import androidx.compose.material3.Surface
28 | import androidx.compose.material3.Text
29 | import androidx.compose.material3.TopAppBar
30 | import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
31 | import androidx.compose.material3.darkColorScheme
32 | import androidx.compose.runtime.Composable
33 | import androidx.compose.runtime.mutableStateListOf
34 | import androidx.compose.runtime.remember
35 | import androidx.compose.runtime.rememberCoroutineScope
36 | import androidx.compose.ui.Alignment
37 | import androidx.compose.ui.Modifier
38 | import androidx.compose.ui.geometry.Offset
39 | import androidx.compose.ui.geometry.Rect
40 | import androidx.compose.ui.graphics.Brush
41 | import androidx.compose.ui.graphics.Color
42 | import androidx.compose.ui.graphics.toArgb
43 | import androidx.compose.ui.layout.ContentScale
44 | import androidx.compose.ui.platform.LocalContext
45 | import androidx.compose.ui.unit.dp
46 | import androidx.core.view.WindowCompat
47 | import coil3.compose.AsyncImage
48 | import coil3.request.ImageRequest
49 | import coil3.request.allowHardware
50 | import com.github.badoualy.storyeditor.StoryEditor
51 | import com.github.badoualy.storyeditor.StoryEditorState
52 | import com.github.badoualy.storyeditor.element.text.StoryTextElement
53 | import com.github.badoualy.storyeditor.element.text.TextElement
54 | import kotlinx.coroutines.launch
55 |
56 | class MainActivity : ComponentActivity() {
57 |
58 | override fun onCreate(savedInstanceState: Bundle?) {
59 | super.onCreate(savedInstanceState)
60 | WindowCompat.setDecorFitsSystemWindows(window, false)
61 | window.statusBarColor = Color.Black.toArgb()
62 |
63 | setContent {
64 | MaterialTheme(darkColorScheme()) {
65 | Surface(
66 | color = Color.Black,
67 | contentColor = Color.White,
68 | modifier = Modifier.systemBarsPadding()
69 | ) {
70 | Box {
71 | Content(modifier = Modifier)
72 | Box(
73 | modifier = Modifier
74 | .fillMaxWidth()
75 | .height(72.dp)
76 | .background(
77 | Brush.verticalGradient(
78 | 0f to Color.Black.copy(alpha = 0.2f),
79 | 0.4f to Color.Black.copy(alpha = 0.2f),
80 | 1f to Color.Black.copy(alpha = 0f),
81 | )
82 | )
83 | )
84 | TopAppBar(
85 | title = { Text("Kyoto - Kiyomizudera") },
86 | navigationIcon = {
87 | IconButton(onClick = {}) {
88 | Icon(Icons.Default.Close, contentDescription = null)
89 | }
90 | },
91 | colors = topAppBarColors(
92 | containerColor = Color.Transparent,
93 | navigationIconContentColor = Color.White,
94 | titleContentColor = Color.White
95 | )
96 | )
97 | }
98 | }
99 | }
100 | }
101 | }
102 | }
103 |
104 | @Composable
105 | private fun Content(
106 | modifier: Modifier = Modifier
107 | ) {
108 | val elements = remember {
109 | mutableStateListOf(
110 | StoryTextElement(
111 | initialText = "Kiyomizudera\nKyoto",
112 | positionFraction = Offset(0.2f, 0.2f)
113 | ),
114 | // Test enforceInitialTextLines
115 | // StoryTextElement(
116 | // initialText = "abcdefghijklmnopqrstuvwxyz",
117 | // positionFraction = Offset(StoryTextElementDefaults.EditPositionFraction.x, 0.5f),
118 | // enforceInitialTextLines = true
119 | // )
120 | )
121 | }
122 |
123 | val editorState = rememberStoryEditorState()
124 |
125 | Box {
126 | StoryEditor(
127 | state = editorState,
128 | modifier = modifier.fillMaxSize(),
129 | onClick = {
130 | val element = StoryTextElement()
131 | editorState.focusedElement = element
132 | elements.add(element)
133 | },
134 | onDeleteElement = {
135 | elements.remove(it)
136 | },
137 | background = {
138 | AsyncImage(
139 | ImageRequest.Builder(LocalContext.current)
140 | .data("https://www.cercledesvoyages.com/wp-content/uploads/2020/12/iStock-509472000.jpg")
141 | // Very important to avoid hardware bitmap crash when taking screenshot
142 | .allowHardware(false)
143 | .build(),
144 | contentDescription = null,
145 | contentScale = ContentScale.Crop,
146 | modifier = Modifier
147 | .fillMaxWidth()
148 | .aspectRatio(9f / 16f)
149 | )
150 | },
151 | shape = RoundedCornerShape(8.dp)
152 | ) {
153 | elements.forEach { element ->
154 | Element(element = element, modifier = Modifier) {
155 | TextElement(
156 | element = element,
157 | )
158 | }
159 | }
160 | }
161 |
162 | val context = LocalContext.current
163 | val coroutineScope = rememberCoroutineScope()
164 | Button(
165 | onClick = {
166 | coroutineScope.launch {
167 | editorState.takeScreenshot(context)
168 | }
169 | },
170 | modifier = Modifier
171 | .align(Alignment.BottomEnd)
172 | .padding(24.dp)
173 | ) {
174 | Text("Screenshot")
175 | }
176 | }
177 | }
178 |
179 | @Composable
180 | private fun rememberStoryEditorState(): StoryEditorState {
181 | return remember {
182 | StoryEditorState(
183 | elementsBoundsFraction = Rect(0.01f, 0.1f, 0.99f, 0.99f),
184 | editMode = true,
185 | debug = true,
186 | screenshotMode = StoryEditorState.ScreenshotMode.CONTENT
187 | )
188 | }
189 | }
190 |
191 | private suspend fun StoryEditorState.takeScreenshot(context: Context) {
192 | try {
193 | val bitmap = takeScreenshot()
194 | Log.i("StoryEditorSample", "Screenshot success")
195 | context.openFileOutput("screenshot.jpg", Context.MODE_PRIVATE).use {
196 | bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it)
197 | bitmap.recycle()
198 | }
199 | } catch (e: Exception) {
200 | Log.e("StoryEditorSample", "Failed to take screenshot", e)
201 | }
202 | }
203 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/story-editor/src/main/java/com/github/badoualy/storyeditor/StoryElementTransformation.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("MemberVisibilityCanBePrivate", "unused")
2 |
3 | package com.github.badoualy.storyeditor
4 |
5 | import androidx.compose.animation.core.Animatable
6 | import androidx.compose.animation.core.VectorConverter
7 | import androidx.compose.runtime.Stable
8 | import androidx.compose.runtime.getValue
9 | import androidx.compose.runtime.mutableFloatStateOf
10 | import androidx.compose.runtime.mutableStateOf
11 | import androidx.compose.runtime.setValue
12 | import androidx.compose.ui.geometry.Offset
13 | import androidx.compose.ui.geometry.Rect
14 | import androidx.compose.ui.geometry.Size
15 | import androidx.compose.ui.geometry.isSpecified
16 | import androidx.compose.ui.geometry.isUnspecified
17 | import androidx.compose.ui.geometry.takeOrElse
18 | import androidx.compose.ui.graphics.Matrix
19 | import androidx.compose.ui.graphics.graphicsLayer
20 | import androidx.compose.ui.unit.DpSize
21 | import androidx.compose.ui.unit.IntSize
22 | import androidx.compose.ui.unit.toSize
23 | import com.github.badoualy.storyeditor.util.coerceInOrCenter
24 | import com.github.badoualy.storyeditor.util.rotateBy
25 | import com.github.badoualy.storyeditor.util.rotateZ
26 | import kotlinx.coroutines.coroutineScope
27 | import kotlinx.coroutines.launch
28 | import kotlinx.coroutines.runBlocking
29 | import kotlinx.coroutines.withTimeout
30 |
31 | /**
32 | * Holds current transformation state for the element (position, scale, rotation, ...).
33 | *
34 | * @param initialSizeFraction the requested initial size in fraction of the editor's size.
35 | * If set, when the element is first laid out, it'll compute the [scale] value to honor the requested size.
36 | * @param scale element scale. If [initialSizeFraction] is not null, this value will be overridden once the view is laid out.
37 | * @param rotation angle in degrees of the element with the origin being the center.
38 | * @param positionFraction position in fraction of the editor's size.
39 | * @param minScale minimum scale value allowed with pinching
40 | * @param maxScale max scale value allowed with pinching
41 | */
42 | @Stable
43 | open class StoryElementTransformation(
44 | private var initialSizeFraction: Size? = null,
45 | scale: Float = 1f,
46 | rotation: Float = 0f,
47 | positionFraction: Offset = Offset(0.15f, 0.30f),
48 | val minScale: Float = 0.5f,
49 | val maxScale: Float = 2f
50 | ) {
51 |
52 | var gesturesEnabled by mutableStateOf(true)
53 |
54 | // Real values
55 | var scale by mutableFloatStateOf(scale)
56 | private set
57 | var rotation by mutableFloatStateOf(rotation)
58 | private set
59 | var positionFraction by mutableStateOf(positionFraction.takeOrElse { Offset.Zero })
60 | private set
61 |
62 | // Currently displayed state's values
63 | private val _displayScale = Animatable(scale)
64 | private val _displayRotation = Animatable(rotation)
65 | private val _displayPositionFraction =
66 | Animatable(positionFraction.takeOrElse { Offset.Zero }, Offset.VectorConverter)
67 |
68 | // Values currently displayed (can be different from real values if an animation is in progress
69 | val displayScale get() = _displayScale.value
70 | val displayRotation get() = _displayRotation.value
71 |
72 | // Unscaled sizes
73 |
74 | /** Unscaled element size in px */
75 | var size: IntSize by mutableStateOf(IntSize.Zero)
76 | private set
77 |
78 | /** Unscaled element size in fraction (percent) of editor's size */
79 | var sizeFraction: Size by mutableStateOf(initialSizeFraction ?: Size.Zero)
80 | private set
81 |
82 | /** Unscaled element size in dp */
83 | var sizeDp: DpSize by mutableStateOf(DpSize.Zero)
84 | private set
85 |
86 | /** Unscaled element hitbox (with padding for gestures) size in px */
87 | var hitboxSize: IntSize by mutableStateOf(IntSize.Zero)
88 | private set
89 |
90 | // Display scaled size (unscaled sizes multiplied by displayScale)
91 |
92 | /** Scaled element size in px */
93 | val scaledSize: Size
94 | get() = size.toSize() * displayScale
95 |
96 | /** Scaled element size in fraction (percent) of editor's size */
97 | val scaledSizeFraction: Size
98 | get() = sizeFraction * displayScale
99 |
100 | /** Scaled element size in dp */
101 | val scaledSizeDp: DpSize
102 | get() = sizeDp * displayScale
103 |
104 | /** Scaled element hitbox (with padding) size in px */
105 | val scaledHitboxSize: Size
106 | get() = hitboxSize.toSize() * displayScale
107 |
108 | private val isAnimating: Boolean
109 | get() = _displayScale.isRunning || _displayPositionFraction.isRunning || _displayRotation.isRunning
110 | internal var isOverridingDisplayState = false
111 | private set
112 |
113 | /**
114 | * Override currently displayed transformation state to the given values
115 | *
116 | * Note: you can still access real values, and reset the display state to those values via [resetDisplayState]
117 | */
118 | suspend fun setDisplayState(
119 | scale: Float,
120 | rotation: Float,
121 | positionFraction: Offset,
122 | animate: Boolean
123 | ) {
124 | // If size is zero, it means no draw phase passed, no need to animate
125 | if (animate && size != IntSize.Zero) {
126 | // Might get cancelled if animateTo is called elsewhere while running
127 | try {
128 | coroutineScope {
129 | launch { _displayScale.animateTo(scale) }
130 | launch { _displayRotation.animateTo(rotation) }
131 | launch { _displayPositionFraction.animateTo(positionFraction) }
132 | }
133 | } catch (e: Throwable) {
134 | }
135 | } else {
136 | _displayScale.snapTo(scale)
137 | _displayRotation.snapTo(rotation)
138 | _displayPositionFraction.snapTo(positionFraction)
139 | }
140 | }
141 |
142 | /**
143 | * Reset currently displayed transformation state to the real values
144 | */
145 | suspend fun resetDisplayState(animate: Boolean) {
146 | setDisplayState(
147 | scale = scale,
148 | rotation = rotation,
149 | positionFraction = positionFraction,
150 | animate = animate
151 | )
152 | }
153 |
154 | /**
155 | * Called when a transformable element starts being edited. This will disable gestures,
156 | * and override display state to the given values (by default disable scales/rotation).
157 | */
158 | suspend fun startEdit(
159 | scale: Float = 1f,
160 | rotation: Float = 0f,
161 | positionFraction: Offset
162 | ) {
163 | // Disable scale/rotation and override position to given edit position
164 | isOverridingDisplayState = true
165 | gesturesEnabled = false
166 | setDisplayState(
167 | scale = scale,
168 | rotation = rotation,
169 | positionFraction = positionFraction,
170 | animate = true
171 | )
172 | }
173 |
174 | /**
175 | * Called when a transformable element stops being edited. Gestures are re-enabled,
176 | * and display state is animated back to real values.
177 | */
178 | suspend fun stopEdit(
179 | editorSize: IntSize,
180 | bounds: Rect,
181 | coercePosition: Boolean = true
182 | ) {
183 | if (coercePosition) {
184 | // Make sure the item fits in the bounds
185 | positionFraction = positionFraction
186 | .fractionToPx(editorSize)
187 | .coerceOffsetInBounds(bounds)
188 | .pxToFraction(editorSize)
189 | }
190 |
191 | // Stop position override
192 | resetDisplayState(animate = true)
193 | gesturesEnabled = true
194 | isOverridingDisplayState = false
195 | }
196 |
197 | fun centerAt(positionFraction: Offset) {
198 | this.positionFraction = centerPositionToTopLeftPosition(positionFraction)
199 | }
200 |
201 | suspend fun centerDisplayAt(positionFraction: Offset, animate: Boolean) {
202 | val target = centerPositionToTopLeftPosition(
203 | centerPositionFraction = positionFraction,
204 | sizeFraction = scaledSizeFraction
205 | )
206 | if (animate) {
207 | _displayPositionFraction.animateTo(target)
208 | } else {
209 | _displayPositionFraction.snapTo(target)
210 | }
211 | }
212 |
213 | /**
214 | * @return the position to use in [StoryElementTransformation] to have the center at [this] fraction position.
215 | *
216 | * eg: if you want the element to be centered, `Offset(0.5f, 0.5f).asCenterFraction()`
217 | */
218 | fun centerPositionToTopLeftPosition(
219 | centerPositionFraction: Offset,
220 | sizeFraction: Size = this.sizeFraction * scale
221 | ): Offset {
222 | val positionOffset = Offset(sizeFraction.width / 2f, sizeFraction.height / 2f)
223 | return centerPositionFraction - positionOffset
224 | }
225 |
226 | fun resetScale() {
227 | scale = 1f
228 | }
229 |
230 | internal fun updateSize(
231 | size: IntSize,
232 | sizeDp: DpSize,
233 | hitboxSize: IntSize,
234 | editorSize: IntSize
235 | ) {
236 | this.size = size
237 | sizeFraction = Size(
238 | size.width / editorSize.width.toFloat(),
239 | size.height / editorSize.height.toFloat()
240 | )
241 | this.sizeDp = sizeDp
242 | this.hitboxSize = hitboxSize
243 |
244 | // Compute scale to get the requested size
245 | initialSizeFraction?.let { requestedRatioSize ->
246 | this.initialSizeFraction = null
247 | val ratioWidth = this.size.width / editorSize.width.toFloat()
248 | val newValue = requestedRatioSize.width / ratioWidth
249 |
250 | scale = newValue
251 | snapDisplayValue { _displayScale.snapTo(newValue) }
252 | }
253 | }
254 |
255 | internal fun updateScale(scale: Float, bounds: Rect) {
256 | val maxFactor = minOf(
257 | bounds.width / size.width,
258 | bounds.height / size.height,
259 | maxScale
260 | )
261 | val newValue = when {
262 | maxFactor > minScale -> scale.coerceIn(minScale, maxFactor)
263 | else -> scale
264 | }
265 |
266 | this.scale = newValue
267 | snapDisplayValue { _displayScale.snapTo(newValue) }
268 | }
269 |
270 | internal fun updateRotation(rotation: Float) {
271 | this.rotation = rotation
272 | snapDisplayValue { _displayRotation.snapTo(rotation) }
273 | }
274 |
275 | internal fun updatePosition(
276 | pan: Offset,
277 | editorSize: IntSize,
278 | bounds: Rect
279 | ) {
280 | val newValue = positionFraction
281 | .fractionToPx(editorSize)
282 | // Apply changes from gesture
283 | .let { it + pan.rotateBy(rotation) * scale }
284 | .coerceOffsetInBounds(bounds = bounds)
285 | .pxToFraction(editorSize)
286 |
287 | positionFraction = newValue
288 | snapDisplayValue { _displayPositionFraction.snapTo(newValue) }
289 | }
290 |
291 | /**
292 | * @return position for [graphicsLayer]'s translationX/translationY
293 | */
294 | internal fun scaledHitboxPosition(editorSize: IntSize): Offset {
295 | // Offset for scale
296 | // The scale from graphicsLayer is centered on the coordinates
297 | // If left = 150 with width = 400, centerX is 350
298 | // When scaled to 3*, width = 1200, centerX will stay at 350, and left = -250
299 | val scaledHitboxSize = scaledHitboxSize.takeIf { it.isSpecified } ?: Size.Zero
300 | val hitboxSize = hitboxSize
301 | val scaledSize = scaledSize.takeIf { it.isSpecified } ?: Size.Zero
302 |
303 | val scaleOffset = Offset(
304 | (scaledHitboxSize.width - hitboxSize.width) / 2f,
305 | (scaledHitboxSize.height - hitboxSize.height) / 2f
306 | ).takeIf { it.isSpecified } ?: Offset.Zero
307 |
308 | // Offset for hitbox padding
309 | val hitboxOffset = Offset(
310 | (scaledHitboxSize.width - scaledSize.width) / 2f,
311 | (scaledHitboxSize.height - scaledSize.height) / 2f,
312 | ).takeIf { it.isSpecified } ?: Offset.Zero
313 |
314 | return _displayPositionFraction.value.fractionToPx(editorSize) + scaleOffset - hitboxOffset
315 | }
316 |
317 | private fun Offset.coerceOffsetInBounds(bounds: Rect): Offset {
318 | return try {
319 | val scaledElementSize = scaledSize
320 | val elementRect = Rect(this, scaledElementSize)
321 |
322 | // Apply rotation transformation onto rect
323 | val matrix = Matrix().apply { rotateZ(displayRotation, elementRect.center) }
324 | val transformedRect = matrix.map(elementRect)
325 |
326 | // Coerce in bounds
327 | val coercedRect = transformedRect.coerceInOrCenter(bounds)
328 |
329 | // Difference between coerced position and original position
330 | val offset = (coercedRect.topLeft - transformedRect.topLeft) / scale
331 |
332 | this + offset
333 | } catch (e: Exception) {
334 | this
335 | }
336 | }
337 |
338 | private fun Offset.fractionToPx(editorSize: IntSize): Offset {
339 | if (isUnspecified) return Offset.Zero
340 | return Offset(x = x * editorSize.width, y = y * editorSize.height)
341 | }
342 |
343 | private fun Offset.pxToFraction(editorSize: IntSize): Offset {
344 | if (isUnspecified) return Offset.Zero
345 | return Offset(x = x / editorSize.width, y = y / editorSize.height)
346 | }
347 |
348 | private inline fun snapDisplayValue(crossinline block: suspend () -> Unit) {
349 | if (isAnimating) return
350 | if (isOverridingDisplayState) return
351 |
352 | runCatching {
353 | runBlocking {
354 | withTimeout(100) {
355 | block()
356 | }
357 | }
358 | }
359 | }
360 | }
361 |
--------------------------------------------------------------------------------
/story-editor/src/main/java/com/github/badoualy/storyeditor/StoryEditor.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("MemberVisibilityCanBePrivate")
2 | @file:OptIn(ExperimentalComposeUiApi::class)
3 |
4 | package com.github.badoualy.storyeditor
5 |
6 | import androidx.compose.foundation.border
7 | import androidx.compose.foundation.focusable
8 | import androidx.compose.foundation.gestures.awaitFirstDown
9 | import androidx.compose.foundation.gestures.detectTapGestures
10 | import androidx.compose.foundation.gestures.detectTransformGestures
11 | import androidx.compose.foundation.gestures.forEachGesture
12 | import androidx.compose.foundation.layout.Box
13 | import androidx.compose.foundation.layout.PaddingValues
14 | import androidx.compose.foundation.layout.fillMaxSize
15 | import androidx.compose.foundation.layout.padding
16 | import androidx.compose.foundation.layout.size
17 | import androidx.compose.runtime.Composable
18 | import androidx.compose.runtime.LaunchedEffect
19 | import androidx.compose.runtime.Stable
20 | import androidx.compose.runtime.derivedStateOf
21 | import androidx.compose.runtime.getValue
22 | import androidx.compose.runtime.key
23 | import androidx.compose.runtime.mutableStateOf
24 | import androidx.compose.runtime.remember
25 | import androidx.compose.runtime.setValue
26 | import androidx.compose.runtime.snapshotFlow
27 | import androidx.compose.ui.Alignment
28 | import androidx.compose.ui.ExperimentalComposeUiApi
29 | import androidx.compose.ui.Modifier
30 | import androidx.compose.ui.composed
31 | import androidx.compose.ui.draw.clip
32 | import androidx.compose.ui.focus.FocusRequester
33 | import androidx.compose.ui.focus.focusRequester
34 | import androidx.compose.ui.focus.onFocusChanged
35 | import androidx.compose.ui.geometry.Offset
36 | import androidx.compose.ui.graphics.Color
37 | import androidx.compose.ui.graphics.RectangleShape
38 | import androidx.compose.ui.graphics.Shape
39 | import androidx.compose.ui.graphics.graphicsLayer
40 | import androidx.compose.ui.input.pointer.changedToUp
41 | import androidx.compose.ui.input.pointer.pointerInput
42 | import androidx.compose.ui.layout.onSizeChanged
43 | import androidx.compose.ui.platform.LocalDensity
44 | import androidx.compose.ui.platform.LocalFocusManager
45 | import androidx.compose.ui.platform.LocalSoftwareKeyboardController
46 | import androidx.compose.ui.unit.DpSize
47 | import androidx.compose.ui.unit.IntSize
48 | import androidx.compose.ui.unit.dp
49 | import androidx.compose.ui.unit.toSize
50 | import androidx.compose.ui.util.fastAll
51 | import androidx.compose.ui.util.fastAny
52 | import androidx.compose.ui.util.fastMap
53 | import androidx.compose.ui.zIndex
54 | import com.github.badoualy.storyeditor.component.EditorDeleteButton
55 | import com.github.badoualy.storyeditor.util.horizontalPadding
56 | import com.github.badoualy.storyeditor.util.verticalPadding
57 |
58 | @Composable
59 | fun StoryEditor(
60 | onClick: () -> Unit,
61 | onDeleteElement: (StoryElement) -> Unit,
62 | background: @Composable () -> Unit,
63 | modifier: Modifier = Modifier,
64 | state: StoryEditorState = remember { StoryEditorState() },
65 | shape: Shape = RectangleShape,
66 | content: @Composable StoryEditorScope.() -> Unit
67 | ) {
68 | // When used in a Pager, without wrapping in a key, the size is never reported, investigate
69 | key(state) {
70 | StoryEditorContent(
71 | state = state,
72 | onClick = onClick,
73 | onDeleteElement = onDeleteElement,
74 | background = background,
75 | shape = shape,
76 | content = content,
77 | modifier = modifier.screenshotLayer(
78 | editorState = state,
79 | layer = ScreenshotLayer.EDITOR
80 | )
81 | )
82 | }
83 | }
84 |
85 | @Composable
86 | private fun StoryEditorContent(
87 | state: StoryEditorState,
88 | onClick: () -> Unit,
89 | onDeleteElement: (StoryElement) -> Unit,
90 | modifier: Modifier = Modifier,
91 | background: @Composable () -> Unit,
92 | shape: Shape = RectangleShape,
93 | content: @Composable StoryEditorScope.() -> Unit
94 | ) {
95 | Box(modifier = modifier.fillMaxSize()) {
96 | // Background
97 | Box(
98 | modifier = Modifier
99 | .clip(shape)
100 | .screenshotLayer(
101 | editorState = state,
102 | layer = ScreenshotLayer.BACKGROUND
103 | )
104 | .onSizeChanged { state.updateBackgroundSize(it) }
105 | .pointerInput(state, state.editMode) {
106 | if (!state.editMode) return@pointerInput
107 |
108 | detectTapGestures {
109 | if (state.draggedElement != null) return@detectTapGestures
110 | if (state.focusedElement != null) return@detectTapGestures
111 | onClick()
112 | }
113 | }
114 | ) {
115 | background()
116 | }
117 |
118 | // Elements
119 | Box(
120 | modifier = Modifier
121 | .fillMaxSize()
122 | .screenshotLayer(
123 | editorState = state,
124 | layer = ScreenshotLayer.ELEMENTS
125 | )
126 | ) {
127 | val scope = remember(state, onDeleteElement) {
128 | StoryEditorScopeImpl(state, onDeleteElement)
129 | }
130 | with(scope) {
131 | // Wait for editorSize to be reported before actually adding elements to composition
132 | val isEditorSizeReported by remember { derivedStateOf { state.editorSize.width > 0 } }
133 | if (isEditorSizeReported) {
134 | content()
135 | }
136 | }
137 | }
138 |
139 | // Controls overlay
140 | if (state.editMode) {
141 | val editorSizeDp = with(LocalDensity.current) { state.editorSize.toSize().toDpSize() }
142 | Box(modifier = Modifier.size(editorSizeDp)) {
143 | EditorDeleteButton(
144 | editorState = state,
145 | onDelete = onDeleteElement,
146 | modifier = Modifier.align(Alignment.BottomCenter)
147 | )
148 | }
149 | }
150 | }
151 | }
152 |
153 | @Stable
154 | interface StoryEditorScope {
155 |
156 | @Composable
157 | fun Element(
158 | element: StoryElement,
159 | modifier: Modifier,
160 | content: @Composable StoryEditorElementScope.() -> Unit
161 | )
162 | }
163 |
164 | @Stable
165 | interface StoryEditorElementScope {
166 |
167 | val editorState: StoryEditorState
168 |
169 | fun deleteElement(element: StoryElement)
170 |
171 | fun Modifier.focusableElement(
172 | element: StoryElement,
173 | focusRequester: FocusRequester,
174 | skipFocusable: Boolean = false
175 | ): Modifier
176 |
177 | fun Modifier.elementTransformation(
178 | element: TransformableStoryElement,
179 | clickEnabled: Boolean = editorState.editMode,
180 | unfocusOnGesture: Boolean = false,
181 | onClick: () -> Unit,
182 | hitboxPadding: PaddingValues = PaddingValues(0.dp)
183 | ): Modifier
184 | }
185 |
186 | private class StoryEditorScopeImpl(
187 | private val editorState: StoryEditorState,
188 | private val onDeleteElement: (StoryElement) -> Unit
189 | ) : StoryEditorScope {
190 |
191 | @Composable
192 | override fun Element(
193 | element: StoryElement,
194 | modifier: Modifier,
195 | content: @Composable StoryEditorElementScope.() -> Unit
196 | ) {
197 | key(element) {
198 | val isFocusedElement by remember { derivedStateOf { editorState.focusedElement === element } }
199 |
200 | Box(
201 | // Make sure the focus element is on top of others
202 | modifier = modifier.zIndex(if (isFocusedElement) 1f else 0f)
203 | ) {
204 | val scope = remember(onDeleteElement) {
205 | StoryEditorElementScopeImpl(editorState, onDeleteElement)
206 | }
207 | with(scope) {
208 | content()
209 | }
210 | }
211 | }
212 | }
213 | }
214 |
215 | private class StoryEditorElementScopeImpl(
216 | override val editorState: StoryEditorState,
217 | private val onDeleteElement: (StoryElement) -> Unit
218 | ) : StoryEditorElementScope {
219 |
220 | override fun deleteElement(element: StoryElement) {
221 | onDeleteElement(element)
222 | }
223 |
224 | override fun Modifier.focusableElement(
225 | element: StoryElement,
226 | focusRequester: FocusRequester,
227 | skipFocusable: Boolean
228 | ): Modifier {
229 | return composed(
230 | "StoryEditorScopeImpl.focusableElement",
231 | element,
232 | focusRequester,
233 | skipFocusable,
234 | editorState,
235 | editorState.editMode
236 | ) {
237 | if (!editorState.editMode) return@composed Modifier
238 |
239 | val focusManager = LocalFocusManager.current
240 | val keyboardController = LocalSoftwareKeyboardController.current
241 |
242 | val isFocused by remember { derivedStateOf { editorState.focusedElement === element } }
243 | var localFocusState by remember { mutableStateOf(false) }
244 | var waitingForFocus by remember { mutableStateOf(isFocused) }
245 | LaunchedEffect(Unit) {
246 | snapshotFlow { isFocused }
247 | .collect { isFocused ->
248 | if (localFocusState != isFocused) {
249 | if (isFocused) {
250 | waitingForFocus = true
251 | focusRequester.requestFocus()
252 | } else {
253 | waitingForFocus = false
254 | focusManager.clearFocus()
255 | }
256 | }
257 |
258 | if (isFocused) {
259 | element.startEdit(
260 | editorSize = editorState.editorSize,
261 | bounds = editorState.elementsBounds
262 | )
263 | } else {
264 | val shouldDelete = element.stopEdit(
265 | editorSize = editorState.editorSize,
266 | bounds = editorState.elementsBounds
267 | )
268 | if (!shouldDelete) {
269 | deleteElement(element)
270 | }
271 | }
272 | }
273 | }
274 |
275 | Modifier
276 | .focusRequester(focusRequester)
277 | .onFocusChanged {
278 | if (waitingForFocus && !it.hasFocus) {
279 | // Ignore event if we're waiting for focus
280 | // This can happen when the element enters composition,
281 | // onFocusedChanged will be called once with hasFocus=false
282 | return@onFocusChanged
283 | }
284 |
285 | localFocusState = it.hasFocus
286 | if (it.hasFocus) {
287 | // The view can be focused from a click, make sure that the property is set
288 | editorState.focusedElement = element
289 | waitingForFocus = false
290 | } else if (editorState.focusedElement === element) {
291 | editorState.focusedElement = null
292 | keyboardController?.hide()
293 | }
294 | }
295 | .then(if (!skipFocusable) Modifier.focusable() else Modifier)
296 | }
297 | }
298 |
299 | override fun Modifier.elementTransformation(
300 | element: TransformableStoryElement,
301 | clickEnabled: Boolean,
302 | unfocusOnGesture: Boolean,
303 | onClick: () -> Unit,
304 | hitboxPadding: PaddingValues
305 | ): Modifier {
306 | val transformation = element.transformation
307 | return this
308 | .detectAndApplyTransformation(element = element, unfocusOnGesture = unfocusOnGesture)
309 | .then(
310 | if (clickEnabled) {
311 | Modifier.dragTapListener(element = element, onTap = onClick)
312 | } else {
313 | Modifier
314 | }
315 | )
316 | .hitbox(transformation = transformation, paddingValues = hitboxPadding)
317 | }
318 |
319 | private fun Modifier.detectAndApplyTransformation(
320 | element: TransformableStoryElement,
321 | unfocusOnGesture: Boolean
322 | ): Modifier {
323 | val transformation = element.transformation
324 | return this
325 | .graphicsLayer {
326 | scaleX = transformation.displayScale
327 | scaleY = transformation.displayScale
328 | rotationZ = transformation.displayRotation
329 | val position = transformation.scaledHitboxPosition(
330 | editorSize = editorState.editorSize
331 | )
332 | translationX = position.x
333 | translationY = position.y
334 | }
335 | .then(if (editorState.debug) Modifier.border(1.dp, Color.Red) else Modifier)
336 | .then(
337 | if (editorState.editMode) {
338 | Modifier.pointerInput(editorState, element) {
339 | detectTransformGestures { _, pan, zoom, rotation ->
340 | if (editorState.focusedElement === element && unfocusOnGesture) {
341 | editorState.focusedElement = null
342 | }
343 | if (editorState.draggedElement !== element) return@detectTransformGestures
344 | if (editorState.focusedElement != null) return@detectTransformGestures
345 | if (!transformation.gesturesEnabled) return@detectTransformGestures
346 |
347 | transformation.updateScale(
348 | scale = (transformation.scale * zoom),
349 | bounds = editorState.elementsBounds
350 | )
351 | transformation.updateRotation(
352 | rotation = transformation.rotation + rotation
353 | )
354 | transformation.updatePosition(
355 | pan = pan,
356 | editorSize = editorState.editorSize,
357 | bounds = editorState.elementsBounds
358 | )
359 | }
360 | }
361 | } else {
362 | Modifier
363 | }
364 | )
365 | }
366 |
367 | private fun Modifier.dragTapListener(
368 | element: TransformableStoryElement,
369 | onTap: () -> Unit,
370 | dragThreshold: Int = 50
371 | ): Modifier {
372 | val dragThresholdSquare = dragThreshold * dragThreshold
373 | return this.pointerInput(
374 | editorState,
375 | element
376 | ) {
377 | val transformation = element.transformation
378 |
379 | forEachGesture {
380 | awaitPointerEventScope {
381 | val down = awaitFirstDown(requireUnconsumed = false)
382 |
383 | do {
384 | val event = awaitPointerEvent()
385 |
386 | // Only allow 1 element to be dragged at the same time
387 | if (editorState.draggedElement.let { it != null && it !== element }) {
388 | return@awaitPointerEventScope
389 | }
390 |
391 | // Detect taps
392 | val isTapEvent = editorState.draggedElement == null &&
393 | event.changes.fastAll { it.changedToUp() } &&
394 | event.changes[0].uptimeMillis - down.uptimeMillis < 500
395 | if (isTapEvent) {
396 | onTap()
397 | return@awaitPointerEventScope
398 | }
399 |
400 | if (!transformation.gesturesEnabled) return@awaitPointerEventScope
401 | // No drag while an element is focused
402 | if (editorState.focusedElement != null) return@awaitPointerEventScope
403 |
404 | // Detect drags beyond a threshold
405 | val movedBeyondThreshold = event.changes
406 | .fastMap { (down.position - it.position).getDistanceSquared() }
407 | .fastAny { it > dragThresholdSquare }
408 | if (movedBeyondThreshold) {
409 | editorState.draggedElement = element
410 | }
411 |
412 | // Update pointer position
413 | if (editorState.draggedElement != null) {
414 | val pointerPosition = event.changes.lastOrNull()?.position
415 | editorState.pointerPosition = if (pointerPosition != null) {
416 | pointerPosition + transformation.scaledHitboxPosition(editorState.editorSize)
417 | } else {
418 | Offset.Unspecified
419 | }
420 | }
421 |
422 | val canceled = event.changes.fastAny { it.isConsumed }
423 | } while (!canceled && event.changes.fastAny { it.pressed })
424 |
425 | editorState.draggedElement = null
426 | // Important, reset position AFTER setting dragged element to null
427 | editorState.pointerPosition = Offset.Unspecified
428 | }
429 | }
430 | }
431 | }
432 |
433 | private fun Modifier.hitbox(
434 | transformation: StoryElementTransformation,
435 | paddingValues: PaddingValues
436 | ): Modifier {
437 | return composed(
438 | "StoryEditorScopeImpl.hitbox",
439 | editorState,
440 | transformation,
441 | paddingValues
442 | ) {
443 | val density = LocalDensity.current
444 | val hitboxPaddingSize = with(density) {
445 | IntSize(
446 | width = paddingValues.horizontalPadding().roundToPx(),
447 | height = paddingValues.verticalPadding().roundToPx()
448 | )
449 | }
450 | Modifier
451 | .padding(paddingValues)
452 | .onSizeChanged {
453 | transformation.updateSize(
454 | size = it,
455 | sizeDp = with(density) {
456 | DpSize(
457 | width = it.width.toDp(),
458 | height = it.height.toDp()
459 | )
460 | },
461 | hitboxSize = IntSize(
462 | width = it.width + hitboxPaddingSize.width,
463 | height = it.height + hitboxPaddingSize.height
464 | ),
465 | editorSize = editorState.editorSize
466 | )
467 | }
468 | }
469 | }
470 | }
471 |
--------------------------------------------------------------------------------
/story-editor/src/main/java/com/github/badoualy/storyeditor/element/text/TextElement.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("MemberVisibilityCanBePrivate")
2 |
3 | package com.github.badoualy.storyeditor.element.text
4 |
5 | import androidx.compose.animation.animateColorAsState
6 | import androidx.compose.foundation.background
7 | import androidx.compose.foundation.clickable
8 | import androidx.compose.foundation.interaction.MutableInteractionSource
9 | import androidx.compose.foundation.layout.Box
10 | import androidx.compose.foundation.layout.IntrinsicSize
11 | import androidx.compose.foundation.layout.PaddingValues
12 | import androidx.compose.foundation.layout.fillMaxSize
13 | import androidx.compose.foundation.layout.imePadding
14 | import androidx.compose.foundation.layout.padding
15 | import androidx.compose.foundation.layout.width
16 | import androidx.compose.foundation.layout.widthIn
17 | import androidx.compose.foundation.text.BasicTextField
18 | import androidx.compose.foundation.text.KeyboardActions
19 | import androidx.compose.foundation.text.KeyboardOptions
20 | import androidx.compose.foundation.text.selection.LocalTextSelectionColors
21 | import androidx.compose.foundation.text.selection.TextSelectionColors
22 | import androidx.compose.runtime.Composable
23 | import androidx.compose.runtime.CompositionLocalProvider
24 | import androidx.compose.runtime.Stable
25 | import androidx.compose.runtime.derivedStateOf
26 | import androidx.compose.runtime.getValue
27 | import androidx.compose.runtime.mutableStateOf
28 | import androidx.compose.runtime.remember
29 | import androidx.compose.runtime.setValue
30 | import androidx.compose.ui.Alignment
31 | import androidx.compose.ui.Modifier
32 | import androidx.compose.ui.draw.drawWithContent
33 | import androidx.compose.ui.focus.FocusRequester
34 | import androidx.compose.ui.geometry.CornerRadius
35 | import androidx.compose.ui.geometry.Offset
36 | import androidx.compose.ui.geometry.Rect
37 | import androidx.compose.ui.geometry.Size
38 | import androidx.compose.ui.graphics.Color
39 | import androidx.compose.ui.graphics.SolidColor
40 | import androidx.compose.ui.graphics.drawscope.clipRect
41 | import androidx.compose.ui.platform.LocalContext
42 | import androidx.compose.ui.platform.LocalDensity
43 | import androidx.compose.ui.platform.LocalFocusManager
44 | import androidx.compose.ui.text.TextLayoutResult
45 | import androidx.compose.ui.text.TextRange
46 | import androidx.compose.ui.text.TextStyle
47 | import androidx.compose.ui.text.font.FontFamily
48 | import androidx.compose.ui.text.font.FontWeight
49 | import androidx.compose.ui.text.input.TextFieldValue
50 | import androidx.compose.ui.text.style.LineHeightStyle
51 | import androidx.compose.ui.text.style.TextAlign
52 | import androidx.compose.ui.unit.Dp
53 | import androidx.compose.ui.unit.IntSize
54 | import androidx.compose.ui.unit.TextUnit
55 | import androidx.compose.ui.unit.dp
56 | import androidx.compose.ui.unit.isSpecified
57 | import androidx.compose.ui.unit.sp
58 | import com.github.badoualy.storyeditor.StoryEditorElementScope
59 | import com.github.badoualy.storyeditor.StoryEditorState.ScreenshotMode
60 | import com.github.badoualy.storyeditor.StoryElement
61 | import com.github.badoualy.storyeditor.StoryElementTransformation
62 | import com.github.badoualy.storyeditor.TransformableStoryElement
63 | import com.github.badoualy.storyeditor.util.ClearFocusOnKeyboardCloseEffect
64 | import com.github.badoualy.storyeditor.util.getLines
65 | import com.github.badoualy.storyeditor.util.horizontalPadding
66 | import com.github.badoualy.storyeditor.util.plus
67 | import com.github.badoualy.storyeditor.util.toDpSize
68 | import kotlinx.collections.immutable.ImmutableList
69 | import kotlinx.collections.immutable.toImmutableList
70 |
71 | object StoryTextElementDefaults {
72 |
73 | val HitboxPadding = PaddingValues(50.dp)
74 | val Padding = PaddingValues(horizontal = 6.dp, vertical = 0.dp)
75 | val BackgroundRadius = 4.dp
76 | val EditPositionFraction = Offset(0.15f, 0.15f)
77 |
78 | object FontStyle {
79 |
80 | val Classic = StoryTextElement.FontStyle(
81 | name = "Classic",
82 | textStyle = TextStyle(fontWeight = FontWeight.Bold, fontSize = 24.sp)
83 | )
84 | val Monospace = StoryTextElement.FontStyle(
85 | name = "Monospace",
86 | textStyle = Classic.textStyle.copy(fontFamily = FontFamily.Monospace)
87 | )
88 | val Serif = StoryTextElement.FontStyle(
89 | name = "Serif",
90 | textStyle = Classic.textStyle.copy(fontFamily = FontFamily.Serif)
91 | )
92 | val Cursive = StoryTextElement.FontStyle(
93 | name = "Cursive",
94 | textStyle = Classic.textStyle.copy(fontFamily = FontFamily.Cursive)
95 | )
96 |
97 | val DefaultList = listOf(Classic, Monospace, Serif, Cursive).toImmutableList()
98 | }
99 | }
100 |
101 | /**
102 | * @param enforceInitialTextLines if true, the text size will be automatically reduced if necessary
103 | * to keep the same line breaks than the initial text
104 | */
105 | @Stable
106 | class StoryTextElement(
107 | val initialText: String = "",
108 | alignType: AlignType = AlignType.START,
109 | fontStyle: FontStyle = StoryTextElementDefaults.FontStyle.Classic,
110 | colorSchemeType: ColorSchemeType = ColorSchemeType.BACKGROUND,
111 | colorScheme: StoryElement.ColorScheme = StoryElement.ColorScheme.White,
112 |
113 | initialSizeFraction: Size? = null,
114 | scale: Float = 1f,
115 | rotation: Float = 0f,
116 | positionFraction: Offset = StoryTextElementDefaults.EditPositionFraction,
117 | private val editPositionFraction: Offset = StoryTextElementDefaults.EditPositionFraction,
118 |
119 | minScale: Float = 0.5f,
120 | maxScale: Float = 3f,
121 |
122 | val enforceInitialTextLines: Boolean = true
123 | ) : StoryElement, TransformableStoryElement {
124 |
125 | var text by mutableStateOf(TextFieldValue(initialText))
126 | var alignType by mutableStateOf(alignType)
127 | var fontStyle by mutableStateOf(fontStyle)
128 | var colorSchemeType by mutableStateOf(colorSchemeType)
129 | var colorScheme by mutableStateOf(colorScheme)
130 | var textLines: String = initialText
131 | private set
132 |
133 | override val transformation = StoryElementTransformation(
134 | initialSizeFraction = initialSizeFraction,
135 | scale = scale,
136 | rotation = rotation,
137 | positionFraction = positionFraction,
138 |
139 | minScale = minScale,
140 | maxScale = maxScale
141 | )
142 |
143 | override suspend fun startEdit(editorSize: IntSize, bounds: Rect) {
144 | // Set cursor position at the end
145 | text = text.copy(selection = TextRange(text.text.length))
146 |
147 | // Override position
148 | transformation.startEdit(positionFraction = editPositionFraction)
149 | }
150 |
151 | override suspend fun stopEdit(editorSize: IntSize, bounds: Rect): Boolean {
152 | text = text.copy(text = text.text.trim())
153 | if (text.text.isBlank()) return false
154 |
155 | // Stop position override
156 | transformation.stopEdit(editorSize, bounds)
157 | return true
158 | }
159 |
160 | internal fun updateLayoutResult(textLayoutResult: TextLayoutResult) {
161 | textLines = textLayoutResult.getLines()
162 | }
163 |
164 | internal fun toggleAlignType() {
165 | val index = (alignType.ordinal + 1) % AlignType.entries.size
166 | alignType = AlignType.entries[index]
167 | }
168 |
169 | internal fun toggleColorSchemeType() {
170 | val index = (colorSchemeType.ordinal + 1) % ColorSchemeType.entries.size
171 | colorSchemeType = ColorSchemeType.entries[index]
172 | }
173 |
174 | fun textStyle(): TextStyle {
175 | return fontStyle.textStyle.copy(
176 | color = textColor(),
177 | textAlign = textAlign()
178 | )
179 | }
180 |
181 | fun backgroundColor(): Color {
182 | return when (colorSchemeType) {
183 | ColorSchemeType.BACKGROUND -> colorScheme.primary
184 | ColorSchemeType.INVERTED -> colorScheme.secondary
185 | ColorSchemeType.TEXT_ONLY -> Color.Transparent
186 | }
187 | }
188 |
189 | fun textColor(): Color {
190 | return when (colorSchemeType) {
191 | ColorSchemeType.BACKGROUND -> colorScheme.secondary
192 | ColorSchemeType.INVERTED -> colorScheme.primary
193 | ColorSchemeType.TEXT_ONLY -> colorScheme.primary
194 | }
195 | }
196 |
197 | private fun textAlign(): TextAlign {
198 | return when (alignType) {
199 | AlignType.START -> TextAlign.Start
200 | AlignType.CENTER -> TextAlign.Center
201 | AlignType.END -> TextAlign.End
202 | }
203 | }
204 |
205 | enum class AlignType { START, CENTER, END }
206 |
207 | enum class ColorSchemeType {
208 | /** primary is background */
209 | BACKGROUND,
210 |
211 | /** primary is text color */
212 | INVERTED,
213 |
214 | /** primary is text color, and no background */
215 | TEXT_ONLY
216 | }
217 |
218 | data class FontStyle(val name: String, val textStyle: TextStyle)
219 | }
220 |
221 | @Composable
222 | fun StoryEditorElementScope.TextElement(
223 | element: StoryTextElement,
224 | modifier: Modifier = Modifier,
225 | hitboxPadding: PaddingValues = StoryTextElementDefaults.HitboxPadding,
226 | elementPadding: PaddingValues = StoryTextElementDefaults.Padding,
227 | backgroundRadius: Dp = StoryTextElementDefaults.BackgroundRadius,
228 | lineSpacingExtra: TextUnit = 5.sp,
229 | keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
230 | keyboardActions: KeyboardActions = KeyboardActions.Default,
231 | fontStyles: ImmutableList = StoryTextElementDefaults.FontStyle.DefaultList,
232 | colorSchemes: ImmutableList = StoryElement.ColorScheme.DefaultList,
233 | ) {
234 | Box(modifier = modifier.fillMaxSize()) {
235 | // Editor overlay when focused
236 | val isFocused by remember(editorState, element) {
237 | derivedStateOf {
238 | editorState.focusedElement === element
239 | }
240 | }
241 | if (isFocused) {
242 | val focusManager = LocalFocusManager.current
243 | focusManager.ClearFocusOnKeyboardCloseEffect()
244 |
245 | TextElementEditorOverlay(
246 | element = element,
247 | isScreenshotModeEnabled = editorState.screenshotMode != ScreenshotMode.DISABLED,
248 | onClickOutside = {
249 | focusManager.clearFocus()
250 | },
251 | fontStyles = fontStyles,
252 | colorSchemes = colorSchemes
253 | )
254 | }
255 |
256 | // Actual element
257 | val focusRequester = remember { FocusRequester() }
258 | Box(
259 | modifier = Modifier
260 | .elementTransformation(
261 | element = element,
262 | onClick = {
263 | // request focus on TextField to edit text
264 | if (!isFocused) {
265 | focusRequester.requestFocus()
266 | }
267 | },
268 | hitboxPadding = hitboxPadding
269 | )
270 | ) {
271 | val isEnabled by remember(editorState, element) {
272 | derivedStateOf {
273 | editorState.isFocusable(element)
274 | }
275 | }
276 |
277 | // Instead of using BoxWithConstraints, we use editorState.elementBounds to compute maxWidth
278 | // But it means we might have a few frames with maxWidth to <= 0 until the size is reported
279 | val density = LocalDensity.current
280 | val hitboxPaddingPx = with(density) { hitboxPadding.horizontalPadding().toPx() }
281 | val elementPaddingPx = with(density) { elementPadding.horizontalPadding().toPx() }
282 | val maxWidth by remember(editorState, density) {
283 | derivedStateOf {
284 | editorState.elementsBounds.width - hitboxPaddingPx - elementPaddingPx
285 | }
286 | }
287 |
288 | TextElementTextField(
289 | element = element,
290 | enabled = isEnabled,
291 | elementPadding = elementPadding,
292 | backgroundRadius = backgroundRadius,
293 | lineSpacingExtra = lineSpacingExtra,
294 | maxWidth = maxWidth,
295 | keyboardOptions = keyboardOptions,
296 | keyboardActions = keyboardActions,
297 | modifier = Modifier.focusableElement(element, focusRequester, skipFocusable = true),
298 | )
299 | }
300 | }
301 | }
302 |
303 | @Composable
304 | fun TextElementTextField(
305 | element: StoryTextElement,
306 | enabled: Boolean,
307 | modifier: Modifier = Modifier,
308 | elementPadding: PaddingValues = PaddingValues(),
309 | backgroundRadius: Dp = 0.dp,
310 | lineSpacingExtra: TextUnit = 0.sp,
311 | maxWidth: Float = Float.POSITIVE_INFINITY,
312 | keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
313 | keyboardActions: KeyboardActions = KeyboardActions.Default,
314 | onValueChange: (TextFieldValue) -> Unit = { element.text = it },
315 | decorationBox: @Composable (innerTextField: @Composable () -> Unit) -> Unit =
316 | @Composable { innerTextField -> innerTextField() }
317 | ) {
318 | val isEmpty by remember(element) { derivedStateOf { element.text.text.isEmpty() } }
319 | var linesBounds by remember { mutableStateOf(listOf()) }
320 |
321 | val textColor by animateColorAsState(element.textColor())
322 | val backgroundColor by animateColorAsState(element.backgroundColor())
323 | val textStyle = element.textStyle()
324 |
325 | // Resolve size to apply AutoResize effect
326 | val density = LocalDensity.current
327 | val context = LocalContext.current
328 | val resolvedTextSize = if (element.enforceInitialTextLines) {
329 | if (maxWidth <= 0) return
330 | remember(element, maxWidth) {
331 | resolveAutoResizeTextSize(
332 | text = element.initialText,
333 | textStyle = textStyle,
334 | maxWidth = maxWidth,
335 | density = density,
336 | context = context
337 | )
338 | }
339 | } else {
340 | textStyle.fontSize
341 | }
342 |
343 | val mergedTextStyle = textStyle.copy(
344 | color = textColor,
345 | fontSize = resolvedTextSize,
346 | lineHeight = if (textStyle.lineHeight.isSpecified) {
347 | (textStyle.lineHeight.value + lineSpacingExtra.value).sp
348 | } else {
349 | TextUnit.Unspecified
350 | },
351 | lineHeightStyle = LineHeightStyle(
352 | alignment = LineHeightStyle.Alignment.Top,
353 | trim = LineHeightStyle.Trim.LastLineBottom
354 | )
355 | )
356 | val textSelectionColors = remember(element.colorScheme.secondary) {
357 | TextSelectionColors(
358 | handleColor = element.colorScheme.secondary,
359 | backgroundColor = element.colorScheme.secondary.copy(alpha = 0.4f)
360 | )
361 | }
362 |
363 | CompositionLocalProvider(LocalTextSelectionColors provides textSelectionColors) {
364 | BasicTextField(
365 | value = element.text,
366 | onValueChange = onValueChange,
367 | modifier = Modifier
368 | .width(IntrinsicSize.Min)
369 | // Cursor thickness is 2.dp
370 | .widthIn(min = 2.dp)
371 | .drawWithContent {
372 | if (linesBounds.isEmpty()) {
373 | drawContent()
374 | return@drawWithContent
375 | }
376 |
377 | // Draw background on each line
378 | val paddingSize = elementPadding
379 | .toDpSize()
380 | .toSize()
381 | val cornerRadius = CornerRadius(backgroundRadius.toPx())
382 | val lastVisibleLine = linesBounds.lastOrNull { it.bottom <= size.height }
383 |
384 | linesBounds.forEach { lineBounds ->
385 | if (lineBounds.width == 0f || lineBounds.bottom > size.height) return@forEach
386 | drawRoundRect(
387 | color = backgroundColor,
388 | topLeft = lineBounds.topLeft,
389 | size = lineBounds.size + paddingSize,
390 | cornerRadius = cornerRadius
391 | )
392 | }
393 |
394 | // Clip to the last visible line to make sure we don't display vertically cropped text
395 | // because of TextField internal scroll modifier
396 | clipRect(bottom = lastVisibleLine?.bottom ?: size.height) {
397 | this@drawWithContent.drawContent()
398 | }
399 | }
400 | .then(if (isEmpty) Modifier else Modifier.padding(elementPadding))
401 | .then(modifier),
402 | textStyle = mergedTextStyle,
403 | cursorBrush = SolidColor(textColor),
404 | enabled = enabled,
405 | keyboardOptions = keyboardOptions,
406 | keyboardActions = keyboardActions,
407 | onTextLayout = { layout ->
408 | element.updateLayoutResult(layout)
409 | if (layout.layoutInput.text.isEmpty()) {
410 | linesBounds = emptyList()
411 | return@BasicTextField
412 | }
413 |
414 | // Because we add space to the bottom of the line and we trim lastLine bottom, last line is the real height
415 | val lineHeightPx = layout.multiParagraph.getLineHeight(layout.lineCount - 1)
416 |
417 | // Build bounding rect for each line
418 | linesBounds = List(layout.lineCount) { line ->
419 | val lineContent = layout.layoutInput.text.text.substring(
420 | layout.getLineStart(line),
421 | layout.getLineEnd(line)
422 | )
423 |
424 | val top = layout.getLineTop(line)
425 | val bottom = top + lineHeightPx
426 | if (lineContent.isBlank()) return@List Rect(0f, top, 0f, bottom)
427 | Rect(
428 | left = layout.getLineLeft(line),
429 | top = top,
430 | right = layout.getLineRight(line),
431 | bottom = bottom,
432 | )
433 | }
434 | },
435 | decorationBox = decorationBox,
436 | )
437 | }
438 | }
439 |
440 | @Composable
441 | fun TextElementEditorOverlay(
442 | element: StoryTextElement,
443 | isScreenshotModeEnabled: Boolean,
444 | onClickOutside: () -> Unit,
445 | modifier: Modifier = Modifier,
446 | fontStyles: ImmutableList,
447 | colorSchemes: ImmutableList,
448 | ) {
449 | Box(
450 | modifier = modifier
451 | .fillMaxSize()
452 | .background(Color.Black.copy(alpha = 0.4f))
453 | .imePadding()
454 | .clickable(
455 | interactionSource = remember { MutableInteractionSource() },
456 | indication = null,
457 | onClick = onClickOutside
458 | ),
459 | contentAlignment = Alignment.BottomStart
460 | ) {
461 | TextElementEditorBar(
462 | colorSchemes = colorSchemes,
463 | fontStyles = fontStyles,
464 | currentColorScheme = element::colorScheme,
465 | currentAlignType = element::alignType,
466 | currentFontStyle = element::fontStyle,
467 | currentColorSchemeType = element::colorSchemeType,
468 | onAlignTypeClick = element::toggleAlignType,
469 | onColorSchemeTypeClick = element::toggleColorSchemeType,
470 | onSelectFontStyle = { element.fontStyle = it },
471 | onSelectColorScheme = { element.colorScheme = it }
472 | )
473 | }
474 | }
475 |
--------------------------------------------------------------------------------