diff --git a/src/lib/chat.ts b/src/lib/chat.ts index aeb8d1d..914e3ad 100644 --- a/src/lib/chat.ts +++ b/src/lib/chat.ts @@ -1,6 +1,6 @@ import { derived, readable, type Readable } from 'svelte/store'; import type { BlahChatServerConnection } from './blah/connection/chatServer'; -import type { BlahRichText } from './richText'; +import type { BlahRichText } from '@blah-im/core/richText'; import { messageFromBlah, type Chat, type Message, type User } from './types'; import { BlahError } from './blah/connection/error'; diff --git a/src/lib/components/DropdownMenu/Content.svelte b/src/lib/components/DropdownMenu/Content.svelte index 25159e9..378dee0 100644 --- a/src/lib/components/DropdownMenu/Content.svelte +++ b/src/lib/components/DropdownMenu/Content.svelte @@ -4,26 +4,26 @@ import { expoOut } from 'svelte/easing'; import { scale } from 'svelte/transition'; - - interface Props { - class?: $$Props['class']; - children?: import('svelte').Snippet; - [key: string]: any + interface Props extends DropdownMenuContentProps { + class?: string; } let { class: className = '', children, ...rest }: Props = $props(); - + + const fullClassName = tw( + 'group border-ss-secondary bg-sb-overlay min-w-32 origin-top rounded-lg border p-1 shadow-lg', + className + ); - - {@render children?.()} + + {#snippet child({ wrapperProps, props, open })} + {#if open} +
+
+ {@render children?.()} +
+
+ {/if} + {/snippet}
diff --git a/src/lib/components/RichTextInput.svelte b/src/lib/components/RichTextInput.svelte index 3cf738e..0519bf2 100644 --- a/src/lib/components/RichTextInput.svelte +++ b/src/lib/components/RichTextInput.svelte @@ -4,27 +4,27 @@ import InputFrame from '$lib/components/InputFrame.svelte'; import { tw } from '$lib/tw'; - interface Props { - delta?: Delta | null; - plainText?: string | undefined; + delta?: Delta; + plainText?: string; keyboardSubmitMethod?: 'enter' | 'shiftEnter' | undefined; + onKeyboardSubmit?: () => void; placeholder?: string; - editor: Editor | undefined; + editor?: Editor; class?: string; children?: import('svelte').Snippet; } let { - delta = $bindable(null), + delta = $bindable(undefined), plainText = $bindable(undefined), keyboardSubmitMethod = undefined, + onKeyboardSubmit, placeholder = '', editor = $bindable(), class: className = '', children }: Props = $props(); - const loadClientComponent = async () => { if (!browser) return; @@ -38,16 +38,16 @@

{placeholder}

- {:then Input} - {@render children?.()} - + {/await} diff --git a/src/lib/components/RichTextInput/ClientInput.svelte b/src/lib/components/RichTextInput/ClientInput.svelte index f562064..d11fd8a 100644 --- a/src/lib/components/RichTextInput/ClientInput.svelte +++ b/src/lib/components/RichTextInput/ClientInput.svelte @@ -4,6 +4,7 @@ interface Props { delta?: Delta; + editor?: Editor; plainText?: string | undefined; placeholder?: string; keyboardSubmitMethod?: 'enter' | 'shiftEnter' | undefined; @@ -13,6 +14,7 @@ let { delta = $bindable(new Delta()), + editor = $bindable(initEditor()), plainText = $bindable(undefined), placeholder = '', keyboardSubmitMethod = undefined, @@ -20,8 +22,6 @@ children }: Props = $props(); - let editor: Editor = $state(initEditor()); - function initEditor() { const modules = keyboardSubmitMethod ? { diff --git a/src/lib/mock/messages.ts b/src/lib/mock/messages.ts index 1daec44..fee1ece 100644 --- a/src/lib/mock/messages.ts +++ b/src/lib/mock/messages.ts @@ -3,58 +3,37 @@ import type { Message, User } from '$lib/types'; import { getRandomUser } from './users'; const messageContents: BlahRichText[] = [ - [['更好的例子可能是link和hashtag不應該共存']], - [['理論上mono是可以BIUS的,只是可能不太常見']], - [[['這個是一個link', { link: 'https://google.com' }]]], - [['這是一個', ['#hashtag', { hashtag: true }]]], + ['更好的例子可能是link和hashtag不應該共存'], + ['理論上mono是可以BIUS的,只是可能不太常見'], + [['這個是一個link', { link: 'https://google.com' }]], + ['這是一個', ['#hashtag', { tag: true }]], + ['這是一個', ['link', { link: 'https://google.com' }], '和一個', ['#hashtag', { tag: true }]], + ['可以, 反正我都手写了('], + ['但我們也可以約定這種entity一定要有plain text fallback'], + ['這樣的話,我們就可以在不支援的地方用plain text fallback'], + ['有可能有僅attribute的run'], [ - [ - '這是一個', - ['link', { link: 'https://google.com' }], - '和一個', - ['#hashtag', { hashtag: true }] - ] + '我现在是约定 text piece 一定非空,也就是说空字符串应该是空数组(但能不能真的这么发言要打个问号)' ], - [['可以, 反正我都手写了(']], - [['但我們也可以約定這種entity一定要有plain text fallback']], - [['這樣的話,我們就可以在不支援的地方用plain text fallback']], - [['有可能有僅attribute的run']], + ['确实合并相邻的 run 就可以 canonicalize'], + ['比如我可能不希望往数据库里这么存 json ,还需要考虑检索之类的'], + ['是我蠢了'], + ['我觉得这个问题是因为我们没有定义好什么是一个 run'], + ['目标是如果前端/后端使用和协议不一致的格式存储的话 roundtrip 后一致'], [ - [ - '我现在是约定 text piece 一定非空,也就是说空字符串应该是空数组(但能不能真的这么发言要打个问号)' - ] + '你们这个 canonicalize 真的靠谱吗,,,感觉哪怕跑了一遍以后也会有多个不同 message 视觉效果相同的情况' ], - [['确实合并相邻的 run 就可以 canonicalize']], - [['比如我可能不希望往数据库里这么存 json ,还需要考虑检索之类的']], - [['是我蠢了']], - [['我觉得这个问题是因为我们没有定义好什么是一个 run']], - [['目标是如果前端/后端使用和协议不一致的格式存储的话 roundtrip 后一致']], + ['稍微有点烦('], [ [ - '你们这个 canonicalize 真的靠谱吗,,,感觉哪怕跑了一遍以后也会有多个不同 message 视觉效果相同的情况' - ] - ], - [['稍微有点烦(']], - [ - [ - [ - '奥运会究竟该如何报道?中国媒体的表现真的这么不堪吗?', - { link: 'https://www.bilibili.com/video/BV1TZ421L7hj/' } - ] + '奥运会究竟该如何报道?中国媒体的表现真的这么不堪吗?', + { link: 'https://www.bilibili.com/video/BV1TZ421L7hj/' } ], - [ - '在视频中,可爸深入分析了中国媒体在巴黎奥运会中的表现,探讨了媒体人员的规模、采访类型、团队构成以及值得称赞与批评的采访案例。文章指出,尽管注册媒体工作者数量庞大,但真正的记者数量相对较少,且面临专业能力不足和流量逻辑冲击等问题。同时,作者强调了媒体在维护国家荣誉和传递奥运精神方面的重要性,呼吁媒体挖掘运动员故事,以建立观众与运动员之间的情感联系。这篇文章为了解当前中国体育媒体的现状和未来发展提供了深刻的见解和反思。' - ], - [''], - ['---'], - [''], - ['非常好的视频。强烈推荐观看。'] + '\n在视频中,可爸深入分析了中国媒体在巴黎奥运会中的表现,探讨了媒体人员的规模、采访类型、团队构成以及值得称赞与批评的采访案例。文章指出,尽管注册媒体工作者数量庞大,但真正的记者数量相对较少,且面临专业能力不足和流量逻辑冲击等问题。同时,作者强调了媒体在维护国家荣誉和传递奥运精神方面的重要性,呼吁媒体挖掘运动员故事,以建立观众与运动员之间的情感联系。这篇文章为了解当前中国体育媒体的现状和未来发展提供了深刻的见解和反思。\n---\n\n非常好的视频。强烈推荐观看。' ], - [['pieces:[], attrs:[] 两者等长。然后判断合并就是 attrs 有没有相邻重复元素']], + ['pieces:[], attrs:[] 两者等长。然后判断合并就是 attrs 有没有相邻重复元素'], [ - [ - '记้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎得้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎做้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎ ้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎o้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎v้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎e้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎r้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎f้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎l้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎o้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎w้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎ ้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎h้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎i้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎d้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎d้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎e้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎n้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎' - ] + '记้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎得้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎做้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎ ้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎o้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎v้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎e้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎r้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎f้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎l้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎o้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎w้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎ ้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎h้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎i้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎d้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎d้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎e้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎n้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎' ] ]; diff --git a/src/lib/richText.ts b/src/lib/richText.ts new file mode 100644 index 0000000..616e1be --- /dev/null +++ b/src/lib/richText.ts @@ -0,0 +1,79 @@ +import type { BlahRichText, BlahRichTextSpanAttributes } from '@blah-im/core/richText'; +import canonicalize from 'canonicalize'; +import type { AttributeMap, Delta } from 'typewriter-editor'; + +function isObjectEmpty(obj: object) { + for (const _ in obj) return false; + return true; +} + +function deltaAttributesToBlahRichTextSpanAttributes( + attributes?: AttributeMap +): BlahRichTextSpanAttributes | null { + if (!attributes) return null; + + const blahRichTextSpanAttributes: BlahRichTextSpanAttributes = {}; + + if (attributes.bold) blahRichTextSpanAttributes.b = true; + if (attributes.italic) blahRichTextSpanAttributes.i = true; + if (attributes.code) blahRichTextSpanAttributes.m = true; + if (attributes.link) blahRichTextSpanAttributes.link = attributes.link; + + if (attributes.underline) blahRichTextSpanAttributes.u = true; + if (attributes.strikethrough) blahRichTextSpanAttributes.s = true; + + return isObjectEmpty(blahRichTextSpanAttributes) ? null : blahRichTextSpanAttributes; +} + +export function deltaToBlahRichText(delta: Delta, trim: boolean = true): BlahRichText { + const spans: BlahRichText = []; + + let lastText = ''; + let lastAttributes: BlahRichTextSpanAttributes | null = null; + let canonicalizedLastAttributes: string = 'null'; + + function commitSpan(trim?: 'start' | 'end'): boolean { + const trimmedLastText = + trim === 'start' ? lastText.trimStart() : trim === 'end' ? lastText.trimEnd() : lastText; + if (trimmedLastText === '') return false; + spans.push(lastAttributes === null ? trimmedLastText : [trimmedLastText, lastAttributes]); + return true; + } + + let isFirstSpan = true; + for (const op of delta.ops) { + // Not sure in what cases op.insert would not be a string, but let's be safe + if (typeof op.insert !== 'string') continue; + + const attributes = deltaAttributesToBlahRichTextSpanAttributes(op.attributes); + const canonicalizedAttributes = canonicalize(attributes) ?? 'null'; + + if (canonicalizedAttributes === canonicalizedLastAttributes) { + lastText += op.insert; + continue; + } + + const commited = commitSpan(trim && isFirstSpan ? 'start' : undefined); + if (commited) isFirstSpan = false; + + lastText = op.insert; + lastAttributes = attributes; + canonicalizedLastAttributes = canonicalizedAttributes; + } + const lastCommited = commitSpan(trim ? 'end' : undefined); + if (trim && !lastCommited) { + // The last segment is empty, so we need to trim the one before it + let lastSpan = spans.pop(); + if (!lastSpan) return spans; + + if (typeof lastSpan === 'string') { + lastSpan = lastSpan.trimEnd(); + if (lastSpan !== '') spans.push(lastSpan); + } else { + lastSpan[0] = lastSpan[0].trimEnd(); + if (lastSpan[0] !== '') spans.push(lastSpan); + } + } + + return spans; +} diff --git a/src/routes/(app)/chats/[server]/[chatId]/+page.svelte b/src/routes/(app)/chats/[server]/[chatId]/+page.svelte index 3db25ef..27aa98d 100644 --- a/src/routes/(app)/chats/[server]/[chatId]/+page.svelte +++ b/src/routes/(app)/chats/[server]/[chatId]/+page.svelte @@ -1,36 +1,29 @@
{#if server} - {@const { info, sectionedMessages, sendMessage } = useChat(server, roomId)} - sendMessage(e.detail)} /> + {@const { sendMessage, ...rest } = useChat(server, roomId)} + {:else} To view this chat, you need to connect to chat server diff --git a/src/routes/(app)/chats/[server]/[chatId]/ChatInput.svelte b/src/routes/(app)/chats/[server]/[chatId]/ChatInput.svelte index 084e576..3d7a9a4 100644 --- a/src/routes/(app)/chats/[server]/[chatId]/ChatInput.svelte +++ b/src/routes/(app)/chats/[server]/[chatId]/ChatInput.svelte @@ -1,17 +1,21 @@ - + diff --git a/src/routes/(internal)/_rich-text/+page.svelte b/src/routes/(internal)/_rich-text/+page.svelte index beadc87..77958ff 100644 --- a/src/routes/(internal)/_rich-text/+page.svelte +++ b/src/routes/(internal)/_rich-text/+page.svelte @@ -1,9 +1,9 @@