get it working on web

This commit is contained in:
Samuel Newman
2025-07-17 14:39:55 +03:00
parent 07b1b6d5b7
commit 6622a1951d
2 changed files with 116 additions and 0 deletions
@@ -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],
)
@@ -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
}