@atlaskit/editor-plugin-show-diff 16.0.1 → 16.0.3

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/afm-products/tsconfig.json +3 -0
  3. package/dist/cjs/pm-plugins/calculateDiff/calculateDiffDecorations.js +91 -51
  4. package/dist/cjs/pm-plugins/decorations/createContributorTagWidget.js +20 -1
  5. package/dist/cjs/pm-plugins/decorations/extractContributorTags.js +121 -21
  6. package/dist/cjs/pm-plugins/decorations/utils/getAttrChangeRanges.js +19 -8
  7. package/dist/cjs/pm-plugins/decorations/utils/getMarkChangeRanges.js +18 -4
  8. package/dist/cjs/pm-plugins/decorations/utils/mapStepRangeToFinal.js +34 -0
  9. package/dist/cjs/pm-plugins/main.js +18 -0
  10. package/dist/cjs/ui/ContributorTag/buildContributorTagDom.js +51 -5
  11. package/dist/cjs/ui/ContributorTag/contributorLabel.js +55 -0
  12. package/dist/cjs/ui/ContributorTag/contributorTagController.js +40 -43
  13. package/dist/cjs/ui/ContributorTag/messages.js +5 -0
  14. package/dist/cjs/ui/DiffNavigation/announceActiveDiff.js +54 -0
  15. package/dist/cjs/ui/DiffNavigation/messages.js +19 -0
  16. package/dist/es2019/pm-plugins/calculateDiff/calculateDiffDecorations.js +48 -13
  17. package/dist/es2019/pm-plugins/decorations/createContributorTagWidget.js +18 -1
  18. package/dist/es2019/pm-plugins/decorations/extractContributorTags.js +70 -10
  19. package/dist/es2019/pm-plugins/decorations/utils/getAttrChangeRanges.js +16 -0
  20. package/dist/es2019/pm-plugins/decorations/utils/getMarkChangeRanges.js +12 -4
  21. package/dist/es2019/pm-plugins/decorations/utils/mapStepRangeToFinal.js +28 -0
  22. package/dist/es2019/pm-plugins/main.js +18 -0
  23. package/dist/es2019/ui/ContributorTag/buildContributorTagDom.js +48 -4
  24. package/dist/es2019/ui/ContributorTag/contributorLabel.js +49 -0
  25. package/dist/es2019/ui/ContributorTag/contributorTagController.js +39 -44
  26. package/dist/es2019/ui/ContributorTag/messages.js +5 -0
  27. package/dist/es2019/ui/DiffNavigation/announceActiveDiff.js +49 -0
  28. package/dist/es2019/ui/DiffNavigation/messages.js +13 -0
  29. package/dist/esm/pm-plugins/calculateDiff/calculateDiffDecorations.js +91 -51
  30. package/dist/esm/pm-plugins/decorations/createContributorTagWidget.js +20 -1
  31. package/dist/esm/pm-plugins/decorations/extractContributorTags.js +121 -21
  32. package/dist/esm/pm-plugins/decorations/utils/getAttrChangeRanges.js +19 -8
  33. package/dist/esm/pm-plugins/decorations/utils/getMarkChangeRanges.js +17 -4
  34. package/dist/esm/pm-plugins/decorations/utils/mapStepRangeToFinal.js +28 -0
  35. package/dist/esm/pm-plugins/main.js +18 -0
  36. package/dist/esm/ui/ContributorTag/buildContributorTagDom.js +50 -4
  37. package/dist/esm/ui/ContributorTag/contributorLabel.js +49 -0
  38. package/dist/esm/ui/ContributorTag/contributorTagController.js +41 -44
  39. package/dist/esm/ui/ContributorTag/messages.js +5 -0
  40. package/dist/esm/ui/DiffNavigation/announceActiveDiff.js +48 -0
  41. package/dist/esm/ui/DiffNavigation/messages.js +13 -0
  42. package/dist/types/pm-plugins/decorations/extractContributorTags.d.ts +14 -1
  43. package/dist/types/pm-plugins/decorations/utils/getAttrChangeRanges.d.ts +3 -0
  44. package/dist/types/pm-plugins/decorations/utils/getMarkChangeRanges.d.ts +4 -1
  45. package/dist/types/pm-plugins/decorations/utils/mapStepRangeToFinal.d.ts +8 -0
  46. package/dist/types/showDiffPluginType.d.ts +7 -1
  47. package/dist/types/ui/ContributorTag/buildContributorTagDom.d.ts +12 -2
  48. package/dist/types/ui/ContributorTag/contributorLabel.d.ts +13 -0
  49. package/dist/types/ui/ContributorTag/contributorTagController.d.ts +11 -0
  50. package/dist/types/ui/ContributorTag/messages.d.ts +1 -0
  51. package/dist/types/ui/DiffNavigation/announceActiveDiff.d.ts +18 -0
  52. package/dist/types/ui/DiffNavigation/messages.d.ts +5 -0
  53. package/package.json +5 -4
@@ -39,7 +39,24 @@ export const mountContributorTag = ({
39
39
  diffId
40
40
  });
41
41
  controller.mount(host);
42
- return controller;
42
+
43
+ /**
44
+ * Kept alive when ProseMirror only rebuilt the widget desc around this same host: a document
45
+ * change remaps the decoration and fires `destroy`, but an element `toDOM` is reused verbatim
46
+ * and runs no mount callback, so tearing down here left an active change with no tag
47
+ * (EDITOR-8971).
48
+ *
49
+ * Deferred, because the host is still attached while that update is in flight. A host outside a
50
+ * live editor root is a real removal — including the view being destroyed, which drops the root.
51
+ */
52
+ return {
53
+ destroy: () => queueMicrotask(() => {
54
+ var _mountContext$getEdit;
55
+ if (!host.isConnected || !((_mountContext$getEdit = mountContext.getEditorRoot()) !== null && _mountContext$getEdit !== void 0 && _mountContext$getEdit.contains(host))) {
56
+ controller.destroy();
57
+ }
58
+ })
59
+ };
43
60
  };
44
61
  export const unmountContributorTag = mount => {
45
62
  mount === null || mount === void 0 ? void 0 : mount.destroy();
@@ -34,12 +34,44 @@ const leadsReplacement = widget => fg('confluence_ncs_step_diffing_version_histo
34
34
  */
35
35
  const isSameBlockChange = (inner, block) => inner.spec.attributionKey === block.spec.attributionKey && (block.from <= inner.from && inner.to <= block.to || inner.from <= block.from && block.to <= inner.to);
36
36
 
37
+ /**
38
+ * Which of two changes in the same range leads, and so is the one a tag captions. Position first,
39
+ * then `side`, since deleted content shares its `from` with the content that replaced it and paints
40
+ * above; then the narrower range, so a change nested in another captions itself.
41
+ */
42
+ const byLeadingPosition = (a, b) => {
43
+ var _a$decoration$spec$si, _b$decoration$spec$si;
44
+ return a.decoration.from - b.decoration.from || ((_a$decoration$spec$si = a.decoration.spec.side) !== null && _a$decoration$spec$si !== void 0 ? _a$decoration$spec$si : 0) - ((_b$decoration$spec$si = b.decoration.spec.side) !== null && _b$decoration$spec$si !== void 0 ? _b$decoration$spec$si : 0) || a.decoration.to - a.decoration.from - (b.decoration.to - b.decoration.from);
45
+ };
46
+
47
+ /** The targets a navigation range wholly covers, leader first. */
48
+ const containedBy = (targets, {
49
+ from,
50
+ to
51
+ }) => targets.filter(({
52
+ decoration
53
+ }) => from <= decoration.from && decoration.to <= to).sort(byLeadingPosition);
54
+
55
+ /**
56
+ * The one tag a navigation step reveals. `spec.isActive` is no use here: it covers everything the
57
+ * group's union range touches, so several tags revealed over each other (EDITOR-8971). Hence
58
+ * containment, and the leading change of what it covers.
59
+ */
60
+ const resolveActiveTarget = (surviving, activeIndexPos) => containedBy(surviving, activeIndexPos)[0];
61
+
37
62
  /**
38
63
  * One model per diff decoration whose attribution resolves to a supplied contributor; anything
39
64
  * unattributed, untaggable or unresolved is skipped. A replacement's two decorations are collapsed
40
65
  * into a single tag, on whichever half renders first — see `isSameChange` and `leadsReplacement`.
66
+ *
67
+ * `activeIndexPos`, when given, reveals exactly one of the tags — see `resolveActiveTarget`.
68
+ * Absent, each tag keeps the active state its own decoration was drawn with.
69
+ *
70
+ * `stops`, when given, is the navigation stop list the step buttons walk
71
+ * (`getScrollableDecorations`). One contributor cannot hold two tags in one stop, since only the
72
+ * leading one would ever be reachable. Absent, every decoration keeps its own tag.
41
73
  */
42
- export const extractContributorTags = (decorations, contributors) => {
74
+ export const extractContributorTags = (decorations, contributors, activeIndexPos, stops) => {
43
75
  if (!contributors) {
44
76
  return [];
45
77
  }
@@ -77,10 +109,15 @@ export const extractContributorTags = (decorations, contributors) => {
77
109
  const linkedDiffIds = new Map();
78
110
  const folded = new Set();
79
111
  const fold = (host, target) => {
80
- var _linkedDiffIds$get;
112
+ var _linkedDiffIds$get, _linkedDiffIds$get2;
81
113
  folded.add(target);
82
114
  const hostDiffId = host.decoration.spec.diffId;
83
- linkedDiffIds.set(hostDiffId, [...((_linkedDiffIds$get = linkedDiffIds.get(hostDiffId)) !== null && _linkedDiffIds$get !== void 0 ? _linkedDiffIds$get : []), target.decoration.spec.diffId]);
115
+ const targetDiffId = target.decoration.spec.diffId;
116
+ // A target that already hosts folded tags hands them up, so no hover target is orphaned when
117
+ // one host folds into another.
118
+ const inherited = (_linkedDiffIds$get = linkedDiffIds.get(targetDiffId)) !== null && _linkedDiffIds$get !== void 0 ? _linkedDiffIds$get : [];
119
+ linkedDiffIds.delete(targetDiffId);
120
+ linkedDiffIds.set(hostDiffId, [...((_linkedDiffIds$get2 = linkedDiffIds.get(hostDiffId)) !== null && _linkedDiffIds$get2 !== void 0 ? _linkedDiffIds$get2 : []), targetDiffId, ...inherited]);
84
121
  };
85
122
  for (const target of targets) {
86
123
  const {
@@ -120,15 +157,38 @@ export const extractContributorTags = (decorations, contributors) => {
120
157
  fold(host, target);
121
158
  }
122
159
  }
123
- return targets.filter(target => !folded.has(target)).map(({
124
- contributor,
125
- decoration: {
126
- spec
160
+
161
+ // One tag per contributor per navigation stop. The folds above only catch a replacement's two
162
+ // halves and a block's own highlights; two of one contributor's changes that merely touch are a
163
+ // single stop (`groupTouchingDecorations`) yet kept a tag each, so the stop's trailing tag was
164
+ // drawn but could never be stepped to (EDITOR-8971). It folds into the leading tag of its
165
+ // contributor, staying a hover target. Contributors are kept apart: a stop spanning two of them
166
+ // still captions each, rather than crediting one for the other's change.
167
+ for (const stop of stops !== null && stops !== void 0 ? stops : []) {
168
+ const byContributor = new Map();
169
+ for (const target of containedBy(targets.filter(target => !folded.has(target)), stop)) {
170
+ var _target$decoration$sp, _byContributor$get;
171
+ const key = (_target$decoration$sp = target.decoration.spec.attributionKey) !== null && _target$decoration$sp !== void 0 ? _target$decoration$sp : '';
172
+ byContributor.set(key, [...((_byContributor$get = byContributor.get(key)) !== null && _byContributor$get !== void 0 ? _byContributor$get : []), target]);
127
173
  }
128
- }) => {
174
+ for (const [leader, ...trailing] of byContributor.values()) {
175
+ trailing.forEach(target => fold(leader, target));
176
+ }
177
+ }
178
+ const surviving = targets.filter(target => !folded.has(target));
179
+ const activeTarget = activeIndexPos === undefined ? undefined : resolveActiveTarget(surviving, activeIndexPos);
180
+ return surviving.map(target => {
181
+ const {
182
+ contributor,
183
+ decoration: {
184
+ spec
185
+ }
186
+ } = target;
129
187
  const connected = contributor.connectedToKey ? contributors[contributor.connectedToKey] : undefined;
130
188
  const connectedContributor = connected ? toTagContributor(connected) : undefined;
131
189
  const linked = linkedDiffIds.get(spec.diffId);
190
+ // Only the navigated tag reveals; with no active range, the decoration's own state stands.
191
+ const isActive = activeIndexPos === undefined ? spec.isActive : target === activeTarget;
132
192
  return {
133
193
  contributor: toTagContributor(contributor),
134
194
  diffId: spec.diffId,
@@ -138,8 +198,8 @@ export const extractContributorTags = (decorations, contributors) => {
138
198
  ...(spec.colorScheme ? {
139
199
  colorScheme: spec.colorScheme
140
200
  } : {}),
141
- ...(spec.isActive !== undefined ? {
142
- isActive: spec.isActive
201
+ ...(isActive !== undefined ? {
202
+ isActive
143
203
  } : {}),
144
204
  ...(spec.isInserted !== undefined ? {
145
205
  isInserted: spec.isInserted
@@ -49,6 +49,7 @@ export const getAttrChangeRanges = (doc, attrStepContexts, originalDoc) => {
49
49
  return attrStepContexts.map(attrStepContext => {
50
50
  const {
51
51
  afterNode,
52
+ attributionKey,
52
53
  beforeNode,
53
54
  finalPos,
54
55
  originalPos,
@@ -74,6 +75,9 @@ export const getAttrChangeRanges = (doc, attrStepContexts, originalDoc) => {
74
75
  const watchedAttrs = inlineNodeAttrMap[nodeName];
75
76
  if (attrsToCheck.some(v => watchedAttrs.includes(v))) {
76
77
  return {
78
+ ...(attributionKey ? {
79
+ attributionKey
80
+ } : {}),
77
81
  fromB: finalPos,
78
82
  toB: finalPos + nodeAtPos.nodeSize,
79
83
  isInline: true,
@@ -90,6 +94,9 @@ export const getAttrChangeRanges = (doc, attrStepContexts, originalDoc) => {
90
94
  // taskItem node: state attribute change — highlight the taskItem node
91
95
  if (attrsToCheck.some(v => taskItemAttrs.includes(v)) && (nodeAtPos === null || nodeAtPos === void 0 ? void 0 : nodeAtPos.type.name) === 'taskItem') {
92
96
  return {
97
+ ...(attributionKey ? {
98
+ attributionKey
99
+ } : {}),
93
100
  fromB: finalPos,
94
101
  toB: finalPos + nodeAtPos.nodeSize
95
102
  };
@@ -101,6 +108,9 @@ export const getAttrChangeRanges = (doc, attrStepContexts, originalDoc) => {
101
108
  // panel as a "deleted" widget for a before/after comparison.
102
109
  if (attrsToCheck.some(v => panelAttrs.includes(v)) && nodeAtPos && getBaseNodeTypeName(nodeAtPos.type) === 'panel') {
103
110
  return {
111
+ ...(attributionKey ? {
112
+ attributionKey
113
+ } : {}),
104
114
  fromB: finalPos,
105
115
  toB: finalPos + nodeAtPos.nodeSize,
106
116
  ...(originalPos !== undefined && originalNodeAtPos && {
@@ -118,6 +128,9 @@ export const getAttrChangeRanges = (doc, attrStepContexts, originalDoc) => {
118
128
  }
119
129
  const isInline = nodeAtPos.type.name === 'inlineExtension';
120
130
  return {
131
+ ...(attributionKey ? {
132
+ attributionKey
133
+ } : {}),
121
134
  fromB: finalPos,
122
135
  toB: finalPos + nodeAtPos.nodeSize,
123
136
  isInline
@@ -128,6 +141,9 @@ export const getAttrChangeRanges = (doc, attrStepContexts, originalDoc) => {
128
141
  if (attrsToCheck.some(v => mediaAttrs.includes(v)) && $pos.parent.type === doc.type.schema.nodes.mediaSingle) {
129
142
  const startPos = $pos.pos + $pos.parentOffset;
130
143
  return {
144
+ ...(attributionKey ? {
145
+ attributionKey
146
+ } : {}),
131
147
  fromB: startPos,
132
148
  toB: startPos + $pos.parent.nodeSize - 1
133
149
  };
@@ -1,4 +1,6 @@
1
1
  import { AddMarkStep, RemoveMarkStep } from '@atlaskit/editor-prosemirror/transform';
2
+ import { getAttributionKey } from '../colorSchemes/attributions';
3
+ import { mapStepRangeToFinal } from './mapStepRangeToFinal';
2
4
  const extractMarkStep = step => {
3
5
  if (step instanceof AddMarkStep) {
4
6
  return {
@@ -18,10 +20,10 @@ const extractMarkStep = step => {
18
20
  }
19
21
  return undefined;
20
22
  };
21
- export const getMarkChangeRanges = steps => {
23
+ export const getMarkChangeRanges = (steps, stepAttributions = [], stepMaps = []) => {
22
24
  const resultRanges = [];
23
25
  let lastOp;
24
- for (const step of steps) {
26
+ for (const [stepIndex, step] of steps.entries()) {
25
27
  const op = extractMarkStep(step);
26
28
  if (!op) {
27
29
  continue;
@@ -31,9 +33,15 @@ export const getMarkChangeRanges = steps => {
31
33
  if (lastOp && lastOp.from === op.from && lastOp.to === op.to && lastOp.markName === op.markName && lastOp.type !== op.type) {
32
34
  resultRanges.pop();
33
35
  } else {
36
+ const finalRange = mapStepRangeToFinal(op.from, op.to, stepIndex, stepMaps);
37
+ if (!finalRange) {
38
+ lastOp = op;
39
+ continue;
40
+ }
34
41
  resultRanges.push({
35
- fromB: op.from,
36
- toB: op.to
42
+ attributionKey: getAttributionKey(stepAttributions[stepIndex]),
43
+ fromB: finalRange.fromB,
44
+ toB: finalRange.toB
37
45
  });
38
46
  }
39
47
  lastOp = op;
@@ -0,0 +1,28 @@
1
+ import { Mapping } from '@atlaskit/editor-prosemirror/transform';
2
+ // ProseMirror's assoc value chooses which side of an insertion a mapped boundary stays on.
3
+ const FROM_ASSOC = -1;
4
+ const TO_ASSOC = 1;
5
+
6
+ /** Maps a step's range into final-document coordinates when the rollout path supplies step maps. */
7
+ export const mapStepRangeToFinal = (from, to, stepIndex, stepMaps) => {
8
+ if (!stepMaps) {
9
+ return {
10
+ fromB: from,
11
+ toB: to
12
+ };
13
+ }
14
+
15
+ // The range is already in the document immediately after this step, so skip its own map and
16
+ // apply only the maps from subsequent steps.
17
+ const firstSubsequentStepIndex = stepIndex + 1;
18
+ const finalRange = new Mapping(stepMaps.slice(firstSubsequentStepIndex));
19
+ const fromB = finalRange.mapResult(from, FROM_ASSOC);
20
+ const toB = finalRange.mapResult(to, TO_ASSOC);
21
+ if (fromB.deleted && toB.deleted) {
22
+ return undefined;
23
+ }
24
+ return {
25
+ fromB: fromB.pos,
26
+ toB: toB.pos
27
+ };
28
+ };
@@ -4,6 +4,7 @@ import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
4
4
  import { PluginKey } from '@atlaskit/editor-prosemirror/state';
5
5
  import { Step as ProseMirrorStep } from '@atlaskit/editor-prosemirror/transform-override';
6
6
  import { DecorationSet } from '@atlaskit/editor-prosemirror/view';
7
+ import { getActiveDiffAnnouncement } from '../ui/DiffNavigation/announceActiveDiff';
7
8
  import { calculateDiffDecorations } from './calculateDiff/calculateDiffDecorations';
8
9
  import { isDiffDecoration, isDiffDecorationSpec } from './decorations/decorationKeys';
9
10
  import { enforceCustomStepRegisters } from './enforceCustomStepRegisters';
@@ -285,6 +286,23 @@ export const createPlugin = (config, getIntl, api, onEditorView) => {
285
286
  api === null || api === void 0 ? void 0 : api.core.actions.execute(toggleExpandRange(activeDecoration.from, activeDecoration.to, true));
286
287
  }
287
288
  cancelPendingScrollToDecoration = scrollToDecoration(view, scrollableDecorations, pluginState.activeIndex);
289
+
290
+ // Stepping only scrolls — no focus moves and no content changes — so without this a
291
+ // screen-reader user gets silence. Announced through the live region rather than by
292
+ // focusing the change, since the "next change" control has to stay focused to be
293
+ // pressed again. After the scroll above, so the announcement never precedes it.
294
+ if (isExtendedEnabled(pluginState === null || pluginState === void 0 ? void 0 : pluginState.diffType)) {
295
+ const announcement = getActiveDiffAnnouncement({
296
+ activeIndex: pluginState.activeIndex,
297
+ contributorTags: pluginState.contributorTags,
298
+ decorations: scrollableDecorations,
299
+ intl: getIntl()
300
+ });
301
+ if (announcement) {
302
+ var _api$accessibilityUti;
303
+ api === null || api === void 0 ? void 0 : (_api$accessibilityUti = api.accessibilityUtils) === null || _api$accessibilityUti === void 0 ? void 0 : _api$accessibilityUti.actions.ariaNotify(announcement);
304
+ }
305
+ }
288
306
  }
289
307
  },
290
308
  destroy() {
@@ -41,6 +41,30 @@ export const CONTRIBUTOR_TAG_REVEALED_ATTRIBUTE = 'data-revealed';
41
41
  */
42
42
  export const TAG_EXIT_FALLBACK_MS = 1200;
43
43
 
44
+ /**
45
+ * The tooltip's look, inline rather than through `VANILLA_TOOLTIP_DEFAULT_CLASS`: this tooltip is
46
+ * hoisted out of the tag, and that class's rule is scoped under `.ProseMirror` — see
47
+ * `resolveTooltipContainer`. Otherwise `vanillaTooltipDefaultStyles` in editor-core, which is the
48
+ * look every other vanilla tooltip has; keep the two in step.
49
+ */
50
+ export const CONTRIBUTOR_TAG_TOOLTIP_STYLES = {
51
+ boxSizing: 'border-box',
52
+ maxWidth: '240px',
53
+ backgroundColor: "var(--ds-background-neutral-bold, #292A2E)",
54
+ // A `popover` is given one by the UA stylesheet.
55
+ border: 'none',
56
+ borderRadius: "var(--ds-radius-small, 3px)",
57
+ color: "var(--ds-text-inverse, #FFFFFF)",
58
+ font: "var(--ds-font-body-small, normal 400 12px/16px \"Atlassian Sans\", ui-sans-serif, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Ubuntu, \"Helvetica Neue\", sans-serif)",
59
+ fontFamily: "var(--ds-font-family-body, \"Atlassian Sans\", ui-sans-serif, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Ubuntu, \"Helvetica Neue\", sans-serif)",
60
+ overflowWrap: 'break-word',
61
+ paddingBlock: "var(--ds-space-050, 4px)",
62
+ paddingInline: "var(--ds-space-075, 6px)",
63
+ whiteSpace: 'normal',
64
+ // A tooltip is never a hit target — it hangs over the document's own content.
65
+ pointerEvents: 'none'
66
+ };
67
+
44
68
  // Positioned against the host widget its decoration renders on the change's first character:
45
69
  // `bottom: 100%` lifts the tag onto the line above, `inset-inline-start` starts it on that
46
70
  // character.
@@ -124,6 +148,17 @@ const srLabelStyle = convertToInlineCss({
124
148
  userSelect: 'none',
125
149
  whiteSpace: 'nowrap'
126
150
  });
151
+
152
+ /**
153
+ * Ids for the hidden label that the tag's `aria-labelledby` points at. Namespaced and random because
154
+ * the tag renders inside the document and shares the host page's id space — the same reason
155
+ * `VanillaTooltip` does it this way. The counter is a fallback for non-secure contexts, where
156
+ * `crypto.randomUUID` is unavailable.
157
+ */
158
+ const generateLabelId = (() => {
159
+ let count = 0;
160
+ return () => typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? `${CONTRIBUTOR_TAG_CLASS}-label-${crypto.randomUUID()}` : `${CONTRIBUTOR_TAG_CLASS}-label-${count += 1}`;
161
+ })();
127
162
  const createSpan = (doc, style, attributes = {}) => {
128
163
  const span = doc.createElement('span');
129
164
  span.setAttribute('style', style);
@@ -139,21 +174,30 @@ const createSpan = (doc, style, attributes = {}) => {
139
174
  * would need a cast per reference below.
140
175
  */
141
176
  export const buildContributorTagDom = doc => {
177
+ const labelId = generateLabelId();
142
178
  const root = createSpan(doc, constraintStyle);
143
179
  const tag = createSpan(doc, tagStyle, {
144
180
  class: CONTRIBUTOR_TAG_CLASS,
145
181
  'data-testid': CONTRIBUTOR_TAG_TESTID,
146
- // Deliberately no `aria-label`: it would be announced ahead of the element's contents, but
147
- // design requires the contributor *after* the change — hence the visually hidden label.
182
+ // Named by the hidden label rather than `aria-label`, which would be a second copy of the same
183
+ // sentence. `role="note"` takes its name from the author, not its contents, so without this
184
+ // the tag is a focus stop with no accessible name.
185
+ 'aria-labelledby': labelId,
148
186
  role: 'note',
149
- // Focusable so keyboard and screen-reader users can reach the tag and its tooltip.
187
+ // Focusable so keyboard and screen-reader users can reach the tag and its tooltip. Its focus
188
+ // ring is drawn inward — see `contributorTagStyles` in editor-core.
150
189
  tabindex: '0'
151
190
  });
152
191
  const avatars = createSpan(doc, avatarsStyle);
192
+ // `aria-hidden`: the hidden label restates this name in a full sentence, so leaving it in the
193
+ // tree announced the contributor twice ("Priya Changed by Priya").
153
194
  const name = createSpan(doc, nameStyle, {
195
+ 'aria-hidden': 'true',
154
196
  'data-testid': CONTRIBUTOR_TAG_NAME_TESTID
155
197
  });
156
- const srLabel = createSpan(doc, srLabelStyle);
198
+ const srLabel = createSpan(doc, srLabelStyle, {
199
+ id: labelId
200
+ });
157
201
  tag.append(avatars, name, srLabel);
158
202
  root.appendChild(tag);
159
203
  return {
@@ -0,0 +1,49 @@
1
+ import { contributorTagMessages } from './messages';
2
+ const getContributorName = (contributor, formatMessage) => {
3
+ var _contributor$name, _contributor$agentKin;
4
+ const name = (_contributor$name = contributor.name) === null || _contributor$name === void 0 ? void 0 : _contributor$name.trim();
5
+ if (name) {
6
+ return name;
7
+ }
8
+
9
+ // `agentKind` defaults to `'external'` per the plugin type contract; an unidentified external
10
+ // agent falls back to a generic label rather than rendering a nameless tag.
11
+ if (contributor.kind === 'agent' && ((_contributor$agentKin = contributor.agentKind) !== null && _contributor$agentKin !== void 0 ? _contributor$agentKin : 'external') === 'external') {
12
+ return formatMessage(contributorTagMessages.externalAgentName);
13
+ }
14
+ return '';
15
+ };
16
+
17
+ /**
18
+ * Whether the label has to spell out that the contributor is an agent. A sighted user reads that off
19
+ * the `aria-hidden` avatar, so the label is the only other carrier.
20
+ *
21
+ * Excluded: a connected pair, which says it via `changedByConnected`, and an unnamed external agent,
22
+ * whose name `getContributorName` has already resolved to "External agent".
23
+ */
24
+ const isUnaccompaniedNamedAgent = model => {
25
+ var _model$contributor$na;
26
+ return !model.connectedContributor && model.contributor.kind === 'agent' && Boolean((_model$contributor$na = model.contributor.name) === null || _model$contributor$na === void 0 ? void 0 : _model$contributor$na.trim());
27
+ };
28
+ /**
29
+ * The two strings a tag's model resolves to. Shared with `getActiveDiffAnnouncement`, so stepping
30
+ * onto a change says the same sentence as the tag pinned to it rather than a copy free to drift.
31
+ */
32
+ export const formatContributorLabel = (model, formatMessage) => {
33
+ const isPrimaryAgent = model.contributor.kind === 'agent';
34
+ const primaryName = getContributorName(model.contributor, formatMessage);
35
+ const secondaryName = model.connectedContributor ? getContributorName(model.connectedContributor, formatMessage) : '';
36
+ const agentName = isPrimaryAgent ? primaryName : secondaryName;
37
+ const userName = isPrimaryAgent ? secondaryName : primaryName;
38
+ return {
39
+ fullLabel: model.connectedContributor ? formatMessage(contributorTagMessages.changedByConnected, {
40
+ agentName,
41
+ userName
42
+ }) : formatMessage(isUnaccompaniedNamedAgent(model) ? contributorTagMessages.changedByAgent : contributorTagMessages.changedBy, {
43
+ name: primaryName
44
+ }),
45
+ // A connected pair only ever shows the agent; the pair is spelled out in `fullLabel`, because
46
+ // the tag is capped at `MAX_TAG_WIDTH` and two names rarely fit inside it.
47
+ visibleName: model.connectedContributor ? agentName || primaryName : primaryName
48
+ };
49
+ };
@@ -3,24 +3,9 @@ import { bind, bindAll } from 'bind-event-listener';
3
3
  import { VanillaTooltip } from '@atlaskit/editor-common/vanilla-tooltip';
4
4
  import { getAccentTokens } from '../../pm-plugins/decorations/colorSchemes/factory';
5
5
  import { colorSchemeRegistry } from '../../pm-plugins/decorations/colorSchemes/schemes';
6
- import { buildContributorTagDom, CONTRIBUTOR_TAG_REVEALED_ATTRIBUTE, TAG_EXIT_FALLBACK_MS } from './buildContributorTagDom';
6
+ import { buildContributorTagDom, CONTRIBUTOR_TAG_REVEALED_ATTRIBUTE, CONTRIBUTOR_TAG_TOOLTIP_STYLES, TAG_EXIT_FALLBACK_MS } from './buildContributorTagDom';
7
7
  import { contributorAvatarRenderer } from './contributorAvatarRenderer';
8
- import { contributorTagMessages } from './messages';
9
- const getContributorName = (contributor, formatMessage) => {
10
- var _contributor$name, _contributor$agentKin;
11
- const name = (_contributor$name = contributor.name) === null || _contributor$name === void 0 ? void 0 : _contributor$name.trim();
12
- if (name) {
13
- return name;
14
- }
15
-
16
- // `agentKind` defaults to `'external'` per the plugin type contract; an unidentified external
17
- // agent falls back to a generic label rather than rendering a nameless tag.
18
- if (contributor.kind === 'agent' && ((_contributor$agentKin = contributor.agentKind) !== null && _contributor$agentKin !== void 0 ? _contributor$agentKin : 'external') === 'external') {
19
- return formatMessage(contributorTagMessages.externalAgentName);
20
- }
21
- return '';
22
- };
23
-
8
+ import { formatContributorLabel } from './contributorLabel';
24
9
  /**
25
10
  * Resolves the tag's accent from the same scheme value the highlight was drawn from, so the colour
26
11
  * the two carry can never drift — only the tone does, which is `getAccentTokens`'s to pick.
@@ -99,8 +84,8 @@ const fadeTagOut = (tag, release) => {
99
84
 
100
85
  /** The contributors a tag draws, keyed so a republished model can be recognised as the same pair. */
101
86
  const identityOf = model => [model === null || model === void 0 ? void 0 : model.contributor, model === null || model === void 0 ? void 0 : model.connectedContributor].map(contributor => {
102
- var _contributor$agentKin2, _contributor$avatarUr;
103
- return contributor ? `${contributor.kind}:${(_contributor$agentKin2 = contributor.agentKind) !== null && _contributor$agentKin2 !== void 0 ? _contributor$agentKin2 : ''}:${contributor.name}:${(_contributor$avatarUr = contributor.avatarUrl) !== null && _contributor$avatarUr !== void 0 ? _contributor$avatarUr : ''}` : '';
87
+ var _contributor$agentKin, _contributor$avatarUr;
88
+ return contributor ? `${contributor.kind}:${(_contributor$agentKin = contributor.agentKind) !== null && _contributor$agentKin !== void 0 ? _contributor$agentKin : ''}:${contributor.name}:${(_contributor$avatarUr = contributor.avatarUrl) !== null && _contributor$avatarUr !== void 0 ? _contributor$avatarUr : ''}` : '';
104
89
  }).join('|');
105
90
 
106
91
  /**
@@ -322,24 +307,12 @@ export class ContributorTagController {
322
307
  return;
323
308
  }
324
309
  const {
325
- formatMessage
326
- } = this.options.getIntl();
327
- const isPrimaryAgent = model.contributor.kind === 'agent';
328
- const primaryName = getContributorName(model.contributor, formatMessage);
329
- const secondaryName = model.connectedContributor ? getContributorName(model.connectedContributor, formatMessage) : '';
330
- const agentName = isPrimaryAgent ? primaryName : secondaryName;
331
- const userName = isPrimaryAgent ? secondaryName : primaryName;
332
-
333
- // A connected pair only ever shows the agent; the pair is spelled out in the label, because the
334
- // tag is capped at `MAX_TAG_WIDTH` and two names rarely fit inside it.
335
- this.dom.name.textContent = model.connectedContributor ? agentName || primaryName : primaryName;
336
- this.fullLabel = model.connectedContributor ? formatMessage(contributorTagMessages.changedByConnected, {
337
- agentName,
338
- userName
339
- }) : formatMessage(contributorTagMessages.changedBy, {
340
- name: primaryName
341
- });
342
- this.dom.srLabel.textContent = this.fullLabel;
310
+ fullLabel,
311
+ visibleName
312
+ } = formatContributorLabel(model, this.options.getIntl().formatMessage);
313
+ this.dom.name.textContent = visibleName;
314
+ this.fullLabel = fullLabel;
315
+ this.dom.srLabel.textContent = fullLabel;
343
316
  }
344
317
  accentOf(model) {
345
318
  var _model$isInserted;
@@ -395,15 +368,37 @@ export class ContributorTagController {
395
368
  if (!content || !this.dom) {
396
369
  return;
397
370
  }
398
- this.tooltip = new VanillaTooltip(this.dom.tag, content, undefined,
399
- // `ak-editor-vanilla-tooltip-default` opts into the shared `VanillaTooltip` look, defined as
400
- // `vanillaTooltipDefaultStyles` in both EditorContentContainer stylesheets — keep in sync when
401
- // renaming. Those styles are scoped under `.ProseMirror`, and the tag renders inside it.
402
- 'ak-editor-vanilla-tooltip-default');
371
+ this.tooltip = new VanillaTooltip(this.dom.tag, content,
372
+ // Generated id.
373
+ undefined,
374
+ // No class: the look is inline, because a hoisted tooltip is outside the `.ProseMirror`
375
+ // scope `VANILLA_TOOLTIP_DEFAULT_CLASS` is keyed on — see `CONTRIBUTOR_TAG_TOOLTIP_STYLES`.
376
+ '',
377
+ // Default delay, no `onShow`, and the default `top` placement.
378
+ undefined, CONTRIBUTOR_TAG_TOOLTIP_STYLES, undefined, undefined, this.resolveTooltipContainer());
403
379
  // `VanillaTooltip` sets `aria-describedby` on its trigger, which would announce the label a
404
- // second time — the visually hidden child already announces it, after the change as design
405
- // requires.
380
+ // second time — the hidden child already announces it, and names the tag with it.
406
381
  this.dom.tag.removeAttribute('aria-describedby');
382
+ // Out of the tag but still in the document, so its text would otherwise be a third copy of the
383
+ // same sentence. It stays visible for sighted users, who need the part of the name the tag
384
+ // clipped.
385
+ this.tooltip.element.setAttribute('aria-hidden', 'true');
386
+ }
387
+
388
+ /**
389
+ * Where the tooltip is appended, rather than inside the tag.
390
+ *
391
+ * Popper and the browser only agree on a top-layer popover's origin when nothing above it is
392
+ * transformed, and the tag hangs under the editor's own transformed nodes — inside a wide image
393
+ * that is `.rich-media-item`, whose `translateX(-50%)` threw the tooltip off screen
394
+ * (EDITOR-8971). The content area is the nearest ancestor with none of that above it, and keeps
395
+ * the tooltip inside the editor it belongs to; the document body covers an appearance that has
396
+ * no content area.
397
+ */
398
+ resolveTooltipContainer() {
399
+ var _editorRoot$closest;
400
+ const editorRoot = this.options.getEditorRoot();
401
+ return (_editorRoot$closest = editorRoot === null || editorRoot === void 0 ? void 0 : editorRoot.closest('.ak-editor-content-area')) !== null && _editorRoot$closest !== void 0 ? _editorRoot$closest : editorRoot === null || editorRoot === void 0 ? void 0 : editorRoot.ownerDocument.body;
407
402
  }
408
403
 
409
404
  /**
@@ -5,6 +5,11 @@ export const contributorTagMessages = defineMessages({
5
5
  defaultMessage: 'Changed by {name}',
6
6
  description: 'Screen-reader label and tooltip for the tag pinned to a highlighted change in the version-history diff, naming the single person or agent that made the change. The name placeholder is the contributor display name.'
7
7
  },
8
+ changedByAgent: {
9
+ id: 'editor-plugin-show-diff.ContributorTag.changedByAgent',
10
+ defaultMessage: 'Changed by {name}, an AI agent',
11
+ description: 'Screen-reader label and tooltip for the tag pinned to a highlighted change in the version-history diff, when a named AI agent made the change rather than a person. The name placeholder is the agent display name.'
12
+ },
8
13
  changedByConnected: {
9
14
  id: 'editor-plugin-show-diff.ContributorTag.changedByConnected',
10
15
  defaultMessage: 'Changed by {agentName} with {userName}',
@@ -0,0 +1,49 @@
1
+ import { isDiffDecoration } from '../../pm-plugins/decorations/decorationKeys';
2
+ import { formatContributorLabel } from '../ContributorTag/contributorLabel';
3
+ import { diffNavigationMessages } from './messages';
4
+
5
+ /**
6
+ * The tag that captions the given decoration, if any. `linkedDiffIds` is checked as well as
7
+ * `diffId`, because a replacement's two decorations share one tag and navigation can land on either.
8
+ */
9
+ const findTagFor = (decoration, contributorTags) => {
10
+ if (!(contributorTags !== null && contributorTags !== void 0 && contributorTags.length) || !isDiffDecoration(decoration)) {
11
+ return undefined;
12
+ }
13
+ const {
14
+ diffId
15
+ } = decoration.spec;
16
+ return contributorTags.find(tag => {
17
+ var _tag$linkedDiffIds;
18
+ return tag.diffId === diffId || ((_tag$linkedDiffIds = tag.linkedDiffIds) === null || _tag$linkedDiffIds === void 0 ? void 0 : _tag$linkedDiffIds.includes(diffId));
19
+ });
20
+ };
21
+
22
+ /**
23
+ * What a screen reader should say once the reader has stepped onto a change: where they are in the
24
+ * diff, and — when the plugin can credit the change — who made it. Stepping otherwise only scrolls,
25
+ * which is silent.
26
+ *
27
+ * Returns `undefined` for an index that resolves no change, so the caller says nothing at all
28
+ * rather than "Change 4 of 3".
29
+ */
30
+ export const getActiveDiffAnnouncement = ({
31
+ activeIndex,
32
+ contributorTags,
33
+ decorations,
34
+ intl
35
+ }) => {
36
+ const activeDecoration = decorations[activeIndex];
37
+ if (!activeDecoration) {
38
+ return undefined;
39
+ }
40
+ const position = {
41
+ index: activeIndex + 1,
42
+ total: decorations.length
43
+ };
44
+ const tag = findTagFor(activeDecoration, contributorTags);
45
+ return tag ? intl.formatMessage(diffNavigationMessages.activeChangeWithContributor, {
46
+ ...position,
47
+ contributor: formatContributorLabel(tag, intl.formatMessage).fullLabel
48
+ }) : intl.formatMessage(diffNavigationMessages.activeChange, position);
49
+ };
@@ -0,0 +1,13 @@
1
+ import { defineMessages } from 'react-intl';
2
+ export const diffNavigationMessages = defineMessages({
3
+ activeChange: {
4
+ id: 'editor-plugin-show-diff.DiffNavigation.activeChange',
5
+ defaultMessage: 'Change {index} of {total}',
6
+ description: 'Announced to screen readers when the reader steps to the next or previous change in the version-history diff. The index placeholder is the position of the change stepped to, counting from 1; total is how many changes the diff has.'
7
+ },
8
+ activeChangeWithContributor: {
9
+ id: 'editor-plugin-show-diff.DiffNavigation.activeChangeWithContributor',
10
+ defaultMessage: 'Change {index} of {total}. {contributor}',
11
+ description: 'Announced to screen readers when the reader steps to the next or previous change in the version-history diff and that change is credited to someone. The index and total placeholders are as in activeChange. The contributor placeholder is an already-translated sentence (changedBy, changedByAgent or changedByConnected), so it must be kept whole and only repositioned.'
12
+ }
13
+ });