Compare commits

..
Author SHA1 Message Date
0dce189cee chore(deps): update mtp digest to b067614
Some checks failed
renovate/stability-days Updates have not met minimum release age requirement
Dependency builds / Build desktop (pull_request) Failing after 10m15s
Dependency builds / Build web (pull_request) Failing after 13m18s
Dependency builds / Build mobile (pull_request) Failing after 16m5s
2026-08-10 01:00:32 +02:00
a40bf3a2c2 Merge pull request 'chore(deps): update mtp-transport digest to b067614' (#69) from renovate/mtp-transport-digest into dev
All checks were successful
/ build-web (push) Successful in 8m22s
/ build-desktop (linux) (push) Successful in 13m59s
/ build-mobile (push) Successful in 25m47s
/ release (push) Successful in 3m26s
2026-08-10 00:16:46 +02:00
dbb407606b
feat(markdown): migrate to methanium/ui's markdown
Some checks failed
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
2026-08-10 00:16:42 +02:00
f763af13be chore(deps): update mtp-transport digest to b067614
Some checks failed
renovate/stability-days Updates have not met minimum release age requirement
Dependency builds / Build web (pull_request) Failing after 14m21s
Dependency builds / Build desktop (pull_request) Failing after 14m21s
Dependency builds / Build mobile (pull_request) Failing after 15m6s
2026-08-09 21:01:24 +02:00
38 changed files with 1394 additions and 3076 deletions

View file

@ -685,12 +685,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
@ -923,7 +917,6 @@ dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto 0.2.9",
"rustc_version",
"subtle",
@ -1008,25 +1001,14 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid 0.9.6",
"pem-rfc7468 0.7.0",
"zeroize",
]
[[package]]
name = "der"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d"
dependencies = [
"const-oid 0.10.2",
"pem-rfc7468 1.0.0",
"const-oid",
"pem-rfc7468",
"zeroize",
]
@ -1091,7 +1073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid 0.10.2",
"const-oid",
"crypto-common 0.2.2",
"ctutils",
]
@ -1253,38 +1235,14 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8 0.10.2",
"signature 2.2.0",
]
[[package]]
name = "ed25519"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
dependencies = [
"pkcs8 0.11.0",
"signature 3.0.0",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek 4.1.3",
"ed25519 2.2.3",
"serde",
"sha2 0.10.9",
"subtle",
"zeroize",
"pkcs8",
"signature",
]
[[package]]
@ -1294,10 +1252,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
dependencies = [
"curve25519-dalek 5.0.0",
"ed25519 3.0.0",
"ed25519",
"serde",
"sha2 0.11.0",
"signature 3.0.0",
"signature",
"subtle",
"zeroize",
]
@ -2817,14 +2775,14 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d"
dependencies = [
"const-oid 0.10.2",
"const-oid",
"crypto-common 0.2.2",
"ctutils",
"hybrid-array",
"module-lattice",
"pkcs8 0.11.0",
"pkcs8",
"shake",
"signature 3.0.0",
"signature",
]
[[package]]
@ -2870,12 +2828,12 @@ version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
dependencies = [
"mtp-client",
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-codec",
"mtp-common",
"mtp-crypto",
"mtp-host",
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-transport",
"mtp-type-map",
"mtp-webserver",
]
@ -2884,26 +2842,14 @@ name = "mtp-client"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
dependencies = [
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-codec",
"mtp-common",
"mtp-crypto",
"mtp-transport",
"rand 0.10.2",
"tokio",
]
[[package]]
name = "mtp-codec"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
dependencies = [
"base64 0.23.1",
"byteorder",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"rand 0.10.2",
]
[[package]]
name = "mtp-codec"
version = "0.2.0"
@ -2911,23 +2857,12 @@ source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856b
dependencies = [
"base64 0.23.1",
"byteorder",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-common",
"mtp-crypto",
"mtp-type-map",
"rand 0.10.2",
]
[[package]]
name = "mtp-common"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
dependencies = [
"quinn",
"rustls",
"thiserror 2.0.19",
"wtransport",
]
[[package]]
name = "mtp-common"
version = "0.2.0"
@ -2939,26 +2874,6 @@ dependencies = [
"wtransport",
]
[[package]]
name = "mtp-crypto"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
dependencies = [
"base64 0.23.1",
"chacha20poly1305",
"ed25519-dalek 2.2.0",
"getrandom 0.4.3",
"hkdf",
"ml-dsa",
"rand 0.10.2",
"rand_core 0.10.1",
"rustls",
"sha2 0.11.0",
"thiserror 1.0.69",
"tokio",
"zeroize",
]
[[package]]
name = "mtp-crypto"
version = "0.2.0"
@ -2966,7 +2881,7 @@ source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856b
dependencies = [
"base64 0.23.1",
"chacha20poly1305",
"ed25519-dalek 3.0.0",
"ed25519-dalek",
"getrandom 0.4.3",
"hkdf",
"ml-dsa",
@ -2986,43 +2901,25 @@ name = "mtp-host"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
dependencies = [
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-codec",
"mtp-common",
"mtp-crypto",
"mtp-transport",
"rand 0.10.2",
"tokio",
"tracing",
"wtransport",
]
[[package]]
name = "mtp-transport"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
dependencies = [
"async-trait",
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"rcgen",
"rustls",
"rustls-native-certs",
"sha2 0.11.0",
"tokio",
"tracing",
"wtransport",
]
[[package]]
name = "mtp-transport"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
dependencies = [
"async-trait",
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-codec",
"mtp-common",
"mtp-crypto",
"rcgen",
"rustls",
"rustls-native-certs",
@ -3032,15 +2929,6 @@ dependencies = [
"wtransport",
]
[[package]]
name = "mtp-type-map"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
dependencies = [
"serde",
"serde_yaml",
]
[[package]]
name = "mtp-type-map"
version = "0.2.0"
@ -3064,11 +2952,11 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-util",
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-codec",
"mtp-common",
"mtp-crypto",
"mtp-host",
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-transport",
"quinn",
"rand 0.10.2",
"rustls",
@ -3572,15 +3460,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "pem-rfc7468"
version = "1.0.0"
@ -3666,24 +3545,14 @@ dependencies = [
"futures-io",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der 0.7.10",
"spki 0.7.3",
]
[[package]]
name = "pkcs8"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
dependencies = [
"der 0.8.1",
"spki 0.8.0",
"der",
"spki",
]
[[package]]
@ -4735,15 +4604,6 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "signature"
version = "3.0.0"
@ -4852,16 +4712,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der 0.7.10",
]
[[package]]
name = "spki"
version = "0.8.0"
@ -4869,7 +4719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
dependencies = [
"base64ct",
"der 0.8.1",
"der",
]
[[package]]
@ -5502,7 +5352,7 @@ dependencies = [
"base64 0.22.1",
"jni 0.22.4",
"mtp",
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-transport",
"reqwest 0.12.28",
"serde",
"serde_json",

View file

@ -25,7 +25,7 @@ base64 = "0.22"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
mtp = { git = "https://git.methanium.net/methanium/mtp.git", rev = "b067614a684eb1856bc5db7b3fd82148c036ce6b", features = ["client", "crypto"] }
mtp-transport = { git = "https://git.methanium.net/methanium/mtp.git", rev = "7182272e3edb0079d7b74b45b2f62a298d2fee22" }
mtp-transport = { git = "https://git.methanium.net/methanium/mtp.git", rev = "b067614a684eb1856bc5db7b3fd82148c036ce6b" }
webpki-root-certs = "1"
tauri-plugin-deep-link = "2"
tauri-plugin-notification = "2"

View file

@ -23,7 +23,6 @@
"@tensamin/chat": "workspace:*",
"@tensamin/crypto": "workspace:*",
"@tensamin/hotkeys": "workspace:*",
"@tensamin/markdown": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/notifications": "workspace:*",
"@tensamin/onboarding": "workspace:*",

View file

@ -1,6 +1,6 @@
import type { User } from "@tensamin/user/context";
import { Avatar, AvatarFallback, AvatarImage, Button } from "@methanium/ui";
import Text from "@tensamin/markdown/text";
import { Text } from "@methanium/ui/markdown";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useState } from "react";

View file

@ -1,4 +1,4 @@
import { createReadStream, realpathSync, statSync } from "node:fs";
import { createReadStream, statSync } from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@ -11,11 +11,6 @@ import { methaniumUi } from "@methanium/ui/vite";
const host = process.env.TAURI_DEV_HOST;
const appDir = dirname(fileURLToPath(import.meta.url));
const markdownPackageDir = resolve(appDir, "../../packages/markdown");
function resolveMarkdownDependency(packageName: string): string {
return realpathSync(resolve(markdownPackageDir, "node_modules", packageName));
}
function deepFilterAssetHeaders(rootDir: string): Plugin {
const serveModel = (
@ -57,24 +52,6 @@ export default defineConfig({
clearScreen: false,
resolve: {
tsconfigPaths: true,
alias: [
{
find: "@codemirror/commands",
replacement: resolveMarkdownDependency("@codemirror/commands"),
},
{
find: "@codemirror/lang-markdown",
replacement: resolveMarkdownDependency("@codemirror/lang-markdown"),
},
{
find: "@codemirror/state",
replacement: resolveMarkdownDependency("@codemirror/state"),
},
{
find: "@codemirror/view",
replacement: resolveMarkdownDependency("@codemirror/view"),
},
],
dedupe: [
"react",
"react-dom",
@ -90,10 +67,6 @@ export default defineConfig({
"@tensamin/user",
"@tensamin/tauri",
"@tensamin/chat",
"@codemirror/commands",
"@codemirror/lang-markdown",
"@codemirror/state",
"@codemirror/view",
],
},
server: {
@ -133,7 +106,6 @@ export default defineConfig({
"@tensamin/crypto",
"@tensamin/crypto/context",
"@tensamin/hotkeys",
"@tensamin/markdown",
"@tensamin/mtp",
"@tensamin/notifications",
"@tensamin/onboarding",

View file

@ -1,21 +0,0 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,21 +0,0 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,21 +0,0 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,21 +0,0 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,21 +0,0 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,21 +0,0 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,22 +0,0 @@
MIT License
Copyright (c) 2022present Jason Sofonia & Justine De Caires
Copyright (c) 20142021 Twitter
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -2,60 +2,6 @@
Generated from pnpm-lock.yaml and installed packages in workspace node_modules folders.
## @codemirror/autocomplete@6.20.3
- License: MIT
- Repository: git+https://code.haverbeke.berlin/codemirror/autocomplete.git
- Description: Autocompletion for the CodeMirror code editor
- Included files: LICENSE
- Folder: `licenses/@codemirror_autocomplete@6.20.3`
- Source package dir: `packages/markdown/node_modules/@codemirror/autocomplete`
## @codemirror/commands@6.10.4
- License: MIT
- Repository: git+https://code.haverbeke.berlin/codemirror/commands.git
- Description: Collection of editing commands for the CodeMirror code editor
- Included files: LICENSE
- Folder: `licenses/@codemirror_commands@6.10.4`
- Source package dir: `packages/markdown/node_modules/@codemirror/commands`
## @codemirror/lang-markdown@6.5.2
- License: MIT
- Repository: git+https://code.haverbeke.berlin/codemirror/lang-markdown.git
- Description: Markdown language support for the CodeMirror code editor
- Included files: LICENSE
- Folder: `licenses/@codemirror_lang-markdown@6.5.2`
- Source package dir: `packages/markdown/node_modules/@codemirror/lang-markdown`
## @codemirror/language@6.12.4
- License: MIT
- Repository: git+https://code.haverbeke.berlin/codemirror/language.git
- Description: Language support infrastructure for the CodeMirror code editor
- Included files: LICENSE
- Folder: `licenses/@codemirror_language@6.12.4`
- Source package dir: `packages/markdown/node_modules/@codemirror/language`
## @codemirror/state@6.7.1
- License: MIT
- Repository: git+https://code.haverbeke.berlin/codemirror/state.git
- Description: Editor state data structures for the CodeMirror code editor
- Included files: LICENSE
- Folder: `licenses/@codemirror_state@6.7.1`
- Source package dir: `packages/markdown/node_modules/@codemirror/state`
## @codemirror/view@6.43.8
- License: MIT
- Repository: git+https://code.haverbeke.berlin/codemirror/view.git
- Description: DOM view component for the CodeMirror code editor
- Included files: LICENSE
- Folder: `licenses/@codemirror_view@6.43.8`
- Source package dir: `packages/markdown/node_modules/@codemirror/view`
## @eslint/js@10.0.1
- License: MIT
@ -84,11 +30,11 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
- Folder: `licenses/@livekit_components-react@2.9.23`
- Source package dir: `packages/call/node_modules/@livekit/components-react`
## @methanium/ui@0.0.22
## @methanium/ui@0.0.28
- License: UNKNOWN
- Included files: LICENSE
- Folder: `licenses/@methanium_ui@0.0.22`
- Folder: `licenses/@methanium_ui@0.0.28`
- Source package dir: `apps/tauri/node_modules/@methanium/ui`
## @tailwindcss/vite@4.3.3
@ -197,16 +143,6 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
- Folder: `licenses/@tauri-apps_plugin-notification@2.3.3`
- Source package dir: `packages/notifications/node_modules/@tauri-apps/plugin-notification`
## @twemoji/api@17.0.3
- License: MIT AND CC-BY-4.0
- Homepage: https://github.com/jdecked/twemoji
- Repository: git://github.com/jdecked/twemoji.git
- Description: A Unicode standard based way to implement emoji across all platforms.
- Included files: LICENSE
- Folder: `licenses/@twemoji_api@17.0.3`
- Source package dir: `packages/markdown/node_modules/@twemoji/api`
## @types/node@26.2.0
- License: MIT
@ -305,15 +241,6 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
- Folder: `licenses/electron-builder@26.15.3`
- Source package dir: `apps/electron/node_modules/electron-builder`
## emojibase-data@17.0.0
- License: MIT
- Repository: git@github.com:milesj/emojibase.git
- Description: Evergreen emoji datasets.
- Included files: LICENSE
- Folder: `licenses/emojibase-data@17.0.0`
- Source package dir: `packages/markdown/node_modules/emojibase-data`
## esbuild@0.28.1
- License: MIT

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2017-2019 Miles Johnson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -3,7 +3,7 @@
"specVersion": "1.5",
"version": 1,
"metadata": {
"timestamp": "2026-08-09T18:16:10.177Z",
"timestamp": "2026-08-09T22:09:04.469Z",
"tools": [
{
"vendor": "OpenAI",
@ -16,192 +16,6 @@
}
},
"components": [
{
"type": "library",
"bomRef": "pkg:npm/%40codemirror/autocomplete@6.20.3",
"name": "@codemirror/autocomplete",
"version": "6.20.3",
"purl": "pkg:npm/%40codemirror/autocomplete@6.20.3",
"description": "Autocompletion for the CodeMirror code editor",
"licenses": [
{
"license": {
"id": "MIT"
}
}
],
"externalReferences": [
{
"type": "vcs",
"url": "git+https://code.haverbeke.berlin/codemirror/autocomplete.git"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/@codemirror_autocomplete@6.20.3"
},
{
"name": "local:sourcePackageDir",
"value": "packages/markdown/node_modules/@codemirror/autocomplete"
}
]
},
{
"type": "library",
"bomRef": "pkg:npm/%40codemirror/commands@6.10.4",
"name": "@codemirror/commands",
"version": "6.10.4",
"purl": "pkg:npm/%40codemirror/commands@6.10.4",
"description": "Collection of editing commands for the CodeMirror code editor",
"licenses": [
{
"license": {
"id": "MIT"
}
}
],
"externalReferences": [
{
"type": "vcs",
"url": "git+https://code.haverbeke.berlin/codemirror/commands.git"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/@codemirror_commands@6.10.4"
},
{
"name": "local:sourcePackageDir",
"value": "packages/markdown/node_modules/@codemirror/commands"
}
]
},
{
"type": "library",
"bomRef": "pkg:npm/%40codemirror/lang-markdown@6.5.2",
"name": "@codemirror/lang-markdown",
"version": "6.5.2",
"purl": "pkg:npm/%40codemirror/lang-markdown@6.5.2",
"description": "Markdown language support for the CodeMirror code editor",
"licenses": [
{
"license": {
"id": "MIT"
}
}
],
"externalReferences": [
{
"type": "vcs",
"url": "git+https://code.haverbeke.berlin/codemirror/lang-markdown.git"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/@codemirror_lang-markdown@6.5.2"
},
{
"name": "local:sourcePackageDir",
"value": "packages/markdown/node_modules/@codemirror/lang-markdown"
}
]
},
{
"type": "library",
"bomRef": "pkg:npm/%40codemirror/language@6.12.4",
"name": "@codemirror/language",
"version": "6.12.4",
"purl": "pkg:npm/%40codemirror/language@6.12.4",
"description": "Language support infrastructure for the CodeMirror code editor",
"licenses": [
{
"license": {
"id": "MIT"
}
}
],
"externalReferences": [
{
"type": "vcs",
"url": "git+https://code.haverbeke.berlin/codemirror/language.git"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/@codemirror_language@6.12.4"
},
{
"name": "local:sourcePackageDir",
"value": "packages/markdown/node_modules/@codemirror/language"
}
]
},
{
"type": "library",
"bomRef": "pkg:npm/%40codemirror/state@6.7.1",
"name": "@codemirror/state",
"version": "6.7.1",
"purl": "pkg:npm/%40codemirror/state@6.7.1",
"description": "Editor state data structures for the CodeMirror code editor",
"licenses": [
{
"license": {
"id": "MIT"
}
}
],
"externalReferences": [
{
"type": "vcs",
"url": "git+https://code.haverbeke.berlin/codemirror/state.git"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/@codemirror_state@6.7.1"
},
{
"name": "local:sourcePackageDir",
"value": "packages/markdown/node_modules/@codemirror/state"
}
]
},
{
"type": "library",
"bomRef": "pkg:npm/%40codemirror/view@6.43.8",
"name": "@codemirror/view",
"version": "6.43.8",
"purl": "pkg:npm/%40codemirror/view@6.43.8",
"description": "DOM view component for the CodeMirror code editor",
"licenses": [
{
"license": {
"id": "MIT"
}
}
],
"externalReferences": [
{
"type": "vcs",
"url": "git+https://code.haverbeke.berlin/codemirror/view.git"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/@codemirror_view@6.43.8"
},
{
"name": "local:sourcePackageDir",
"value": "packages/markdown/node_modules/@codemirror/view"
}
]
},
{
"type": "library",
"bomRef": "pkg:npm/%40eslint/js@10.0.1",
@ -304,15 +118,15 @@
},
{
"type": "library",
"bomRef": "pkg:npm/%40methanium/ui@0.0.22",
"bomRef": "pkg:npm/%40methanium/ui@0.0.28",
"name": "@methanium/ui",
"version": "0.0.22",
"purl": "pkg:npm/%40methanium/ui@0.0.22",
"version": "0.0.28",
"purl": "pkg:npm/%40methanium/ui@0.0.28",
"externalReferences": [],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/@methanium_ui@0.0.22"
"value": "licenses/@methanium_ui@0.0.28"
},
{
"name": "local:sourcePackageDir",
@ -692,41 +506,6 @@
}
]
},
{
"type": "library",
"bomRef": "pkg:npm/%40twemoji/api@17.0.3",
"name": "@twemoji/api",
"version": "17.0.3",
"purl": "pkg:npm/%40twemoji/api@17.0.3",
"description": "A Unicode standard based way to implement emoji across all platforms.",
"licenses": [
{
"license": {
"name": "MIT AND CC-BY-4.0"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "https://github.com/jdecked/twemoji"
},
{
"type": "vcs",
"url": "git://github.com/jdecked/twemoji.git"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/@twemoji_api@17.0.3"
},
{
"name": "local:sourcePackageDir",
"value": "packages/markdown/node_modules/@twemoji/api"
}
]
},
{
"type": "library",
"bomRef": "pkg:npm/%40types/node@26.2.0",
@ -1069,37 +848,6 @@
}
]
},
{
"type": "library",
"bomRef": "pkg:npm/emojibase-data@17.0.0",
"name": "emojibase-data",
"version": "17.0.0",
"purl": "pkg:npm/emojibase-data@17.0.0",
"description": "Evergreen emoji datasets.",
"licenses": [
{
"license": {
"id": "MIT"
}
}
],
"externalReferences": [
{
"type": "vcs",
"url": "git@github.com:milesj/emojibase.git"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/emojibase-data@17.0.0"
},
{
"name": "local:sourcePackageDir",
"value": "packages/markdown/node_modules/emojibase-data"
}
]
},
{
"type": "library",
"bomRef": "pkg:npm/esbuild@0.28.1",

View file

@ -1,85 +1,7 @@
{
"generatedAt": "2026-08-09T18:16:10.172Z",
"packageCount": 60,
"generatedAt": "2026-08-09T22:09:04.466Z",
"packageCount": 52,
"packages": [
{
"name": "@codemirror/autocomplete",
"version": "6.20.3",
"license": "MIT",
"homepage": null,
"repository": "git+https://code.haverbeke.berlin/codemirror/autocomplete.git",
"description": "Autocompletion for the CodeMirror code editor",
"files": [
"LICENSE"
],
"licenseFolder": "licenses/@codemirror_autocomplete@6.20.3",
"sourcePackageDir": "packages/markdown/node_modules/@codemirror/autocomplete"
},
{
"name": "@codemirror/commands",
"version": "6.10.4",
"license": "MIT",
"homepage": null,
"repository": "git+https://code.haverbeke.berlin/codemirror/commands.git",
"description": "Collection of editing commands for the CodeMirror code editor",
"files": [
"LICENSE"
],
"licenseFolder": "licenses/@codemirror_commands@6.10.4",
"sourcePackageDir": "packages/markdown/node_modules/@codemirror/commands"
},
{
"name": "@codemirror/lang-markdown",
"version": "6.5.2",
"license": "MIT",
"homepage": null,
"repository": "git+https://code.haverbeke.berlin/codemirror/lang-markdown.git",
"description": "Markdown language support for the CodeMirror code editor",
"files": [
"LICENSE"
],
"licenseFolder": "licenses/@codemirror_lang-markdown@6.5.2",
"sourcePackageDir": "packages/markdown/node_modules/@codemirror/lang-markdown"
},
{
"name": "@codemirror/language",
"version": "6.12.4",
"license": "MIT",
"homepage": null,
"repository": "git+https://code.haverbeke.berlin/codemirror/language.git",
"description": "Language support infrastructure for the CodeMirror code editor",
"files": [
"LICENSE"
],
"licenseFolder": "licenses/@codemirror_language@6.12.4",
"sourcePackageDir": "packages/markdown/node_modules/@codemirror/language"
},
{
"name": "@codemirror/state",
"version": "6.7.1",
"license": "MIT",
"homepage": null,
"repository": "git+https://code.haverbeke.berlin/codemirror/state.git",
"description": "Editor state data structures for the CodeMirror code editor",
"files": [
"LICENSE"
],
"licenseFolder": "licenses/@codemirror_state@6.7.1",
"sourcePackageDir": "packages/markdown/node_modules/@codemirror/state"
},
{
"name": "@codemirror/view",
"version": "6.43.8",
"license": "MIT",
"homepage": null,
"repository": "git+https://code.haverbeke.berlin/codemirror/view.git",
"description": "DOM view component for the CodeMirror code editor",
"files": [
"LICENSE"
],
"licenseFolder": "licenses/@codemirror_view@6.43.8",
"sourcePackageDir": "packages/markdown/node_modules/@codemirror/view"
},
{
"name": "@eslint/js",
"version": "10.0.1",
@ -121,7 +43,7 @@
},
{
"name": "@methanium/ui",
"version": "0.0.22",
"version": "0.0.28",
"license": "UNKNOWN",
"homepage": null,
"repository": null,
@ -129,7 +51,7 @@
"files": [
"LICENSE"
],
"licenseFolder": "licenses/@methanium_ui@0.0.22",
"licenseFolder": "licenses/@methanium_ui@0.0.28",
"sourcePackageDir": "apps/tauri/node_modules/@methanium/ui"
},
{
@ -277,19 +199,6 @@
"licenseFolder": "licenses/@tauri-apps_plugin-notification@2.3.3",
"sourcePackageDir": "packages/notifications/node_modules/@tauri-apps/plugin-notification"
},
{
"name": "@twemoji/api",
"version": "17.0.3",
"license": "MIT AND CC-BY-4.0",
"homepage": "https://github.com/jdecked/twemoji",
"repository": "git://github.com/jdecked/twemoji.git",
"description": "A Unicode standard based way to implement emoji across all platforms.",
"files": [
"LICENSE"
],
"licenseFolder": "licenses/@twemoji_api@17.0.3",
"sourcePackageDir": "packages/markdown/node_modules/@twemoji/api"
},
{
"name": "@types/node",
"version": "26.2.0",
@ -421,19 +330,6 @@
"licenseFolder": "licenses/electron-builder@26.15.3",
"sourcePackageDir": "apps/electron/node_modules/electron-builder"
},
{
"name": "emojibase-data",
"version": "17.0.0",
"license": "MIT",
"homepage": null,
"repository": "git@github.com:milesj/emojibase.git",
"description": "Evergreen emoji datasets.",
"files": [
"LICENSE"
],
"licenseFolder": "licenses/emojibase-data@17.0.0",
"sourcePackageDir": "packages/markdown/node_modules/emojibase-data"
},
{
"name": "esbuild",
"version": "0.28.1",

View file

@ -24,7 +24,6 @@
"@tensamin/cache": "workspace:*",
"@tensamin/crypto": "workspace:*",
"@tensamin/hotkeys": "workspace:*",
"@tensamin/markdown": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",

View file

@ -1,5 +1,5 @@
import { Button } from "@methanium/ui";
import Emoji from "@tensamin/markdown/emoji";
import { Emoji } from "@methanium/ui/markdown";
import { getRecentEmojis, useEmojiRanks } from "./emojiRanks";
export default function EmojiPicker({

View file

@ -1,6 +1,6 @@
import { useStorage } from "@tensamin/storage/context";
import { useCallback, useEffect, useState } from "react";
import { normalizeShortcode } from "@tensamin/markdown/emoji";
import { normalizeShortcode } from "@methanium/ui/markdown";
const RANKS_CHANGED_EVENT = "tensamin-reaction-ranks-changed";
let recordQueue = Promise.resolve();

View file

@ -1,4 +1,4 @@
import Input, { type InputController } from "@tensamin/markdown/input";
import { Input, type InputController } from "@methanium/ui/markdown";
import {
Card,
CardHeader,
@ -276,7 +276,7 @@ export default function InputComponent({
<CardHeader className="relative p-0 flex flex-col">
<div className="w-full flex items-center">
<Input
className="w-full"
className="w-full p-2"
onControllerChange={setComposer}
paddingY="13px"
paddingX="13px"

View file

@ -1,4 +1,4 @@
import Text from "@tensamin/markdown/text";
import { Text } from "@methanium/ui/markdown";
import { useStorage } from "@tensamin/storage/context";
import {
Avatar,
@ -22,9 +22,9 @@ import {
X,
ZoomIn,
} from "lucide-react";
import { useState, useMemo, useEffect, useRef, WheelEvent } from "react";
import { useState, useMemo, useEffect, useRef, type WheelEvent } from "react";
import MediaSaveButton from "./mediaSaveButton";
import { User, useUser } from "@tensamin/user/context";
import { type User, useUser } from "@tensamin/user/context";
const zoomLevels = [1, 1.5, 2, 3];

View file

@ -1,5 +1,4 @@
import type { RawMessage } from "../values";
import Text from "@tensamin/markdown/text";
import { AlertTriangle, Check, CheckLine, RefreshCw } from "lucide-react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import type { User } from "@tensamin/user/context";
@ -9,6 +8,7 @@ import {
AvatarFallback,
AvatarImage,
Button,
Card,
cn,
Skeleton,
} from "@methanium/ui";
@ -16,11 +16,10 @@ import MessageContextMenu from "./messageContextMenu";
import Media from "./media";
import { useStorage } from "@tensamin/storage/context";
import { useMTP } from "@tensamin/mtp";
import Input from "@tensamin/markdown/input";
import { getMessage, useChat } from "../context";
import { decryptChatText, encryptChatText } from "@tensamin/crypto/chatSecret";
import { log, toast } from "@tensamin/shared/log";
import Emoji, { normalizeShortcode } from "@tensamin/markdown/emoji";
import { Emoji, Input, normalizeShortcode, Text } from "@methanium/ui/markdown";
import { useRecordEmojiUse } from "./emojiRanks";
import { useUser } from "@tensamin/user/context";
import ReplyBox from "./replyBox";
@ -347,7 +346,7 @@ function MessageComponent({
</Avatar>
)}
<div
className={cn("flex flex-col group", {
className={cn("flex flex-col group w-full", {
"min-w-0 flex-1": editing,
})}
>
@ -388,20 +387,21 @@ function MessageComponent({
)}
{editing ? (
<div className="flex w-full flex-col gap-1">
<Input
autoFocus
className="w-full"
styled
setValue={setEditDraft}
value={editDraft}
onSubmit={() => {
if (!editDraft.trim()) return;
if (editDraft !== message.Content) {
void submitEditMessage(editDraft);
}
onSetEditing(false);
}}
/>
<Card className="p-2!">
<Input
autoFocus
className="w-full"
setValue={setEditDraft}
value={editDraft}
onSubmit={() => {
if (!editDraft.trim()) return;
if (editDraft !== message.Content) {
void submitEditMessage(editDraft);
}
onSetEditing(false);
}}
/>
</Card>
<div className="flex gap-1">
<Button
size="xs"
@ -429,7 +429,7 @@ function MessageComponent({
</div>
</div>
) : (
<div className="flex gap-1">
<div className="flex gap-1 w-full!">
{isValidURL ? (
<Media
link={message.Content}
@ -440,12 +440,12 @@ function MessageComponent({
})}
/>
) : (
<Text value={message.Content} />
)}
{message.Edited && (
<p className="text-xs text-muted-foreground self-center">
(edited)
</p>
<div className="min-w-0 flex-1">
<Text
showEditedIndicator={message.Edited}
value={message.Content}
/>
</div>
)}
</div>
)}

View file

@ -44,7 +44,7 @@ import type {
Ref,
} from "react";
import { useChat } from "../context";
import Emoji from "@tensamin/markdown/emoji";
import { Emoji } from "@methanium/ui/markdown";
import EmojiPicker from "./emojiPicker";
import { getRecentEmojis, useEmojiRanks } from "./emojiRanks";

View file

@ -6,7 +6,7 @@ import {
cn,
Skeleton,
} from "@methanium/ui";
import Text from "@tensamin/markdown/text";
import { Text } from "@methanium/ui/markdown";
import type { User } from "@tensamin/user/context";
import Wrapper from "@tensamin/user/wrapper";
import { Forward, X } from "lucide-react";
@ -77,12 +77,11 @@ export default function ReplyBox({
) : null}
{content !== undefined && (
<div className="h-5.5 min-w-0 flex-1 truncate flex gap-1">
<Text fontSize="0.88rem" value={content} />
{edited && (
<p className="text-xs text-muted-foreground self-center">
(edited)
</p>
)}
<Text
fontSize="0.88rem"
showEditedIndicator={edited}
value={content}
/>
</div>
)}
</>

View file

@ -1,30 +0,0 @@
{
"name": "@tensamin/markdown",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
"./text": "./src/text.tsx",
"./input": "./src/input.tsx",
"./emoji": "./src/emoji.tsx"
},
"scripts": {
"format": "pnpm exec prettier --write .",
"lint": "eslint src",
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.8",
"@methanium/ui": "*",
"@twemoji/api": "^17.0.3",
"emojibase-data": "^17.0.0",
"lucide-react": "^1.30.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
}
}

View file

@ -1,138 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
vi.mock("@methanium/ui", () => ({
Tooltip: ({ children }: { children: ReactNode }) => children,
TooltipContent: ({ children }: { children: ReactNode }) => children,
TooltipTrigger: ({ render }: { render: ReactNode }) => render,
}));
import { EditorState } from "@codemirror/state";
import { CompletionContext } from "@codemirror/autocomplete";
import { markdown } from "@codemirror/lang-markdown";
import { normalizeShortcode, resolveEmoji, searchEmojis } from "./emojiData";
import { parseEmojiText, parseInlineNodes } from "./markdown";
import {
createEmojiCompletionSource,
findEmojiRanges,
MAX_RENDERED_EMOJI_OPTIONS,
} from "./input";
describe("emoji shortcodes", () => {
it("normalizes aliases to their canonical shortcode", () => {
expect(normalizeShortcode(":flame:")).toBe(":fire:");
expect(normalizeShortcode("+1")).toBe(":thumbsup:");
});
it("resolves every search result to a Twemoji hexcode", () => {
const results = searchEmojis("fire");
expect(results[0]?.shortcode).toBe(":fire:");
expect(results.every((emoji) => emoji.hexcode.length > 0)).toBe(true);
});
it("shows all emojis for an empty query", () => {
expect(searchEmojis("").length).toBeGreaterThan(1000);
});
it("bounds the number of mounted autocomplete rows", () => {
expect(MAX_RENDERED_EMOJI_OPTIONS).toBeLessThanOrEqual(100);
});
it("parses known shortcodes and preserves unknown ones", () => {
expect(parseEmojiText("a :fire: b :not_an_emoji:")).toEqual([
{ type: "text", value: "a " },
{ type: "emoji", shortcode: ":fire:" },
{ type: "text", value: " b :not_an_emoji:" },
]);
});
it("recognizes a valid shortcode sharing an unknown closing colon", () => {
expect(parseEmojiText(":bla:thumbsup:")).toEqual([
{ type: "text", value: ":bla" },
{ type: "emoji", shortcode: ":thumbsup:" },
]);
const state = EditorState.create({
doc: ":bla:thumbsup:",
extensions: [markdown()],
});
expect(findEmojiRanges(state)[0]?.from).toBe(4);
});
it("does not parse underscores inside emoji shortcodes as emphasis", () => {
expect(parseInlineNodes("before :white_check_mark: after")).toEqual([
{ type: "text", value: "before " },
{ type: "emoji", shortcode: ":white_check_mark:" },
{ type: "text", value: " after" },
]);
});
it("contains the picker defaults", () => {
expect(resolveEmoji(":thumbsup:")).toBeDefined();
expect(resolveEmoji(":white_check_mark:")).toBeDefined();
});
it("finds completed emoji shortcodes in editor state", () => {
const state = EditorState.create({
doc: "before :fire: after :not_an_emoji:",
extensions: [markdown()],
});
expect(
findEmojiRanges(state).map(({ from, shortcode, to }) => ({
from,
shortcode,
to,
})),
).toEqual([{ from: 7, shortcode: ":fire:", to: 13 }]);
});
it("does not replace emoji shortcodes inside code", () => {
const state = EditorState.create({
doc: "`:fire:`\n\n```\n:fire:\n```\n\n:fire:",
extensions: [markdown()],
});
expect(findEmojiRanges(state)).toHaveLength(1);
expect(findEmojiRanges(state)[0]?.from).toBe(26);
});
it("ranks frequently used emojis first for a bare colon", async () => {
const state = EditorState.create({ doc: ":", extensions: [markdown()] });
const result = await createEmojiCompletionSource({
":fire:": 50,
":thumbsup:": 2,
})(new CompletionContext(state, 1, false));
expect(result?.options[0]?.displayLabel).toBe(":fire:");
expect(result?.options[1]?.displayLabel).toBe(":thumbsup:");
});
it("keeps typed relevance above usage frequency", async () => {
const state = EditorState.create({
doc: ":fire",
extensions: [markdown()],
});
const result = await createEmojiCompletionSource({
":fire_engine:": 10000,
":fire:": 1,
})(new CompletionContext(state, 5, false));
expect(result?.options[0]?.displayLabel).toBe(":fire:");
expect(result?.options[0]?.boost).toBeGreaterThan(
result?.options[1]?.boost ?? 0,
);
});
it("merges alias frequencies into canonical completions", async () => {
const state = EditorState.create({ doc: ":", extensions: [markdown()] });
const result = await createEmojiCompletionSource({
":fire:": 2,
":flame:": 3,
})(new CompletionContext(state, 1, false));
const fire = result?.options.find(
(option) => option.displayLabel === ":fire:",
);
expect(fire?.boost).toBe(15);
});
});

View file

@ -1,50 +0,0 @@
import twemoji from "@twemoji/api";
import { Tooltip, TooltipContent, TooltipTrigger } from "@methanium/ui";
import { resolveEmoji } from "./emojiData";
export {
emojis,
findEmojiShortcodes,
normalizeShortcode,
resolveEmoji,
searchEmojis,
} from "./emojiData";
export type { EmojiDefinition } from "./emojiData";
export function getEmojiUrl(shortcode: string): string | undefined {
const emoji = resolveEmoji(shortcode);
return emoji ? `${twemoji.base}svg/${emoji.hexcode}.svg` : undefined;
}
export default function Emoji({
className = "h-6 w-6",
shortcode,
tooltip = true,
}: {
className?: string;
shortcode: string;
tooltip?: boolean;
}) {
const emoji = resolveEmoji(shortcode);
if (!emoji) return <span>{shortcode}</span>;
const image = (
<img
alt={emoji.shortcode}
className={className}
decoding="async"
draggable={false}
loading="lazy"
src={`${twemoji.base}svg/${emoji.hexcode}.svg`}
/>
);
if (!tooltip) return image;
return (
<Tooltip>
<TooltipTrigger render={image} />
<TooltipContent sideOffset={8}>{emoji.shortcode}</TooltipContent>
</Tooltip>
);
}

View file

@ -1,94 +0,0 @@
import shortcodeData from "emojibase-data/en/shortcodes/joypixels.json";
export type EmojiDefinition = {
aliases: readonly string[];
hexcode: string;
name: string;
shortcode: string;
};
function normalizeName(value: string) {
return value
.trim()
.replace(/^:+|:+$/g, "")
.toLowerCase();
}
export const emojis: readonly EmojiDefinition[] = Object.entries(
shortcodeData as Record<string, string | string[]>,
).map(([hexcode, value]) => {
const aliases = Array.isArray(value) ? value : [value];
const name = aliases[0];
return {
aliases,
hexcode: hexcode.toLowerCase().replaceAll("_", "-"),
name,
shortcode: `:${name}:`,
};
});
const emojiByName = new Map<string, EmojiDefinition>();
for (const emoji of emojis) {
for (const alias of emoji.aliases) {
emojiByName.set(normalizeName(alias), emoji);
}
}
export function resolveEmoji(value: string): EmojiDefinition | undefined {
return emojiByName.get(normalizeName(value));
}
export function normalizeShortcode(value: string): string | undefined {
return resolveEmoji(value)?.shortcode;
}
export function findEmojiShortcodes(value: string) {
const matches: Array<{
emoji: EmojiDefinition;
from: number;
to: number;
}> = [];
let searchFrom = 0;
while (searchFrom < value.length) {
const from = value.indexOf(":", searchFrom);
if (from === -1) break;
const candidate = value.slice(from).match(/^:([a-z0-9_+-]+):/i);
if (!candidate) {
searchFrom = from + 1;
continue;
}
const emoji = resolveEmoji(candidate[1]);
if (!emoji) {
// The closing colon may also open the next valid shortcode.
searchFrom = from + candidate[0].length - 1;
continue;
}
const to = from + candidate[0].length;
matches.push({ emoji, from, to });
searchFrom = to;
}
return matches;
}
export function searchEmojis(query: string): EmojiDefinition[] {
const normalizedQuery = normalizeName(query);
if (!normalizedQuery) return [...emojis];
return emojis
.map((emoji) => {
const names = emoji.aliases.map(normalizeName);
const exact = names.includes(normalizedQuery);
const prefix = names.some((name) => name.startsWith(normalizedQuery));
const contains = names.some((name) => name.includes(normalizedQuery));
return { emoji, rank: exact ? 0 : prefix ? 1 : contains ? 2 : 3 };
})
.filter(({ rank }) => rank < 3)
.sort((a, b) => a.rank - b.rank || a.emoji.name.localeCompare(b.emoji.name))
.map(({ emoji }) => emoji);
}

View file

@ -1,850 +0,0 @@
import { markdown } from "@codemirror/lang-markdown";
import { syntaxTree } from "@codemirror/language";
import {
acceptCompletion,
autocompletion,
completionStatus,
pickedCompletion,
startCompletion,
type Completion,
type CompletionContext,
type CompletionResult,
} from "@codemirror/autocomplete";
import {
EditorState,
EditorSelection,
Annotation,
Compartment,
Prec,
Transaction,
type Extension,
type Range,
type SelectionRange,
} from "@codemirror/state";
import {
Decoration,
EditorView,
keymap,
placeholder,
ViewPlugin,
WidgetType,
type DecorationSet,
type KeyBinding,
type ViewUpdate,
} from "@codemirror/view";
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from "@codemirror/commands";
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import { createRoot, type Root } from "react-dom/client";
import { collectInlineRanges, ensureMarkdownStyles } from "./markdown";
import Emoji, {
findEmojiShortcodes,
getEmojiUrl,
resolveEmoji,
searchEmojis,
} from "./emoji";
export const MAX_RENDERED_EMOJI_OPTIONS = 100;
export type InputController = {
focus: () => void;
hasFocus: () => boolean;
insertText: (text: string) => void;
};
export type InputProps = {
ref?: HTMLDivElement;
placeholder?: string;
value: string;
setValue: (value: string) => void;
onSubmit?: () => void;
invertEnterBehavior?: boolean;
styled?: boolean;
fontSize?: CSSProperties["fontSize"];
paddingX?: CSSProperties["padding"];
paddingY?: CSSProperties["padding"];
className?: string;
emojiFrequencies?: Readonly<Record<string, number>>;
onEmojiSelect?: (shortcode: string) => void;
autoFocus?: boolean;
onControllerChange?: (controller: InputController | null) => void;
};
function toCssLength(value: CSSProperties["padding"]): string | undefined {
if (value === undefined) {
return undefined;
}
return typeof value === "number" ? `${value}px` : value;
}
function toCssPadding(
vertical: CSSProperties["padding"],
horizontal: CSSProperties["padding"],
styled: boolean,
): string {
const defaultVertical = styled ? "0.25rem" : "0";
const defaultHorizontal = styled ? "0.625rem" : "0";
return `${toCssLength(vertical) ?? defaultVertical} ${toCssLength(horizontal) ?? defaultHorizontal}`;
}
const hiddenTokenDecoration = Decoration.mark({ class: "tm-md-hidden-token" });
const strongDecoration = Decoration.mark({ class: "tm-md-strong" });
const emDecoration = Decoration.mark({ class: "tm-md-em" });
const delDecoration = Decoration.mark({ class: "tm-md-del" });
const codeDecoration = Decoration.mark({ class: "tm-md-code" });
const linkDecoration = Decoration.mark({ class: "tm-md-link" });
const codeLineDecoration = Decoration.line({ class: "tm-md-code-line" });
const externalValueSync = Annotation.define<boolean>();
const widgetRoots = new WeakMap<HTMLElement, Root>();
type EmojiRange = {
from: number;
shortcode: string;
to: number;
url: string;
};
class EmojiWidget extends WidgetType {
readonly shortcode: string;
readonly url: string;
constructor(shortcode: string, url: string) {
super();
this.shortcode = shortcode;
this.url = url;
}
eq(other: EmojiWidget) {
return other.shortcode === this.shortcode && other.url === this.url;
}
toDOM() {
const container = document.createElement("span");
const root = createRoot(container);
root.render(
<Emoji className="tm-md-editor-emoji" shortcode={this.shortcode} />,
);
widgetRoots.set(container, root);
return container;
}
destroy(dom: HTMLElement) {
widgetRoots.get(dom)?.unmount();
widgetRoots.delete(dom);
}
ignoreEvent() {
return true;
}
}
function codeRanges(state: EditorState) {
const ranges: Array<{ from: number; to: number }> = [];
syntaxTree(state).iterate({
enter(node) {
if (
node.name === "InlineCode" ||
node.name === "FencedCode" ||
node.name === "CodeBlock"
) {
ranges.push({ from: node.from, to: node.to });
return false;
}
},
});
return ranges;
}
export function findEmojiRanges(state: EditorState): EmojiRange[] {
const document = state.doc.toString();
const excluded = codeRanges(state);
const ranges: EmojiRange[] = [];
for (const match of findEmojiShortcodes(document)) {
const { from, to } = match;
const inCode = excluded.some((range) => from < range.to && to > range.from);
const emoji = inCode ? undefined : match.emoji;
const url = emoji ? getEmojiUrl(emoji.shortcode) : undefined;
if (emoji && url) {
ranges.push({ from, shortcode: emoji.shortcode, to, url });
}
}
return ranges;
}
class EmojiPluginValue {
decorations: DecorationSet;
ranges: EmojiRange[];
constructor(view: EditorView) {
this.ranges = findEmojiRanges(view.state);
this.decorations = this.buildDecorations();
}
update(update: ViewUpdate) {
if (
update.docChanged ||
syntaxTree(update.startState) !== syntaxTree(update.state)
) {
this.ranges = findEmojiRanges(update.state);
this.decorations = this.buildDecorations();
}
}
private buildDecorations() {
return Decoration.set(
this.ranges.map((range) =>
Decoration.replace({
inclusive: false,
widget: new EmojiWidget(range.shortcode, range.url),
}).range(range.from, range.to),
),
true,
);
}
}
const emojiDecorations = ViewPlugin.fromClass(EmojiPluginValue, {
decorations: (instance) => instance.decorations,
provide: (plugin) =>
EditorView.atomicRanges.of(
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
),
});
function deleteEmoji(view: EditorView, direction: "backward" | "forward") {
const ranges = view.plugin(emojiDecorations)?.ranges ?? [];
const deletions: Array<{ from: number; to: number }> = [];
for (const selection of view.state.selection.ranges) {
if (selection.empty) {
const emoji = ranges.find((range) =>
direction === "backward"
? selection.from > range.from && selection.from <= range.to
: selection.from >= range.from && selection.from < range.to,
);
if (emoji) deletions.push({ from: emoji.from, to: emoji.to });
continue;
}
let from = selection.from;
let to = selection.to;
let changed = false;
for (const emoji of ranges) {
if (from < emoji.to && to > emoji.from) {
from = Math.min(from, emoji.from);
to = Math.max(to, emoji.to);
changed = true;
}
}
if (changed) deletions.push({ from, to });
}
if (deletions.length === 0) return false;
const merged = deletions
.sort((a, b) => a.from - b.from)
.reduce<Array<{ from: number; to: number }>>((result, deletion) => {
const previous = result.at(-1);
if (previous && deletion.from <= previous.to) {
previous.to = Math.max(previous.to, deletion.to);
} else {
result.push({ ...deletion });
}
return result;
}, []);
view.dispatch({
changes: merged.map((range) => ({ from: range.from, to: range.to })),
selection: EditorSelection.cursor(merged[0].from),
scrollIntoView: true,
userEvent: direction === "backward" ? "delete.backward" : "delete.forward",
});
return true;
}
/**
* Builds markdown styling decorations every time the document or cursor selection changes.
* Token delimiters are hidden unless the cursor is currently intersecting that token range.
*/
const markdownDecorations = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
}
}
},
{
decorations: (instance: { decorations: DecorationSet }) =>
instance.decorations,
},
);
/**
* Executes Input.
* @param props Parameter props.
* @returns unknown.
*/
export default function Input(props: InputProps) {
ensureMarkdownStyles();
const shellClassName = props.styled
? "min-h-8 w-full min-w-0 rounded-lg border border-input bg-transparent text-base transition-colors outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
: "";
const elementRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | undefined>(undefined);
const setValueRef = useRef(props.setValue);
const onSubmitRef = useRef<InputProps["onSubmit"]>(props.onSubmit);
const onEmojiSelectRef = useRef<InputProps["onEmojiSelect"]>(
props.onEmojiSelect,
);
const invertEnterBehaviorRef = useRef(Boolean(props.invertEnterBehavior));
const completionCompartmentRef = useRef<Compartment | null>(null);
completionCompartmentRef.current ??= new Compartment();
const completionCompartment = completionCompartmentRef.current;
useEffect(() => {
setValueRef.current = props.setValue;
onSubmitRef.current = props.onSubmit;
onEmojiSelectRef.current = props.onEmojiSelect;
invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior);
}, [
props.onEmojiSelect,
props.onSubmit,
props.invertEnterBehavior,
props.setValue,
]);
useEffect(() => {
if (!elementRef.current) return;
const state = EditorState.create({
doc: props.value,
extensions: createEditorExtensions(
(value) => {
setValueRef.current(value);
},
() => props.placeholder,
() => invertEnterBehaviorRef.current,
() => onSubmitRef.current?.(),
completionCompartment,
props.emojiFrequencies,
(shortcode) => onEmojiSelectRef.current?.(shortcode),
),
});
viewRef.current = new EditorView({
state,
parent: elementRef.current,
});
props.onControllerChange?.({
focus: () => viewRef.current?.contentDOM.focus({ preventScroll: true }),
hasFocus: () => viewRef.current?.hasFocus ?? false,
insertText: (text) => {
const editor = viewRef.current;
if (!editor) return;
editor.dispatch({
...editor.state.replaceSelection(text),
annotations: Transaction.userEvent.of("input.type"),
scrollIntoView: true,
});
},
});
if (props.autoFocus) viewRef.current.focus();
return () => {
props.onControllerChange?.(null);
viewRef.current?.destroy();
viewRef.current = undefined;
};
// Run once to initialize/destroy the editor instance.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const editor = viewRef.current;
if (!editor) return;
const next = props.value;
const current = editor.state.doc.toString();
if (next === current) return;
editor.dispatch({
changes: {
from: 0,
to: current.length,
insert: next,
},
annotations: [
externalValueSync.of(true),
Transaction.addToHistory.of(false),
],
filter: false,
});
}, [props.value]);
useEffect(() => {
const editor = viewRef.current;
const compartment = completionCompartmentRef.current;
if (!editor || !compartment) return;
const wasActive = completionStatus(editor.state) === "active";
editor.dispatch({
effects: compartment.reconfigure(
createEmojiAutocomplete(props.emojiFrequencies, (shortcode) =>
onEmojiSelectRef.current?.(shortcode),
),
),
});
if (wasActive) startCompletion(editor);
}, [props.emojiFrequencies]);
return (
<div
ref={elementRef}
className={`tm-md-root ${shellClassName} ${props.className ?? ""}`}
style={
{
fontSize: props.fontSize ?? "1rem",
"--tm-md-content-padding": toCssPadding(
props.paddingY,
props.paddingX,
Boolean(props.styled),
),
} as CSSProperties & {
"--tm-md-content-padding"?: string;
}
}
/>
);
}
/**
* Executes createEditorExtensions.
* @param onChange Parameter onChange.
* @param getPlaceholder Parameter getPlaceholder.
* @param getInvertEnterBehavior Parameter getInvertEnterBehavior.
* @param onSubmit Parameter onSubmit.
* @returns Extension[].
*/
function createEditorExtensions(
onChange: (value: string) => void,
getPlaceholder: () => string | undefined,
getInvertEnterBehavior: () => boolean,
onSubmit: () => void,
completionCompartment: Compartment,
emojiFrequencies: Readonly<Record<string, number>> | undefined,
onEmojiSelect: (shortcode: string) => void,
): Extension[] {
const editorKeymap = [
...defaultKeymap,
...historyKeymap,
indentWithTab,
] as unknown as readonly KeyBinding[];
const customEnterKeymap = keymap.of([
{
key: "Shift-Enter",
run: (view) => {
if (completionStatus(view.state) === "active") {
return acceptCompletion(view);
}
if (!getInvertEnterBehavior()) {
return false;
}
onSubmit();
return true;
},
},
{
key: "Enter",
run: (view) => {
if (completionStatus(view.state) === "active") {
return acceptCompletion(view);
}
if (getInvertEnterBehavior()) {
return false;
}
onSubmit();
return true;
},
},
]);
const completionTabKeymap = keymap.of([
{
key: "Tab",
run: (view) =>
completionStatus(view.state) === "active"
? acceptCompletion(view)
: false,
},
]);
const emojiDeletionKeymap = keymap.of([
{
key: "Backspace",
run: (view) => deleteEmoji(view, "backward"),
},
{
key: "Delete",
run: (view) => deleteEmoji(view, "forward"),
},
]);
return [
history(),
markdown(),
completionCompartment.of(
createEmojiAutocomplete(emojiFrequencies, onEmojiSelect),
),
emojiDecorations,
keymap.of(editorKeymap),
Prec.highest(completionTabKeymap),
Prec.highest(emojiDeletionKeymap),
Prec.highest(customEnterKeymap),
EditorView.lineWrapping,
placeholder(getPlaceholder() ?? ""),
EditorView.updateListener.of((update: ViewUpdate) => {
if (!update.docChanged) return;
if (
update.transactions.some(
(transaction) => transaction.annotation(externalValueSync) === true,
)
) {
return;
}
onChange(update.state.doc.toString());
}),
EditorView.theme({
"&": {
fontSize: "inherit",
},
"&.cm-editor": {
width: "100%",
},
}),
EditorView.editorAttributes.of({
class: "tm-md-editor",
spellcheck: "true",
"aria-label": "Markdown input",
}),
markdownDecorations,
];
}
function createEmojiAutocomplete(
frequencies: Readonly<Record<string, number>> | undefined,
onEmojiSelect: (shortcode: string) => void,
) {
return autocompletion({
activateOnTyping: true,
addToOptions: [
{
position: 20,
render(completion) {
const container = document.createElement("span");
createRoot(container).render(
<Emoji
className="tm-md-completion-emoji"
shortcode={completion.label}
/>,
);
return container;
},
},
],
maxRenderedOptions: MAX_RENDERED_EMOJI_OPTIONS,
override: [createEmojiCompletionSource(frequencies, onEmojiSelect)],
});
}
function normalizedFrequencies(
frequencies: Readonly<Record<string, number>> | undefined,
) {
const normalized = new Map<string, number>();
for (const [value, frequency] of Object.entries(frequencies ?? {})) {
const shortcode = resolveEmoji(value)?.shortcode;
if (!shortcode || !Number.isFinite(frequency) || frequency <= 0) continue;
normalized.set(shortcode, (normalized.get(shortcode) ?? 0) + frequency);
}
return normalized;
}
export function createEmojiCompletionSource(
frequencies?: Readonly<Record<string, number>>,
onEmojiSelect: (shortcode: string) => void = () => undefined,
) {
const normalized = normalizedFrequencies(frequencies);
const maxFrequency = Math.max(0, ...normalized.values());
return (context: CompletionContext): CompletionResult | null => {
const token = context.matchBefore(/:[a-z0-9_+-]*$/i);
if (!token) return null;
if (
codeRanges(context.state).some(
(range) => token.from < range.to && token.to > range.from,
)
) {
return null;
}
const characterBefore = context.state.sliceDoc(
Math.max(0, token.from - 1),
token.from,
);
if (characterBefore && /[a-z0-9_]/i.test(characterBefore)) return null;
const query = token.text.slice(1).toLowerCase();
const options: Completion[] = searchEmojis(query)
.map((emoji) => {
const aliases = emoji.aliases.map((alias) => alias.toLowerCase());
const matchedAlias =
aliases.find((alias) => alias === query) ??
aliases.find((alias) => alias.startsWith(query)) ??
aliases.find((alias) => alias.includes(query)) ??
emoji.name;
const relevance = !query
? 0
: matchedAlias === query
? 80
: matchedAlias.startsWith(query)
? 40
: 0;
const frequency = normalized.get(emoji.shortcode) ?? 0;
const usage =
maxFrequency > 0
? (15 * Math.log1p(frequency)) / Math.log1p(maxFrequency)
: 0;
return {
apply(view, completion, from, to) {
view.dispatch({
annotations: pickedCompletion.of(completion),
changes: { from, insert: `${emoji.shortcode} `, to },
selection: EditorSelection.cursor(
from + emoji.shortcode.length + 1,
),
});
onEmojiSelect(emoji.shortcode);
},
boost: relevance + usage,
displayLabel: emoji.shortcode,
label: `:${matchedAlias}:`,
type: "text",
frequency,
relevance,
} satisfies Completion & { frequency: number; relevance: number };
})
.sort(
(a, b) =>
b.relevance - a.relevance ||
b.frequency - a.frequency ||
(a.displayLabel ?? a.label).localeCompare(b.displayLabel ?? b.label),
);
return {
from: token.from,
options,
validFor: /^:[a-z0-9_+-]*$/i,
};
};
}
export const emojiCompletionSource = createEmojiCompletionSource();
/**
* Executes buildDecorations.
* @param view Parameter view.
* @returns DecorationSet.
*/
function buildDecorations(view: EditorView): DecorationSet {
const builder: Range<Decoration>[] = [];
const selections = view.state.selection.ranges.map(
(range: SelectionRange) => ({
from: range.from,
to: range.to,
}),
);
let codeFenceOpen = false;
for (
let lineNumber = 1;
lineNumber <= view.state.doc.lines;
lineNumber += 1
) {
const line = view.state.doc.line(lineNumber);
const text = line.text;
const lineFrom = line.from;
const trimmed = text.trim();
const fence = text.match(/^```\s*([^`]*)$/);
if (fence) {
const ticksStart = lineFrom + text.indexOf("```");
const ticksEnd = ticksStart + 3;
addHiddenToken(builder, selections, { from: ticksStart, to: ticksEnd });
if (trimmed.length > 3) {
addHiddenToken(builder, selections, {
from: ticksEnd,
to: line.to,
});
}
codeFenceOpen = !codeFenceOpen;
continue;
}
if (codeFenceOpen) {
builder.push(codeLineDecoration.range(lineFrom));
continue;
}
const heading = text.match(/^(#{1,6})\s+/);
if (heading) {
const markerLength = heading[0].length;
addHiddenToken(builder, selections, {
from: lineFrom,
to: lineFrom + markerLength,
});
const level = heading[1].length;
const headingClass = Decoration.mark({
class: `tm-md-heading tm-md-h${String(level)}`,
});
const contentFrom = lineFrom + markerLength;
if (contentFrom < line.to) {
builder.push(headingClass.range(contentFrom, line.to));
}
}
const quote = text.match(/^>\s?/);
if (quote) {
addHiddenToken(builder, selections, {
from: lineFrom,
to: lineFrom + quote[0].length,
});
}
const unordered = text.match(/^(\s*)([-+*])\s+(?:\[( |x|X)\]\s+)?/);
if (unordered) {
const markerStart = lineFrom + unordered[1].length;
const markerEnd = markerStart + unordered[2].length + 1;
addHiddenToken(builder, selections, { from: markerStart, to: markerEnd });
const checkbox = unordered[0].match(/\[( |x|X)\]\s+$/);
if (checkbox) {
const checkboxStart = lineFrom + unordered[0].lastIndexOf("[");
addHiddenToken(builder, selections, {
from: checkboxStart,
to: checkboxStart + checkbox[0].length,
});
}
}
const ordered = text.match(/^(\s*)(\d+\.)\s+/);
if (ordered) {
const markerStart = lineFrom + ordered[1].length;
addHiddenToken(builder, selections, {
from: markerStart,
to: markerStart + ordered[2].length + 1,
});
}
if (/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(trimmed)) {
if (lineFrom < line.to) {
builder.push(
Decoration.mark({ class: "tm-md-hr" }).range(lineFrom, line.to),
);
}
continue;
}
const tableSeparator = /^\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?$/.test(
text,
);
if (tableSeparator) {
if (lineFrom < line.to) {
builder.push(
Decoration.mark({ class: "tm-md-del" }).range(lineFrom, line.to),
);
}
continue;
}
const { styleRanges, tokenRanges } = collectInlineRanges(text, lineFrom);
for (const range of styleRanges) {
if (range.from >= range.to) continue;
if (range.className === "tm-md-strong") {
builder.push(strongDecoration.range(range.from, range.to));
} else if (range.className === "tm-md-em") {
builder.push(emDecoration.range(range.from, range.to));
} else if (range.className === "tm-md-del") {
builder.push(delDecoration.range(range.from, range.to));
} else if (range.className === "tm-md-code") {
builder.push(codeDecoration.range(range.from, range.to));
} else if (range.className === "tm-md-link") {
builder.push(linkDecoration.range(range.from, range.to));
}
}
for (const token of tokenRanges) {
addHiddenToken(builder, selections, token);
}
}
return Decoration.set(builder, true);
}
/**
* Keeps markdown syntax visible only when user selection intersects the token.
* This preserves cursor predictability and cross-token selection while still hiding syntax during reading.
*/
function addHiddenToken(
builder: Range<Decoration>[],
selections: ReadonlyArray<{ from: number; to: number }>,
token: {
from: number;
to: number;
},
): void {
if (token.from >= token.to) return;
const overlapsSelection = selections.some((selection) => {
const selectionFrom = Math.min(selection.from, selection.to);
const selectionTo = Math.max(selection.from, selection.to);
if (selectionFrom === selectionTo) {
return selectionFrom >= token.from && selectionFrom <= token.to;
}
return selectionFrom < token.to && selectionTo > token.from;
});
if (overlapsSelection) return;
builder.push(hiddenTokenDecoration.range(token.from, token.to));
}

View file

@ -1,815 +0,0 @@
import {
Fragment,
useEffect,
useRef,
useState,
type ReactElement,
type ReactNode,
} from "react";
import Emoji from "./emoji";
import { findEmojiShortcodes } from "./emojiData";
import { Check } from "lucide-react";
type InlineNode =
| { type: "text"; value: string }
| { type: "emoji"; shortcode: string }
| { type: "strong"; value: string }
| { type: "em"; value: string }
| { type: "del"; value: string }
| { type: "code"; value: string }
| { type: "link"; label: string; href: string }
| { type: "image"; alt: string; src: string };
type InlineDecorationRange = {
from: number;
to: number;
className: string;
};
type InlineTokenRange = {
from: number;
to: number;
};
type ListItem = {
text: string;
checked: boolean | null;
};
type TableBlock = {
type: "table";
headers: string[];
rows: string[][];
};
type MarkdownBlock =
| {
type: "paragraph";
text: string;
}
| {
type: "heading";
level: number;
text: string;
}
| {
type: "hr";
}
| {
type: "blockquote";
text: string;
}
| {
type: "code";
language: string;
code: string;
}
| {
type: "list";
ordered: boolean;
items: ListItem[];
}
| TableBlock;
const INLINE_TOKEN_REGEX =
/!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|(?<![a-zA-Z0-9:])_([^_\n]+)_(?![a-zA-Z0-9:])/g;
function CopiedIndicator({
block,
visible,
}: {
block: boolean;
visible: boolean;
}) {
return (
<span
className={`pointer-events-none inline-flex align-middle text-foreground transition-opacity duration-200 ease-out ${block ? "mt-3 shrink-0" : "ml-1"} ${visible ? "opacity-100" : "opacity-0"}`}
aria-live="polite"
aria-hidden={!visible}
>
<Check className="size-3.5" />
</span>
);
}
function CopyableCode({
block = false,
language,
value,
}: {
block?: boolean;
language?: string;
value: string;
}) {
const [copied, setCopied] = useState(false);
const copiedTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
useEffect(
() => () => {
clearTimeout(copiedTimer.current);
},
[],
);
async function copy() {
await navigator.clipboard.writeText(value);
setCopied(true);
clearTimeout(copiedTimer.current);
copiedTimer.current = setTimeout(() => setCopied(false), 1200);
}
const code = (
<code
className={block ? "" : "bg-card border p-0.5 rounded text-sm"}
data-language={language}
role="button"
tabIndex={0}
onClick={() => void copy()}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
void copy();
}}
>
{value}
</code>
);
if (block) {
return (
<div className="flex min-w-0 items-start gap-1">
<pre className="tm-md-pre min-w-0 flex-1">{code}</pre>
<CopiedIndicator block visible={copied} />
</div>
);
}
return (
<>
{code}
<CopiedIndicator block={false} visible={copied} />
</>
);
}
/**
* Executes parseInlineNodes.
* @param input Parameter input.
* @returns InlineNode[].
*/
export function parseInlineNodes(input: string): InlineNode[] {
const nodes: InlineNode[] = [];
let cursor = 0;
let match = INLINE_TOKEN_REGEX.exec(input);
while (match) {
const index = match.index;
const raw = match[0];
if (index > cursor) {
nodes.push(...parseEmojiText(input.slice(cursor, index)));
}
if (match[1] !== undefined && match[2] !== undefined) {
nodes.push({ type: "image", alt: match[1], src: normalizeUrl(match[2]) });
} else if (match[3] !== undefined && match[4] !== undefined) {
nodes.push({
type: "link",
label: match[3],
href: normalizeUrl(match[4]),
});
} else if (match[5] !== undefined) {
nodes.push({ type: "code", value: match[5] });
} else if (match[6] !== undefined) {
nodes.push({ type: "del", value: match[6] });
} else if (match[7] !== undefined || match[8] !== undefined) {
nodes.push({ type: "strong", value: match[7] ?? match[8] ?? "" });
} else if (match[9] !== undefined || match[10] !== undefined) {
nodes.push({ type: "em", value: match[9] ?? match[10] ?? "" });
} else {
nodes.push(...parseEmojiText(raw));
}
cursor = index + raw.length;
match = INLINE_TOKEN_REGEX.exec(input);
}
if (cursor < input.length) {
nodes.push(...parseEmojiText(input.slice(cursor)));
}
INLINE_TOKEN_REGEX.lastIndex = 0;
return nodes;
}
export function parseEmojiText(input: string): InlineNode[] {
const nodes: InlineNode[] = [];
let cursor = 0;
for (const match of findEmojiShortcodes(input)) {
if (match.from > cursor) {
nodes.push({ type: "text", value: input.slice(cursor, match.from) });
}
nodes.push({ type: "emoji", shortcode: match.emoji.shortcode });
cursor = match.to;
}
if (cursor < input.length) {
nodes.push({ type: "text", value: input.slice(cursor) });
}
return nodes;
}
/**
* Executes collectInlineRanges.
* @param input Parameter input.
* @param offset Parameter offset.
* @returns {
styleRanges: InlineDecorationRange[];
tokenRanges: InlineTokenRange[];
}.
*/
export function collectInlineRanges(
input: string,
offset = 0,
): {
styleRanges: InlineDecorationRange[];
tokenRanges: InlineTokenRange[];
} {
const styleRanges: InlineDecorationRange[] = [];
const tokenRanges: InlineTokenRange[] = [];
let match = INLINE_TOKEN_REGEX.exec(input);
while (match) {
const raw = match[0];
const start = offset + match.index;
const end = start + raw.length;
if (match[1] !== undefined && match[2] !== undefined) {
const openLength = 2;
const closeLength = raw.endsWith(")") ? 1 : 0;
const imageEnd = start + openLength + match[1].length;
tokenRanges.push({ from: start, to: start + openLength });
tokenRanges.push({ from: imageEnd, to: imageEnd + 1 });
const srcStart = imageEnd + 1;
const srcEnd = end - closeLength;
tokenRanges.push({ from: srcStart, to: srcStart + 1 });
tokenRanges.push({ from: srcEnd, to: srcEnd + closeLength });
} else if (match[3] !== undefined && match[4] !== undefined) {
const label = match[3];
const labelStart = start + 1;
const labelEnd = labelStart + label.length;
tokenRanges.push({ from: start, to: start + 1 });
tokenRanges.push({ from: labelEnd, to: labelEnd + 1 });
tokenRanges.push({ from: labelEnd + 1, to: labelEnd + 2 });
tokenRanges.push({ from: end - 1, to: end });
styleRanges.push({
from: labelStart,
to: labelEnd,
className: "tm-md-link",
});
} else if (match[5] !== undefined) {
const codeStart = start + 1;
const codeEnd = end - 1;
tokenRanges.push({ from: start, to: start + 1 });
tokenRanges.push({ from: end - 1, to: end });
styleRanges.push({
from: codeStart,
to: codeEnd,
className: "tm-md-code",
});
} else if (match[6] !== undefined) {
const contentStart = start + 2;
const contentEnd = end - 2;
tokenRanges.push({ from: start, to: start + 2 });
tokenRanges.push({ from: end - 2, to: end });
styleRanges.push({
from: contentStart,
to: contentEnd,
className: "tm-md-del",
});
} else if (match[7] !== undefined || match[8] !== undefined) {
const contentStart = start + 2;
const contentEnd = end - 2;
tokenRanges.push({ from: start, to: start + 2 });
tokenRanges.push({ from: end - 2, to: end });
styleRanges.push({
from: contentStart,
to: contentEnd,
className: "tm-md-strong",
});
} else if (match[9] !== undefined || match[10] !== undefined) {
const contentStart = start + 1;
const contentEnd = end - 1;
tokenRanges.push({ from: start, to: start + 1 });
tokenRanges.push({ from: end - 1, to: end });
styleRanges.push({
from: contentStart,
to: contentEnd,
className: "tm-md-em",
});
}
match = INLINE_TOKEN_REGEX.exec(input);
}
INLINE_TOKEN_REGEX.lastIndex = 0;
return { styleRanges, tokenRanges };
}
/**
* Executes parseMarkdownBlocks.
* @param markdown Parameter markdown.
* @returns MarkdownBlock[].
*/
export function parseMarkdownBlocks(markdown: string): MarkdownBlock[] {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const blocks: MarkdownBlock[] = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
index += 1;
continue;
}
const codeFence = line.match(/^```\s*([^`]*)$/);
if (codeFence) {
const language = (codeFence[1] ?? "").trim();
const codeLines: string[] = [];
index += 1;
while (index < lines.length && !/^```\s*$/.test(lines[index])) {
codeLines.push(lines[index]);
index += 1;
}
if (index < lines.length) {
index += 1;
}
blocks.push({ type: "code", language, code: codeLines.join("\n") });
continue;
}
if (/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(line.trim())) {
blocks.push({ type: "hr" });
index += 1;
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
blocks.push({
type: "heading",
level: heading[1].length,
text: heading[2],
});
index += 1;
continue;
}
const quote = line.match(/^>\s?(.*)$/);
if (quote) {
const quoteLines: string[] = [quote[1]];
index += 1;
while (index < lines.length) {
const next = lines[index].match(/^>\s?(.*)$/);
if (!next) break;
quoteLines.push(next[1]);
index += 1;
}
blocks.push({ type: "blockquote", text: quoteLines.join("\n") });
continue;
}
const tableCandidate = readTable(lines, index);
if (tableCandidate) {
blocks.push(tableCandidate.block);
index = tableCandidate.nextIndex;
continue;
}
const unordered = line.match(/^\s*[-*+]\s+(.*)$/);
const ordered = line.match(/^\s*\d+\.\s+(.*)$/);
if (unordered || ordered) {
const orderedList = Boolean(ordered);
const items: ListItem[] = [];
while (index < lines.length) {
const current = lines[index];
const match = orderedList
? current.match(/^\s*\d+\.\s+(.*)$/)
: current.match(/^\s*[-*+]\s+(.*)$/);
if (!match) break;
const task = match[1].match(/^\[( |x|X)\]\s+(.*)$/);
if (task) {
items.push({
text: task[2],
checked: task[1].toLowerCase() === "x",
});
} else {
items.push({ text: match[1], checked: null });
}
index += 1;
}
blocks.push({ type: "list", ordered: orderedList, items });
continue;
}
const paragraphLines = [line];
index += 1;
while (
index < lines.length &&
lines[index].trim() &&
!/^(#{1,6})\s+/.test(lines[index]) &&
!/^```\s*/.test(lines[index]) &&
!/^>\s?/.test(lines[index]) &&
!/^\s*[-*+]\s+/.test(lines[index]) &&
!/^\s*\d+\.\s+/.test(lines[index]) &&
!/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(lines[index].trim())
) {
paragraphLines.push(lines[index]);
index += 1;
}
blocks.push({ type: "paragraph", text: paragraphLines.join("\n") });
}
return blocks;
}
/**
* Executes renderInline.
* @param nodes Parameter nodes.
* @returns React.ReactNode[].
*/
function renderInline(nodes: InlineNode[]): ReactNode[] {
return nodes.map((node, index) => {
if (node.type === "text") {
return node.value;
}
if (node.type === "emoji") {
return (
<Emoji key={index} className="tm-md-emoji" shortcode={node.shortcode} />
);
}
if (node.type === "strong") {
return (
<strong key={index} className="tm-md-strong">
{renderInline(parseEmojiText(node.value))}
</strong>
);
}
if (node.type === "em") {
return (
<em key={index} className="tm-md-em">
{renderInline(parseEmojiText(node.value))}
</em>
);
}
if (node.type === "del") {
return (
<del key={index} className="tm-md-del">
{renderInline(parseEmojiText(node.value))}
</del>
);
}
if (node.type === "code") {
return <CopyableCode key={index} value={node.value} />;
}
if (node.type === "link") {
return (
<a
key={index}
className="tm-md-link"
href={node.href}
target="_blank"
rel="noreferrer"
>
{renderInline(parseEmojiText(node.label))}
</a>
);
}
return (
<img
key={index}
className="tm-md-image"
src={node.src}
alt={node.alt}
loading="lazy"
decoding="async"
/>
);
});
}
/**
* Executes renderBlocks.
* @param blocks Parameter blocks.
* @returns React.ReactElement.
*/
export function renderBlocks(blocks: MarkdownBlock[]): ReactElement {
return (
<>
{blocks.map((block, blockIndex) => {
if (block.type === "heading") {
const className = `tm-md-heading tm-md-h${String(block.level)}`;
if (block.level === 1)
return (
<h1 key={blockIndex} className={className}>
{renderInline(parseInlineNodes(block.text))}
</h1>
);
if (block.level === 2)
return (
<h2 key={blockIndex} className={className}>
{renderInline(parseInlineNodes(block.text))}
</h2>
);
if (block.level === 3)
return (
<h3 key={blockIndex} className={className}>
{renderInline(parseInlineNodes(block.text))}
</h3>
);
if (block.level === 4)
return (
<h4 key={blockIndex} className={className}>
{renderInline(parseInlineNodes(block.text))}
</h4>
);
if (block.level === 5)
return (
<h5 key={blockIndex} className={className}>
{renderInline(parseInlineNodes(block.text))}
</h5>
);
return (
<h6 key={blockIndex} className={className}>
{renderInline(parseInlineNodes(block.text))}
</h6>
);
}
if (block.type === "blockquote") {
return (
<blockquote key={blockIndex} className="tm-md-blockquote">
{block.text.split("\n").map((line, lineIndex) => (
<p key={lineIndex}>{renderInline(parseInlineNodes(line))}</p>
))}
</blockquote>
);
}
if (block.type === "code") {
return (
<CopyableCode
key={blockIndex}
block
language={block.language}
value={block.code}
/>
);
}
if (block.type === "list") {
const Tag = block.ordered ? "ol" : "ul";
return (
<Tag
key={blockIndex}
className={block.ordered ? "tm-md-ol" : "tm-md-ul"}
>
{block.items.map((item, itemIndex) => (
<li key={itemIndex} className="tm-md-li">
{item.checked !== null ? (
<input
type="checkbox"
checked={item.checked}
disabled
className="tm-md-checkbox"
/>
) : null}
<span>{renderInline(parseInlineNodes(item.text))}</span>
</li>
))}
</Tag>
);
}
if (block.type === "table") {
return (
<div key={blockIndex} className="tm-md-table-wrap">
<table className="tm-md-table">
<thead>
<tr>
{block.headers.map((header, headerIndex) => (
<th key={headerIndex}>
{renderInline(parseInlineNodes(header))}
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<td key={cellIndex}>
{renderInline(parseInlineNodes(cell))}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
if (block.type === "hr") {
return <hr key={blockIndex} className="tm-md-hr" />;
}
return (
<p key={blockIndex}>
{block.text.split("\n").map((line, lineIndex) => (
<Fragment key={lineIndex}>
{lineIndex > 0 ? <br /> : null}
{renderInline(parseInlineNodes(line))}
</Fragment>
))}
</p>
);
})}
</>
);
}
/**
* Executes normalizeUrl.
* @param input Parameter input.
* @returns string.
*/
function normalizeUrl(input: string): string {
const value = input.trim();
if (/^(https?:|mailto:|tel:|\/)/i.test(value)) {
return value;
}
return "#";
}
/**
* Executes splitTableRow.
* @param row Parameter row.
* @returns string[].
*/
function splitTableRow(row: string): string[] {
const cleaned = row.trim().replace(/^\|/, "").replace(/\|$/, "");
return cleaned.split("|").map((cell) => cell.trim());
}
/**
* Executes readTable.
* @param lines Parameter lines.
* @param index Parameter index.
* @returns { block: TableBlock; nextIndex: number } | null.
*/
function readTable(
lines: string[],
index: number,
): { block: TableBlock; nextIndex: number } | null {
const header = lines[index] ?? "";
const separator = lines[index + 1] ?? "";
if (!header.includes("|") || !separator.includes("|")) {
return null;
}
const separatorCells = splitTableRow(separator);
const isSeparator = separatorCells.every((cell) => /^:?-{3,}:?$/.test(cell));
if (!isSeparator) {
return null;
}
const headers = splitTableRow(header);
const rows: string[][] = [];
let cursor = index + 2;
while (cursor < lines.length && lines[cursor].includes("|")) {
rows.push(splitTableRow(lines[cursor]));
cursor += 1;
}
return {
block: { type: "table", headers, rows },
nextIndex: cursor,
};
}
const markdownStyles = `
.tm-md-root { color: var(--foreground); line-height: 1.65; font-size: 1rem; }
.tm-md-heading { margin: 0.2rem 0 0.35rem; font-weight: 700; line-height: 1.25; }
.tm-md-h1 { font-size: 1.65rem; }
.tm-md-h2 { font-size: 1.45rem; }
.tm-md-h3 { font-size: 1.25rem; }
.tm-md-h4 { font-size: 1.1rem; }
.tm-md-h5 { font-size: 1rem; }
.tm-md-h6 { font-size: 0.95rem; opacity: 0.9; }
.tm-md-blockquote { margin: 0.45rem 0; padding-left: 0.75rem; opacity: 0.95; }
.tm-md-blockquote p { margin: 0.2rem 0; }
.tm-md-pre { margin: 0.45rem 0; padding: 0.65rem 0.75rem; border-radius: 0.5rem; background: var(--muted); overflow-x: auto; }
.tm-md-code, .tm-md-codeblock { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.87em; cursor: pointer; }
.tm-md-code { padding: 0.08rem 0.32rem; border: 1px solid var(--border); border-radius: 0.28rem; background: var(--muted); }
.tm-md-codeblock { display: block; }
.tm-md-code:focus-visible, .tm-md-codeblock:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.tm-md-strong { font-weight: 700; }
.tm-md-em { font-style: italic; }
.tm-md-del { text-decoration: line-through; }
.tm-md-link { color: var(--primary); text-decoration: underline; text-underline-offset: 0.14rem; }
.tm-md-image { display: block; max-width: 100%; border-radius: 0.4rem; margin: 0.5rem 0; }
.tm-md-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; }
.tm-md-ul, .tm-md-ol { margin: 0.3rem 0 0.35rem 1.2rem; padding: 0; }
.tm-md-li { margin: 0.2rem 0; }
.tm-md-checkbox { margin-right: 0.5rem; vertical-align: middle; }
.tm-md-table-wrap { overflow-x: auto; margin: 0.45rem 0; }
.tm-md-table { border-collapse: collapse; width: 100%; min-width: 16rem; }
.tm-md-table th, .tm-md-table td { padding: 0.4rem 0.5rem; text-align: left; }
.tm-md-table th { background: var(--muted); font-weight: 600; }
.tm-md-hr { margin: 0.55rem 0; }
.cm-editor.tm-md-editor { border-radius: inherit; background: transparent; caret-color: var(--foreground); }
.cm-editor.tm-md-editor.cm-focused { outline: none; box-shadow: none; }
.cm-editor.tm-md-editor .cm-scroller { font-family: inherit; line-height: 1.55; max-height: 30vh; overflow-y: auto; overflow-x: hidden; }
.cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); }
.cm-editor.tm-md-editor .cm-content { padding: var(--tm-md-content-padding, 0.25rem 0.625rem); min-height: 2rem; }
.cm-editor.tm-md-editor .cm-line { padding: 0; color: var(--foreground); }
.cm-editor.tm-md-editor .tm-md-editor-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; object-fit: contain; pointer-events: none; }
.cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; }
.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: var(--muted); border-radius: 0.3rem; }
.cm-tooltip.cm-tooltip-autocomplete { min-width: 18rem; max-width: min(26rem, calc(100vw - 1rem)); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--popover); color: var(--popover-foreground); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); font-family: "Public Sans Variable", sans-serif; font-size: 0.875rem; }
.cm-editor.tm-md-editor .cm-tooltip.cm-tooltip-autocomplete > ul { max-height: min(20rem, 45vh); padding: 0.25rem; font-family: "Public Sans Variable", sans-serif; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar { width: 6px; height: 6px; }
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-track { background: transparent; }
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-thumb { border-radius: 9999px; background: var(--border); }
.cm-tooltip.cm-tooltip-autocomplete > ul > li { display: flex; min-height: 2.25rem; align-items: center; border-radius: calc(var(--radius) * 0.8); padding: 0.3rem 0.5rem; color: var(--popover-foreground); }
.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover,
.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] { background: var(--accent); color: var(--accent-foreground); }
.cm-tooltip.cm-tooltip-autocomplete .cm-completionIcon { display: none; }
.cm-tooltip.cm-tooltip-autocomplete .cm-completionLabel { overflow: hidden; text-overflow: ellipsis; }
.cm-tooltip.cm-tooltip-autocomplete .cm-completionMatchedText { color: inherit; text-decoration: none; font-weight: 600; }
.cm-tooltip-autocomplete .tm-md-completion-emoji { display: inline-block; width: 1.35rem; height: 1.35rem; flex: 0 0 auto; margin-right: 0.5rem; vertical-align: middle; }
`;
/**
* Executes ensureMarkdownStyles.
* @param none This function has no parameters.
* @returns void.
*/
export function ensureMarkdownStyles(): void {
if (typeof document === "undefined") return;
const styleId = "tensamin-markdown-styles";
let style = document.getElementById(styleId) as HTMLStyleElement | null;
if (!style) {
style = document.createElement("style");
style.id = styleId;
document.head.appendChild(style);
}
if (style.textContent !== markdownStyles) {
style.textContent = markdownStyles;
}
}

View file

@ -1,30 +0,0 @@
import { useMemo, type CSSProperties } from "react";
import {
ensureMarkdownStyles,
parseMarkdownBlocks,
renderBlocks,
} from "./markdown";
export type TextProps = {
value: string;
fontSize?: CSSProperties["fontSize"];
};
/**
* Executes Text.
* @param props Parameter props.
* @returns unknown.
*/
export default function Text(props: TextProps) {
ensureMarkdownStyles();
const blocks = useMemo(() => parseMarkdownBlocks(props.value), [props.value]);
const renderedBlocks = useMemo(() => renderBlocks(blocks), [blocks]);
return (
<div className="tm-md-root" style={{ fontSize: props.fontSize }}>
{renderedBlocks}
</div>
);
}

View file

@ -1 +0,0 @@
- Improve the Input box

View file

@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}

View file

@ -16,7 +16,6 @@
"@tanstack/react-router": "^1.170.21",
"@tensamin/cache": "workspace:*",
"@tensamin/hotkeys": "workspace:*",
"@tensamin/markdown": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",

View file

@ -1,4 +1,4 @@
import MDInput from "@tensamin/markdown/input";
import { Input as MDInput } from "@methanium/ui/markdown";
import { useMTP } from "@tensamin/mtp";
import { mtp } from "@tensamin/shared/data";
import { useStorage } from "@tensamin/storage/context";
@ -145,7 +145,6 @@ export default function Page() {
value={draftUser.Username || ""}
/>
<MDInput
styled
paddingY="4px"
paddingX="10px"
fontSize=".875rem"

1483
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -6,5 +6,5 @@ allowBuilds:
electron-winstaller: true
esbuild: true
overrides:
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.22/methanium-ui.tgz"
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.28/methanium-ui.tgz"
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz"