From c6d2e54923e779180f456bef3ba275dcb2f74d5d Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 11 Jan 2024 00:38:47 -0800 Subject: [PATCH] selectable text experiment --- .gitignore | 4 +- .../expo-selectable-text/android/build.gradle | 89 +++++++++ .../android/src/main/AndroidManifest.xml | 2 + .../ExpoSelectableTextModule.kt | 47 +++++ .../selectabletext/ExpoSelectableTextView.kt | 7 + .../expo-module.config.json | 9 + modules/expo-selectable-text/index.ts | 26 +++ .../ios/ExpoProText.podspec | 21 ++ .../ios/ExpoProTextModule.swift | 26 +++ .../ios/ExpoProTextUtil.swift | 188 ++++++++++++++++++ .../ios/ExpoProTextView.swift | 136 +++++++++++++ .../ios/TextSegment.swift | 13 ++ .../expo-selectable-text/ios/TextStyle.swift | 31 +++ .../src/ExpoProText.types.ts | 52 +++++ .../src/ExpoProTextModule.ts | 5 + .../src/ExpoProTextModule.web.ts | 13 ++ .../src/ExpoProTextView.tsx | 109 ++++++++++ .../src/ExpoProTextView.web.tsx | 11 + 18 files changed, 787 insertions(+), 2 deletions(-) create mode 100644 modules/expo-selectable-text/android/build.gradle create mode 100644 modules/expo-selectable-text/android/src/main/AndroidManifest.xml create mode 100644 modules/expo-selectable-text/android/src/main/java/expo/modules/selectabletext/ExpoSelectableTextModule.kt create mode 100644 modules/expo-selectable-text/android/src/main/java/expo/modules/selectabletext/ExpoSelectableTextView.kt create mode 100644 modules/expo-selectable-text/expo-module.config.json create mode 100644 modules/expo-selectable-text/index.ts create mode 100644 modules/expo-selectable-text/ios/ExpoProText.podspec create mode 100644 modules/expo-selectable-text/ios/ExpoProTextModule.swift create mode 100644 modules/expo-selectable-text/ios/ExpoProTextUtil.swift create mode 100644 modules/expo-selectable-text/ios/ExpoProTextView.swift create mode 100644 modules/expo-selectable-text/ios/TextSegment.swift create mode 100644 modules/expo-selectable-text/ios/TextStyle.swift create mode 100644 modules/expo-selectable-text/src/ExpoProText.types.ts create mode 100644 modules/expo-selectable-text/src/ExpoProTextModule.ts create mode 100644 modules/expo-selectable-text/src/ExpoProTextModule.web.ts create mode 100644 modules/expo-selectable-text/src/ExpoProTextView.tsx create mode 100644 modules/expo-selectable-text/src/ExpoProTextView.web.tsx diff --git a/.gitignore b/.gitignore index 7a6137b190..f96d0d5ff7 100644 --- a/.gitignore +++ b/.gitignore @@ -91,8 +91,8 @@ web-build/ # Android & iOS folders -android/ -ios/ +/android/ +/ios/ # environment variables .env diff --git a/modules/expo-selectable-text/android/build.gradle b/modules/expo-selectable-text/android/build.gradle new file mode 100644 index 0000000000..9e85c5c2d8 --- /dev/null +++ b/modules/expo-selectable-text/android/build.gradle @@ -0,0 +1,89 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'maven-publish' + +group = 'expo.modules.selectabletext' +version = '0.2.0' + +buildscript { + def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") + if (expoModulesCorePlugin.exists()) { + apply from: expoModulesCorePlugin + applyKotlinExpoModulesCorePlugin() + } + + // Simple helper that allows the root project to override versions declared by this library. + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback + } + + // Ensures backward compatibility + ext.getKotlinVersion = { + if (ext.has("kotlinVersion")) { + ext.kotlinVersion() + } else { + ext.safeExtGet("kotlinVersion", "1.8.10") + } + } + + repositories { + mavenCentral() + } + + dependencies { + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}") + } +} + +afterEvaluate { + publishing { + publications { + release(MavenPublication) { + from components.release + } + } + repositories { + maven { + url = mavenLocal().url + } + } + } +} + +android { + compileSdkVersion safeExtGet("compileSdkVersion", 33) + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.majorVersion + } + + namespace "expo.modules.selectabletext" + defaultConfig { + minSdkVersion safeExtGet("minSdkVersion", 21) + targetSdkVersion safeExtGet("targetSdkVersion", 33) + versionCode 1 + versionName "0.2.0" + } + lintOptions { + abortOnError false + } + publishing { + singleVariant("release") { + withSourcesJar() + } + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation project(':expo-modules-core') + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}" +} diff --git a/modules/expo-selectable-text/android/src/main/AndroidManifest.xml b/modules/expo-selectable-text/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..bdae66c8f5 --- /dev/null +++ b/modules/expo-selectable-text/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/modules/expo-selectable-text/android/src/main/java/expo/modules/selectabletext/ExpoSelectableTextModule.kt b/modules/expo-selectable-text/android/src/main/java/expo/modules/selectabletext/ExpoSelectableTextModule.kt new file mode 100644 index 0000000000..37f5f89e9a --- /dev/null +++ b/modules/expo-selectable-text/android/src/main/java/expo/modules/selectabletext/ExpoSelectableTextModule.kt @@ -0,0 +1,47 @@ +package expo.modules.selectabletext + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class ExpoSelectableTextModule : Module() { + // Each module class must implement the definition function. The definition consists of components + // that describes the module's functionality and behavior. + // See https://docs.expo.dev/modules/module-api for more details about available components. + override fun definition() = ModuleDefinition { + // Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument. + // Can be inferred from module's class name, but it's recommended to set it explicitly for clarity. + // The module will be accessible from `requireNativeModule('ExpoSelectableText')` in JavaScript. + Name("ExpoSelectableText") + + // Sets constant properties on the module. Can take a dictionary or a closure that returns a dictionary. + Constants( + "PI" to Math.PI + ) + + // Defines event names that the module can send to JavaScript. + Events("onChange") + + // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread. + Function("hello") { + "Hello world! ๐Ÿ‘‹" + } + + // Defines a JavaScript function that always returns a Promise and whose native code + // is by default dispatched on the different thread than the JavaScript runtime runs on. + AsyncFunction("setValueAsync") { value: String -> + // Send an event to JavaScript. + sendEvent("onChange", mapOf( + "value" to value + )) + } + + // Enables the module to be used as a native view. Definition components that are accepted as part of + // the view definition: Prop, Events. + View(ExpoSelectableTextView::class) { + // Defines a setter for the `name` prop. + Prop("name") { view: ExpoSelectableTextView, prop: String -> + println(prop) + } + } + } +} diff --git a/modules/expo-selectable-text/android/src/main/java/expo/modules/selectabletext/ExpoSelectableTextView.kt b/modules/expo-selectable-text/android/src/main/java/expo/modules/selectabletext/ExpoSelectableTextView.kt new file mode 100644 index 0000000000..5a13932613 --- /dev/null +++ b/modules/expo-selectable-text/android/src/main/java/expo/modules/selectabletext/ExpoSelectableTextView.kt @@ -0,0 +1,7 @@ +package expo.modules.selectabletext + +import android.content.Context +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.views.ExpoView + +class ExpoSelectableTextView(context: Context, appContext: AppContext) : ExpoView(context, appContext) diff --git a/modules/expo-selectable-text/expo-module.config.json b/modules/expo-selectable-text/expo-module.config.json new file mode 100644 index 0000000000..f2ceb5a7a0 --- /dev/null +++ b/modules/expo-selectable-text/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["ios", "android", "web"], + "ios": { + "modules": ["ExpoSelectableTextModule"] + }, + "android": { + "modules": ["expo.modules.selectabletext.ExpoSelectableTextModule"] + } +} diff --git a/modules/expo-selectable-text/index.ts b/modules/expo-selectable-text/index.ts new file mode 100644 index 0000000000..5fe8d2e4bc --- /dev/null +++ b/modules/expo-selectable-text/index.ts @@ -0,0 +1,26 @@ +import { NativeModulesProxy, EventEmitter, Subscription } from 'expo-modules-core'; + +// Import the native module. On web, it will be resolved to ExpoSelectableText.web.ts +// and on native platforms to ExpoSelectableText.ts +import ExpoSelectableTextModule from './src/ExpoSelectableTextModule'; +import ExpoSelectableTextView from './src/ExpoSelectableTextView'; +import { ChangeEventPayload, ExpoSelectableTextViewProps } from './src/ExpoSelectableText.types'; + +// Get the native constant value. +export const PI = ExpoSelectableTextModule.PI; + +export function hello(): string { + return ExpoSelectableTextModule.hello(); +} + +export async function setValueAsync(value: string) { + return await ExpoSelectableTextModule.setValueAsync(value); +} + +const emitter = new EventEmitter(ExpoSelectableTextModule ?? NativeModulesProxy.ExpoSelectableText); + +export function addChangeListener(listener: (event: ChangeEventPayload) => void): Subscription { + return emitter.addListener('onChange', listener); +} + +export { ExpoSelectableTextView, ExpoSelectableTextViewProps, ChangeEventPayload }; diff --git a/modules/expo-selectable-text/ios/ExpoProText.podspec b/modules/expo-selectable-text/ios/ExpoProText.podspec new file mode 100644 index 0000000000..0564384ee1 --- /dev/null +++ b/modules/expo-selectable-text/ios/ExpoProText.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'ExpoProText' + s.version = '1.0.0' + s.summary = 'A sample project summary' + s.description = 'A sample project description' + s.author = '' + s.homepage = 'https://docs.expo.dev/modules/' + s.platform = :ios, '13.0' + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + # Swift/Objective-C compatibility + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/modules/expo-selectable-text/ios/ExpoProTextModule.swift b/modules/expo-selectable-text/ios/ExpoProTextModule.swift new file mode 100644 index 0000000000..5f1fb95d92 --- /dev/null +++ b/modules/expo-selectable-text/ios/ExpoProTextModule.swift @@ -0,0 +1,26 @@ +import ExpoModulesCore + +public class ExpoProTextModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoProText") + + View(ExpoProTextView.self) { + Events("onTextPress", "onTextLongPress") + + Prop("segments") { (view: ExpoProTextView, prop: String) in + // Convert the JSON to segments + if let data = prop.data(using: .utf8) { + print("yes") + if let segments = try? JSONDecoder().decode(TextSegments.self, from: data) { + print("yes2") + view.segments = segments.segments + } + } + } + + Prop("selectable") { (view: ExpoProTextView, prop: Bool) in + view.textView.isSelectable = prop + } + } + } +} diff --git a/modules/expo-selectable-text/ios/ExpoProTextUtil.swift b/modules/expo-selectable-text/ios/ExpoProTextUtil.swift new file mode 100644 index 0000000000..59936adc1f --- /dev/null +++ b/modules/expo-selectable-text/ios/ExpoProTextUtil.swift @@ -0,0 +1,188 @@ +public class ExpoProTextUtil { + public static func hexToUIColor(hex: String?) -> UIColor { + guard let hex else { + return UIColor.black + } + + let r, g, b, a: CGFloat + + if hex.hasPrefix("#") { + let start = hex.index(hex.startIndex, offsetBy: 1) + let hexColor = String(hex[start...]) + + if hexColor.count == 8 { + let scanner = Scanner(string: hexColor) + var hexNumber: UInt64 = 0 + + if scanner.scanHexInt64(&hexNumber) { + r = CGFloat((hexNumber & 0xff000000) >> 24) / 255 + g = CGFloat((hexNumber & 0x00ff0000) >> 16) / 255 + b = CGFloat((hexNumber & 0x0000ff00) >> 8) / 255 + a = CGFloat(hexNumber & 0x000000ff) / 255 + + return UIColor(red: r, green: g, blue: b, alpha: a) + } + } else if hexColor.count == 6 { + let scanner = Scanner(string: hexColor) + var hexNumber: UInt64 = 0 + + if scanner.scanHexInt64(&hexNumber) { + r = CGFloat((hexNumber & 0xff0000) >> 16) / 255 + g = CGFloat((hexNumber & 0x00ff00) >> 8) / 255 + b = CGFloat(hexNumber & 0x0000ff) / 255 + + return UIColor(red: r, green: g, blue: b, alpha: 1.0) + } + } + } + + return UIColor.black + } +} + +// https://github.com/WorldDownTown/CSS3ColorsSwift/blob/master/CSS3ColorsSwift/CSS3ColorsSwift.swift + +// +// CSS3Colors.swift +// CSS3Colors +// +// Created by Keisuke Shoji on 2016/11/07. +// Copyright ยฉ 2016ๅนด Keisuke Shoji. All rights reserved. +// + +import UIKit + +public extension UIColor { + + class var whiteSmoke: UIColor { return #colorLiteral(red: 0.9607843137, green: 0.9607843137, blue: 0.9607843137, alpha: 1) } // F5F5F5 + class var ghostWhite: UIColor { return #colorLiteral(red: 0.9725490196, green: 0.9725490196, blue: 1.0000000000, alpha: 1) } // F8F8FF + class var aliceBlue: UIColor { return #colorLiteral(red: 0.9411764706, green: 0.9725490196, blue: 1.0000000000, alpha: 1) } // F0F8FF + class var lavender: UIColor { return #colorLiteral(red: 0.9019607843, green: 0.9019607843, blue: 0.9803921569, alpha: 1) } // E6E6FA + class var azure: UIColor { return #colorLiteral(red: 0.9411764706, green: 1.0000000000, blue: 1.0000000000, alpha: 1) } // F0FFFF + class var lightCyan: UIColor { return #colorLiteral(red: 0.8784313725, green: 1.0000000000, blue: 1.0000000000, alpha: 1) } // E0FFFF + class var mintCream: UIColor { return #colorLiteral(red: 0.9607843137, green: 1.0000000000, blue: 0.9803921569, alpha: 1) } // F5FFFA + class var honeyDew: UIColor { return #colorLiteral(red: 0.9411764706, green: 1.0000000000, blue: 0.9411764706, alpha: 1) } // F0FFF0 + class var ivory: UIColor { return #colorLiteral(red: 1.0000000000, green: 1.0000000000, blue: 0.9411764706, alpha: 1) } // FFFFF0 + class var beige: UIColor { return #colorLiteral(red: 0.9607843137, green: 0.9607843137, blue: 0.8627450980, alpha: 1) } // F5F5DC + class var lightYellow: UIColor { return #colorLiteral(red: 1.0000000000, green: 1.0000000000, blue: 0.8784313725, alpha: 1) } // FFFFE0 + class var lightGoldenRodYellow: UIColor { return #colorLiteral(red: 0.9803921569, green: 0.9803921569, blue: 0.8235294118, alpha: 1) } // FAFAD2 + class var lemonChiffon: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.9803921569, blue: 0.8039215686, alpha: 1) } // FFFACD + class var floralWhite: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.9803921569, blue: 0.9411764706, alpha: 1) } // FFFAF0 + class var oldLace: UIColor { return #colorLiteral(red: 0.9921568627, green: 0.9607843137, blue: 0.9019607843, alpha: 1) } // FDF5E6 + class var cornSilk: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.9725490196, blue: 0.8627450980, alpha: 1) } // FFF8DC + class var papayaWhip: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.9372549020, blue: 0.8352941176, alpha: 1) } // FFEFD5 + class var blanchedAlmond: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.9215686275, blue: 0.8039215686, alpha: 1) } // FFEBCD + class var bisque: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.8941176471, blue: 0.7686274510, alpha: 1) } // FFE4C4 + class var snow: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.9803921569, blue: 0.9803921569, alpha: 1) } // FFFAFA + class var linen: UIColor { return #colorLiteral(red: 0.9803921569, green: 0.9411764706, blue: 0.9019607843, alpha: 1) } // FAF0E6 + class var antiqueWhite: UIColor { return #colorLiteral(red: 0.9803921569, green: 0.9215686275, blue: 0.8431372549, alpha: 1) } // FAEBD7 + class var seaShell: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.9607843137, blue: 0.9333333333, alpha: 1) } // FFF5EE + class var lavenderBlush: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.9411764706, blue: 0.9607843137, alpha: 1) } // FFF0F5 + class var mistyRose: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.8941176471, blue: 0.8823529412, alpha: 1) } // FFE4E1 + class var gainsboro: UIColor { return #colorLiteral(red: 0.8627450980, green: 0.8627450980, blue: 0.8627450980, alpha: 1) } // DCDCDC + class var lightGrayCSS3: UIColor { return #colorLiteral(red: 0.8274509804, green: 0.8274509804, blue: 0.8274509804, alpha: 1) } // D3D3D3 + class var lightSteelBlue: UIColor { return #colorLiteral(red: 0.6901960784, green: 0.7686274510, blue: 0.8705882353, alpha: 1) } // B0C4DE + class var lightBlue: UIColor { return #colorLiteral(red: 0.6784313725, green: 0.8470588235, blue: 0.9019607843, alpha: 1) } // ADD8E6 + class var lightSkyBlue: UIColor { return #colorLiteral(red: 0.5294117647, green: 0.8078431373, blue: 0.9803921569, alpha: 1) } // 87CEFA + class var powderBlue: UIColor { return #colorLiteral(red: 0.6901960784, green: 0.8784313725, blue: 0.9019607843, alpha: 1) } // B0E0E6 + class var paleTurquoise: UIColor { return #colorLiteral(red: 0.6862745098, green: 0.9333333333, blue: 0.9333333333, alpha: 1) } // AFEEEE + class var skyBlue: UIColor { return #colorLiteral(red: 0.5294117647, green: 0.8078431373, blue: 0.9215686275, alpha: 1) } // 87CEEB + class var mediumAquamarine: UIColor { return #colorLiteral(red: 0.4000000000, green: 0.8039215686, blue: 0.6666666667, alpha: 1) } // 66CDAA + class var aquamarine: UIColor { return #colorLiteral(red: 0.4980392157, green: 1.0000000000, blue: 0.8313725490, alpha: 1) } // 7FFFD4 + class var paleGreen: UIColor { return #colorLiteral(red: 0.5960784314, green: 0.9843137255, blue: 0.5960784314, alpha: 1) } // 98FB98 + class var lightGreen: UIColor { return #colorLiteral(red: 0.5647058824, green: 0.9333333333, blue: 0.5647058824, alpha: 1) } // 90EE90 + class var khaki: UIColor { return #colorLiteral(red: 0.9411764706, green: 0.9019607843, blue: 0.5490196078, alpha: 1) } // F0E68C + class var paleGoldenRod: UIColor { return #colorLiteral(red: 0.9333333333, green: 0.9098039216, blue: 0.6666666667, alpha: 1) } // EEE8AA + class var moccasin: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.8941176471, blue: 0.7098039216, alpha: 1) } // FFE4B5 + class var navajoWhite: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.8705882353, blue: 0.6784313725, alpha: 1) } // FFDEAD + class var peachPuff: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.8549019608, blue: 0.7254901961, alpha: 1) } // FFDAB9 + class var wheat: UIColor { return #colorLiteral(red: 0.9607843137, green: 0.8705882353, blue: 0.7019607843, alpha: 1) } // F5DEB3 + class var pink: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.7529411765, blue: 0.7960784314, alpha: 1) } // FFC0CB + class var lightPink: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.7137254902, blue: 0.7568627451, alpha: 1) } // FFB6C1 + class var thistle: UIColor { return #colorLiteral(red: 0.8470588235, green: 0.7490196078, blue: 0.8470588235, alpha: 1) } // D8BFD8 + class var plum: UIColor { return #colorLiteral(red: 0.8666666667, green: 0.6274509804, blue: 0.8666666667, alpha: 1) } // DDA0DD + class var silver: UIColor { return #colorLiteral(red: 0.7529411765, green: 0.7529411765, blue: 0.7529411765, alpha: 1) } // C0C0C0 + class var darkGrayCSS3: UIColor { return #colorLiteral(red: 0.6627450980, green: 0.6627450980, blue: 0.6627450980, alpha: 1) } // A9A9A9 + class var lightSlateGray: UIColor { return #colorLiteral(red: 0.4666666667, green: 0.5333333333, blue: 0.6000000000, alpha: 1) } // 778899 + class var slateGray: UIColor { return #colorLiteral(red: 0.4392156863, green: 0.5019607843, blue: 0.5647058824, alpha: 1) } // 708090 + class var slateBlue: UIColor { return #colorLiteral(red: 0.4156862745, green: 0.3529411765, blue: 0.8039215686, alpha: 1) } // 6A5ACD + class var steelBlue: UIColor { return #colorLiteral(red: 0.2745098039, green: 0.5098039216, blue: 0.7058823529, alpha: 1) } // 4682B4 + class var mediumSlateBlue: UIColor { return #colorLiteral(red: 0.4823529412, green: 0.4078431373, blue: 0.9333333333, alpha: 1) } // 7B68EE + class var royalBlue: UIColor { return #colorLiteral(red: 0.2549019608, green: 0.4117647059, blue: 0.8823529412, alpha: 1) } // 4169E1 + class var dodgerBlue: UIColor { return #colorLiteral(red: 0.1176470588, green: 0.5647058824, blue: 1.0000000000, alpha: 1) } // 1E90FF + class var cornflowerBlue: UIColor { return #colorLiteral(red: 0.3921568627, green: 0.5843137255, blue: 0.9294117647, alpha: 1) } // 6495ED + class var deepSkyBlue: UIColor { return #colorLiteral(red: 0.0000000000, green: 0.7490196078, blue: 1.0000000000, alpha: 1) } // 00BFFF + class var aqua: UIColor { return #colorLiteral(red: 0.0000000000, green: 1.0000000000, blue: 1.0000000000, alpha: 1) } // 00FFFF + class var turquoise: UIColor { return #colorLiteral(red: 0.2509803922, green: 0.8784313725, blue: 0.8156862745, alpha: 1) } // 40E0D0 + class var mediumTurquoise: UIColor { return #colorLiteral(red: 0.2823529412, green: 0.8196078431, blue: 0.8000000000, alpha: 1) } // 48D1CC + class var darkTurquoise: UIColor { return #colorLiteral(red: 0.0000000000, green: 0.8078431373, blue: 0.8196078431, alpha: 1) } // 00CED1 + class var lightSeaGreen: UIColor { return #colorLiteral(red: 0.1254901961, green: 0.6980392157, blue: 0.6666666667, alpha: 1) } // 20B2AA + class var mediumSpringGreen: UIColor { return #colorLiteral(red: 0.0000000000, green: 0.9803921569, blue: 0.6039215686, alpha: 1) } // 00FA9A + class var springGreen: UIColor { return #colorLiteral(red: 0.0000000000, green: 1.0000000000, blue: 0.4980392157, alpha: 1) } // 00FF7F + class var lime: UIColor { return #colorLiteral(red: 0.0000000000, green: 1.0000000000, blue: 0.0000000000, alpha: 1) } // 00FF00 + class var limeGreen: UIColor { return #colorLiteral(red: 0.1960784314, green: 0.8039215686, blue: 0.1960784314, alpha: 1) } // 32CD32 + class var yellowGreen: UIColor { return #colorLiteral(red: 0.6039215686, green: 0.8039215686, blue: 0.1960784314, alpha: 1) } // 9ACD32 + class var lawnGreen: UIColor { return #colorLiteral(red: 0.4862745098, green: 0.9882352941, blue: 0.0000000000, alpha: 1) } // 7CFC00 + class var chartreuse: UIColor { return #colorLiteral(red: 0.4980392157, green: 1.0000000000, blue: 0.0000000000, alpha: 1) } // 7FFF00 + class var greenYellow: UIColor { return #colorLiteral(red: 0.6784313725, green: 1.0000000000, blue: 0.1843137255, alpha: 1) } // ADFF2F + class var gold: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.8431372549, blue: 0.0000000000, alpha: 1) } // FFD700 + class var orangeCSS3: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.6470588235, blue: 0.0000000000, alpha: 1) } // FFA500 + class var darkOrange: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.5490196078, blue: 0.0000000000, alpha: 1) } // FF8C00 + class var goldenRod: UIColor { return #colorLiteral(red: 0.8549019608, green: 0.6470588235, blue: 0.1254901961, alpha: 1) } // DAA520 + class var burlyWood: UIColor { return #colorLiteral(red: 0.8705882353, green: 0.7215686275, blue: 0.5294117647, alpha: 1) } // DEB887 + class var tan: UIColor { return #colorLiteral(red: 0.8235294118, green: 0.7058823529, blue: 0.5490196078, alpha: 1) } // D2B48C + class var sandyBrown: UIColor { return #colorLiteral(red: 0.9568627451, green: 0.6431372549, blue: 0.3764705882, alpha: 1) } // F4A460 + class var darkSalmon: UIColor { return #colorLiteral(red: 0.9137254902, green: 0.5882352941, blue: 0.4784313725, alpha: 1) } // E9967A + class var lightCoral: UIColor { return #colorLiteral(red: 0.9411764706, green: 0.5019607843, blue: 0.5019607843, alpha: 1) } // F08080 + class var salmon: UIColor { return #colorLiteral(red: 0.9803921569, green: 0.5019607843, blue: 0.4470588235, alpha: 1) } // FA8072 + class var lightSalmon: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.6274509804, blue: 0.4784313725, alpha: 1) } // FFA07A + class var coral: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.4980392157, blue: 0.3137254902, alpha: 1) } // FF7F50 + class var tomato: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.3882352941, blue: 0.2784313725, alpha: 1) } // FF6347 + class var orangeRed: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.2705882353, blue: 0.0000000000, alpha: 1) } // FF4500 + class var deepPink: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.0784313725, blue: 0.5764705882, alpha: 1) } // FF1493 + class var hotPink: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.4117647059, blue: 0.7058823529, alpha: 1) } // FF69B4 + class var paleVioletRed: UIColor { return #colorLiteral(red: 0.8470588235, green: 0.4392156863, blue: 0.5764705882, alpha: 1) } // D87093 + class var violet: UIColor { return #colorLiteral(red: 0.9333333333, green: 0.5098039216, blue: 0.9333333333, alpha: 1) } // EE82EE + class var orchid: UIColor { return #colorLiteral(red: 0.8549019608, green: 0.4392156863, blue: 0.8392156863, alpha: 1) } // DA70D6 + class var fuchsia: UIColor { return #colorLiteral(red: 1.0000000000, green: 0.0000000000, blue: 1.0000000000, alpha: 1) } // FF00FF + class var mediumOrchid: UIColor { return #colorLiteral(red: 0.7294117647, green: 0.3333333333, blue: 0.8274509804, alpha: 1) } // BA55D3 + class var darkOrchid: UIColor { return #colorLiteral(red: 0.6000000000, green: 0.1960784314, blue: 0.8000000000, alpha: 1) } // 9932CC + class var darkViolet: UIColor { return #colorLiteral(red: 0.5803921569, green: 0.0000000000, blue: 0.8274509804, alpha: 1) } // 9400D3 + class var blueViolet: UIColor { return #colorLiteral(red: 0.5411764706, green: 0.1686274510, blue: 0.8862745098, alpha: 1) } // 8A2BE2 + class var mediumPurple: UIColor { return #colorLiteral(red: 0.5764705882, green: 0.4392156863, blue: 0.8470588235, alpha: 1) } // 9370D8 + class var mediumBlue: UIColor { return #colorLiteral(red: 0.0000000000, green: 0.0000000000, blue: 0.8039215686, alpha: 1) } // 0000CD + class var darkCyan: UIColor { return #colorLiteral(red: 0.0000000000, green: 0.5450980392, blue: 0.5450980392, alpha: 1) } // 008B8B + class var cadetBlue: UIColor { return #colorLiteral(red: 0.3725490196, green: 0.6196078431, blue: 0.6274509804, alpha: 1) } // 5F9EA0 + class var darkSeaGreen: UIColor { return #colorLiteral(red: 0.5607843137, green: 0.7372549020, blue: 0.5607843137, alpha: 1) } // 8FBC8F + class var mediumSeaGreen: UIColor { return #colorLiteral(red: 0.2352941176, green: 0.7019607843, blue: 0.4431372549, alpha: 1) } // 3CB371 + class var teal: UIColor { return #colorLiteral(red: 0.0000000000, green: 0.5019607843, blue: 0.5019607843, alpha: 1) } // 008080 + class var forestGreen: UIColor { return #colorLiteral(red: 0.1333333333, green: 0.5450980392, blue: 0.1333333333, alpha: 1) } // 228B22 + class var seaGreen: UIColor { return #colorLiteral(red: 0.1803921569, green: 0.5450980392, blue: 0.3411764706, alpha: 1) } // 2E8B57 + class var darkKhaki: UIColor { return #colorLiteral(red: 0.7411764706, green: 0.7176470588, blue: 0.4196078431, alpha: 1) } // BDB76B + class var peru: UIColor { return #colorLiteral(red: 0.8039215686, green: 0.5215686275, blue: 0.2470588235, alpha: 1) } // CD853F + class var crimson: UIColor { return #colorLiteral(red: 0.8627450980, green: 0.0784313725, blue: 0.2352941176, alpha: 1) } // DC143C + class var indianRed: UIColor { return #colorLiteral(red: 0.8039215686, green: 0.3607843137, blue: 0.3607843137, alpha: 1) } // CD5C5C + class var rosyBrown: UIColor { return #colorLiteral(red: 0.7372549020, green: 0.5607843137, blue: 0.5607843137, alpha: 1) } // BC8F8F + class var mediumVioletRed: UIColor { return #colorLiteral(red: 0.7803921569, green: 0.0823529412, blue: 0.5215686275, alpha: 1) } // C71585 + class var dimGray: UIColor { return #colorLiteral(red: 0.4117647059, green: 0.4117647059, blue: 0.4117647059, alpha: 1) } // 696969 + class var midnightBlue: UIColor { return #colorLiteral(red: 0.0980392157, green: 0.0980392157, blue: 0.4392156863, alpha: 1) } // 191970 + class var darkSlateBlue: UIColor { return #colorLiteral(red: 0.2823529412, green: 0.2392156863, blue: 0.5450980392, alpha: 1) } // 483D8B + class var darkBlue: UIColor { return #colorLiteral(red: 0.0000000000, green: 0.0000000000, blue: 0.5450980392, alpha: 1) } // 00008B + class var navy: UIColor { return #colorLiteral(red: 0.0000000000, green: 0.0000000000, blue: 0.5019607843, alpha: 1) } // 000080 + class var darkSlateGray: UIColor { return #colorLiteral(red: 0.1843137255, green: 0.3098039216, blue: 0.3098039216, alpha: 1) } // 2F4F4F + class var greenCSS3: UIColor { return #colorLiteral(red: 0.0000000000, green: 0.5019607843, blue: 0.0000000000, alpha: 1) } // 008000 + class var darkGreen: UIColor { return #colorLiteral(red: 0.0000000000, green: 0.3921568627, blue: 0.0000000000, alpha: 1) } // 006400 + class var darkOliveGreen: UIColor { return #colorLiteral(red: 0.3333333333, green: 0.4196078431, blue: 0.1843137255, alpha: 1) } // 556B2F + class var oliveDrab: UIColor { return #colorLiteral(red: 0.4196078431, green: 0.5568627451, blue: 0.1372549020, alpha: 1) } // 6B8E23 + class var olive: UIColor { return #colorLiteral(red: 0.5019607843, green: 0.5019607843, blue: 0.0000000000, alpha: 1) } // 808000 + class var darkGoldenRod: UIColor { return #colorLiteral(red: 0.7215686275, green: 0.5254901961, blue: 0.0431372549, alpha: 1) } // B8860B + class var chocolate: UIColor { return #colorLiteral(red: 0.8235294118, green: 0.4117647059, blue: 0.1176470588, alpha: 1) } // D2691E + class var sienna: UIColor { return #colorLiteral(red: 0.6274509804, green: 0.3215686275, blue: 0.1764705882, alpha: 1) } // A0522D + class var saddleBrown: UIColor { return #colorLiteral(red: 0.5450980392, green: 0.2705882353, blue: 0.0745098039, alpha: 1) } // 8B4513 + class var fireBrick: UIColor { return #colorLiteral(red: 0.6980392157, green: 0.1333333333, blue: 0.1333333333, alpha: 1) } // B22222 + class var brownCSS3: UIColor { return #colorLiteral(red: 0.6470588235, green: 0.1647058824, blue: 0.1647058824, alpha: 1) } // A52A2A + class var maroon: UIColor { return #colorLiteral(red: 0.5019607843, green: 0.0000000000, blue: 0.0000000000, alpha: 1) } // 800000 + class var darkRed: UIColor { return #colorLiteral(red: 0.5450980392, green: 0.0000000000, blue: 0.0000000000, alpha: 1) } // 8B0000 + class var darkMagenta: UIColor { return #colorLiteral(red: 0.5450980392, green: 0.0000000000, blue: 0.5450980392, alpha: 1) } // 8B008B + class var indigo: UIColor { return #colorLiteral(red: 0.2941176471, green: 0.0000000000, blue: 0.5098039216, alpha: 1) } // 4B0082 +} diff --git a/modules/expo-selectable-text/ios/ExpoProTextView.swift b/modules/expo-selectable-text/ios/ExpoProTextView.swift new file mode 100644 index 0000000000..25ae0a74cb --- /dev/null +++ b/modules/expo-selectable-text/ios/ExpoProTextView.swift @@ -0,0 +1,136 @@ +import Foundation +import ExpoModulesCore + +class ExpoProTextView: ExpoView { + let textView: UITextView = UITextView() + var segments: Array = [] { + didSet { + setText() + } + } + + let onTextPress = EventDispatcher() + let onTextLongPress = EventDispatcher() + let onTextLayout = EventDispatcher() + + public required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + + // Configure default appearance + textView.scrollsToTop = false + textView.isEditable = false + textView.isScrollEnabled = false + textView.backgroundColor = .clear + + // Add the text view to the root view + addSubview(textView) + + // Configure the press recognizer + configurePressRecognizer() + } + + override func layoutSubviews() { + // Set the textView's frame on layout + setSize() + } + + func configurePressRecognizer() -> Void { + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:))) + textView.addGestureRecognizer(tapGestureRecognizer) + } + + @IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void { + if let segment = getPressedSegment(sender) { + if segment.handlePress, textView.selectedTextRange == nil { + onTextPress([ + "index": segment.index + ]) + } else { + // Clear the selected text range if we are not pressing on a link + textView.selectedTextRange = nil + } + } + } + + func setSize() -> Void { + // Figure out the height of our text and create a CGRect + let maxWidth = bounds.width + let sizeThatFits = textView.sizeThatFits(CGSize(width: maxWidth, height: CGFloat(MAXFLOAT))) + let size = CGSize(width: maxWidth, height: sizeThatFits.height) + textView.frame.size = size + + onTextLayout([ + "height": sizeThatFits.height, + "width": maxWidth + ]) + } + + func setText() -> Void { + let finalAttributedString = NSMutableAttributedString() + + segments.forEach { segment in + // Set some generic attributes that don't need ranges + let attributes: [NSAttributedString.Key:Any] = [ + .font: UIFont.systemFont(ofSize: segment.style?.fontSize ?? 12.0, weight: segment.style?.fontWeight?.toFontWeight() ?? .regular), + .foregroundColor: ExpoProTextUtil.hexToUIColor(hex: segment.style?.color), + ] + + // Create the attributed string with the generic attributes + let string = NSMutableAttributedString(string: segment.text, attributes: attributes) + + // Set the paragraph style attributes if necessary + if let lineHeight = segment.style?.lineHeight { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = lineHeight + string.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: NSMakeRange(0, string.length)) + } + + if let textDecorationLine = segment.style?.textDecorationLine { + if textDecorationLine == .underline || textDecorationLine == .underlineLineThrough { + string.addAttribute(NSAttributedString.Key.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: NSMakeRange(0, string.length)) + } + + if textDecorationLine == .lineThrough || textDecorationLine == .underlineLineThrough { + string.addAttribute(NSAttributedString.Key.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: NSMakeRange(0, string.length)) + } + } + + finalAttributedString.append(string) + } + + textView.attributedText = finalAttributedString + + self.setNeedsLayout() + } + + func getPressedSegment(_ sender: UITapGestureRecognizer) -> TextSegment? { + let layoutManager = textView.layoutManager + var location = sender.location(in: textView) + + // Remove the padding + location.x -= textView.textContainerInset.left + location.y -= textView.textContainerInset.top + + // Get the index of the char + let charIndex = layoutManager.characterIndex(for: location, in: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil) + + let text = textView.attributedText.string + var foundSegment: TextSegment? + + // Check each segment + segments.forEach { segment in + let range = text.range(of: segment.text) + // Figure out the bounds + if let lowerBound = range?.lowerBound, let upperBound = range?.upperBound { + let lowerIndex = text.distance(from: text.startIndex , to: lowerBound) + let upperIndex = text.distance(from: text.startIndex, to: upperBound) + + if charIndex >= lowerIndex, charIndex <= upperIndex { + foundSegment = segment + } + } + } + + return foundSegment + } +} diff --git a/modules/expo-selectable-text/ios/TextSegment.swift b/modules/expo-selectable-text/ios/TextSegment.swift new file mode 100644 index 0000000000..8c410aaaab --- /dev/null +++ b/modules/expo-selectable-text/ios/TextSegment.swift @@ -0,0 +1,13 @@ +import ExpoModulesCore + +struct TextSegments: Decodable { + let segments: Array +} + +struct TextSegment: Decodable { + let index: Int + let text: String + let style: TextStyle? + let handlePress: Bool + let handleLongPress: Bool +} diff --git a/modules/expo-selectable-text/ios/TextStyle.swift b/modules/expo-selectable-text/ios/TextStyle.swift new file mode 100644 index 0000000000..4de16d4a75 --- /dev/null +++ b/modules/expo-selectable-text/ios/TextStyle.swift @@ -0,0 +1,31 @@ +struct TextStyle: Decodable { + var color: String? = "black" + var fontSize: CGFloat? = 12 + var fontStyle: String? = "normal" + var fontWeight: FontWeight? + var letterSpacing: Double? + var textAlign: String? = "auto" + var lineHeight: Double? + var textDecorationLine: TextDecorationLine? +} + +enum FontWeight: String, Decodable { + case bold + case normal + + func toFontWeight() -> UIFont.Weight { + switch self { + case .bold: + return .bold + case .normal: + return .regular + } + } +} + +enum TextDecorationLine: String, Decodable { + case underline + case lineThrough = "line-through" + case underlineLineThrough = "underline line-through" + case normal +} diff --git a/modules/expo-selectable-text/src/ExpoProText.types.ts b/modules/expo-selectable-text/src/ExpoProText.types.ts new file mode 100644 index 0000000000..80fa156651 --- /dev/null +++ b/modules/expo-selectable-text/src/ExpoProText.types.ts @@ -0,0 +1,52 @@ +import React from 'react' +import { ColorValue } from 'react-native' + +interface ExpoProTextViewCommonProps { + selectable?: boolean +} + +export interface ExpoProTextViewProps extends ExpoProTextViewCommonProps { + children: React.ReactNode + style?: ExpoProTextStyle + onPress?: () => void + onLongPress?: () => void +} + +export interface ExpoProTextNativeViewProps extends ExpoProTextViewCommonProps { + segments: string + textStyle?: ExpoProTextStyle + onTextPress?: (event: ExpoProTextPressEvent) => void + onTextLongPress?: (event: ExpoProTextPressEvent) => void + onTextLayout?: (event: ExpoProTextLayoutEvent) => void + disableLongPress: boolean +} + +interface ExpoProTextStyle { + color: ColorValue + fontSize?: number + fontStyle?: 'normal' | 'italic' + fontWeight?: 'normal' | 'bold' + letterSpacing?: number + textAlign?: 'auto' | 'left' | 'right' | 'center' | 'justify' + lineHeight?: number +} + +export interface ExpoProTextLayoutEvent { + nativeEvent: { + height: number + } +} + +export interface ExpoProTextPressEvent { + nativeEvent: { + index: number + } +} + +export interface ExpoProTextSegment { + index: number + text: string + style?: ExpoProTextStyle + handlePress?: boolean + handleLongPress?: boolean +} diff --git a/modules/expo-selectable-text/src/ExpoProTextModule.ts b/modules/expo-selectable-text/src/ExpoProTextModule.ts new file mode 100644 index 0000000000..541b6700a5 --- /dev/null +++ b/modules/expo-selectable-text/src/ExpoProTextModule.ts @@ -0,0 +1,5 @@ +import { requireNativeModule } from 'expo-modules-core'; + +// It loads the native module object from the JSI or falls back to +// the bridge module (from NativeModulesProxy) if the remote debugger is on. +export default requireNativeModule('ExpoProText'); diff --git a/modules/expo-selectable-text/src/ExpoProTextModule.web.ts b/modules/expo-selectable-text/src/ExpoProTextModule.web.ts new file mode 100644 index 0000000000..be76495bbf --- /dev/null +++ b/modules/expo-selectable-text/src/ExpoProTextModule.web.ts @@ -0,0 +1,13 @@ +import { EventEmitter } from 'expo-modules-core'; + +const emitter = new EventEmitter({} as any); + +export default { + PI: Math.PI, + async setValueAsync(value: string): Promise { + emitter.emit('onChange', { value }); + }, + hello() { + return 'Hello world! ๐Ÿ‘‹'; + }, +}; diff --git a/modules/expo-selectable-text/src/ExpoProTextView.tsx b/modules/expo-selectable-text/src/ExpoProTextView.tsx new file mode 100644 index 0000000000..e800ffafd4 --- /dev/null +++ b/modules/expo-selectable-text/src/ExpoProTextView.tsx @@ -0,0 +1,109 @@ +import { requireNativeViewManager } from 'expo-modules-core' +import * as React from 'react' + +import { + ExpoProTextLayoutEvent, + ExpoProTextNativeViewProps, + ExpoProTextPressEvent, + ExpoProTextSegment, + ExpoProTextViewProps, +} from './ExpoProText.types' +import { Text, View } from 'react-native' + +const NativeView: React.ComponentType = + requireNativeViewManager('ExpoProText') + +export default function ExpoProTextView({ + style, + children, + selectable = false, + onPress, + onLongPress, +}: ExpoProTextViewProps) { + const [dims, setDims] = React.useState({ height: 0 }) + const segmentPressCallbacks = React.useRef< + Array<{ index: number; onPress: () => void }> + >([]) + const segmentLongPressCallbacks = React.useRef< + Array<{ index: number, onLongPress: () => void, }> + >([]) + + const onTextLayout = React.useCallback((e: ExpoProTextLayoutEvent) => { + setDims({ + height: e.nativeEvent.height, + }) + }, []) + + const onTextPress = React.useCallback((e: ExpoProTextPressEvent) => { + console.log(e.nativeEvent) + const onPressSegment = segmentPressCallbacks.current.find( + (s) => s.index === e.nativeEvent.index, + ) + onPressSegment?.onPress() + }, []) + + const textSegments = React.useMemo(() => { + const segments: ExpoProTextSegment[] = [] + + for (const [index, child] of React.Children.toArray(children).entries()) { + if (typeof child === 'string') { + segments.push({ + index, + text: child, + style, + handlePress: onPress !== undefined, + handleLongPress: onLongPress !== undefined, + }) + } else if ( + React.isValidElement(child) && + (child as React.ReactElement).type === Text + ) { + const { onPress, onLongPress, children, style } = child.props + + segments.push({ + index, + text: children, + style, + handlePress: onPress !== undefined, + handleLongPress: onLongPress !== undefined, + }) + + if (onPress !== undefined) { + segmentPressCallbacks.current.push({ + index, + onPress, + }) + } + + if (onLongPress !== undefined) { + segmentLongPressCallbacks.current.push({ + index, + onLongPress, + }) + } + } + } + + return segments + }, [children]) + + const segmentsJson = React.useMemo(() => { + const json = JSON.stringify({ segments: textSegments }) + console.log(json) + return json + }, [textSegments]) + + return ( + + + + ) +} diff --git a/modules/expo-selectable-text/src/ExpoProTextView.web.tsx b/modules/expo-selectable-text/src/ExpoProTextView.web.tsx new file mode 100644 index 0000000000..f3319886ae --- /dev/null +++ b/modules/expo-selectable-text/src/ExpoProTextView.web.tsx @@ -0,0 +1,11 @@ +import * as React from 'react' + +import { ExpoProTextViewProps } from './ExpoProText.types' + +export default function ExpoProTextView(props: ExpoProTextViewProps) { + return ( +
+ {props.name} +
+ ) +}