@arrisa/message
v1.0.29
Published
Arrisa — messenger compose, formatted text, and entity I/O
Readme
@arrisa/message
Messenger compose for Arrisa — chat-style editor presets (composeField block field, messengerCompose inline/spoiler), markdown shortcuts/paste, host mentions & custom emoji, and FormattedText (plain text + UTF-16 offset entities) I/O.
Built on @arrisa/schema compose/messenger marks. Layering: schema chrome → this package (wire format + compose).
Install
pnpm add @arrisa/message @arrisa/editor @arrisa/schema @arrisa/state @arrisa/doc
# or npm / yarnAdd @arrisa/history if you need undo/redo (not included in composeField / messengerCompose).
Quick start
Block chat field (lists, quotes, fenced code):
import {Arrisa} from "@arrisa/editor"
import {history} from "@arrisa/history"
import {composeField, docToFormattedText, formattedTextToDoc} from "@arrisa/message"
let editor = Arrisa.create({
parent: document.getElementById("compose")!,
config: [composeField(), history()],
})Inline/spoiler messenger field:
import {Arrisa} from "@arrisa/editor"
import {history} from "@arrisa/history"
import {messengerCompose, docToFormattedText, formattedTextToDoc} from "@arrisa/message"
let editor = Arrisa.create({
parent: document.getElementById("compose")!,
config: [
messengerCompose({
resolveMention: (username) => userIds[username] ?? null,
}),
history(),
],
})On send — plain text + entities (UTF-16 offsets):
let payload = docToFormattedText(editor.state.doc, {autoDetect: true})
// { text: string, entities?: MessageEntity[] }
// Hydrate a draft
let doc = formattedTextToDoc(payload, editor.state.schema)Features / concepts
FormattedText
Wire shape used by many chat backends:
type FormattedText = {
text: string
entities?: MessageEntity[]
}- Offsets and lengths are UTF-16 code units (JavaScript string indices).
- Entities include flag marks (
bold,italic, …),text_url,pre,blockquote,unordered_list,ordered_list,mention_name,custom_emoji, and auto types (url,email,hashtag, …).
Compose presets
composeField(config?) installs a block chat field (Doc + lists/quote/code, no spoiler):
| Feature | Default |
|---------|---------|
| composeSchema (block Doc + compose marks) | always |
| composeKeymap (default keymap off) | always; submit?: "Enter" \| "Shift-Enter" omits that chord |
| Mark exclusivity (none / code-strike / custom) | "none" |
| Mention + custom emoji schema elements | hostElements: false |
| Markdown input rules (**bold**, `code`, finished fences; no spoiler) | markdown: true |
| Markdown plain-text paste | markdownPaste: true |
| Floating selection toolbar (Menu.Group.top) | floating: true |
| Embedded toolbar (embeddedMenu) | false |
| Placeholder | "Message…" |
| Sync @username → mention | only if resolveMention provided |
| Mention picker query (onMentionQuery) | omitted. Word-start @query + caret box; null when unfocused / no query / no rect. Does not consume Space. Leave resolveMention unset for picker hosts. |
| Schedule query (scheduleQuery) | omitted / false (off). true ≡ scheduleQuery({}). A config {now, locale, invokers, joiners, onAccept, onQuery, enabled, actions} is passed through. Paints the caret-tied phrase and offers a caret action list. Arrisa does not send and does not draw an armed banner; onAccept is the host arming hook. Mention query wins. Chip is Arrisa Tooltip unless onQuery is set. |
| Link add prompt | linkPrompt → link({prompt}) (default "floating") |
| Submit chord (submit) | omitted (Enter splits, Shift-Enter breaks). "Enter" / "Shift-Enter" omits that chord; the other is insertLineBreak. Host binds send with EditorState.prec.high(KeyBinding.of({key, run})). Arrisa does not send. A missing host binding may fall through to beforeinput split. |
Embedded toolbar + custom link UI:
composeField({
floating: false,
embedded: {parent: toolbarEl, theme: false, class: "host-compose-tools"},
linkPrompt: (ctx) => {
hostOpenLinkField({
href: ctx.href,
onSubmit: (url) => ctx.apply(url),
onCancel: ctx.cancel,
})
},
})Default toggle removes existing links or opens add UI; it does not prefill href (remove-first). cancel dismisses the built-in floating field and focuses the editor; custom prompts still own their DOM.
onMentionQuery fires from an Arrisa.updateListener when the collapsed caret sits in a plain-text mention query. Offsets are UTF-16 dump indices (docToFormattedText space). rect is editor.selectionRect() ({top,left,width,height}) on that turn. Use detectMentionQuery(state) to inspect the dump range without a live editor.
| Caret | Callback |
|-------|----------|
| Word-start @ (start of textblock or whitespace before @) | query = chars until caret ("" if @ alone) |
| Same, more typing (@al) | same from, growing query / to / rect |
| Mid-word / email (a@b) | null |
| Inside or on a MentionName mark (including caret after a completed @name label) | null |
| Left of @, or whitespace / ] in the query | null |
| Inside pre / inline code | null |
| Unfocused, non-collapsed selection, or no caret box | null |
Does not consume Space or wrap text. Keep resolveMention unset when the host owns a picker (insertMention / insertMentionSpec with the query from/to).
composeField({scheduleQuery}) is opt-in (default off). Typed 24h datetime phrases are painted and offered as a caret chip. v1 matches at 14:00 (closest 24h), tomorrow at 14:00, next week at 14:00, {day} at 14:00 (closest in 7 days), and 27.09.26 at 14:00 (dd.mm.yy). locale selects the default invoker/joiner pack (en / ru). Pass invokers / joiners to replace that pack. Arrisa does not send or queue; onAccept is the host arming hook. If a mention query is open, schedule detect returns null (mention wins). The chip is an Arrisa Tooltip unless onQuery is set (host chip; Arrisa still paints and still owns accept/keymap).
composeField({
scheduleQuery: {
now: () => new Date(),
locale: "en",
invokers: [
{kind: "tomorrow", words: ["tomorrow", "tmrw"]},
{kind: "weekday", words: ["monday"], weekday: 1},
],
joiners: ["at"],
onAccept: (hit) => hostArmSchedule(hit),
},
})messengerCompose(config?) remains the inline/spoiler path:
| Feature | Default |
|---------|---------|
| messengerSchema (inline doc + marks) | always |
| Mark exclusivity (none / code-strike / custom) | "none" |
| Mention + custom emoji schema elements | hostElements: true |
| Markdown input rules (**bold**, `code`, …) | markdown: true |
| Markdown plain-text paste | markdownPaste: true |
| Floating selection toolbar (inline group) | floating: true |
| Placeholder | "Message…" |
| Sync @username → mention | only if resolveMention provided |
Does not include history — add @arrisa/history in the host.
Markdown
Typing (input rules) and paste share messenger-style delimiters:
**bold**,__italic__,~~strike~~,||spoiler||. Typing`code`applies the inline code mark. After conversion, further typing is unmarked- A finished fence becomes a code block when the schema can contain one: one line
```code```, one paragraph with line breaks, or a closing```paragraph after an opening```/```langparagraph. Otherwise the backticks stay text ```followed by a space at the start of a block still creates an empty code block.```langthen space sets the language.- Paste also understands
[label](url), fenced code, and>quotes viaparseMarkdownText composeFieldomits the spoiler input rule; paste/import skip spoiler entities whenSpoileris not in the schema
Host atoms
MentionName— mark with host user id; non-inclusive so typing after does not extend it. ExportmentionText("label"default,"userId", or a function) serializes the dump slice independently of the visible label; overlapping marks cover that serialized length. ImportmentionLabel(userId, raw)replaces the dump slice with a display label.composeField({onMentionQuery})emits a dump-range query + caret box for a host picker and does not consume Space — leaveresolveMentionunset on that path.insertMention/insertMentionSpectake optionalfrom/todump offsets (UTF-16, same space asdocToFormattedText). Both are required together; one without the other or an out-of-range pair returnsfalseand leaves the document unchanged. Omit both to insert at the selection. When dump offsets come from a custommentionTextserializer, pass the samementionTexton insert (default mapping is"label"). The caret is left after the mention (no trailing space required).CustomEmoji— leaf{documentId, alt}; text contribution isaltfor entity offsets
Main public API
Compose
import {
composeField,
type ComposeFieldConfig,
messengerCompose,
type MessengerComposeConfig,
messengerMarks,
messengerSchema,
markExclusivity,
} from "@arrisa/message"
// Schema / command pieces are also available from @arrisa/schema and @arrisa/command.
composeField({
exclusivity: "none",
floating: true, // or FloatingMenuConfig | false
embedded: false, // or EmbeddedMenuConfig
placeholder: "Message…", // or false
markdown: true,
markdownPaste: true,
hostElements: false,
resolveMention: (u) => ids[u] ?? null, // omit when using onMentionQuery
onMentionQuery: (q) => hostShowMentionPicker(q),
scheduleQuery: { // default off; true ≡ scheduleQuery({})
now: () => new Date(),
locale: "en",
onAccept: (hit) => hostArmSchedule(hit),
},
linkPrompt: "floating", // or false | (req) => { req.apply(url) }
submit: "Enter", // omit Enter; host binds send. or "Shift-Enter"
})
messengerCompose({
exclusivity: "code-strike",
floating: true, // or FloatingMenuConfig | false
placeholder: "Message…", // or false
markdown: true,
markdownPaste: true,
hostElements: true,
resolveMention: (u) => ids[u] ?? null,
})messengerMarks / messengerSchema / markExclusivity are re-exported for hosts that only need pieces. Prefer composeField for a block chat field; messengerCompose for the inline/spoiler path.
Entities and I/O
import {
type FormattedText,
type MessageEntity,
type FlagEntity,
type TextUrlEntity,
type PreEntity,
type BlockquoteEntity,
type UnorderedListEntity,
type OrderedListEntity,
type MentionNameEntity,
type CustomEmojiEntity,
type AutoEntity,
entityInBounds,
unorderedListEntity,
orderedListEntity,
docToFormattedText,
type ToFormattedOptions,
formattedTextToDoc,
materializeRuns,
type FromFormattedOptions,
} from "@arrisa/message"
let ft = docToFormattedText(doc, {
blockSeparator: "\n",
autoDetect: true, // skips type "mention" when MentionName is in the schema
mentionText: (userId) => `@[${userId}]`,
blockquoteCanCollapse: true,
})
let doc2 = formattedTextToDoc(ft, schema, {
blockSeparator: "\n",
mentionLabel: (userId, raw) => labels[userId] ?? raw,
})- Inline schemas — single stream;
\nbecomes line breaks when present. - Block schemas — paragraphs;
pre→ code block;unordered_list/ordered_listwrap asBulletList/OrderedListofListItems (one item per paragraph or code block);blockquotewraps ranges. Cut-set covers pre + lists + blockquote; lists wrap before quotes so a quote covering a whole list becomesBlockquote > List. Quote-only and partial quotes are supported. OrderedstartIndexis omitted when 1. One-level lists are required. - Out-of-range entities are dropped on import (
entityInBounds). - Custom emoji — marks that only partially overlap a
custom_emojiatom are dropped around the atom (fully-contained entities only).
Auto entities
import {detectAutoEntities, mergeAutoEntities, type AutoDetectOptions} from "@arrisa/message"
// Used by docToFormattedText({autoDetect: true})
let auto = detectAutoEntities(text, {types: ["url", "email", "hashtag"]})Detects url, email, phone, hashtag, cashtag, bot_command, mention without overlapping skip ranges or each other.
docToFormattedText({autoDetect: true}) omits type "mention" when the document schema registers MentionName. Explicit autoDetect: {types: [...]} is never rewritten.
mergeAutoEntities / autoDetect skip only exclusive ranges (pre, inline code, custom_emoji, text_url, mention_name, existing autos) — not style marks. A bold URL gets both bold and url.
Mark ↔ entity map
import {
markToEntityPartial,
entityToMark,
DEFAULT_FLAG_MARKS,
markKey,
isInlineEntityMark,
} from "@arrisa/message"Markdown
import {
markdownInputRules,
markdownBold,
markdownItalic,
markdownStrike,
markdownSpoiler,
markdownCode,
markdownCodeBlock,
parseMarkdownText,
markdownPaste,
markdownClipboardParser,
looksLikeMarkdown,
} from "@arrisa/message"Schema elements and insert
import {
CustomEmoji,
MentionName,
customEmoji,
mentionName,
messengerHostElements,
type CustomEmojiParam,
insertCustomEmoji,
insertMention,
insertCustomEmojiSpec,
insertMentionSpec,
mentionResolve,
mentionResolveRule,
detectMentionQuery,
type MentionQuery,
} from "@arrisa/message"
// Programmatic insert (pure specs available for tests)
insertMentionSpec(state, {userId: "u1", label: "@alice"})
// Replace a dump-text range (UTF-16 offsets, same space as docToFormattedText)
insertMentionSpec(state, {userId: "u1", label: "@alice", from: 3, to: 6})
insertMentionSpec(state, {
userId: "u1",
label: "@alice",
from: 8,
to: 11,
mentionText: (id) => `@[${id}]`,
})
insertCustomEmojiSpec(state, {documentId: "sticker-1", alt: "👍"})
// Typing: @alice␣ → mention when resolve returns an id
mentionResolve((name) => name == "alice" ? "user-alice" : null)
// Picker path (no Space): dump range without a live editor
detectMentionQuery(state) // {from, to, query} | nullSchedule query
Opt-in typed datetime offer. Not an AutoEntity and not a schema mark. Arrisa does not send and does not draw an armed / queued mark under the field — hosts render that from onAccept. Phrase paint and the action list apply only while the caret sits on the complete phrase; typing past it clears both, deleting the extra text restores them. Mention query wins. Chip is Arrisa Tooltip unless onQuery is set.
import {
composeField,
scheduleQuery,
detectScheduleQuery,
scheduleQueryListener,
acceptScheduleQuery,
type ScheduleQuery,
type ScheduleQueryConfig,
type ScheduleInvoker,
type ScheduleAction,
} from "@arrisa/message"
composeField({
scheduleQuery: {
now: () => new Date(),
locale: "en",
onAccept: (hit) => hostArmSchedule(hit),
},
})
// Standalone (composeField({scheduleQuery: true}) ≡ scheduleQuery({}))
scheduleQuery({
now: () => new Date(),
locale: "ru",
invokers: [{kind: "tomorrow", words: ["завтра"]}],
joiners: ["в", "во"],
onAccept: (hit) => hostArmSchedule(hit),
})
detectScheduleQuery(state) // {from, to, phrase, scheduledTime, label} | nullfrom / to are UTF-16 dump offsets. scheduledTime is unix seconds. Clock is 24h H:mm / HH:mm (minutes two digits). invokers is {kind, words} with kind "today" / "tomorrow" / "next-week" / "weekday" (weekday 0–6, Sunday=0). Omitting invokers / joiners uses the locale pack. The caret list is one focusable action by default (Schedule {phrase}). Pass actions(hit, state) to replace it; each {id, label, run?} is a button. ArrowUp / ArrowDown focus the list (Up starts at the last item, Down at the first). Enter runs the focused action. An action with run is called after the phrase is deleted; otherwise onAccept fires. detectScheduleQuery is the pure detector (no rect). scheduleQueryListener emits ScheduleQuery (with rect) for custom chip hosts. acceptScheduleQuery deletes the caret-tied phrase (collapsing a doubled space) and emits scheduleAccepted; the plugin updateListener calls onAccept or run.
Code token roles
Re-exported from @arrisa/schema:
import {highlightCode, type CodeRole, type CodeSpan} from "@arrisa/message"
let spans = highlightCode(source, "ts")highlightCode(text, language?) returns CodeSpan[]. from and to are UTF-16 indexes into text (from inclusive, to exclusive).
Spans are a view. They are not part of PreEntity or the dump.
Blocks over 32_000 UTF-16 units or 400 lines return [].
Roles: kw-flow, kw-decl, modifier, type, function, property, string, interpolation, escape, number, constant, comment, directive, tag, attribute, regex.
Doc comments set emphasis: "bold". Other spans omit emphasis.
Language ids are case-folded. Aliases: typescript → ts, javascript → js, python → py, kotlin → kt, yml → yaml. A missing, empty, or unknown language does not add keywords.
Layering / related packages
@arrisa/schema (composeSchema / messengerSchema)
↓
@arrisa/message (composeField / messengerCompose + FormattedText + host elements)
↓
host chat app| Package | Relationship |
|---------|----------------|
| @arrisa/schema | Marks, composeSchema, messengerSchema, exclusivity config base |
| @arrisa/types | Strong, Link, Code, … mapped to entity flags |
| @arrisa/editor | Input rules, clipboard parser, floating menu, placeholder |
| @arrisa/command | markExclusivity, menu templates |
| @arrisa/history | Undo — add separately |
Security
- Paste/HTML: markdown paste builds a document from plain text; general HTML paste still needs
Arrisa.htmlSanitizeif the host allows rich HTML. - Links: entity
text_url/ Link marks should use safe hrefs (@arrisa/typeshelpers) when rendering clickable UI. - Mentions / emoji: host-controlled ids; treat remote
FormattedTextas untrusted input (validate bounds, id formats). - See SECURITY.md.
License
MIT © inshinrei
