box-annotations 5.2.1-beta.13 → 5.2.1-beta.17

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 (34) hide show
  1. package/dist/annotations.css +1 -1
  2. package/dist/annotations.js +1 -1
  3. package/package.json +13 -13
  4. package/src/@types/api.ts +3 -2
  5. package/src/@types/events.ts +27 -10
  6. package/src/@types/model.ts +1 -0
  7. package/src/adapters/threadedAnnotationsAdapters.ts +17 -1
  8. package/src/api/APIFactory.ts +6 -1
  9. package/src/api/types.ts +23 -1
  10. package/src/common/BaseAnnotator.scss +2 -1
  11. package/src/common/BaseAnnotator.ts +27 -2
  12. package/src/components/Popups/PopupV2.tsx +46 -10
  13. package/src/document/DocumentAnnotator.ts +1 -1
  14. package/src/document/docUtil.ts +2 -1
  15. package/src/highlight/HighlightAnnotations.tsx +27 -14
  16. package/src/highlight/HighlightContainer.tsx +3 -0
  17. package/src/highlight/HighlightCreatorManager.ts +8 -5
  18. package/src/highlight/HighlightListener.ts +4 -2
  19. package/src/highlight/HighlightManager.tsx +13 -2
  20. package/src/popup/PopupLayer.tsx +32 -4
  21. package/src/store/annotations/actions.ts +89 -0
  22. package/src/store/annotations/reducer.ts +48 -0
  23. package/src/store/eventing/create.ts +3 -3
  24. package/src/store/eventing/delete.ts +27 -0
  25. package/src/store/eventing/middleware.ts +40 -4
  26. package/src/store/eventing/replyCreate.ts +34 -0
  27. package/src/store/eventing/replyDelete.ts +35 -0
  28. package/src/store/eventing/replyUpdate.ts +39 -0
  29. package/src/store/eventing/types.ts +4 -4
  30. package/src/store/eventing/update.ts +29 -0
  31. package/src/store/highlight/actions.ts +32 -3
  32. package/src/store/highlight/types.ts +1 -0
  33. package/src/store/options/selectors.ts +2 -2
  34. package/src/store/options/types.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "box-annotations",
3
- "version": "5.2.1-beta.13",
3
+ "version": "5.2.1-beta.17",
4
4
  "description": "Box Annotations",
5
5
  "author": "Box (https://www.box.com/)",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -28,13 +28,13 @@
28
28
  "@babel/preset-react": "^7.25.9",
29
29
  "@babel/preset-typescript": "^7.25.9",
30
30
  "@box/frontend": "^10.0.0",
31
- "@box/blueprint-web": "^14.30.0",
32
- "@box/blueprint-web-assets": "^4.117.2",
33
- "@box/collaboration-popover": "^1.62.3",
31
+ "@box/blueprint-web": "^14.38.0",
32
+ "@box/blueprint-web-assets": "^4.120.5",
33
+ "@box/collaboration-popover": "^1.62.27",
34
34
  "@box/languages": "^1.1.0",
35
- "@box/readable-time": "^1.41.3",
36
- "@box/threaded-annotations": "^1.89.1",
37
- "@box/user-selector": "^1.76.3",
35
+ "@box/readable-time": "^1.41.27",
36
+ "@box/threaded-annotations": "^1.93.2",
37
+ "@box/user-selector": "^1.76.27",
38
38
  "@cfaester/enzyme-adapter-react-18": "^0.8.0",
39
39
  "@commitlint/cli": "^8.3.5",
40
40
  "@commitlint/config-conventional": "^8.2.0",
@@ -128,12 +128,12 @@
128
128
  "worker-farm": "^1.7.0"
129
129
  },
130
130
  "peerDependencies": {
131
- "@box/blueprint-web": "^14.30.0",
132
- "@box/blueprint-web-assets": "^4.117.2",
133
- "@box/collaboration-popover": "^1.62.3",
134
- "@box/readable-time": "^1.41.3",
135
- "@box/threaded-annotations": "^1.89.1",
136
- "@box/user-selector": "^1.76.3"
131
+ "@box/blueprint-web": "^14.38.0",
132
+ "@box/blueprint-web-assets": "^4.120.5",
133
+ "@box/collaboration-popover": "^1.62.27",
134
+ "@box/readable-time": "^1.41.27",
135
+ "@box/threaded-annotations": "^1.93.2",
136
+ "@box/user-selector": "^1.76.27"
137
137
  },
138
138
  "scripts": {
139
139
  "build": "yarn setup && yarn build:prod:dist",
package/src/@types/api.ts CHANGED
@@ -10,5 +10,6 @@ export interface Permissions {
10
10
  }
11
11
 
12
12
  export type TokenLiteral = null | undefined | string | { read: string; write?: string };
13
- export type TokenResolver = () => TokenLiteral | Promise<TokenLiteral>;
14
- export type Token = TokenLiteral | TokenResolver;
13
+ export type TokenMap = { [typedFileId: string]: TokenLiteral };
14
+ export type TokenResolver = (typedFileId?: string) => TokenLiteral | TokenMap | Promise<TokenLiteral | TokenMap>;
15
+ export type Token = TokenLiteral | TokenMap | TokenResolver;
@@ -1,19 +1,36 @@
1
1
  enum Event {
2
- ACTIVE_CHANGE = 'annotations_active_change',
2
+ // Inbound: emitted by the host; box-annotations listens.
3
3
  ACTIVE_SET = 'annotations_active_set',
4
- CREATOR_STAGED_CHANGE = 'creator_staged_change',
5
- CREATOR_STATUS_CHANGE = 'creator_status_change',
4
+ ANNOTATION_REMOVE = 'annotations_remove',
5
+ BOUNDING_BOX_HIGHLIGHTS_SET = 'bounding_box_highlights_set',
6
+ BOUNDING_BOX_HIGHLIGHT_SELECT = 'bounding_box_highlight_select',
7
+ COLOR_SET = 'annotations_color_set',
8
+ VIEW_MODE_SET = 'view_mode_set',
9
+ VISIBLE_SET = 'annotations_visible_set',
10
+
11
+ // Outbound: emitted by box-annotations; the host listens.
12
+ ACTIVE_CHANGE = 'annotations_active_change',
6
13
  ANNOTATION_CREATE = 'annotations_create',
14
+ ANNOTATION_DELETE = 'annotations_delete',
7
15
  ANNOTATION_FETCH_ERROR = 'annotations_fetch_error',
8
- ANNOTATION_REMOVE = 'annotations_remove',
16
+ ANNOTATION_REPLY_CREATE = 'annotations_reply_create',
17
+ ANNOTATION_REPLY_DELETE = 'annotations_reply_delete',
18
+ ANNOTATION_REPLY_UPDATE = 'annotations_reply_update',
19
+ ANNOTATION_UPDATE = 'annotations_update',
9
20
  ANNOTATIONS_INITIALIZED = 'annotations_initialized',
10
21
  ANNOTATIONS_MODE_CHANGE = 'annotations_mode_change',
11
- COLOR_SET = 'annotations_color_set',
12
- BOUNDING_BOX_HIGHLIGHTS_SET = 'bounding_box_highlights_set',
22
+ CREATOR_STAGED_CHANGE = 'creator_staged_change',
23
+ CREATOR_STATUS_CHANGE = 'creator_status_change',
24
+
25
+ // Internal: box-annotations emits and listens to itself (redux state change → DOM side effect).
13
26
  BOUNDING_BOX_HIGHLIGHT_NAVIGATE = 'bounding_box_highlight_navigate',
14
- BOUNDING_BOX_HIGHLIGHT_SELECT = 'bounding_box_highlight_select',
15
- VISIBLE_SET = 'annotations_visible_set',
16
- VIEW_MODE_SET = 'view_mode_set',
27
+ }
28
+
29
+ enum SidebarEvent {
30
+ SIDEBAR_ANNOTATION_UPDATE = 'sidebar.annotations_update',
31
+ SIDEBAR_REPLY_CREATE = 'sidebar.annotations_reply_create',
32
+ SIDEBAR_REPLY_DELETE = 'sidebar.annotations_reply_delete',
33
+ SIDEBAR_REPLY_UPDATE = 'sidebar.annotations_reply_update',
17
34
  }
18
35
 
19
36
  // Existing legacy events, don't rename
@@ -23,4 +40,4 @@ enum LegacyEvent {
23
40
  SCALE = 'scaleannotations',
24
41
  }
25
42
 
26
- export { Event, LegacyEvent };
43
+ export { Event, LegacyEvent, SidebarEvent };
@@ -98,6 +98,7 @@ export interface Reply {
98
98
  created_by: User;
99
99
  id: string;
100
100
  message: string;
101
+ modified_at?: string;
101
102
  parent: {
102
103
  id: string;
103
104
  type: string;
@@ -69,6 +69,20 @@ export const deserializeMentionMarkup = (text: string): DocumentNodeV2 => {
69
69
  return { type: 'doc', content };
70
70
  };
71
71
 
72
+ /**
73
+ * Returns the edit timestamp consumers use to render an edited indicator.
74
+ * Compares parsed instants, not raw strings, so equivalent ISO formats
75
+ * (Z vs +00:00, fractional precision) are treated as unedited.
76
+ */
77
+ const toUpdatedAt = (createdAt: string, modifiedAt: string | undefined): number | undefined => {
78
+ if (!modifiedAt) return undefined;
79
+ const modifiedMs = new Date(modifiedAt).getTime();
80
+ if (Number.isNaN(modifiedMs)) return undefined;
81
+ const createdMs = new Date(createdAt).getTime();
82
+ if (modifiedMs === createdMs) return undefined;
83
+ return modifiedMs;
84
+ };
85
+
72
86
  /**
73
87
  * Converts a box-annotations Reply to a threaded-annotations TextMessageType.
74
88
  */
@@ -83,10 +97,11 @@ export const replyToTextMessage = (reply: Reply): TextMessageTypeV2 => ({
83
97
  message: deserializeMentionMarkup(reply.message),
84
98
  permissions: {
85
99
  canDelete: reply.permissions?.can_delete ?? false,
86
- canEdit: false,
100
+ canEdit: reply.permissions?.can_edit ?? false,
87
101
  canReply: reply.permissions?.can_reply ?? false,
88
102
  canResolve: reply.permissions?.can_resolve ?? false,
89
103
  },
104
+ updatedAt: toUpdatedAt(reply.created_at, reply.modified_at),
90
105
  });
91
106
 
92
107
  // The root message shares the annotation's author and permissions; description
@@ -106,6 +121,7 @@ const descriptionToTextMessage = (annotation: Annotation): TextMessageTypeV2 =>
106
121
  canReply: annotation.permissions?.can_reply ?? false,
107
122
  canResolve: annotation.permissions?.can_resolve ?? false,
108
123
  },
124
+ updatedAt: toUpdatedAt(annotation.created_at, annotation.modified_at),
109
125
  });
110
126
 
111
127
  /**
@@ -1,7 +1,8 @@
1
1
  import Annotations from 'box-ui-elements/es/api/Annotations';
2
2
  import FileCollaborators from 'box-ui-elements/es/api/FileCollaborators';
3
+ import ThreadedComments from 'box-ui-elements/es/api/ThreadedComments';
3
4
  import { DEFAULT_HOSTNAME_API } from 'box-ui-elements/es/constants';
4
- import { AnnotationsAPI, CollaboratorsAPI, APIOptions } from './types';
5
+ import { AnnotationsAPI, CollaboratorsAPI, APIOptions, ThreadedCommentsAPI } from './types';
5
6
 
6
7
  export default class APIFactory {
7
8
  options: APIOptions;
@@ -21,4 +22,8 @@ export default class APIFactory {
21
22
  getCollaboratorsAPI(): CollaboratorsAPI {
22
23
  return new FileCollaborators(this.options);
23
24
  }
25
+
26
+ getThreadedCommentsAPI(): ThreadedCommentsAPI {
27
+ return new ThreadedComments(this.options);
28
+ }
24
29
  }
package/src/api/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Annotation, Collaborator, NewAnnotation, Permissions, Reply, Token } from '../@types';
1
+ import { Annotation, Collaborator, NewAnnotation, Permissions, Reply, ReplyPermissions, Token } from '../@types';
2
2
 
3
3
  export type APICollection<R> = {
4
4
  entries: R[];
@@ -73,6 +73,28 @@ export interface AnnotationsAPI {
73
73
  destroy(): void;
74
74
  }
75
75
 
76
+ export interface ThreadedCommentsAPI {
77
+ deleteComment(args: {
78
+ commentId: string;
79
+ errorCallback: (error: APIError) => void;
80
+ fileId: string | null;
81
+ permissions: ReplyPermissions;
82
+ successCallback: () => void;
83
+ }): void;
84
+
85
+ updateComment(args: {
86
+ commentId: string;
87
+ errorCallback: (error: APIError) => void;
88
+ fileId: string | null;
89
+ message?: string;
90
+ permissions: ReplyPermissions;
91
+ status?: string;
92
+ successCallback: (reply: Reply) => void;
93
+ }): void;
94
+
95
+ destroy(): void;
96
+ }
97
+
76
98
  export interface CollaboratorsAPI {
77
99
  getFileCollaborators(
78
100
  fileId: string | null,
@@ -34,7 +34,8 @@
34
34
  z-index: 10;
35
35
  pointer-events: none;
36
36
 
37
- .ba-Popup {
37
+ .ba-Popup,
38
+ .ba-PopupV2 {
38
39
  z-index: 150;
39
40
  pointer-events: auto;
40
41
  }
@@ -6,7 +6,7 @@ import DeselectManager from './DeselectManager';
6
6
  import EventEmitter from './EventEmitter';
7
7
  import i18n from '../utils/i18n';
8
8
  import messages from '../messages';
9
- import { Event, IntlOptions, LegacyEvent, Permissions } from '../@types';
9
+ import { Event, IntlOptions, LegacyEvent, Permissions, SidebarEvent, Token } from '../@types';
10
10
  import { BoundingBox, getBoundingBoxHighlights } from '../store/boundingBoxHighlights';
11
11
  import { ViewMode } from '../store/options/types';
12
12
  import { Features } from '../BoxAnnotations';
@@ -45,7 +45,7 @@ export type Options = {
45
45
  intl: IntlOptions;
46
46
  locale?: string;
47
47
  onCopyLink?: (params: { annotationId: string; fileVersionId: string }) => void;
48
- token: string;
48
+ token: Token;
49
49
  };
50
50
 
51
51
  export const CSS_CONTAINER_CLASS = 'ba';
@@ -131,6 +131,11 @@ export default class BaseAnnotator extends EventEmitter {
131
131
  this.addListener(Event.BOUNDING_BOX_HIGHLIGHT_SELECT, this.handleSelectBoundingBoxHighlight);
132
132
  this.addListener(Event.VIEW_MODE_SET, this.handleSetViewMode);
133
133
 
134
+ this.addListener(SidebarEvent.SIDEBAR_ANNOTATION_UPDATE, this.handleSidebarAnnotationUpdate);
135
+ this.addListener(SidebarEvent.SIDEBAR_REPLY_CREATE, this.handleSidebarReplyCreate);
136
+ this.addListener(SidebarEvent.SIDEBAR_REPLY_UPDATE, this.handleSidebarReplyUpdate);
137
+ this.addListener(SidebarEvent.SIDEBAR_REPLY_DELETE, this.handleSidebarReplyDelete);
138
+
134
139
  // Load any required data at startup
135
140
  this.hydrate();
136
141
  }
@@ -164,6 +169,10 @@ export default class BaseAnnotator extends EventEmitter {
164
169
  this.removeListener(Event.BOUNDING_BOX_HIGHLIGHT_NAVIGATE, this.handleNavigateBoundingBoxHighlight);
165
170
  this.removeListener(Event.BOUNDING_BOX_HIGHLIGHT_SELECT, this.handleSelectBoundingBoxHighlight);
166
171
  this.removeListener(Event.VIEW_MODE_SET, this.handleSetViewMode);
172
+ this.removeListener(SidebarEvent.SIDEBAR_ANNOTATION_UPDATE, this.handleSidebarAnnotationUpdate);
173
+ this.removeListener(SidebarEvent.SIDEBAR_REPLY_CREATE, this.handleSidebarReplyCreate);
174
+ this.removeListener(SidebarEvent.SIDEBAR_REPLY_UPDATE, this.handleSidebarReplyUpdate);
175
+ this.removeListener(SidebarEvent.SIDEBAR_REPLY_DELETE, this.handleSidebarReplyDelete);
167
176
  }
168
177
 
169
178
  public init(scale = 1, rotation = 0): void {
@@ -357,6 +366,22 @@ export default class BaseAnnotator extends EventEmitter {
357
366
  this.setViewMode(viewMode);
358
367
  };
359
368
 
369
+ protected handleSidebarAnnotationUpdate = (annotation: store.SidebarAnnotationUpdatePayload): void => {
370
+ this.store.dispatch(store.applySidebarAnnotationUpdateAction(annotation));
371
+ };
372
+
373
+ protected handleSidebarReplyCreate = (payload: store.SidebarReplyMutationPayload): void => {
374
+ this.store.dispatch(store.applySidebarReplyCreateAction(payload));
375
+ };
376
+
377
+ protected handleSidebarReplyUpdate = (payload: store.SidebarReplyMutationPayload): void => {
378
+ this.store.dispatch(store.applySidebarReplyUpdateAction(payload));
379
+ };
380
+
381
+ protected handleSidebarReplyDelete = ({ annotationId, id }: { annotationId: string; id: string }): void => {
382
+ this.store.dispatch(store.applySidebarReplyDeleteAction({ annotationId, replyId: id }));
383
+ };
384
+
360
385
  protected hydrate(): void {
361
386
  // Redux dispatch method signature doesn't seem to like async actions
362
387
  this.store.dispatch<any>(store.fetchAnnotationsAction()); // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -21,13 +21,16 @@ import AnnotationCallbacksContext from '../../common/AnnotationCallbacksContext'
21
21
  import {
22
22
  createReplyAction,
23
23
  deleteAnnotationAction,
24
+ deleteReplyAction,
24
25
  setActiveAnnotationIdAction,
25
26
  updateAnnotationAction,
27
+ updateReplyAction,
26
28
  } from '../../store/annotations/actions';
27
29
  import { getAnnotation } from '../../store/annotations/selectors';
28
- import { getApiHost, getFileVersionId, getToken } from '../../store/options';
30
+ import { getApiHost, getFileId, getFileVersionId, getToken } from '../../store/options';
29
31
  import { fetchCollaboratorsAction } from '../../store/users/actions';
30
32
 
33
+ import type { Token, TokenLiteral, TokenMap } from '../../@types';
31
34
  import type { AppState, AppThunkDispatch } from '../../store/types';
32
35
 
33
36
  import createPopper, { PopupReference } from './Popper';
@@ -63,12 +66,32 @@ const createDocumentNode = (content: JSONContent | null): DocumentNodeV2 => {
63
66
  return { type: 'doc', content: [content] } as DocumentNodeV2;
64
67
  };
65
68
 
66
- // Callers render initials as a fallback on null.
67
- // A persistent null across all users usually indicates a stale token.
68
- const fetchAvatarBlob = async (apiHost: string, token: string, userId: string): Promise<string | null> => {
69
+ const literalToString = (literal: TokenLiteral): string | null => {
70
+ if (!literal) return null;
71
+ if (typeof literal === 'string') return literal;
72
+ return literal.read ?? literal.write ?? null;
73
+ };
74
+
75
+ const resolveStringToken = async (token: Token, typedFileId: string): Promise<string | null> => {
76
+ const resolved = typeof token === 'function' ? await token(typedFileId) : token;
77
+ if (!resolved) return null;
78
+ if (typeof resolved === 'string') return resolved;
79
+ if (typedFileId in resolved) return literalToString((resolved as TokenMap)[typedFileId]);
80
+ return literalToString(resolved as TokenLiteral);
81
+ };
82
+
83
+ const fetchAvatarBlob = async (
84
+ apiHost: string,
85
+ token: Token,
86
+ fileId: string | null,
87
+ userId: string,
88
+ ): Promise<string | null> => {
69
89
  try {
90
+ if (!fileId) return null;
91
+ const stringToken = await resolveStringToken(token, `file_${fileId}`);
92
+ if (!stringToken) return null;
70
93
  const response = await fetch(`${apiHost}/2.0/users/${userId}/avatar?pic_type=large`, {
71
- headers: { Authorization: `Bearer ${token}` },
94
+ headers: { Authorization: `Bearer ${stringToken}` },
72
95
  });
73
96
  if (!response.ok) return null;
74
97
  const blob = await response.blob();
@@ -87,6 +110,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
87
110
  const optionsRef = React.useRef<Partial<Options>>(getPopupOptions());
88
111
 
89
112
  const apiHost = useSelector(getApiHost);
113
+ const fileId = useSelector(getFileId);
90
114
  const fileVersionId = useSelector(getFileVersionId);
91
115
  const token = useSelector(getToken);
92
116
  const onCopyLink = React.useMemo(
@@ -111,7 +135,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
111
135
  if (cached) return cached;
112
136
  const capturedApiHost = apiHost;
113
137
  const capturedToken = token;
114
- const url = await fetchAvatarBlob(capturedApiHost, capturedToken, userId);
138
+ const url = await fetchAvatarBlob(capturedApiHost, capturedToken, fileId, userId);
115
139
  if (!url) return null;
116
140
  if (
117
141
  credentialsRef.current.apiHost !== capturedApiHost ||
@@ -128,7 +152,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
128
152
  avatarCacheRef.current.set(userId, url);
129
153
  return url;
130
154
  },
131
- [apiHost, token],
155
+ [apiHost, fileId, token],
132
156
  );
133
157
 
134
158
  React.useEffect(() => {
@@ -247,10 +271,14 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
247
271
 
248
272
  const handleEdit = React.useCallback(
249
273
  async (id: string, content: JSONContent | null): Promise<void> => {
250
- if (!annotationId || id !== annotationId) return;
274
+ if (!annotationId) return;
251
275
  const doc = createDocumentNode(content);
252
276
  const { text } = serializeMentionMarkup(doc);
253
- await dispatch(updateAnnotationAction({ annotationId, payload: { message: text } }));
277
+ if (id === annotationId) {
278
+ await dispatch(updateAnnotationAction({ annotationId, payload: { message: text } }));
279
+ return;
280
+ }
281
+ await dispatch(updateReplyAction({ annotationId, replyId: id, payload: { message: text } }));
254
282
  },
255
283
  [annotationId, dispatch],
256
284
  );
@@ -264,6 +292,14 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
264
292
  [annotationId, dispatch],
265
293
  );
266
294
 
295
+ const handleDelete = React.useCallback(
296
+ async (id: string): Promise<void> => {
297
+ if (!annotationId || id === annotationId) return;
298
+ await dispatch(deleteReplyAction({ annotationId, replyId: id }));
299
+ },
300
+ [annotationId, dispatch],
301
+ );
302
+
267
303
  const handleResolve = React.useCallback(
268
304
  async (): Promise<void> => {
269
305
  if (!annotationId) return;
@@ -314,7 +350,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
314
350
  messages={threadMessages}
315
351
  onAvatarClick={noop}
316
352
  onCopyLink={onCopyLink}
317
- onDelete={noop}
353
+ onDelete={handleDelete}
318
354
  onEdit={handleEdit}
319
355
  onPost={handlePost}
320
356
  onResolve={handleResolve}
@@ -118,7 +118,7 @@ export default class DocumentAnnotator extends BaseAnnotator {
118
118
  );
119
119
  }
120
120
 
121
- managers.add(new HighlightManager({ location: pageNumber, referenceEl: pageReferenceEl, resinTags }));
121
+ managers.add(new HighlightManager({ location: pageNumber, popupPortalEl: this.popupPortalEl, referenceEl: pageReferenceEl, resinTags }));
122
122
  managers.add(new RegionManager({ location: pageNumber, referenceEl: pageReferenceEl, resinTags }));
123
123
 
124
124
  const canvasLayerEl = pageEl.querySelector<HTMLElement>('.canvasWrapper');
@@ -1,4 +1,5 @@
1
1
  type Selection = {
2
+ containerEl: HTMLElement;
2
3
  containerRect: DOMRect;
3
4
  hasError?: boolean;
4
5
  location: number;
@@ -61,5 +62,5 @@ export function getSelection(): Selection | null {
61
62
  location = getPageNumber(selection.focusNode as Element) ?? endPage;
62
63
  }
63
64
 
64
- return { containerRect: containerEl.getBoundingClientRect(), hasError, location, range };
65
+ return { containerEl: containerEl as HTMLElement, containerRect: containerEl.getBoundingClientRect(), hasError, location, range };
65
66
  }
@@ -1,4 +1,5 @@
1
1
  import * as React from 'react';
2
+ import ReactDOM from 'react-dom';
2
3
  import HighlightCanvas from './HighlightCanvas';
3
4
  import HighlightList from './HighlightList';
4
5
  import HighlightSvg from './HighlightSvg';
@@ -20,6 +21,8 @@ type Props = {
20
21
  isPromoting: boolean;
21
22
  isSelecting: boolean;
22
23
  location: number;
24
+ popupPortalEl?: HTMLElement | null;
25
+ rotation: number;
23
26
  selection: SelectionItem | null;
24
27
  setActiveAnnotationId: (annotationId: string | null) => void;
25
28
  setIsPromoting: (isPromoting: boolean) => void;
@@ -38,6 +41,8 @@ const HighlightAnnotations = (props: Props): JSX.Element => {
38
41
  isPending = false,
39
42
  isPromoting = false,
40
43
  isSelecting = false,
44
+ popupPortalEl,
45
+ rotation,
41
46
  selection,
42
47
  setActiveAnnotationId,
43
48
  setIsPromoting,
@@ -121,22 +126,30 @@ const HighlightAnnotations = (props: Props): JSX.Element => {
121
126
  )}
122
127
 
123
128
  {/* Layer 3a: Annotations promoter to promote selection to staged */}
124
- {!isCreating && selection && !selection.hasError && (
125
- <div className="ba-HighlightAnnotations-popup">
126
- <PopupHighlight
127
- onCancel={handleCancel}
128
- onSubmit={handlePromote}
129
- shape={getBoundingRect(selection.rects)}
130
- />
131
- </div>
132
- )}
129
+ {!isCreating && selection && !selection.hasError && (() => {
130
+ const popupShape = selection.popupRect || getBoundingRect(selection.rects);
131
+ const popup = (
132
+ <div className="ba-HighlightAnnotations-popup">
133
+ <PopupHighlight
134
+ onCancel={handleCancel}
135
+ onSubmit={handlePromote}
136
+ shape={popupShape}
137
+ />
138
+ </div>
139
+ );
140
+ return rotation && popupPortalEl ? ReactDOM.createPortal(popup, popupPortalEl) : popup;
141
+ })()}
133
142
 
134
143
  {/* Layer 3b: Highlight error popup */}
135
- {selection && selection.hasError && (
136
- <div className="ba-HighlightAnnotations-popup">
137
- <PopupHighlightError onCancel={handleCancel} shape={getBoundingRect(selection.rects)} />
138
- </div>
139
- )}
144
+ {selection && selection.hasError && (() => {
145
+ const popupShape = selection.popupRect || getBoundingRect(selection.rects);
146
+ const popup = (
147
+ <div className="ba-HighlightAnnotations-popup">
148
+ <PopupHighlightError onCancel={handleCancel} shape={popupShape} />
149
+ </div>
150
+ );
151
+ return rotation && popupPortalEl ? ReactDOM.createPortal(popup, popupPortalEl) : popup;
152
+ })()}
140
153
  </>
141
154
  );
142
155
  };
@@ -13,6 +13,7 @@ import {
13
13
  getCreatorStatus,
14
14
  getIsPromoting,
15
15
  getIsSelecting,
16
+ getRotation,
16
17
  getSelectionForLocation,
17
18
  isCreatorStagedHighlight,
18
19
  Mode,
@@ -33,6 +34,7 @@ export type Props = {
33
34
  isPending: boolean;
34
35
  isPromoting: boolean;
35
36
  isSelecting: boolean;
37
+ rotation: number;
36
38
  selection: SelectionItem | null;
37
39
  staged: CreatorItemHighlight | null;
38
40
  };
@@ -47,6 +49,7 @@ export const mapStateToProps = (state: AppState, { location }: { location: numbe
47
49
  isPending: getCreatorStatus(state) === CreatorStatus.pending,
48
50
  isPromoting: getIsPromoting(state),
49
51
  isSelecting: getIsSelecting(state),
52
+ rotation: getRotation(state),
50
53
  selection: getSelectionForLocation(state, location),
51
54
  staged: isCreatorStagedHighlight(staged) ? staged : null,
52
55
  };
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  AppStore,
3
3
  getIsSelecting,
4
+ getRotation,
4
5
  SelectionArg as Selection,
5
6
  setIsSelectingAction,
6
7
  setSelectionAction,
@@ -41,18 +42,18 @@ export default class HighlightCreatorManager implements Manager {
41
42
  this.store.dispatch(setSelectionAction(null));
42
43
 
43
44
  this.referenceEl.addEventListener('mousedown', this.handleMouseDown);
44
- this.referenceEl.addEventListener('mouseup', this.handleMouseUp);
45
+ document.addEventListener('mouseup', this.handleMouseUp);
45
46
  }
46
47
 
47
- style(styles: Partial<CSSStyleDeclaration>): CSSStyleDeclaration {
48
- return Object.assign(this.referenceEl.style, styles);
48
+ style(): CSSStyleDeclaration {
49
+ return this.referenceEl.style;
49
50
  }
50
51
 
51
52
  destroy(): void {
52
53
  clearTimeout(this.selectionChangeTimer);
53
54
 
54
55
  this.referenceEl.removeEventListener('mousedown', this.handleMouseDown);
55
- this.referenceEl.removeEventListener('mouseup', this.handleMouseUp);
56
+ document.removeEventListener('mouseup', this.handleMouseUp);
56
57
  }
57
58
 
58
59
  handleMouseDown = ({ buttons }: MouseEvent): void => {
@@ -72,7 +73,9 @@ export default class HighlightCreatorManager implements Manager {
72
73
  }
73
74
 
74
75
  this.selectionChangeTimer = window.setTimeout(() => {
75
- this.store.dispatch(setSelectionAction(this.getSelection()));
76
+ const selection = this.getSelection();
77
+ const rotation = getRotation(this.store.getState());
78
+ this.store.dispatch(setSelectionAction(selection ? { ...selection, rotation } : null));
76
79
  this.store.dispatch(setIsSelectingAction(false));
77
80
  }, this.selectionChangeDelay);
78
81
  };
@@ -1,5 +1,5 @@
1
1
  import debounce from 'lodash/debounce';
2
- import { AppStore, getIsSelecting, SelectionArg as Selection, setSelectionAction } from '../store';
2
+ import { AppStore, getIsSelecting, getRotation, SelectionArg as Selection, setSelectionAction } from '../store';
3
3
 
4
4
  export type Options = {
5
5
  getSelection: () => Selection | null;
@@ -30,7 +30,9 @@ export default class HighlightListener {
30
30
  return;
31
31
  }
32
32
 
33
- this.store.dispatch(setSelectionAction(this.getSelection()));
33
+ const selection = this.getSelection();
34
+ const rotation = getRotation(this.store.getState());
35
+ this.store.dispatch(setSelectionAction(selection ? { ...selection, rotation } : null));
34
36
  };
35
37
 
36
38
  debounceHandleSelectionChange = debounce(this.handleSelectionChange, SELECTION_CHANGE_DEBOUNCE);
@@ -1,9 +1,20 @@
1
1
  import * as React from 'react';
2
2
  import * as ReactDOM from 'react-dom/client';
3
- import BaseManager, { Props } from '../common/BaseManager';
3
+ import BaseManager, { Options as BaseOptions, Props } from '../common/BaseManager';
4
4
  import HighlightContainer from './HighlightContainer';
5
5
 
6
+ export type Options = BaseOptions & {
7
+ popupPortalEl?: HTMLElement | null;
8
+ };
9
+
6
10
  export default class HighlightManager extends BaseManager {
11
+ popupPortalEl?: HTMLElement | null;
12
+
13
+ constructor({ popupPortalEl, ...options }: Options) {
14
+ super(options);
15
+ this.popupPortalEl = popupPortalEl;
16
+ }
17
+
7
18
  decorate(): void {
8
19
  this.reactEl.classList.add('ba-Layer--highlight');
9
20
  this.reactEl.dataset.testid = 'ba-Layer--highlight';
@@ -14,6 +25,6 @@ export default class HighlightManager extends BaseManager {
14
25
  this.root = ReactDOM.createRoot(this.reactEl);
15
26
  }
16
27
 
17
- this.root.render(<HighlightContainer location={this.location} {...props} />);
28
+ this.root.render(<HighlightContainer location={this.location} popupPortalEl={this.popupPortalEl} {...props} />);
18
29
  }
19
30
  }