@tensor_1/react 2.39.0 → 2.40.1
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/README.md +18 -0
- package/dist/chart-analytic-model.d.ts +10 -2
- package/dist/chart-analytic-model.js +24 -17
- package/dist/chart-analytic.cjs +25 -18
- package/dist/chart-analytic.d.cts +10 -2
- package/dist/chart-panels-model.js +1 -1
- package/dist/components/Comments.d.ts +4 -1
- package/dist/components/Comments.js +3 -2
- package/dist/components/Markdown.d.ts +9 -2
- package/dist/components/Markdown.js +16 -2
- package/dist/components/RichTextEditor.d.ts +17 -0
- package/dist/components/RichTextEditor.js +179 -11
- package/dist/components/ToggleRow.d.ts +8 -1
- package/dist/components/ToggleRow.js +17 -3
- package/dist/icons/Icon.d.ts +12 -12
- package/dist/index.cjs +490 -475
- package/dist/index.d.cts +12 -2
- package/dist/markdown-model-Cuwi6pIk.d.cts +110 -0
- package/dist/markdown-model.d.ts +38 -1
- package/dist/markdown-model.js +67 -0
- package/dist/markdown.cjs +90 -4
- package/dist/markdown.d.cts +11 -75
- package/dist/markdown.d.ts +1 -1
- package/dist/markdown.js +16 -2
- package/dist/rich-text/mention.d.ts +14 -0
- package/dist/rich-text/mention.js +57 -0
- package/dist/rich-text/pipeline.js +4 -1
- package/dist/rich-text.cjs +1795 -1483
- package/dist/rich-text.d.cts +27 -1
- package/dist/rich-text.d.ts +3 -0
- package/dist/rich-text.js +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -103,6 +103,23 @@ hidden input), ARIA props, `variant` (`field` / `composer`, which renders in `Co
|
|
|
103
103
|
work in every preset; the toolbar is one tab stop with arrow-key navigation and scrolls sideways on narrow screens.
|
|
104
104
|
`RichTextEditorFallback` takes the same props and renders a plain textarea, for loading and error-boundary states.
|
|
105
105
|
|
|
106
|
+
**Mentions (`onMentionSearch`).** Pass `onMentionSearch(query)` and typing `@` opens a "People" list of the
|
|
107
|
+
candidates it answers (`{ id, label, description? }`, at most 20, ids of letters, digits and `_ . -` only). The
|
|
108
|
+
product owns the query and who may be mentioned; UIx never fetches. Arrow keys move, Enter or Tab picks, Escape closes
|
|
109
|
+
and keeps the typed text; while the answer is pending, empty or failed the list is a status line
|
|
110
|
+
(`mentionSearching`, `mentionEmpty`, `mentionFailed`), never an empty listbox. A pick becomes one token (selected and
|
|
111
|
+
deleted as a unit) drawn with `.uix-mention`, stored as `@[Name](mention:<id>)` with `[`, `]` and `\` in the name
|
|
112
|
+
escaped. The token is part of the schema in every editor, so stored mentions survive load → edit → save byte-exact; it
|
|
113
|
+
is never a link, so `isSafeUrl` neither sees nor drops it. `Markdown` draws the same token and takes
|
|
114
|
+
`resolveMention(id, storedName)` for a current or masked name. `findMentions(markdown)`, `formatMention`,
|
|
115
|
+
`readMention` and `isMentionId` (from `@tensor_1/react/markdown` and `/rich-text`) read and write the form on a
|
|
116
|
+
server; code spans and blocks hold no mentions.
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
<RichTextEditor value={md} onChange={setMd} onMentionSearch={(q) => api.people.search(q)} labels={{ mentionSuggestions: t('people') }} />
|
|
120
|
+
<Markdown resolveMention={(id, stored) => nameOf(id) ?? stored}>{md}</Markdown>
|
|
121
|
+
```
|
|
122
|
+
|
|
106
123
|
**No network access.** The editor, picker and reaction bar make no requests. `@tiptap/extension-emoji` is configured
|
|
107
124
|
with a list stripped of its CDN `fallbackImage` URLs and GitHub image emoji (`forceFallbackImages: false`), and the
|
|
108
125
|
emoji dataset is a bundled dynamic-import chunk. A jsdom egress test guards this.
|
|
@@ -180,6 +197,7 @@ Radial mode still shortens long labels to fit its ring, but every node carries i
|
|
|
180
197
|
### Conversations, save feedback and kept tabs
|
|
181
198
|
|
|
182
199
|
- **`Comment variant="system"`** marks a product event, such as a status change, apart from people. It has no speech bubble and a `systemLabel` byline (default "System").
|
|
200
|
+
- **`Comment avatar={false}`** drops the avatar column (`.uix-comment--bare`), for a comment on a rail that already marks each row, such as a system event in a timeline.
|
|
183
201
|
- **`Comment replyTo`** quotes the answered message above the body. Its label comes from `replyToLabel` (default "In reply to"). Mentions in a body use the `.uix-mention` class.
|
|
184
202
|
- **`SaveStatus`** shows `idle`, `saving`, `saved` or `failed` for inline edits and autosave. It offers `onRetry` when a save fails, and every string comes from `labels`. The status text sits in a polite live region that stays mounted while idle, so keep one `SaveStatus` mounted and change its `state`. Mounting it only when a save starts means the first announcement is lost.
|
|
185
203
|
- **`TabPanel keepMounted`** keeps an inactive panel in the DOM, `hidden` and out of the tab order, so switching back keeps its state and data. It is off by default.
|
|
@@ -95,7 +95,9 @@ declare function eventMarks(events: readonly ChartEvent[], options: {
|
|
|
95
95
|
/**
|
|
96
96
|
* A forecast as three series: a hidden lower bound, the band stacked on it (upper − lower) in
|
|
97
97
|
* `--uix-chart-forecast-band`, and the dashed centre line in the series colour, starting at `from`.
|
|
98
|
-
* The centre carries a
|
|
98
|
+
* The centre carries a "Projection" label over the projected stretch, unless `regionLabel` is
|
|
99
|
+
* false — ChartPanels turns it off, because its legend already names the dashed line and, in a
|
|
100
|
+
* narrow panel, the label collides with a threshold's gutter label.
|
|
99
101
|
*/
|
|
100
102
|
declare function forecastSeries(forecast: ChartForecast, options: {
|
|
101
103
|
name: string;
|
|
@@ -107,6 +109,8 @@ declare function forecastSeries(forecast: ChartForecast, options: {
|
|
|
107
109
|
xAxisIndex?: number;
|
|
108
110
|
yAxisIndex?: number;
|
|
109
111
|
idPrefix?: string;
|
|
112
|
+
/** Label the projected stretch "Projection" inside the plot (default true). */
|
|
113
|
+
regionLabel?: boolean;
|
|
110
114
|
}): LineSeriesOption[];
|
|
111
115
|
/** The previous period as a thin SOLID ghost line (dashes are reserved for projections). */
|
|
112
116
|
declare function comparisonSeries(comparison: ChartComparison, options: {
|
|
@@ -131,7 +135,11 @@ declare function partialPeriod(lastX: ChartX, options: {
|
|
|
131
135
|
* or at the start of a run of nulls). `badSide` defaults to `above` (`floor` thresholds: `below`).
|
|
132
136
|
*/
|
|
133
137
|
declare function findCrossings(values: readonly (number | null)[], threshold: Pick<ChartThreshold, 'value' | 'badSide' | 'kind'>): number[];
|
|
134
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* A ringed point at each first crossing. Only the latest carries a visible "Crossed <x>" label (it is
|
|
140
|
+
* the one a headline's "since" names); earlier crossings show theirs on hover, so a series that
|
|
141
|
+
* crosses repeatedly never stacks colliding labels along the line.
|
|
142
|
+
*/
|
|
135
143
|
declare function crossingPoints(values: readonly (number | null)[], threshold: ChartThreshold, options: {
|
|
136
144
|
x: readonly ChartX[];
|
|
137
145
|
tokens: UixChartTokens;
|
|
@@ -161,7 +161,7 @@ function forecastSeries(forecast, options) {
|
|
|
161
161
|
symbol: "none",
|
|
162
162
|
animation: false,
|
|
163
163
|
z: 3,
|
|
164
|
-
...last ? {
|
|
164
|
+
...last && options.regionLabel !== false ? {
|
|
165
165
|
markArea: markAreaBase([[
|
|
166
166
|
{
|
|
167
167
|
xAxis: forecast.from,
|
|
@@ -221,22 +221,29 @@ function findCrossings(values, threshold) {
|
|
|
221
221
|
}
|
|
222
222
|
function crossingPoints(values, threshold, options) {
|
|
223
223
|
const labels = { ...DEFAULT_ANALYTIC_LABELS, ...options.labels };
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
224
|
+
const crossings = findCrossings(values, threshold);
|
|
225
|
+
const label = (text, show) => ({
|
|
226
|
+
show,
|
|
227
|
+
position: "top",
|
|
228
|
+
distance: 8,
|
|
229
|
+
formatter: text,
|
|
230
|
+
color: options.tokens.text,
|
|
231
|
+
fontFamily: options.tokens.fontSans,
|
|
232
|
+
fontSize: 11
|
|
233
|
+
});
|
|
234
|
+
const data = crossings.map((index, i) => {
|
|
235
|
+
const text = labels.crossed(labels.formatX(options.x[index]));
|
|
236
|
+
const latest = i === crossings.length - 1;
|
|
237
|
+
return {
|
|
238
|
+
name: text,
|
|
239
|
+
coord: [options.x[index], values[index]],
|
|
240
|
+
symbol: "circle",
|
|
241
|
+
symbolSize: 10,
|
|
242
|
+
itemStyle: { color: options.tokens.surface, borderColor: options.tokens.text, borderWidth: 2 },
|
|
243
|
+
label: label(text, latest),
|
|
244
|
+
...latest ? {} : { emphasis: { label: label(text, true) } }
|
|
245
|
+
};
|
|
246
|
+
});
|
|
240
247
|
return data.length ? { markPoint: { animation: false, data } } : {};
|
|
241
248
|
}
|
|
242
249
|
export {
|
package/dist/chart-analytic.cjs
CHANGED
|
@@ -539,7 +539,7 @@ function forecastSeries(forecast, options) {
|
|
|
539
539
|
symbol: "none",
|
|
540
540
|
animation: false,
|
|
541
541
|
z: 3,
|
|
542
|
-
...last ? {
|
|
542
|
+
...last && options.regionLabel !== false ? {
|
|
543
543
|
markArea: markAreaBase([[
|
|
544
544
|
{
|
|
545
545
|
xAxis: forecast.from,
|
|
@@ -599,22 +599,29 @@ function findCrossings(values, threshold) {
|
|
|
599
599
|
}
|
|
600
600
|
function crossingPoints(values, threshold, options) {
|
|
601
601
|
const labels = { ...DEFAULT_ANALYTIC_LABELS, ...options.labels };
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
602
|
+
const crossings = findCrossings(values, threshold);
|
|
603
|
+
const label = (text, show) => ({
|
|
604
|
+
show,
|
|
605
|
+
position: "top",
|
|
606
|
+
distance: 8,
|
|
607
|
+
formatter: text,
|
|
608
|
+
color: options.tokens.text,
|
|
609
|
+
fontFamily: options.tokens.fontSans,
|
|
610
|
+
fontSize: 11
|
|
611
|
+
});
|
|
612
|
+
const data = crossings.map((index, i) => {
|
|
613
|
+
const text = labels.crossed(labels.formatX(options.x[index]));
|
|
614
|
+
const latest = i === crossings.length - 1;
|
|
615
|
+
return {
|
|
616
|
+
name: text,
|
|
617
|
+
coord: [options.x[index], values[index]],
|
|
618
|
+
symbol: "circle",
|
|
619
|
+
symbolSize: 10,
|
|
620
|
+
itemStyle: { color: options.tokens.surface, borderColor: options.tokens.text, borderWidth: 2 },
|
|
621
|
+
label: label(text, latest),
|
|
622
|
+
...latest ? {} : { emphasis: { label: label(text, true) } }
|
|
623
|
+
};
|
|
624
|
+
});
|
|
618
625
|
return data.length ? { markPoint: { animation: false, data } } : {};
|
|
619
626
|
}
|
|
620
627
|
|
|
@@ -802,7 +809,7 @@ function chartPanelsModel(spec, tokens, labelOverrides) {
|
|
|
802
809
|
const fromIndex = spec.x.values.findIndex((x) => sameX(x, panel.forecast.from));
|
|
803
810
|
const fromValue = fromIndex >= 0 ? primary.values[fromIndex] ?? null : null;
|
|
804
811
|
const colorIndex = primary.colorIndex ?? 0;
|
|
805
|
-
const fc = forecastSeries(panel.forecast, { name: primary.label, colorIndex, tokens, labels, fromValue, ...axes, idPrefix: `${panel.id}:forecast
|
|
812
|
+
const fc = forecastSeries(panel.forecast, { name: primary.label, colorIndex, tokens, labels, fromValue, ...axes, idPrefix: `${panel.id}:forecast`, regionLabel: false });
|
|
806
813
|
for (const s of fc) seriesPanel[String(s.id)] = panel.id;
|
|
807
814
|
series.push(...fc);
|
|
808
815
|
if (!legend.some((item) => item.swatch === "dash")) {
|
|
@@ -100,7 +100,9 @@ declare function eventMarks(events: readonly ChartEvent[], options: {
|
|
|
100
100
|
/**
|
|
101
101
|
* A forecast as three series: a hidden lower bound, the band stacked on it (upper − lower) in
|
|
102
102
|
* `--uix-chart-forecast-band`, and the dashed centre line in the series colour, starting at `from`.
|
|
103
|
-
* The centre carries a
|
|
103
|
+
* The centre carries a "Projection" label over the projected stretch, unless `regionLabel` is
|
|
104
|
+
* false — ChartPanels turns it off, because its legend already names the dashed line and, in a
|
|
105
|
+
* narrow panel, the label collides with a threshold's gutter label.
|
|
104
106
|
*/
|
|
105
107
|
declare function forecastSeries(forecast: ChartForecast, options: {
|
|
106
108
|
name: string;
|
|
@@ -112,6 +114,8 @@ declare function forecastSeries(forecast: ChartForecast, options: {
|
|
|
112
114
|
xAxisIndex?: number;
|
|
113
115
|
yAxisIndex?: number;
|
|
114
116
|
idPrefix?: string;
|
|
117
|
+
/** Label the projected stretch "Projection" inside the plot (default true). */
|
|
118
|
+
regionLabel?: boolean;
|
|
115
119
|
}): LineSeriesOption[];
|
|
116
120
|
/** The previous period as a thin SOLID ghost line (dashes are reserved for projections). */
|
|
117
121
|
declare function comparisonSeries(comparison: ChartComparison, options: {
|
|
@@ -136,7 +140,11 @@ declare function partialPeriod(lastX: ChartX, options: {
|
|
|
136
140
|
* or at the start of a run of nulls). `badSide` defaults to `above` (`floor` thresholds: `below`).
|
|
137
141
|
*/
|
|
138
142
|
declare function findCrossings(values: readonly (number | null)[], threshold: Pick<ChartThreshold, 'value' | 'badSide' | 'kind'>): number[];
|
|
139
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* A ringed point at each first crossing. Only the latest carries a visible "Crossed <x>" label (it is
|
|
145
|
+
* the one a headline's "since" names); earlier crossings show theirs on hover, so a series that
|
|
146
|
+
* crosses repeatedly never stacks colliding labels along the line.
|
|
147
|
+
*/
|
|
140
148
|
declare function crossingPoints(values: readonly (number | null)[], threshold: ChartThreshold, options: {
|
|
141
149
|
x: readonly ChartX[];
|
|
142
150
|
tokens: UixChartTokens;
|
|
@@ -192,7 +192,7 @@ function chartPanelsModel(spec, tokens, labelOverrides) {
|
|
|
192
192
|
const fromIndex = spec.x.values.findIndex((x) => sameX(x, panel.forecast.from));
|
|
193
193
|
const fromValue = fromIndex >= 0 ? primary.values[fromIndex] ?? null : null;
|
|
194
194
|
const colorIndex = primary.colorIndex ?? 0;
|
|
195
|
-
const fc = forecastSeries(panel.forecast, { name: primary.label, colorIndex, tokens, labels, fromValue, ...axes, idPrefix: `${panel.id}:forecast
|
|
195
|
+
const fc = forecastSeries(panel.forecast, { name: primary.label, colorIndex, tokens, labels, fromValue, ...axes, idPrefix: `${panel.id}:forecast`, regionLabel: false });
|
|
196
196
|
for (const s of fc) seriesPanel[String(s.id)] = panel.id;
|
|
197
197
|
series.push(...fc);
|
|
198
198
|
if (!legend.some((item) => item.swatch === "dash")) {
|
|
@@ -11,7 +11,10 @@ interface CommentsProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
11
11
|
*/
|
|
12
12
|
declare function Comments({ children, className, ...props }: CommentsProps): react.JSX.Element;
|
|
13
13
|
interface CommentProps extends HTMLAttributes<HTMLDivElement> {
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Usually an `<Avatar />` (the grid reserves the leading column). `false` drops the
|
|
16
|
+
* column, for a comment that sits on a rail which already marks each row (a timeline).
|
|
17
|
+
*/
|
|
15
18
|
avatar?: ReactNode;
|
|
16
19
|
author?: ReactNode;
|
|
17
20
|
meta?: ReactNode;
|
|
@@ -17,14 +17,15 @@ function Comment({
|
|
|
17
17
|
}) {
|
|
18
18
|
const system = variant === "system";
|
|
19
19
|
const byline = author ?? (system ? systemLabel : void 0);
|
|
20
|
+
const bare = avatar === false;
|
|
20
21
|
return /* @__PURE__ */ jsxs(
|
|
21
22
|
"div",
|
|
22
23
|
{
|
|
23
|
-
className: cx("uix-comment", system && "uix-comment--system", className),
|
|
24
|
+
className: cx("uix-comment", system && "uix-comment--system", bare && "uix-comment--bare", className),
|
|
24
25
|
"data-variant": system ? "system" : void 0,
|
|
25
26
|
...props,
|
|
26
27
|
children: [
|
|
27
|
-
avatar ?? /* @__PURE__ */ jsx("span", { "aria-hidden": "true" }),
|
|
28
|
+
bare ? null : avatar ?? /* @__PURE__ */ jsx("span", { "aria-hidden": "true" }),
|
|
28
29
|
/* @__PURE__ */ jsxs("div", { className: "uix-comment__body", children: [
|
|
29
30
|
(byline != null || meta != null) && /* @__PURE__ */ jsxs("div", { className: "uix-comment__meta", children: [
|
|
30
31
|
byline != null && /* @__PURE__ */ jsx("span", { className: "uix-comment__author", children: byline }),
|
|
@@ -19,6 +19,12 @@ interface MarkdownProps {
|
|
|
19
19
|
* (`#` → `<h3>` by default, as in TENSOR's original renderer). Capped at `<h6>`.
|
|
20
20
|
*/
|
|
21
21
|
headingOffset?: number;
|
|
22
|
+
/**
|
|
23
|
+
* The name to show for a mention (`@[Name](mention:<id>)`), e.g. the person's current
|
|
24
|
+
* or masked name. Return `undefined` to show the name stored in the text. Without it
|
|
25
|
+
* every mention shows its stored name.
|
|
26
|
+
*/
|
|
27
|
+
resolveMention?: (id: string, storedLabel: string) => string | undefined;
|
|
22
28
|
}
|
|
23
29
|
/**
|
|
24
30
|
* Safe markdown viewer over `.uix-prose`. It never produces an HTML string and never
|
|
@@ -28,9 +34,10 @@ interface MarkdownProps {
|
|
|
28
34
|
* Supports headings, paragraphs (single newline = line break), bold, italic,
|
|
29
35
|
* strikethrough, inline and fenced code, links and bare http(s) URLs, blockquotes,
|
|
30
36
|
* horizontal rules, nested bulleted/numbered lists, read-only task lists, GFM tables
|
|
31
|
-
* (in a keyboard-scrollable wrapper)
|
|
37
|
+
* (in a keyboard-scrollable wrapper), images gated by `resolveImageSrc` and mentions
|
|
38
|
+
* (`@[Name](mention:<id>)`, drawn as `.uix-mention`, named by `resolveMention`).
|
|
32
39
|
* Server-component safe.
|
|
33
40
|
*/
|
|
34
|
-
declare function Markdown({ children, className, isSafeUrl, resolveImageSrc, headingOffset }: MarkdownProps): react.JSX.Element | null;
|
|
41
|
+
declare function Markdown({ children, className, isSafeUrl, resolveImageSrc, headingOffset, resolveMention, }: MarkdownProps): react.JSX.Element | null;
|
|
35
42
|
|
|
36
43
|
export { Markdown, type MarkdownProps, defaultMarkdownIsSafeUrl };
|
|
@@ -33,6 +33,13 @@ function renderInlines(nodes, policy) {
|
|
|
33
33
|
i
|
|
34
34
|
);
|
|
35
35
|
}
|
|
36
|
+
case "mention": {
|
|
37
|
+
const name = policy.resolveMention?.(node.id, node.label) ?? node.label;
|
|
38
|
+
return /* @__PURE__ */ jsxs("span", { className: "uix-mention", "data-mention-id": node.id, children: [
|
|
39
|
+
"@",
|
|
40
|
+
name
|
|
41
|
+
] }, i);
|
|
42
|
+
}
|
|
36
43
|
case "image": {
|
|
37
44
|
const resolved = policy.resolveImageSrc?.(node.src) ?? null;
|
|
38
45
|
if (resolved) {
|
|
@@ -91,10 +98,17 @@ function renderBlock(block, policy, headingOffset, key) {
|
|
|
91
98
|
);
|
|
92
99
|
}
|
|
93
100
|
}
|
|
94
|
-
function Markdown({
|
|
101
|
+
function Markdown({
|
|
102
|
+
children,
|
|
103
|
+
className,
|
|
104
|
+
isSafeUrl = defaultMarkdownIsSafeUrl,
|
|
105
|
+
resolveImageSrc,
|
|
106
|
+
headingOffset = 2,
|
|
107
|
+
resolveMention
|
|
108
|
+
}) {
|
|
95
109
|
const source = typeof children === "string" ? children : "";
|
|
96
110
|
if (source.trim() === "") return null;
|
|
97
|
-
const policy = { isSafeUrl, resolveImageSrc };
|
|
111
|
+
const policy = { isSafeUrl, resolveImageSrc, resolveMention };
|
|
98
112
|
const blocks = parseMarkdown(source);
|
|
99
113
|
return /* @__PURE__ */ jsx("div", { className: cx("uix-prose", "uix-markdown", className), "data-component": "markdown", children: blocks.map((block, i) => renderBlock(block, policy, headingOffset, i)) });
|
|
100
114
|
}
|
|
@@ -2,7 +2,9 @@ import * as react from 'react';
|
|
|
2
2
|
import { ReactNode } from 'react';
|
|
3
3
|
import { EmojiItem } from '@tiptap/extension-emoji';
|
|
4
4
|
import { EmojiLocale } from '../emoji-model.js';
|
|
5
|
+
import { MentionCandidate } from '../rich-text/mention.js';
|
|
5
6
|
import { EmojiPickerLabels } from './EmojiPicker.js';
|
|
7
|
+
import '@tiptap/core';
|
|
6
8
|
import '../overlay-position.js';
|
|
7
9
|
|
|
8
10
|
type RichTextFeatures = 'full' | 'comment' | 'template';
|
|
@@ -44,6 +46,14 @@ interface RichTextLabels {
|
|
|
44
46
|
emoji: string;
|
|
45
47
|
/** Accessible name of the `:shortcode` suggestion list. */
|
|
46
48
|
emojiSuggestions: string;
|
|
49
|
+
/** Accessible name of the `@` people list. */
|
|
50
|
+
mentionSuggestions: string;
|
|
51
|
+
/** Shown while `onMentionSearch` has not answered. */
|
|
52
|
+
mentionSearching: string;
|
|
53
|
+
/** Shown when `onMentionSearch` found nobody. */
|
|
54
|
+
mentionEmpty: string;
|
|
55
|
+
/** Shown when `onMentionSearch` failed. */
|
|
56
|
+
mentionFailed: string;
|
|
47
57
|
undo: string;
|
|
48
58
|
redo: string;
|
|
49
59
|
/** Toggle to markdown source. */
|
|
@@ -86,6 +96,13 @@ interface RichTextEditorProps {
|
|
|
86
96
|
onSubmitShortcut?: () => void;
|
|
87
97
|
/** Emoji button and `:shortcode` suggestions. Default `true`. */
|
|
88
98
|
emoji?: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* People for the `@` list: called with the text typed after `@` (possibly empty) and
|
|
101
|
+
* answering the candidates to offer. The product owns the query and who may be
|
|
102
|
+
* mentioned; UIx never fetches. A picked person is inserted as one token, stored as
|
|
103
|
+
* `@[Name](mention:<id>)`. Without it, typing `@` does nothing special.
|
|
104
|
+
*/
|
|
105
|
+
onMentionSearch?: (query: string) => Promise<ReadonlyArray<MentionCandidate>> | ReadonlyArray<MentionCandidate>;
|
|
89
106
|
/**
|
|
90
107
|
* Same-origin folder of fallback emoji PNGs for devices without a colour emoji font
|
|
91
108
|
* (e.g. `/static/emoji`). Emoji the device cannot draw show as
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
readMarkdown,
|
|
25
25
|
writeMarkdown
|
|
26
26
|
} from "../rich-text/pipeline.js";
|
|
27
|
+
import { isMentionId } from "../markdown-model.js";
|
|
27
28
|
import { Composer, ComposerBar } from "./Composer.js";
|
|
28
29
|
import { EditorIcon } from "./EditorIcons.js";
|
|
29
30
|
import { EmojiPicker } from "./EmojiPicker.js";
|
|
@@ -62,6 +63,10 @@ const DEFAULT_RICH_TEXT_LABELS = {
|
|
|
62
63
|
imagesUnsupported: "Images can't be added here.",
|
|
63
64
|
emoji: "Emoji",
|
|
64
65
|
emojiSuggestions: "Emoji suggestions",
|
|
66
|
+
mentionSuggestions: "People",
|
|
67
|
+
mentionSearching: "Searching\u2026",
|
|
68
|
+
mentionEmpty: "No matching people",
|
|
69
|
+
mentionFailed: "People could not be loaded.",
|
|
65
70
|
undo: "Undo",
|
|
66
71
|
redo: "Redo",
|
|
67
72
|
sourceMode: "Show markdown",
|
|
@@ -92,6 +97,8 @@ function suggestEmoji(query, limit = 8) {
|
|
|
92
97
|
}
|
|
93
98
|
return [...starts, ...tagged].slice(0, limit);
|
|
94
99
|
}
|
|
100
|
+
const mentionSuggestionKey = new PluginKey("uixMentionSuggestion");
|
|
101
|
+
const usableCandidates = (items) => items.filter((c) => isMentionId(c.id) && c.label.trim() !== "").slice(0, 20);
|
|
95
102
|
const htmlAttr = (value) => value === void 0 || value === false || value === "" ? void 0 : String(value);
|
|
96
103
|
const popoverOpen = (el) => {
|
|
97
104
|
try {
|
|
@@ -190,8 +197,10 @@ function RichTextEditor(props) {
|
|
|
190
197
|
className,
|
|
191
198
|
onBlur,
|
|
192
199
|
emojiImageBaseUrl,
|
|
193
|
-
imagesUnavailableReason
|
|
200
|
+
imagesUnavailableReason,
|
|
201
|
+
onMentionSearch
|
|
194
202
|
} = props;
|
|
203
|
+
const mentionsOn = typeof onMentionSearch === "function";
|
|
195
204
|
const emojiBase = useMemo(() => normalizeEmojiImageBaseUrl(emojiImageBaseUrl), [emojiImageBaseUrl]);
|
|
196
205
|
const renderEmoji = useEmojiRenderer(emojiImageBaseUrl);
|
|
197
206
|
const labels = useMemo(() => ({ ...DEFAULT_RICH_TEXT_LABELS, ...labelOverrides }), [labelOverrides]);
|
|
@@ -199,10 +208,11 @@ function RichTextEditor(props) {
|
|
|
199
208
|
const counterId = `${uid}-counter`;
|
|
200
209
|
const statusId = `${uid}-status`;
|
|
201
210
|
const suggestId = `${uid}-emoji`;
|
|
211
|
+
const mentionId = `${uid}-people`;
|
|
202
212
|
const linkPopoverId = `${uid}-link`;
|
|
203
213
|
const controls = PRESETS[features];
|
|
204
214
|
const has = (control) => controls.has(control) && (control !== "emoji" || emoji) && (control !== "image" || !!onUploadImage);
|
|
205
|
-
const latest = useRef({ onChange, onSubmitShortcut, onBlur, isSafeUrl, resolveImageSrc, onUploadImage, placeholder, imagesOn: false, imagesOffText: "" });
|
|
215
|
+
const latest = useRef({ onChange, onSubmitShortcut, onBlur, isSafeUrl, resolveImageSrc, onUploadImage, onMentionSearch, placeholder, imagesOn: false, imagesOffText: "" });
|
|
206
216
|
latest.current = {
|
|
207
217
|
onChange,
|
|
208
218
|
onSubmitShortcut,
|
|
@@ -210,6 +220,7 @@ function RichTextEditor(props) {
|
|
|
210
220
|
isSafeUrl,
|
|
211
221
|
resolveImageSrc,
|
|
212
222
|
onUploadImage,
|
|
223
|
+
onMentionSearch,
|
|
213
224
|
placeholder,
|
|
214
225
|
imagesOn: controls.has("image") && !!onUploadImage,
|
|
215
226
|
imagesOffText: imagesUnavailableReason?.trim() || labels.imagesUnsupported
|
|
@@ -264,6 +275,59 @@ function RichTextEditor(props) {
|
|
|
264
275
|
return false;
|
|
265
276
|
}
|
|
266
277
|
};
|
|
278
|
+
const [mention, setMention] = useState(null);
|
|
279
|
+
const [mentionIndex, setMentionIndex] = useState(0);
|
|
280
|
+
const mentionState = useRef({ mention, mentionIndex });
|
|
281
|
+
mentionState.current = { mention, mentionIndex };
|
|
282
|
+
const mentionSeq = useRef(0);
|
|
283
|
+
const mentionBridge = useRef({ update: () => {
|
|
284
|
+
}, keyDown: () => false });
|
|
285
|
+
mentionBridge.current = {
|
|
286
|
+
update: (p) => {
|
|
287
|
+
const seq = ++mentionSeq.current;
|
|
288
|
+
const search = latest.current.onMentionSearch;
|
|
289
|
+
if (!p || !search) {
|
|
290
|
+
setMention(null);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const rect = p.clientRect?.() ?? null;
|
|
294
|
+
const base = { query: p.query, command: p.command, rect };
|
|
295
|
+
setMention((prev) => ({ ...base, status: "loading", items: prev?.query && p.query.startsWith(prev.query) ? prev.items : [] }));
|
|
296
|
+
setMentionIndex(0);
|
|
297
|
+
Promise.resolve().then(() => search(p.query)).then(
|
|
298
|
+
(found) => {
|
|
299
|
+
if (seq !== mentionSeq.current) return;
|
|
300
|
+
setMention({ ...base, status: "ready", items: usableCandidates(found ?? []) });
|
|
301
|
+
},
|
|
302
|
+
() => {
|
|
303
|
+
if (seq !== mentionSeq.current) return;
|
|
304
|
+
setMention({ ...base, status: "error", items: [] });
|
|
305
|
+
}
|
|
306
|
+
);
|
|
307
|
+
},
|
|
308
|
+
keyDown: ({ event }) => {
|
|
309
|
+
const { mention: open, mentionIndex: index } = mentionState.current;
|
|
310
|
+
if (!open) return false;
|
|
311
|
+
if (event.key === "Escape") {
|
|
312
|
+
mentionSeq.current += 1;
|
|
313
|
+
setMention(null);
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
const picks = open.status === "ready" ? open.items : [];
|
|
317
|
+
if (picks.length === 0) return false;
|
|
318
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
319
|
+
const delta = event.key === "ArrowDown" ? 1 : -1;
|
|
320
|
+
setMentionIndex((index + delta + picks.length) % picks.length);
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
if (event.key === "Enter" || event.key === "Tab") {
|
|
324
|
+
const item = picks[Math.min(index, picks.length - 1)];
|
|
325
|
+
if (item) open.command(item);
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
};
|
|
267
331
|
const headingKey = [...headingLevels].sort().join(",");
|
|
268
332
|
const extensions = useMemo(() => {
|
|
269
333
|
const image = Image.extend({
|
|
@@ -386,6 +450,30 @@ function RichTextEditor(props) {
|
|
|
386
450
|
})];
|
|
387
451
|
}
|
|
388
452
|
});
|
|
453
|
+
const mentionSuggest = Extension.create({
|
|
454
|
+
name: "uixMentionSuggestion",
|
|
455
|
+
addProseMirrorPlugins() {
|
|
456
|
+
return [Suggestion({
|
|
457
|
+
editor: this.editor,
|
|
458
|
+
char: "@",
|
|
459
|
+
pluginKey: mentionSuggestionKey,
|
|
460
|
+
items: () => [],
|
|
461
|
+
command: ({ editor: ed, range, props: item }) => {
|
|
462
|
+
ed.chain().focus().insertContentAt(range, [
|
|
463
|
+
{ type: "mention", attrs: { id: item.id, label: item.label.replace(/\s+/gu, " ").trim() } },
|
|
464
|
+
{ type: "text", text: " " }
|
|
465
|
+
]).run();
|
|
466
|
+
},
|
|
467
|
+
allow: ({ state: state2, range }) => !state2.doc.resolve(range.from).parent.type.spec.code,
|
|
468
|
+
render: () => ({
|
|
469
|
+
onStart: (p) => mentionBridge.current.update(p),
|
|
470
|
+
onUpdate: (p) => mentionBridge.current.update(p),
|
|
471
|
+
onExit: () => mentionBridge.current.update(null),
|
|
472
|
+
onKeyDown: (p) => mentionBridge.current.keyDown(p)
|
|
473
|
+
})
|
|
474
|
+
})];
|
|
475
|
+
}
|
|
476
|
+
});
|
|
389
477
|
return [
|
|
390
478
|
...createSchemaExtensions({
|
|
391
479
|
headingLevels: headingKey.split(",").map(Number),
|
|
@@ -396,9 +484,10 @@ function RichTextEditor(props) {
|
|
|
396
484
|
Placeholder.configure({ placeholder: () => latest.current.placeholder ?? "" }),
|
|
397
485
|
shortcuts,
|
|
398
486
|
...emoji ? [unicodeEmoji] : [],
|
|
399
|
-
...emojiBase ? [emojiImages] : []
|
|
487
|
+
...emojiBase ? [emojiImages] : [],
|
|
488
|
+
...mentionsOn ? [mentionSuggest] : []
|
|
400
489
|
];
|
|
401
|
-
}, [headingKey, emoji, emojiBase]);
|
|
490
|
+
}, [headingKey, emoji, emojiBase, mentionsOn]);
|
|
402
491
|
const managerOf = (ed) => {
|
|
403
492
|
const manager = ed.markdown ?? ed.storage.markdown.manager;
|
|
404
493
|
return patchManager(manager);
|
|
@@ -480,6 +569,8 @@ function RichTextEditor(props) {
|
|
|
480
569
|
const invalid = props["aria-invalid"] || over;
|
|
481
570
|
const describedBy = [props["aria-describedby"], maxLength !== void 0 ? counterId : void 0, status ? statusId : void 0].filter(Boolean).join(" ") || void 0;
|
|
482
571
|
const suggestOpen = !!suggest && mode === "rich";
|
|
572
|
+
const mentionOpen = !suggestOpen && !!mention && mode === "rich";
|
|
573
|
+
const mentionActive = mentionOpen && mention?.status === "ready" && mention.items.length > 0 ? `${mentionId}-${Math.min(mentionIndex, mention.items.length - 1)}` : void 0;
|
|
483
574
|
const rows = minRows ?? (variant === "composer" ? 3 : 4);
|
|
484
575
|
useEffect(() => {
|
|
485
576
|
if (!editor || editor.isDestroyed) return;
|
|
@@ -497,9 +588,9 @@ function RichTextEditor(props) {
|
|
|
497
588
|
"aria-required": htmlAttr(props["aria-required"]),
|
|
498
589
|
"aria-disabled": htmlAttr(disabled),
|
|
499
590
|
"aria-readonly": htmlAttr(readOnly),
|
|
500
|
-
"aria-autocomplete": emoji ? "list" : void 0,
|
|
501
|
-
"aria-controls": suggestOpen ? suggestId : void 0,
|
|
502
|
-
"aria-activedescendant": suggestOpen ? `${suggestId}-${suggestIndex}` :
|
|
591
|
+
"aria-autocomplete": emoji || mentionsOn ? "list" : void 0,
|
|
592
|
+
"aria-controls": suggestOpen ? suggestId : mentionOpen ? mentionId : void 0,
|
|
593
|
+
"aria-activedescendant": suggestOpen ? `${suggestId}-${suggestIndex}` : mentionActive,
|
|
503
594
|
class: "uix-rich-text__content uix-prose",
|
|
504
595
|
style: `min-height: calc(${rows} * 1lh + 2 * var(--uix-space-3))`
|
|
505
596
|
}).filter(([, v]) => v !== void 0))
|
|
@@ -520,7 +611,11 @@ function RichTextEditor(props) {
|
|
|
520
611
|
suggestOpen,
|
|
521
612
|
suggestId,
|
|
522
613
|
suggestIndex,
|
|
523
|
-
rows
|
|
614
|
+
rows,
|
|
615
|
+
mentionsOn,
|
|
616
|
+
mentionOpen,
|
|
617
|
+
mentionId,
|
|
618
|
+
mentionActive
|
|
524
619
|
]);
|
|
525
620
|
const state = useEditorState({
|
|
526
621
|
editor,
|
|
@@ -874,6 +969,16 @@ function RichTextEditor(props) {
|
|
|
874
969
|
onPick: (item) => suggest.command(item),
|
|
875
970
|
renderEmoji
|
|
876
971
|
}
|
|
972
|
+
) : null,
|
|
973
|
+
mentionOpen && mention ? /* @__PURE__ */ jsx(
|
|
974
|
+
MentionSuggestions,
|
|
975
|
+
{
|
|
976
|
+
id: mentionId,
|
|
977
|
+
labels,
|
|
978
|
+
state: mention,
|
|
979
|
+
active: Math.min(mentionIndex, Math.max(mention.items.length - 1, 0)),
|
|
980
|
+
onPick: (item) => mention.command(item)
|
|
981
|
+
}
|
|
877
982
|
) : null
|
|
878
983
|
] });
|
|
879
984
|
const counter = maxLength !== void 0 ? /* @__PURE__ */ jsx("span", { id: counterId, className: "uix-rich-text__counter", "data-over": over || void 0, children: formatLabel(labels.characterCount, { count: markdown.length, max: maxLength }) }) : null;
|
|
@@ -908,8 +1013,7 @@ function RichTextEditor(props) {
|
|
|
908
1013
|
hidden
|
|
909
1014
|
] });
|
|
910
1015
|
}
|
|
911
|
-
function
|
|
912
|
-
const listRef = useRef(null);
|
|
1016
|
+
function useCaretPosition(listRef, rect, size) {
|
|
913
1017
|
const [pos, setPos] = useState(null);
|
|
914
1018
|
useEffect(() => {
|
|
915
1019
|
const el = listRef.current;
|
|
@@ -922,7 +1026,71 @@ function EmojiSuggestions({ id, label, items, query, active, rect, onPick, rende
|
|
|
922
1026
|
{ placement: "bottom-start", offset: 4, padding: 8 }
|
|
923
1027
|
);
|
|
924
1028
|
setPos({ x: result.x, y: result.y });
|
|
925
|
-
}, [rect,
|
|
1029
|
+
}, [listRef, rect, size]);
|
|
1030
|
+
return pos;
|
|
1031
|
+
}
|
|
1032
|
+
function MentionSuggestions({ id, labels, state, active, onPick }) {
|
|
1033
|
+
const listRef = useRef(null);
|
|
1034
|
+
const items = state.status === "ready" ? state.items : [];
|
|
1035
|
+
const pos = useCaretPosition(listRef, state.rect, `${state.status}:${items.length}`);
|
|
1036
|
+
useEffect(() => {
|
|
1037
|
+
document.getElementById(`${id}-${active}`)?.scrollIntoView?.({ block: "nearest" });
|
|
1038
|
+
}, [active, id]);
|
|
1039
|
+
const style = { left: pos?.x ?? -9999, top: pos?.y ?? -9999 };
|
|
1040
|
+
if (items.length === 0) {
|
|
1041
|
+
const note = state.status === "loading" ? labels.mentionSearching : state.status === "error" ? labels.mentionFailed : labels.mentionEmpty;
|
|
1042
|
+
return /* @__PURE__ */ jsx(
|
|
1043
|
+
"div",
|
|
1044
|
+
{
|
|
1045
|
+
ref: listRef,
|
|
1046
|
+
id,
|
|
1047
|
+
role: "status",
|
|
1048
|
+
"aria-label": labels.mentionSuggestions,
|
|
1049
|
+
className: "uix-rich-text__suggest",
|
|
1050
|
+
"data-mention-list": "",
|
|
1051
|
+
"data-state": state.status === "ready" ? "empty" : state.status,
|
|
1052
|
+
style,
|
|
1053
|
+
children: /* @__PURE__ */ jsx("div", { className: "uix-rich-text__suggest-note", children: note })
|
|
1054
|
+
}
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
return /* @__PURE__ */ jsx(
|
|
1058
|
+
"div",
|
|
1059
|
+
{
|
|
1060
|
+
ref: listRef,
|
|
1061
|
+
id,
|
|
1062
|
+
role: "listbox",
|
|
1063
|
+
"aria-label": labels.mentionSuggestions,
|
|
1064
|
+
"aria-busy": state.status === "loading" || void 0,
|
|
1065
|
+
className: "uix-rich-text__suggest",
|
|
1066
|
+
"data-mention-list": "",
|
|
1067
|
+
"data-state": "ready",
|
|
1068
|
+
style,
|
|
1069
|
+
children: items.map((item, i) => /* @__PURE__ */ jsxs(
|
|
1070
|
+
"div",
|
|
1071
|
+
{
|
|
1072
|
+
id: `${id}-${i}`,
|
|
1073
|
+
role: "option",
|
|
1074
|
+
"aria-selected": i === active,
|
|
1075
|
+
className: "uix-rich-text__suggest-item uix-rich-text__suggest-item--person",
|
|
1076
|
+
"data-mention-id": item.id,
|
|
1077
|
+
onMouseDown: (event) => {
|
|
1078
|
+
event.preventDefault();
|
|
1079
|
+
onPick(item);
|
|
1080
|
+
},
|
|
1081
|
+
children: [
|
|
1082
|
+
/* @__PURE__ */ jsx("span", { className: "uix-rich-text__suggest-label", children: item.label }),
|
|
1083
|
+
item.description ? /* @__PURE__ */ jsx("span", { className: "uix-rich-text__suggest-detail", children: item.description }) : null
|
|
1084
|
+
]
|
|
1085
|
+
},
|
|
1086
|
+
item.id
|
|
1087
|
+
))
|
|
1088
|
+
}
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
function EmojiSuggestions({ id, label, items, query, active, rect, onPick, renderEmoji }) {
|
|
1092
|
+
const listRef = useRef(null);
|
|
1093
|
+
const pos = useCaretPosition(listRef, rect, items.length);
|
|
926
1094
|
useEffect(() => {
|
|
927
1095
|
document.getElementById(`${id}-${active}`)?.scrollIntoView?.({ block: "nearest" });
|
|
928
1096
|
}, [active, id]);
|