From 6622a1951daf4675cb2ab9434e31a0c0705fb5d4 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 17 Jul 2025 14:39:55 +0300 Subject: [PATCH] get it working on web --- .../com/composer/text-input/TextInput.web.tsx | 2 + .../text-input/web/CharacterLimitDecorator.ts | 114 ++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/view/com/composer/text-input/web/CharacterLimitDecorator.ts diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 06ff9836c7..7f31803800 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -28,6 +28,7 @@ import {normalizeTextStyles} from '#/alf/typography' import {Portal} from '#/components/Portal' import {Text} from '../../util/text/Text' import {createSuggestion} from './web/Autocomplete' +import {CharacterLimitDecorator} from './web/CharacterLimitDecorator' import {type Emoji} from './web/EmojiPicker' import {LinkDecorator} from './web/LinkDecorator' import {TagDecorator} from './web/TagDecorator' @@ -94,6 +95,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( TiptapText, History, Hardbreak, + CharacterLimitDecorator, ], [autocomplete, placeholder], ) diff --git a/src/view/com/composer/text-input/web/CharacterLimitDecorator.ts b/src/view/com/composer/text-input/web/CharacterLimitDecorator.ts new file mode 100644 index 0000000000..83dc0bfa5f --- /dev/null +++ b/src/view/com/composer/text-input/web/CharacterLimitDecorator.ts @@ -0,0 +1,114 @@ +import {Extension} from '@tiptap/core' +import {type Node as ProsemirrorNode} from '@tiptap/pm/model' +import {Plugin, PluginKey} from '@tiptap/pm/state' +import {Decoration, DecorationSet} from '@tiptap/pm/view' +import Graphemer from 'graphemer' + +import {MAX_GRAPHEME_LENGTH} from '#/lib/constants' + +export const CharacterLimitDecorator = Extension.create({ + name: 'characterLimit', + + addProseMirrorPlugins() { + return [characterLimitPlugin()] + }, +}) + +function getDecorations(doc: ProsemirrorNode, splitter: Graphemer) { + const decorations: Decoration[] = [] + + // Calculate the actual text content including mentions + let fullText = '' + doc.descendants(node => { + if (node.isText) { + fullText += node.text || '' + } else if (node.type.name === 'mention') { + fullText += `@${node.attrs?.id || ''}` + } + }) + + const graphemes = splitter.splitGraphemes(fullText) + + if (graphemes.length > MAX_GRAPHEME_LENGTH) { + // Find the string position where the limit is exceeded + const validText = graphemes.slice(0, MAX_GRAPHEME_LENGTH).join('') + const limitPos = validText.length + + let currentPos = 0 + + // Walk through the document to find text nodes and mentions + doc.descendants((node, pos) => { + if (node.isText) { + const nodeText = node.text || '' + const nodeStart = currentPos + const nodeEnd = currentPos + nodeText.length + + // Check if this text node contains over-limit characters + if (nodeEnd > limitPos) { + const overLimitStart = Math.max(0, limitPos - nodeStart) + const overLimitEnd = nodeText.length + + if (overLimitStart < overLimitEnd) { + // Create decoration for the over-limit portion + const from = pos + overLimitStart + const to = pos + overLimitEnd + + decorations.push( + Decoration.inline(from, to, { + style: 'background-color: rgba(248, 113, 113, 0.3);', + class: 'character-limit-exceeded', + }), + ) + } + } + + currentPos = nodeEnd + } else if (node.type.name === 'mention') { + // Handle mention nodes - they contribute to the text length + const mentionText = `@${node.attrs?.id || ''}` + const nodeStart = currentPos + const nodeEnd = currentPos + mentionText.length + + // Check if this mention is over the limit + if (nodeStart >= limitPos) { + // Entire mention is over the limit + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { + style: 'background-color: rgba(248, 113, 113, 0.3);', + class: 'character-limit-exceeded', + }), + ) + } + + currentPos = nodeEnd + } + }) + } + + return DecorationSet.create(doc, decorations) +} + +function characterLimitPlugin() { + const splitter = new Graphemer() + + const characterLimitPlugin: Plugin = new Plugin({ + key: new PluginKey('characterLimit'), + + state: { + init: (_, {doc}) => getDecorations(doc, splitter), + apply: (transaction, decorationSet) => { + if (transaction.docChanged) { + return getDecorations(transaction.doc, splitter) + } + return decorationSet.map(transaction.mapping, transaction.doc) + }, + }, + + props: { + decorations(state) { + return characterLimitPlugin.getState(state) + }, + }, + }) + return characterLimitPlugin +}