@plannotator/ui 0.43.1 → 0.44.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.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The comment composer's TOKEN HIGHLIGHT LAYER, as pure ranges.
3
+ *
4
+ * A textarea cannot style substrings, so `CommentPopover` paints its text
5
+ * through one mirrored, aria-hidden overlay rendered behind a
6
+ * transparent-text textarea. That overlay used to know about exactly one kind
7
+ * of token (skill references). It now paints a MERGED list of ranges produced
8
+ * by one or more sources, so a second source (host `@` mentions) needs no
9
+ * second overlay — two mirrored layers could never stay pixel-aligned with
10
+ * each other, and only one of them could own the scroll sync.
11
+ *
12
+ * Everything here is pure: no DOM, no styling, no React. The component maps a
13
+ * range to its span (that is where Tailwind classes and `data-*` attributes
14
+ * live, so the class scanner still sees them); this module only decides WHICH
15
+ * bytes are a token and which token wins when two sources claim the same
16
+ * ones.
17
+ */
18
+ import { mentionToken, type MentionPerson } from './mentions';
19
+ import type { SkillReferenceToken } from './skillReferences';
20
+
21
+ /** One highlighted span of the composer's text, half-open `[start, end)`. */
22
+ export type ComposerTokenRange =
23
+ | {
24
+ readonly kind: 'skill';
25
+ readonly start: number;
26
+ readonly end: number;
27
+ readonly skill: SkillReferenceToken;
28
+ }
29
+ | {
30
+ readonly kind: 'mention';
31
+ readonly start: number;
32
+ readonly end: number;
33
+ readonly person: MentionPerson;
34
+ };
35
+
36
+ /**
37
+ * The skill-reference source: the positioned occurrences the autocomplete
38
+ * already found, unchanged. Order, spans and duplicates are preserved — a
39
+ * name referenced twice highlights twice.
40
+ */
41
+ export function skillTokenRanges(
42
+ tokens: readonly SkillReferenceToken[],
43
+ ): ComposerTokenRange[] {
44
+ return tokens.map((skill) => ({
45
+ kind: 'skill',
46
+ start: skill.start,
47
+ end: skill.end,
48
+ skill,
49
+ }));
50
+ }
51
+
52
+ /**
53
+ * The mention source: every occurrence of each tagged person's readable
54
+ * `@Label` token in the text.
55
+ *
56
+ * Driven by the mention ID MODEL, never by a regex over arbitrary `@words`:
57
+ * the people passed in are the ones the author actually picked and whose
58
+ * token still survives (`survivingMentions`), so editing a byte of a token
59
+ * un-chips it in the same breath as it untags the person: a chip follows the
60
+ * body, never a stale pick. (The reported IDS can lag in one inherited case —
61
+ * a label that is a prefix of another label — see HANDOFF § "Mention token
62
+ * chips in the composer".)
63
+ *
64
+ * KNOWN, INHERITED LIMITATION: two people whose labels sanitize to the same
65
+ * token are indistinguishable in a plain-text body, so the FIRST of them
66
+ * listed owns every occurrence of it. That is the same first-match rule
67
+ * `survivingMentions` applies to the ids; it renders a chip either way and
68
+ * never throws.
69
+ */
70
+ export function mentionTokenRanges(
71
+ text: string,
72
+ people: readonly MentionPerson[],
73
+ ): ComposerTokenRange[] {
74
+ const ranges: ComposerTokenRange[] = [];
75
+ const claimed = new Set<string>();
76
+ for (const person of people) {
77
+ const token = mentionToken(person);
78
+ // A person whose label sanitizes to nothing would make every bare `@` a
79
+ // chip; and a token already claimed belongs to the person listed first.
80
+ if (token.length <= 1 || claimed.has(token)) continue;
81
+ claimed.add(token);
82
+ let from = text.indexOf(token);
83
+ while (from !== -1) {
84
+ ranges.push({ kind: 'mention', start: from, end: from + token.length, person });
85
+ from = text.indexOf(token, from + token.length);
86
+ }
87
+ }
88
+ return ranges;
89
+ }
90
+
91
+ /**
92
+ * The one list the overlay paints: every source's ranges, in document order,
93
+ * with overlaps resolved DETERMINISTICALLY and stale ranges dropped.
94
+ *
95
+ * `groups` is in priority order (earlier wins a tie). The rules, applied in
96
+ * this order at each position:
97
+ *
98
+ * 1. a range outside `[0, text.length)`, or empty/inverted, is dropped —
99
+ * these are ranges computed for a text the composer has since changed;
100
+ * 2. earlier `start` wins;
101
+ * 3. at the same start, the LONGER range wins (so `@Marcus Chen` beats a
102
+ * `@Marcus` that is also tagged, rather than chipping half of it);
103
+ * 4. at the same start and length, the earlier group wins;
104
+ * 5. a range that begins inside one already kept is dropped outright — the
105
+ * overlay is a sequence of non-overlapping spans and nothing may nest.
106
+ *
107
+ * With a single skill source this reproduces the pre-refactor loop exactly
108
+ * (which dropped a token whose `start` fell behind the cursor or whose `end`
109
+ * ran past the text); its ranges arrive sorted and non-overlapping, so rules
110
+ * 2-5 never fire.
111
+ */
112
+ export function mergeTokenRanges(
113
+ text: string,
114
+ groups: readonly (readonly ComposerTokenRange[])[],
115
+ ): ComposerTokenRange[] {
116
+ const candidates: { range: ComposerTokenRange; priority: number }[] = [];
117
+ groups.forEach((group, priority) => {
118
+ for (const range of group) {
119
+ if (!Number.isInteger(range.start) || !Number.isInteger(range.end)) continue;
120
+ if (range.start < 0 || range.end > text.length || range.end <= range.start) continue;
121
+ candidates.push({ range, priority });
122
+ }
123
+ });
124
+ candidates.sort(
125
+ (a, b) =>
126
+ a.range.start - b.range.start ||
127
+ b.range.end - a.range.end ||
128
+ a.priority - b.priority,
129
+ );
130
+ const merged: ComposerTokenRange[] = [];
131
+ let pos = 0;
132
+ for (const { range } of candidates) {
133
+ if (range.start < pos) continue;
134
+ merged.push(range);
135
+ pos = range.end;
136
+ }
137
+ return merged;
138
+ }
package/utils/mentions.ts CHANGED
@@ -20,6 +20,12 @@ export interface MentionPerson {
20
20
  readonly label: string;
21
21
  /** Right-aligned hint shown after the label (an email, "Agent"), or null. */
22
22
  readonly detail: string | null;
23
+ /**
24
+ * Optional avatar drawn before the label (0.43.2): an image when `url` is
25
+ * given, else `initials` on a tinted disc (`tint` is any CSS color; absent
26
+ * means the muted surface). Absent → no avatar column, the 0.43.1 row.
27
+ */
28
+ readonly avatar?: { readonly url?: string; readonly initials?: string; readonly tint?: string };
23
29
  /**
24
30
  * Whether this person can open the document. Host data: rows render
25
31
  * identically either way, and a `false` row is only special when the host
@@ -37,6 +43,22 @@ export interface MentionSource {
37
43
  * to keep the menu closed instead.
38
44
  */
39
45
  readonly emptyNotice?: string | null;
46
+ /** Optional heading drawn above the list ("People in this workspace"). Absent → no heading row. */
47
+ readonly heading?: string | null;
48
+ /**
49
+ * Optional class appended to each `@Label` chip the composer paints in its
50
+ * text (0.44.0), for a host that wants its own chip look.
51
+ *
52
+ * THE METRIC RULE IS THE HOST'S TO KEEP: the chip is painted by an overlay
53
+ * mirrored behind a transparent-text textarea, so it may change COLOR,
54
+ * BACKGROUND, BORDER-RADIUS, BOX-SHADOW and TEXT-DECORATION only. Anything
55
+ * that moves a glyph — padding, margin, border width, font-weight,
56
+ * letter-spacing, font-size — drifts the painted text off the textarea's
57
+ * own layout and takes the caret with it. Fake a pill's breathing room
58
+ * with `box-shadow: 0 0 0 Npx <background>`, which paints without
59
+ * occupying space.
60
+ */
61
+ readonly tokenClassName?: string;
40
62
  /** Fires on every text change with the ids whose token still survives in the body. */
41
63
  readonly onMentionsChange?: (ids: readonly string[]) => void;
42
64
  /**