@lovett/ui 0.0.11 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +112 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdown-spec — the editor side of the thread wire format.
|
|
3
|
+
*
|
|
4
|
+
* ADR-148 D3, D4, D13. `body_md` is markdown and stays markdown (D1): the rich
|
|
5
|
+
* editor is an editing surface over that string, never a second storage
|
|
6
|
+
* format. This file is the ONLY place the editor learns how the two syntaxes
|
|
7
|
+
* ADR-147 D11 / ADR-148 D13 add on top of CommonMark + GFM are spelled:
|
|
8
|
+
*
|
|
9
|
+
* @[Marcus Webb](user:usr_9f2) <-> a `mention` node (D4)
|
|
10
|
+
* ++underlined++ <-> the `underline` mark (D13)
|
|
11
|
+
*
|
|
12
|
+
* …plus the fail-closed detector `isRichEditable` (D3 rule 2), and the two
|
|
13
|
+
* helpers the composer and the tests both use, `parseRichMarkdown` /
|
|
14
|
+
* `serializeRichMarkdown`, so there is exactly one parse and one serialize
|
|
15
|
+
* path in the package.
|
|
16
|
+
*
|
|
17
|
+
* NOT re-exported from the package barrel, on purpose. Everything here imports
|
|
18
|
+
* TipTap statically, and the barrel must stay free of it so a thread LIST never
|
|
19
|
+
* pays for ProseMirror (D5, AC#10). The lazily loaded `rich-composer-impl.tsx`
|
|
20
|
+
* is the only production consumer; the tests import this file directly.
|
|
21
|
+
*
|
|
22
|
+
* THE UNDERLINE GRAMMAR, as agreed with the renderer side (`++text++`, no
|
|
23
|
+
* whitespace touching the markers, no `++` inside `++`) is deliberately
|
|
24
|
+
* STRICTER than the tokenizer StarterKit's own Underline ships — that one trims
|
|
25
|
+
* inner whitespace and spans lines — which is why StarterKit's copy is disabled
|
|
26
|
+
* and the mark is defined here. Same name, same commands, same `Mod-u`; only
|
|
27
|
+
* the markdown spelling is ours.
|
|
28
|
+
*
|
|
29
|
+
* ESCAPING. `@tiptap/markdown` backslash-escapes `` \ ` * _ [ ] ~ `` in text
|
|
30
|
+
* and HTML-encodes `& < >`, which already covers most of CommonMark's inline
|
|
31
|
+
* syntax and blockquotes. It does NOT escape `+` (so a literal `++x++` typed in
|
|
32
|
+
* the editor would come back as an underline) or block starters (`# `, `- `,
|
|
33
|
+
* `1. `, `---`), so a paragraph typed as "- not a list" would come back as a
|
|
34
|
+
* list. Those are handled here, before the manager runs, with a private-use
|
|
35
|
+
* sentinel that stands in for the backslash: the manager would double any real
|
|
36
|
+
* backslash it saw, and it would double-encode any entity. The sentinel is
|
|
37
|
+
* swapped for `\` after serialization. A U+E000 already present in user text is
|
|
38
|
+
* dropped first; it is a private-use code point with no meaning to keep.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
Editor,
|
|
43
|
+
Extension,
|
|
44
|
+
InputRule,
|
|
45
|
+
Mark,
|
|
46
|
+
mergeAttributes,
|
|
47
|
+
type Extensions,
|
|
48
|
+
type JSONContent,
|
|
49
|
+
type MarkdownToken,
|
|
50
|
+
type Range,
|
|
51
|
+
markInputRule,
|
|
52
|
+
} from '@tiptap/core'
|
|
53
|
+
import { Mention, type MentionNodeAttrs, type MentionOptions } from '@tiptap/extension-mention'
|
|
54
|
+
import { Markdown, type MarkdownManager } from '@tiptap/markdown'
|
|
55
|
+
import { Node as ProseMirrorNode, type Schema } from '@tiptap/pm/model'
|
|
56
|
+
import { PluginKey } from '@tiptap/pm/state'
|
|
57
|
+
import { StarterKit } from '@tiptap/starter-kit'
|
|
58
|
+
import { parseMentionHref, type MentionTargetType } from './comment-body'
|
|
59
|
+
import { emojiByShortcode, searchEmoji } from './emoji'
|
|
60
|
+
|
|
61
|
+
declare module '@tiptap/core' {
|
|
62
|
+
interface Commands<ReturnType> {
|
|
63
|
+
underline: {
|
|
64
|
+
setUnderline: () => ReturnType
|
|
65
|
+
toggleUnderline: () => ReturnType
|
|
66
|
+
unsetUnderline: () => ReturnType
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* ── Underline (D13) ──────────────────────────────────────────────────────── */
|
|
72
|
+
|
|
73
|
+
const UNDERLINE_MARKER = '++'
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `++text++`: the inner text starts and ends with a non-space, contains no
|
|
77
|
+
* `++` (so no nesting) and no newline. Anything else is literal text, which is
|
|
78
|
+
* what the renderer side does with it too.
|
|
79
|
+
*
|
|
80
|
+
* A backslash escape is consumed as ONE unit, so the closer can never begin
|
|
81
|
+
* inside one: in `++C\+\+++` the escaped pluses are text and the last two
|
|
82
|
+
* characters are the closer. Without this the lazy match ended at the `+` of
|
|
83
|
+
* `\+` and the serialized form did not re-parse to itself.
|
|
84
|
+
*/
|
|
85
|
+
const UNDERLINE_PATTERN = /^\+\+(?=\S)((?:\\[^\n]|(?!\+\+)[^\n\\])+?)(?<=\S)\+\+/
|
|
86
|
+
|
|
87
|
+
interface UnderlineOptions {
|
|
88
|
+
readonly HTMLAttributes: Record<string, unknown>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The `++text++` input rule's pattern. Exported so it can be tested without an
|
|
93
|
+
* Editor: input rules only fire on real text input, which jsdom cannot
|
|
94
|
+
* faithfully simulate, and the regex is where a bug would actually live. The
|
|
95
|
+
* end-to-end behaviour is verified in a browser.
|
|
96
|
+
*
|
|
97
|
+
* STRICTER than TipTap's own bold/strike rules, deliberately, because it has to
|
|
98
|
+
* agree with the RENDERER. `lib/remark-underline.ts` rejects whitespace
|
|
99
|
+
* immediately inside either marker; TipTap's `(?!\s+\*\*)` shape does not, so a
|
|
100
|
+
* copy of it fired on `++ under++` — the editor would have drawn an underline
|
|
101
|
+
* that the renderer then showed as literal plus signs. The lookarounds are the
|
|
102
|
+
* fix: `(?!\s)` after the opener, `(?<!\s)` before the closer, and a lazy
|
|
103
|
+
* `[^+]+?` so a run cannot swallow a second pair. (Lookbehind is ES2018,
|
|
104
|
+
* Safari 16.4+ — the same floor `overflow: clip` already sets.)
|
|
105
|
+
*/
|
|
106
|
+
export const UNDERLINE_INPUT_RULE = /(?:^|\s)(\+\+(?!\s)([^+]+?)(?<!\s)\+\+)$/
|
|
107
|
+
|
|
108
|
+
export const ThreadUnderline = Mark.create<UnderlineOptions>({
|
|
109
|
+
name: 'underline',
|
|
110
|
+
|
|
111
|
+
addOptions() {
|
|
112
|
+
return { HTMLAttributes: {} }
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
parseHTML() {
|
|
116
|
+
return [
|
|
117
|
+
{ tag: 'u' },
|
|
118
|
+
{
|
|
119
|
+
style: 'text-decoration',
|
|
120
|
+
consuming: false,
|
|
121
|
+
getAttrs: (style) =>
|
|
122
|
+
typeof style === 'string' && style.includes('underline') ? {} : false,
|
|
123
|
+
},
|
|
124
|
+
]
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
renderHTML({ HTMLAttributes }) {
|
|
128
|
+
return ['u', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
addCommands() {
|
|
132
|
+
return {
|
|
133
|
+
setUnderline:
|
|
134
|
+
() =>
|
|
135
|
+
({ commands }) =>
|
|
136
|
+
commands.setMark(this.name),
|
|
137
|
+
toggleUnderline:
|
|
138
|
+
() =>
|
|
139
|
+
({ commands }) =>
|
|
140
|
+
commands.toggleMark(this.name),
|
|
141
|
+
unsetUnderline:
|
|
142
|
+
() =>
|
|
143
|
+
({ commands }) =>
|
|
144
|
+
commands.unsetMark(this.name),
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
addKeyboardShortcuts() {
|
|
149
|
+
return {
|
|
150
|
+
'Mod-u': () => this.editor.commands.toggleUnderline(),
|
|
151
|
+
'Mod-U': () => this.editor.commands.toggleUnderline(),
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Type `++text++` and it becomes underlined, the moment the closing `++`
|
|
157
|
+
* lands — the same as `**bold**` and `~~strike~~`, which StarterKit gives us
|
|
158
|
+
* for free. Without this the mark had commands, a shortcut and full markdown
|
|
159
|
+
* serialization but no way to reach it by TYPING, which is the way people
|
|
160
|
+
* who know markdown actually reach it. (Owner, 2026-09-07: "is it not like
|
|
161
|
+
* obsidian where you type them in and it autoformats as you type?")
|
|
162
|
+
*
|
|
163
|
+
* The pattern mirrors TipTap's own bold rule, with `+` swapped for `*`:
|
|
164
|
+
* - `(?:^|\s)` — the run starts at the line start or after whitespace, so
|
|
165
|
+
* `i++;` and `C++` are never rules, matching the renderer's flanking rule
|
|
166
|
+
* (D13) rather than fighting it.
|
|
167
|
+
* - `(?!\s+\+\+)` — no whitespace immediately inside the opener.
|
|
168
|
+
* - `[^+]+` — the content cannot itself contain `+`, so `+++` runs and
|
|
169
|
+
* `a++b++c++d` degrade to text instead of matching greedily.
|
|
170
|
+
*/
|
|
171
|
+
addInputRules() {
|
|
172
|
+
return [markInputRule({ find: UNDERLINE_INPUT_RULE, type: this.type })]
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
markdownTokenizer: {
|
|
176
|
+
name: 'underline',
|
|
177
|
+
level: 'inline',
|
|
178
|
+
start: (src) => src.indexOf(UNDERLINE_MARKER),
|
|
179
|
+
tokenize: (src, _tokens, lexer) => {
|
|
180
|
+
const match = UNDERLINE_PATTERN.exec(src)
|
|
181
|
+
const inner = match?.[1]
|
|
182
|
+
if (match === null || inner === undefined) return undefined
|
|
183
|
+
return { type: 'underline', raw: match[0], text: inner, tokens: lexer.inlineTokens(inner) }
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
parseMarkdown: (token, helpers) =>
|
|
188
|
+
helpers.applyMark('underline', helpers.parseInline(token.tokens ?? [])),
|
|
189
|
+
|
|
190
|
+
renderMarkdown: (node, helpers) => `++${helpers.renderChildren(node)}++`,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
/* ── Mention (D4) ─────────────────────────────────────────────────────────── */
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The Suggestion plugin key the `@` picker (ADR-149) hangs off. Exported so
|
|
197
|
+
* the composer can read the plugin state — "is a Suggestion popup open?" is
|
|
198
|
+
* what D7 (no bubble menu while it is) and D9 (Escape closes it second) need.
|
|
199
|
+
*/
|
|
200
|
+
export const THREAD_MENTION_SUGGESTION_KEY = new PluginKey('threadMentionSuggestion')
|
|
201
|
+
|
|
202
|
+
/** `@[label](scheme:id)`; the label may carry `\]` / `\[` / `\\` escapes. */
|
|
203
|
+
const MENTION_PATTERN = /^@\[((?:\\.|[^\]\\\n])+)\]\(([^)\s]+)\)/
|
|
204
|
+
|
|
205
|
+
function escapeMentionLabel(label: string): string {
|
|
206
|
+
return label.replace(/[\\[\]]/g, (char) => `\\${char}`)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function unescapeMentionLabel(raw: string): string {
|
|
210
|
+
return raw.replace(/\\(.)/g, '$1')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface ThreadMentionAttrs {
|
|
214
|
+
readonly id: string
|
|
215
|
+
readonly label: string
|
|
216
|
+
readonly type: MentionTargetType
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Narrow a node's attrs to a mention without a cast. Null for anything malformed. */
|
|
220
|
+
function readMentionAttrs(attrs: Record<string, unknown> | undefined): ThreadMentionAttrs | null {
|
|
221
|
+
if (attrs === undefined) return null
|
|
222
|
+
const id = attrs['id']
|
|
223
|
+
const label = attrs['label']
|
|
224
|
+
const type = attrs['type']
|
|
225
|
+
if (typeof id !== 'string' || id === '') return null
|
|
226
|
+
if (typeof type !== 'string') return null
|
|
227
|
+
const target = parseMentionHref(`${type}:${id}`)
|
|
228
|
+
if (target === null) return null
|
|
229
|
+
return { id: target.id, label: typeof label === 'string' && label !== '' ? label : id, type: target.type }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export const ThreadMention = Mention.extend({
|
|
233
|
+
addAttributes() {
|
|
234
|
+
return {
|
|
235
|
+
...this.parent?.(),
|
|
236
|
+
type: {
|
|
237
|
+
default: 'user',
|
|
238
|
+
parseHTML: (element) => element.getAttribute('data-mention-type'),
|
|
239
|
+
renderHTML: (attributes) => ({ 'data-mention-type': attributes['type'] }),
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
markdownTokenizer: {
|
|
245
|
+
name: 'mention',
|
|
246
|
+
level: 'inline',
|
|
247
|
+
start: (src) => src.indexOf('@['),
|
|
248
|
+
tokenize: (src) => {
|
|
249
|
+
const match = MENTION_PATTERN.exec(src)
|
|
250
|
+
const rawLabel = match?.[1]
|
|
251
|
+
const href = match?.[2]
|
|
252
|
+
if (match === null || rawLabel === undefined || href === undefined) return undefined
|
|
253
|
+
// Any other `@[…](…)` — an https link, an unknown scheme — is NOT a
|
|
254
|
+
// mention. Returning undefined hands it back to marked, which tokenizes
|
|
255
|
+
// it as a plain link, exactly as the renderer treats it.
|
|
256
|
+
const target = parseMentionHref(href)
|
|
257
|
+
if (target === null) return undefined
|
|
258
|
+
return {
|
|
259
|
+
type: 'mention',
|
|
260
|
+
raw: match[0],
|
|
261
|
+
label: unescapeMentionLabel(rawLabel),
|
|
262
|
+
id: target.id,
|
|
263
|
+
mentionType: target.type,
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
parseMarkdown: (token) => ({
|
|
269
|
+
type: 'mention',
|
|
270
|
+
attrs: {
|
|
271
|
+
id: token['id'],
|
|
272
|
+
label: token['label'],
|
|
273
|
+
type: token['mentionType'],
|
|
274
|
+
mentionSuggestionChar: '@',
|
|
275
|
+
},
|
|
276
|
+
}),
|
|
277
|
+
|
|
278
|
+
renderMarkdown: (node) => {
|
|
279
|
+
const mention = readMentionAttrs(node.attrs)
|
|
280
|
+
// A mention node without a valid target cannot reach the wire: it would
|
|
281
|
+
// serialize to something the server-side extractor would refuse. Its
|
|
282
|
+
// label survives as text so nothing the user wrote disappears.
|
|
283
|
+
if (mention === null) return typeof node.attrs?.['label'] === 'string' ? `@${node.attrs['label']}` : ''
|
|
284
|
+
return `@[${escapeMentionLabel(mention.label)}](${mention.type}:${mention.id})`
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
/* ── The subset (D2 / D3) ─────────────────────────────────────────────────── */
|
|
289
|
+
|
|
290
|
+
export const RICH_HEADING_LEVELS = [1, 2, 3] as const
|
|
291
|
+
const RICH_HEADING_LEVEL_SET: ReadonlySet<number> = new Set<number>(RICH_HEADING_LEVELS)
|
|
292
|
+
|
|
293
|
+
type MentionSuggestion = MentionOptions['suggestion']
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* How many characters after the sigil before the popup may open (ADR-149 D4).
|
|
297
|
+
*
|
|
298
|
+
* A bare `:` is punctuation and far more common in prose than an emoji, so it
|
|
299
|
+
* takes two; a bare `@` is rare enough that one is right. Below the threshold
|
|
300
|
+
* the plugin hands the renderer an EMPTY item list, which is what closes the
|
|
301
|
+
* popup — the same door "no matches" comes through, so there is one rule in
|
|
302
|
+
* the UI and not two.
|
|
303
|
+
*/
|
|
304
|
+
export const MENTION_SUGGESTION_MIN_QUERY = 1
|
|
305
|
+
export const EMOJI_SUGGESTION_MIN_QUERY = 2
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The `:` trigger's Suggestion plugin key, beside the `@` one above. Two keys
|
|
309
|
+
* because they are two plugins; ONE popup, because they are one interaction
|
|
310
|
+
* (D1) — the difference is data and rendering, never behaviour.
|
|
311
|
+
*/
|
|
312
|
+
export const THREAD_EMOJI_SUGGESTION_KEY = new PluginKey('threadEmojiSuggestion')
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* A complete, typed shortcode at the end of the current textblock. The shape
|
|
316
|
+
* is deliberately only the lexical first pass: `EMOJI_INPUT_RULE` then asks
|
|
317
|
+
* the curated table whether the spelling is a real name or alias. Keywords
|
|
318
|
+
* are search aids, not shortcodes, and must not make prose transform.
|
|
319
|
+
*/
|
|
320
|
+
const EMOJI_SHORTCODE_AT_END = /(?:^|\s)(:([^:\s]+):)$/
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Slack-style closing-colon matcher, exported for a direct test. TipTap input
|
|
324
|
+
* rules only run on real text input, while `insertContent` bypasses them.
|
|
325
|
+
*/
|
|
326
|
+
export const EMOJI_INPUT_RULE = (text: string) => {
|
|
327
|
+
const match = EMOJI_SHORTCODE_AT_END.exec(text)
|
|
328
|
+
const shortcode = match?.[1]
|
|
329
|
+
if (match === null || shortcode === undefined || emojiByShortcode(shortcode) === undefined) {
|
|
330
|
+
return null
|
|
331
|
+
}
|
|
332
|
+
return { index: match.index, text: match[0], data: { shortcode } }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const ThreadEmojiShortcode = Extension.create({
|
|
336
|
+
name: 'threadEmojiShortcode',
|
|
337
|
+
|
|
338
|
+
addInputRules() {
|
|
339
|
+
return [
|
|
340
|
+
new InputRule({
|
|
341
|
+
find: EMOJI_INPUT_RULE,
|
|
342
|
+
handler: ({ state, range, match }) => {
|
|
343
|
+
const shortcode = match.data?.['shortcode']
|
|
344
|
+
const entry = typeof shortcode === 'string' ? emojiByShortcode(shortcode) : undefined
|
|
345
|
+
if (entry === undefined || typeof shortcode !== 'string') return null
|
|
346
|
+
state.tr.insertText(entry.glyph, range.to - shortcode.length, range.to)
|
|
347
|
+
return undefined
|
|
348
|
+
},
|
|
349
|
+
}),
|
|
350
|
+
]
|
|
351
|
+
},
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* What the `:` picker hands back when an option is chosen.
|
|
356
|
+
*
|
|
357
|
+
* `id` is the canonical shortcode, and it is deliberately the ONLY thing that
|
|
358
|
+
* crosses the seam: `@tiptap/extension-mention` types its `command` payload as
|
|
359
|
+
* `MentionNodeAttrs`, so an emoji-shaped payload would not type-check through
|
|
360
|
+
* it. Passing the shortcode and re-resolving the glyph from the table on the
|
|
361
|
+
* other side keeps this honest with zero casts, and means the insert can never
|
|
362
|
+
* write a glyph that is not in the table.
|
|
363
|
+
*/
|
|
364
|
+
export interface EmojiSuggestionAttrs {
|
|
365
|
+
readonly id: string
|
|
366
|
+
readonly label: string
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* D2 — an emoji is inserted as UNICODE, never as a shortcode. `body_md` then
|
|
371
|
+
* contains the character, so every render site (the digest, an export, an
|
|
372
|
+
* agent's context, a plain-text copy) shows it without a lookup table.
|
|
373
|
+
*
|
|
374
|
+
* The trailing space, and the guard against doubling it, mirror the mention
|
|
375
|
+
* command exactly, so choosing from either popup leaves the caret in the same
|
|
376
|
+
* place.
|
|
377
|
+
*/
|
|
378
|
+
function insertEmoji({
|
|
379
|
+
editor,
|
|
380
|
+
range,
|
|
381
|
+
props,
|
|
382
|
+
}: {
|
|
383
|
+
editor: Editor
|
|
384
|
+
range: Range
|
|
385
|
+
props: MentionNodeAttrs
|
|
386
|
+
}): void {
|
|
387
|
+
const entry = emojiByShortcode(props.id ?? '')
|
|
388
|
+
if (entry === undefined) return
|
|
389
|
+
const nodeAfter = editor.view.state.selection.$to.nodeAfter
|
|
390
|
+
const to = nodeAfter?.text?.startsWith(' ') === true ? range.to + 1 : range.to
|
|
391
|
+
editor
|
|
392
|
+
.chain()
|
|
393
|
+
.focus()
|
|
394
|
+
.insertContentAt({ from: range.from, to }, [{ type: 'text', text: `${entry.glyph} ` }])
|
|
395
|
+
.run()
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The `:` picker's options, in the shape the Suggestion payload accepts. */
|
|
399
|
+
function emojiSuggestionItems(query: string): EmojiSuggestionAttrs[] {
|
|
400
|
+
return searchEmoji(query).map((entry) => ({ id: entry.name, label: entry.glyph }))
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The two trigger configurations (D1). The DATA half — what each trigger
|
|
405
|
+
* offers and what choosing does — lives here; the RENDERING half is one
|
|
406
|
+
* `render()` per trigger, supplied by the composer, pointing at one popup.
|
|
407
|
+
*/
|
|
408
|
+
export interface RichSuggestionRenderers {
|
|
409
|
+
/** The `@` trigger. Absent, the mention popup never opens. */
|
|
410
|
+
readonly mention?: Partial<MentionSuggestion> | undefined
|
|
411
|
+
/** The `:` trigger. Absent, the emoji popup never opens. */
|
|
412
|
+
readonly emoji?: Partial<MentionSuggestion> | undefined
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The editor's extension set — the D3 subset, written down (ADR-148 D2).
|
|
417
|
+
*
|
|
418
|
+
* StarterKit is configured explicitly so a future minor cannot admit a
|
|
419
|
+
* construct silently. Today the only things it does not keep at default are
|
|
420
|
+
* the heading levels (1–3), the link options an EDITOR needs (a click must
|
|
421
|
+
* edit, not navigate), and `underline: false` — replaced by `ThreadUnderline`
|
|
422
|
+
* above, same name and commands, strict `++` grammar.
|
|
423
|
+
*
|
|
424
|
+
* BOTH triggers hang off the ONE Mention extension, through its `suggestions`
|
|
425
|
+
* array. That is not a trick: `@tiptap/suggestion` is a transitive dependency
|
|
426
|
+
* with no direct entry in `@lovett/ui`'s `package.json`, so under pnpm's
|
|
427
|
+
* strict layout it cannot be imported from this package, and ADR-148 D2
|
|
428
|
+
* deliberately did not install it ("zero new runtime dependencies",
|
|
429
|
+
* ADR-149 AC#1). `suggestions` is the extension's own supported way to run a
|
|
430
|
+
* second trigger, and it gives the `:` plugin the same match-finding,
|
|
431
|
+
* dismissal and floating-ui anchoring the `@` one already had. The `:` trigger
|
|
432
|
+
* never creates a mention node — its `command` inserts text (D2).
|
|
433
|
+
*/
|
|
434
|
+
export function richSubsetExtensions(renderers: RichSuggestionRenderers = {}): Extensions {
|
|
435
|
+
return [
|
|
436
|
+
StarterKit.configure({
|
|
437
|
+
heading: { levels: [...RICH_HEADING_LEVELS] },
|
|
438
|
+
// horizontalRule: default, IN — owner, 2026-09-07.
|
|
439
|
+
underline: false,
|
|
440
|
+
link: {
|
|
441
|
+
openOnClick: false,
|
|
442
|
+
autolink: true,
|
|
443
|
+
linkOnPaste: true,
|
|
444
|
+
enableClickSelection: false,
|
|
445
|
+
},
|
|
446
|
+
}),
|
|
447
|
+
ThreadUnderline,
|
|
448
|
+
ThreadEmojiShortcode,
|
|
449
|
+
ThreadMention.configure({
|
|
450
|
+
HTMLAttributes: { class: 'ds-thread-mention' },
|
|
451
|
+
suggestions: [
|
|
452
|
+
{
|
|
453
|
+
char: '@',
|
|
454
|
+
pluginKey: THREAD_MENTION_SUGGESTION_KEY,
|
|
455
|
+
minQueryLength: MENTION_SUGGESTION_MIN_QUERY,
|
|
456
|
+
...renderers.mention,
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
char: ':',
|
|
460
|
+
pluginKey: THREAD_EMOJI_SUGGESTION_KEY,
|
|
461
|
+
minQueryLength: EMOJI_SUGGESTION_MIN_QUERY,
|
|
462
|
+
// The plugin's own view of what it is offering. The POPUP does not
|
|
463
|
+
// read this — it recomputes from the query, because
|
|
464
|
+
// `@tiptap/suggestion` types its items as `any` and the composer
|
|
465
|
+
// does not take `any` — but leaving it unset would make the plugin's
|
|
466
|
+
// state say "no items" while eight are on screen, which is a lie any
|
|
467
|
+
// future reader of that state would trip over.
|
|
468
|
+
items: ({ query }: { query: string }) => emojiSuggestionItems(query),
|
|
469
|
+
command: insertEmoji,
|
|
470
|
+
// A shortcode typed inside code stays literal: `:tada:` in a snippet
|
|
471
|
+
// is text about an emoji, not an emoji. The mention trigger keeps
|
|
472
|
+
// the extension's own default, which asks the schema whether a
|
|
473
|
+
// mention node may sit here.
|
|
474
|
+
allow: ({ editor }: { editor: Editor }) =>
|
|
475
|
+
!editor.isActive('codeBlock') && !editor.isActive('code'),
|
|
476
|
+
...renderers.emoji,
|
|
477
|
+
},
|
|
478
|
+
],
|
|
479
|
+
}),
|
|
480
|
+
Markdown,
|
|
481
|
+
]
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/* ── Headless probe ───────────────────────────────────────────────────────── */
|
|
485
|
+
|
|
486
|
+
let probe: Editor | null = null
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* One headless editor over the subset, created on first use. It owns the
|
|
490
|
+
* `MarkdownManager` every parse and serialize below goes through and the
|
|
491
|
+
* schema the detector checks against — so the detector, the tests and the live
|
|
492
|
+
* composer agree by construction, not by keeping three configurations in step.
|
|
493
|
+
*/
|
|
494
|
+
function getProbe(): Editor {
|
|
495
|
+
if (probe === null) {
|
|
496
|
+
probe = new Editor({ element: null, extensions: richSubsetExtensions(), content: '' })
|
|
497
|
+
}
|
|
498
|
+
return probe
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function getManager(): MarkdownManager {
|
|
502
|
+
const manager = getProbe().markdown
|
|
503
|
+
if (manager === undefined) {
|
|
504
|
+
throw new Error('rich composer: the Markdown extension did not attach a manager')
|
|
505
|
+
}
|
|
506
|
+
return manager
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function getSchema(): Schema {
|
|
510
|
+
return getProbe().schema
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/* ── Parse / serialize ────────────────────────────────────────────────────── */
|
|
514
|
+
|
|
515
|
+
export function parseRichMarkdown(markdown: string): JSONContent {
|
|
516
|
+
return canonicalizeInlineMarks(getManager().parse(markdown))
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/* ── Canonical mark boundaries ────────────────────────────────────────────── */
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Marks that markdown cannot open or close on whitespace. `*a *` is not
|
|
523
|
+
* emphasis and `` ` a ` `` is `a`; a link, by contrast, keeps `[a ](url)`.
|
|
524
|
+
*/
|
|
525
|
+
const BOUNDARY_MARKS: readonly string[] = ['bold', 'italic', 'underline', 'strike', 'code']
|
|
526
|
+
|
|
527
|
+
function hasMark(node: JSONContent, type: string): boolean {
|
|
528
|
+
return (node.marks ?? []).some((mark) => mark.type === type)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function withoutMark(node: JSONContent, type: string): JSONContent {
|
|
532
|
+
const marks = (node.marks ?? []).filter((mark) => mark.type !== type)
|
|
533
|
+
const { marks: _marks, ...rest } = node
|
|
534
|
+
return marks.length === 0 ? rest : { ...rest, marks }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Push the whitespace at either end of every maximal run of `type`-marked text
|
|
539
|
+
* OUT of the mark — into a node that keeps the run's other marks. One mark at
|
|
540
|
+
* a time, so `**bold with *italic inside* it**` is untouched (the bold run is
|
|
541
|
+
* all three nodes; the italic run has no boundary whitespace) while
|
|
542
|
+
* `*quarter @[…] **creative***` — italic on the space beside a mention, which
|
|
543
|
+
* no serializer can spell — settles into the one form that re-parses to
|
|
544
|
+
* itself. This is the canonicalization D3 rule 1 allows, applied where the
|
|
545
|
+
* manager's own output would otherwise not be a fixed point.
|
|
546
|
+
*/
|
|
547
|
+
function trimMarkRuns(content: readonly JSONContent[], type: string): JSONContent[] {
|
|
548
|
+
const out: JSONContent[] = []
|
|
549
|
+
let run: JSONContent[] = []
|
|
550
|
+
const flush = () => {
|
|
551
|
+
if (run.length === 0) return
|
|
552
|
+
const first = run[0]
|
|
553
|
+
const last = run[run.length - 1]
|
|
554
|
+
if (first === undefined || last === undefined) return
|
|
555
|
+
const leading = /^\s+/.exec(first.text ?? '')?.[0] ?? ''
|
|
556
|
+
if (leading !== '' && leading.length < (first.text ?? '').length) {
|
|
557
|
+
run[0] = { ...first, text: (first.text ?? '').slice(leading.length) }
|
|
558
|
+
out.push(withoutMark({ ...first, text: leading }, type))
|
|
559
|
+
} else if (leading !== '') {
|
|
560
|
+
run[0] = withoutMark(first, type)
|
|
561
|
+
}
|
|
562
|
+
const tail = run[run.length - 1]
|
|
563
|
+
if (tail !== undefined && run.length >= 1 && !(run.length === 1 && leading !== '' && leading.length >= (first.text ?? '').length)) {
|
|
564
|
+
const trailing = /\s+$/.exec(tail.text ?? '')?.[0] ?? ''
|
|
565
|
+
if (trailing !== '' && trailing.length < (tail.text ?? '').length) {
|
|
566
|
+
run[run.length - 1] = { ...tail, text: (tail.text ?? '').slice(0, -trailing.length) }
|
|
567
|
+
out.push(...run)
|
|
568
|
+
out.push(withoutMark({ ...tail, text: trailing }, type))
|
|
569
|
+
run = []
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
if (trailing !== '' && run.length > 1) {
|
|
573
|
+
run[run.length - 1] = withoutMark(tail, type)
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
out.push(...run)
|
|
577
|
+
run = []
|
|
578
|
+
}
|
|
579
|
+
for (const node of content) {
|
|
580
|
+
if (node.type === 'text' && hasMark(node, type)) {
|
|
581
|
+
run.push(node)
|
|
582
|
+
continue
|
|
583
|
+
}
|
|
584
|
+
flush()
|
|
585
|
+
out.push(node)
|
|
586
|
+
}
|
|
587
|
+
flush()
|
|
588
|
+
return out
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function canonicalizeInlineMarks(node: JSONContent): JSONContent {
|
|
592
|
+
const content = node.content
|
|
593
|
+
if (content === undefined) return node
|
|
594
|
+
if (node.type === 'paragraph' || node.type === 'heading') {
|
|
595
|
+
let inline: JSONContent[] = [...content]
|
|
596
|
+
for (const type of BOUNDARY_MARKS) inline = trimMarkRuns(inline, type)
|
|
597
|
+
return { ...node, content: inline }
|
|
598
|
+
}
|
|
599
|
+
return { ...node, content: content.map(canonicalizeInlineMarks) }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Run a document through the schema: attrs get their defaults, invalid
|
|
604
|
+
* content throws. `parse → normalize` is what `editor.setContent` produces,
|
|
605
|
+
* so this is what the round-trip tests compare.
|
|
606
|
+
*/
|
|
607
|
+
export function normalizeRichDocument(doc: JSONContent): JSONContent {
|
|
608
|
+
const node = ProseMirrorNode.fromJSON(getSchema(), doc)
|
|
609
|
+
node.check()
|
|
610
|
+
return node.toJSON()
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const ESCAPE_SENTINEL = '\u{E000}'
|
|
614
|
+
|
|
615
|
+
function hasCodeMark(node: JSONContent): boolean {
|
|
616
|
+
return (node.marks ?? []).some((mark) => mark.type === 'code')
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Block starters the manager does not escape; `>` it already entity-encodes. */
|
|
620
|
+
function escapeBlockStart(text: string): string {
|
|
621
|
+
return text
|
|
622
|
+
.replace(/^(#{1,6})(?=[ \t]|$)/, (hashes) => hashes.replace(/#/g, `${ESCAPE_SENTINEL}#`))
|
|
623
|
+
.replace(/^([-+])(?=[ \t]|$)/, `${ESCAPE_SENTINEL}$1`)
|
|
624
|
+
.replace(/^(\d{1,9})([.)])(?=[ \t]|$)/, `$1${ESCAPE_SENTINEL}$2`)
|
|
625
|
+
.replace(/^-{3,}[ \t]*$/, (rule) => `${ESCAPE_SENTINEL}${rule}`)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function escapePlusRuns(text: string): string {
|
|
629
|
+
return text.replace(/\+{2,}/g, (run) => run.replace(/\+/g, `${ESCAPE_SENTINEL}+`))
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Mark, in a COPY of the tree, every character the manager would let through
|
|
634
|
+
* unescaped. Code is left alone at every level: inside a code block or a code
|
|
635
|
+
* mark the characters are literal already.
|
|
636
|
+
*/
|
|
637
|
+
function markLiteralSyntax(node: JSONContent): JSONContent {
|
|
638
|
+
if (node.type === 'codeBlock') return node
|
|
639
|
+
const content = node.content
|
|
640
|
+
if (content === undefined) return node
|
|
641
|
+
let lineStart = node.type === 'paragraph' || node.type === 'heading'
|
|
642
|
+
const next = content.map((child): JSONContent => {
|
|
643
|
+
if (child.type === 'text') {
|
|
644
|
+
const startsLine = lineStart
|
|
645
|
+
lineStart = false
|
|
646
|
+
if (hasCodeMark(child)) return child
|
|
647
|
+
const source = (child.text ?? '').replaceAll(ESCAPE_SENTINEL, '')
|
|
648
|
+
let text = escapePlusRuns(source)
|
|
649
|
+
if (startsLine) text = escapeBlockStart(text)
|
|
650
|
+
return text === child.text ? child : { ...child, text }
|
|
651
|
+
}
|
|
652
|
+
if (child.type === 'hardBreak') {
|
|
653
|
+
lineStart = true
|
|
654
|
+
return child
|
|
655
|
+
}
|
|
656
|
+
lineStart = false
|
|
657
|
+
return markLiteralSyntax(child)
|
|
658
|
+
})
|
|
659
|
+
return { ...node, content: next }
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export function serializeRichMarkdown(doc: JSONContent): string {
|
|
663
|
+
// The same boundary rule on the way out: a mark the editor put on a trailing
|
|
664
|
+
// space cannot be spelled, so it is moved off the space before the manager
|
|
665
|
+
// sees it, and the output re-parses to exactly what was serialized.
|
|
666
|
+
// Trailing blank lines are not markdown: CommonMark parses `x\n\n` and `x`
|
|
667
|
+
// to the same tree, and the editor always ends in an empty paragraph (the
|
|
668
|
+
// trailing node that lets a caret land after a final list or code block).
|
|
669
|
+
// Trimming the end is what makes the serialized form a fixed point.
|
|
670
|
+
return getManager()
|
|
671
|
+
.serialize(markLiteralSyntax(canonicalizeInlineMarks(doc)))
|
|
672
|
+
.replaceAll(ESCAPE_SENTINEL, '\\')
|
|
673
|
+
.trimEnd()
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/* ── Fail-closed detector (D3 rule 2) ─────────────────────────────────────── */
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Every token marked's lexer may emit for a body the subset can hold. Anything
|
|
680
|
+
* not on this list — `table`, `html`, `image`, a token a future marked adds —
|
|
681
|
+
* fails closed, which is the point: the list is what we can VOUCH for, not
|
|
682
|
+
* what we happen to have seen.
|
|
683
|
+
*/
|
|
684
|
+
const SUPPORTED_TOKENS: ReadonlySet<string> = new Set([
|
|
685
|
+
'space',
|
|
686
|
+
'paragraph',
|
|
687
|
+
'heading',
|
|
688
|
+
'hr',
|
|
689
|
+
'blockquote',
|
|
690
|
+
'list',
|
|
691
|
+
'list_item',
|
|
692
|
+
'code',
|
|
693
|
+
'def',
|
|
694
|
+
'text',
|
|
695
|
+
'strong',
|
|
696
|
+
'em',
|
|
697
|
+
'del',
|
|
698
|
+
'codespan',
|
|
699
|
+
'link',
|
|
700
|
+
'br',
|
|
701
|
+
'escape',
|
|
702
|
+
'underline',
|
|
703
|
+
'mention',
|
|
704
|
+
])
|
|
705
|
+
|
|
706
|
+
function tokensSupported(tokens: readonly MarkdownToken[]): boolean {
|
|
707
|
+
return tokens.every((token) => {
|
|
708
|
+
const type = token.type
|
|
709
|
+
if (type === undefined || !SUPPORTED_TOKENS.has(type)) return false
|
|
710
|
+
if (type === 'heading' && !RICH_HEADING_LEVEL_SET.has(token.depth ?? 1)) return false
|
|
711
|
+
if (type === 'list_item' && token['task'] === true) return false
|
|
712
|
+
const children: MarkdownToken[] = [...(token.tokens ?? []), ...(token.items ?? [])]
|
|
713
|
+
return tokensSupported(children)
|
|
714
|
+
})
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function documentSupported(node: JSONContent): boolean {
|
|
718
|
+
if (node.type === 'heading') {
|
|
719
|
+
const level = node.attrs?.['level']
|
|
720
|
+
if (typeof level !== 'number' || !RICH_HEADING_LEVEL_SET.has(level)) return false
|
|
721
|
+
}
|
|
722
|
+
if (node.type === 'mention' && readMentionAttrs(node.attrs) === null) return false
|
|
723
|
+
return (node.content ?? []).every(documentSupported)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* May this body open in the rich editor?
|
|
728
|
+
*
|
|
729
|
+
* Two checks, because they fail differently. The token scan catches syntax
|
|
730
|
+
* the schema could *absorb* but must not — raw `<u>` would become an underline
|
|
731
|
+
* mark and be re-serialized as `++`, which is exactly the byte change rule 2
|
|
732
|
+
* forbids. The schema check (`Node.fromJSON` + `check()`, plus the attrs the
|
|
733
|
+
* schema does not validate) catches anything the parse emits that the editor
|
|
734
|
+
* cannot hold. Either failing means the plain composer, source untouched.
|
|
735
|
+
*/
|
|
736
|
+
export function isRichEditable(markdown: string): boolean {
|
|
737
|
+
if (markdown.trim() === '') return true
|
|
738
|
+
const manager = getManager()
|
|
739
|
+
let tokens: readonly MarkdownToken[]
|
|
740
|
+
try {
|
|
741
|
+
tokens = manager.instance.lexer(markdown)
|
|
742
|
+
} catch {
|
|
743
|
+
return false
|
|
744
|
+
}
|
|
745
|
+
if (!tokensSupported(tokens)) return false
|
|
746
|
+
try {
|
|
747
|
+
const doc = manager.parse(markdown)
|
|
748
|
+
if (!documentSupported(doc)) return false
|
|
749
|
+
normalizeRichDocument(doc)
|
|
750
|
+
return true
|
|
751
|
+
} catch {
|
|
752
|
+
return false
|
|
753
|
+
}
|
|
754
|
+
}
|