box-annotations 5.2.1-beta.8 → 5.3.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.
Files changed (54) hide show
  1. package/dist/annotations.css +1 -1
  2. package/dist/annotations.js +1 -1
  3. package/dist/annotations.js.LICENSE.txt +1 -1
  4. package/index.js +1 -1
  5. package/package.json +33 -22
  6. package/src/@types/api.ts +3 -2
  7. package/src/@types/events.ts +27 -10
  8. package/src/@types/global.ts +0 -9
  9. package/src/@types/model.ts +9 -0
  10. package/src/BoxAnnotations.ts +5 -1
  11. package/src/adapters/threadedAnnotationsAdapters.ts +39 -26
  12. package/src/api/APIFactory.ts +6 -1
  13. package/src/api/types.ts +23 -1
  14. package/src/common/AnnotationCallbacksContext.ts +7 -0
  15. package/src/common/BaseAnnotator.scss +12 -0
  16. package/src/common/BaseAnnotator.ts +32 -2
  17. package/src/common/BaseManager.ts +2 -0
  18. package/src/common/DeselectListener.tsx +1 -1
  19. package/src/common/withProviders.tsx +6 -2
  20. package/src/components/Popups/PopupDrawingToolbar.tsx +0 -7
  21. package/src/components/Popups/PopupV2.tsx +111 -42
  22. package/src/document/DocumentAnnotator.ts +3 -2
  23. package/src/document/docUtil.ts +2 -1
  24. package/src/drawing/DrawingAnnotations.tsx +23 -14
  25. package/src/drawing/DrawingAnnotationsContainer.tsx +3 -0
  26. package/src/drawing/DrawingCreator.tsx +9 -16
  27. package/src/drawing/DrawingManager.tsx +13 -2
  28. package/src/highlight/HighlightAnnotations.tsx +27 -14
  29. package/src/highlight/HighlightContainer.tsx +3 -0
  30. package/src/highlight/HighlightCreatorManager.ts +8 -5
  31. package/src/highlight/HighlightListener.ts +4 -2
  32. package/src/highlight/HighlightManager.tsx +13 -2
  33. package/src/image/ImageAnnotator.ts +2 -1
  34. package/src/media/MediaAnnotator.ts +1 -0
  35. package/src/popup/PopupLayer.tsx +37 -6
  36. package/src/popup/PopupManager.tsx +4 -2
  37. package/src/region/RegionCreation.tsx +11 -8
  38. package/src/region/RegionCreationContainer.tsx +2 -2
  39. package/src/region/RegionCreator.tsx +9 -17
  40. package/src/store/annotations/actions.ts +89 -0
  41. package/src/store/annotations/reducer.ts +48 -0
  42. package/src/store/eventing/create.ts +3 -3
  43. package/src/store/eventing/delete.ts +27 -0
  44. package/src/store/eventing/middleware.ts +40 -4
  45. package/src/store/eventing/replyCreate.ts +34 -0
  46. package/src/store/eventing/replyDelete.ts +35 -0
  47. package/src/store/eventing/replyUpdate.ts +39 -0
  48. package/src/store/eventing/types.ts +4 -4
  49. package/src/store/eventing/update.ts +29 -0
  50. package/src/store/highlight/actions.ts +32 -3
  51. package/src/store/highlight/types.ts +1 -0
  52. package/src/store/options/selectors.ts +2 -2
  53. package/src/store/options/types.ts +2 -2
  54. package/src/utils/rotate.ts +43 -0
@@ -4,12 +4,13 @@ import ReactDOM from 'react-dom';
4
4
  import { useIntl } from 'react-intl';
5
5
  import { useDispatch, useSelector } from 'react-redux';
6
6
 
7
- import { BlueprintModernizationProvider } from '@box/blueprint-web';
7
+ import { BlueprintModernizationProvider, TooltipProvider } from '@box/blueprint-web';
8
8
  import {
9
9
  MentionContextProvider,
10
10
  MessageEditorV2,
11
11
  ThreadedAnnotationsV2,
12
12
  serializeMentionMarkup,
13
+ serializeMessageToMarkdown,
13
14
  } from '@box/threaded-annotations';
14
15
  import type { DocumentNodeV2, TextMessageTypeV2 } from '@box/threaded-annotations';
15
16
  import type { FetchedAvatarUrls, UserContactType } from '@box/user-selector';
@@ -17,16 +18,20 @@ import type { JSONContent } from '@tiptap/core';
17
18
  import FocusTrap from 'box-ui-elements/es/components/focus-trap/FocusTrap';
18
19
 
19
20
  import { annotationToMessages, collaboratorToUserContact } from '../../adapters/threadedAnnotationsAdapters';
21
+ import AnnotationCallbacksContext from '../../common/AnnotationCallbacksContext';
20
22
  import {
21
23
  createReplyAction,
22
24
  deleteAnnotationAction,
25
+ deleteReplyAction,
23
26
  setActiveAnnotationIdAction,
24
27
  updateAnnotationAction,
28
+ updateReplyAction,
25
29
  } from '../../store/annotations/actions';
26
30
  import { getAnnotation } from '../../store/annotations/selectors';
27
- import { getApiHost, getToken } from '../../store/options';
31
+ import { getApiHost, getFileId, getFileVersionId, getToken, isFeatureEnabled } from '../../store/options';
28
32
  import { fetchCollaboratorsAction } from '../../store/users/actions';
29
33
 
34
+ import type { Token, TokenLiteral, TokenMap } from '../../@types';
30
35
  import type { AppState, AppThunkDispatch } from '../../store/types';
31
36
 
32
37
  import createPopper, { PopupReference } from './Popper';
@@ -62,12 +67,35 @@ const createDocumentNode = (content: JSONContent | null): DocumentNodeV2 => {
62
67
  return { type: 'doc', content: [content] } as DocumentNodeV2;
63
68
  };
64
69
 
65
- // Callers render initials as a fallback on null.
66
- // A persistent null across all users usually indicates a stale token.
67
- const fetchAvatarBlob = async (apiHost: string, token: string, userId: string): Promise<string | null> => {
70
+ const serializePopupMessage = (doc: DocumentNodeV2, isRichTextEnabled: boolean): string =>
71
+ isRichTextEnabled ? serializeMessageToMarkdown(doc) : serializeMentionMarkup(doc).text;
72
+
73
+ const literalToString = (literal: TokenLiteral): string | null => {
74
+ if (!literal) return null;
75
+ if (typeof literal === 'string') return literal;
76
+ return literal.read ?? literal.write ?? null;
77
+ };
78
+
79
+ const resolveStringToken = async (token: Token, typedFileId: string): Promise<string | null> => {
80
+ const resolved = typeof token === 'function' ? await token(typedFileId) : token;
81
+ if (!resolved) return null;
82
+ if (typeof resolved === 'string') return resolved;
83
+ if (typedFileId in resolved) return literalToString((resolved as TokenMap)[typedFileId]);
84
+ return literalToString(resolved as TokenLiteral);
85
+ };
86
+
87
+ const fetchAvatarBlob = async (
88
+ apiHost: string,
89
+ token: Token,
90
+ fileId: string | null,
91
+ userId: string,
92
+ ): Promise<string | null> => {
68
93
  try {
94
+ if (!fileId) return null;
95
+ const stringToken = await resolveStringToken(token, `file_${fileId}`);
96
+ if (!stringToken) return null;
69
97
  const response = await fetch(`${apiHost}/2.0/users/${userId}/avatar?pic_type=large`, {
70
- headers: { Authorization: `Bearer ${token}` },
98
+ headers: { Authorization: `Bearer ${stringToken}` },
71
99
  });
72
100
  if (!response.ok) return null;
73
101
  const blob = await response.blob();
@@ -80,12 +108,23 @@ const fetchAvatarBlob = async (apiHost: string, token: string, userId: string):
80
108
  const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): JSX.Element | null => {
81
109
  const intl = useIntl();
82
110
  const dispatch = useDispatch<AppThunkDispatch>();
111
+ const { onCopyLink: consumerOnCopyLink } = React.useContext(AnnotationCallbacksContext);
83
112
  const popupRef = React.useRef<HTMLDivElement>(null);
84
113
  const popperRef = React.useRef<Instance>();
85
114
  const optionsRef = React.useRef<Partial<Options>>(getPopupOptions());
86
115
 
87
116
  const apiHost = useSelector(getApiHost);
117
+ const fileId = useSelector(getFileId);
118
+ const fileVersionId = useSelector(getFileVersionId);
119
+ const isRichTextEnabled = useSelector((state: AppState) => isFeatureEnabled(state, 'isRichTextEnabled'));
88
120
  const token = useSelector(getToken);
121
+ const onCopyLink = React.useMemo(
122
+ () =>
123
+ consumerOnCopyLink && annotationId && fileVersionId
124
+ ? () => consumerOnCopyLink({ annotationId, fileVersionId })
125
+ : undefined,
126
+ [consumerOnCopyLink, annotationId, fileVersionId],
127
+ );
89
128
  const annotation = useSelector((state: AppState) =>
90
129
  annotationId ? getAnnotation(state, annotationId) : undefined,
91
130
  );
@@ -101,7 +140,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
101
140
  if (cached) return cached;
102
141
  const capturedApiHost = apiHost;
103
142
  const capturedToken = token;
104
- const url = await fetchAvatarBlob(capturedApiHost, capturedToken, userId);
143
+ const url = await fetchAvatarBlob(capturedApiHost, capturedToken, fileId, userId);
105
144
  if (!url) return null;
106
145
  if (
107
146
  credentialsRef.current.apiHost !== capturedApiHost ||
@@ -118,7 +157,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
118
157
  avatarCacheRef.current.set(userId, url);
119
158
  return url;
120
159
  },
121
- [apiHost, token],
160
+ [apiHost, fileId, token],
122
161
  );
123
162
 
124
163
  React.useEffect(() => {
@@ -153,7 +192,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
153
192
  if (!annotation) return undefined;
154
193
 
155
194
  const userIds = Array.from(
156
- new Set(annotationToMessages(annotation).map(msg => String(msg.author.id))),
195
+ new Set(annotationToMessages(annotation, isRichTextEnabled).map(msg => String(msg.author.id))),
157
196
  );
158
197
  let cancelled = false;
159
198
 
@@ -171,18 +210,18 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
171
210
  return () => {
172
211
  cancelled = true;
173
212
  };
174
- }, [annotation, getOrFetchAvatarBlob]);
213
+ }, [annotation, getOrFetchAvatarBlob, isRichTextEnabled]);
175
214
 
176
215
  const threadMessages: TextMessageTypeV2[] = React.useMemo(() => {
177
216
  if (!annotation) return [];
178
- return annotationToMessages(annotation).map(msg => ({
217
+ return annotationToMessages(annotation, isRichTextEnabled).map(msg => ({
179
218
  ...msg,
180
219
  author: {
181
220
  ...msg.author,
182
221
  avatarUrl: avatarBlobs[String(msg.author.id)],
183
222
  },
184
223
  }));
185
- }, [annotation, avatarBlobs]);
224
+ }, [annotation, avatarBlobs, isRichTextEnabled]);
186
225
 
187
226
  const isResolved = annotation?.status === 'resolved';
188
227
  const resolvedBy = isResolved
@@ -225,14 +264,28 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
225
264
  const handlePost = React.useCallback(
226
265
  async (content: JSONContent | null): Promise<void> => {
227
266
  const doc = createDocumentNode(content);
228
- const { text } = serializeMentionMarkup(doc);
267
+ const text = serializePopupMessage(doc, isRichTextEnabled);
229
268
  if (annotationId) {
230
269
  await dispatch(createReplyAction({ annotationId, message: text }));
231
270
  } else {
232
271
  onSubmit(text);
233
272
  }
234
273
  },
235
- [annotationId, dispatch, onSubmit],
274
+ [annotationId, dispatch, isRichTextEnabled, onSubmit],
275
+ );
276
+
277
+ const handleEdit = React.useCallback(
278
+ async (id: string, content: JSONContent | null): Promise<void> => {
279
+ if (!annotationId) return;
280
+ const doc = createDocumentNode(content);
281
+ const text = serializePopupMessage(doc, isRichTextEnabled);
282
+ if (id === annotationId) {
283
+ await dispatch(updateAnnotationAction({ annotationId, payload: { message: text } }));
284
+ return;
285
+ }
286
+ await dispatch(updateReplyAction({ annotationId, replyId: id, payload: { message: text } }));
287
+ },
288
+ [annotationId, dispatch, isRichTextEnabled],
236
289
  );
237
290
 
238
291
  const handleThreadDelete = React.useCallback(
@@ -244,6 +297,14 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
244
297
  [annotationId, dispatch],
245
298
  );
246
299
 
300
+ const handleDelete = React.useCallback(
301
+ async (id: string): Promise<void> => {
302
+ if (!annotationId || id === annotationId) return;
303
+ await dispatch(deleteReplyAction({ annotationId, replyId: id }));
304
+ },
305
+ [annotationId, dispatch],
306
+ );
307
+
247
308
  const handleResolve = React.useCallback(
248
309
  async (): Promise<void> => {
249
310
  if (!annotationId) return;
@@ -281,34 +342,42 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
281
342
  role="presentation"
282
343
  >
283
344
  <BlueprintModernizationProvider enableModernizedComponents>
284
- <MentionContextProvider value={{
285
- collaborationPopoverProps: {
286
- onSubmit: () => Promise.resolve(),
287
- },
288
- }}>
289
- {annotationId ? (
290
- <ThreadedAnnotationsV2
291
- isAnnotations
292
- isResolved={isResolved}
293
- messages={threadMessages}
294
- onAvatarClick={noop}
295
- onDelete={noop}
296
- onPost={handlePost}
297
- onResolve={handleResolve}
298
- onThreadDelete={handleThreadDelete}
299
- onUnresolve={handleUnresolve}
300
- resolvedAt={resolvedAt}
301
- resolvedBy={resolvedBy}
302
- userSelectorProps={userSelectorProps}
303
- />
304
- ) : (
305
- <MessageEditorV2
306
- isFirstAnnotation
307
- onPost={handlePost}
308
- userSelectorProps={userSelectorProps}
309
- />
310
- )}
311
- </MentionContextProvider>
345
+ <TooltipProvider>
346
+ <MentionContextProvider value={{
347
+ collaborationPopoverProps: {
348
+ onSubmit: () => Promise.resolve(),
349
+ },
350
+ // Mentions are restricted to file collaborators; the invite popover is intentionally never shown.
351
+ fetchCollaboratorState: async () => true,
352
+ }}>
353
+ {annotationId ? (
354
+ <ThreadedAnnotationsV2
355
+ isAnnotations
356
+ isResolved={isResolved}
357
+ isRichTextEnabled={isRichTextEnabled}
358
+ messages={threadMessages}
359
+ onAvatarClick={noop}
360
+ onCopyLink={onCopyLink}
361
+ onDelete={handleDelete}
362
+ onEdit={handleEdit}
363
+ onPost={handlePost}
364
+ onResolve={handleResolve}
365
+ onThreadDelete={handleThreadDelete}
366
+ onUnresolve={handleUnresolve}
367
+ resolvedAt={resolvedAt}
368
+ resolvedBy={resolvedBy}
369
+ userSelectorProps={userSelectorProps}
370
+ />
371
+ ) : (
372
+ <MessageEditorV2
373
+ isFirstAnnotation
374
+ isRichTextEnabled={isRichTextEnabled}
375
+ onPost={handlePost}
376
+ userSelectorProps={userSelectorProps}
377
+ />
378
+ )}
379
+ </MentionContextProvider>
380
+ </TooltipProvider>
312
381
  </BlueprintModernizationProvider>
313
382
  <div data-threaded-annotations-portal />
314
383
  </div>
@@ -103,7 +103,7 @@ export default class DocumentAnnotator extends BaseAnnotator {
103
103
 
104
104
  // Annotations mode
105
105
  managers.add(new PopupManager({ location: pageNumber, popupPortalEl: this.popupPortalEl, referenceEl: pageReferenceEl, resinTags }));
106
- managers.add(new DrawingManager({ location: pageNumber, referenceEl: pageReferenceEl, resinTags }));
106
+ managers.add(new DrawingManager({ location: pageNumber, popupPortalEl: this.popupPortalEl, referenceEl: pageReferenceEl, resinTags }));
107
107
 
108
108
  const textLayer = pageEl.querySelector('.textLayer') as HTMLElement;
109
109
 
@@ -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');
@@ -224,6 +224,7 @@ export default class DocumentAnnotator extends BaseAnnotator {
224
224
  }
225
225
 
226
226
  manager.render({
227
+ callbacks: { onCopyLink: this.onCopyLink },
227
228
  intl: this.intl,
228
229
  store: this.store,
229
230
  });
@@ -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 React from 'react';
2
+ import ReactDOM from 'react-dom';
2
3
  import classNames from 'classnames';
3
4
  import DecoratedDrawingPath from './DecoratedDrawingPath';
4
5
  import DrawingList from './DrawingList';
@@ -22,8 +23,10 @@ export type Props = {
22
23
  drawnPathGroups: Array<PathGroup>;
23
24
  isCreating: boolean;
24
25
  location: number;
26
+ popupPortalEl?: HTMLElement | null;
25
27
  redoDrawingPathGroup: () => void;
26
28
  resetDrawing: () => void;
29
+ rotation: number;
27
30
  setActiveAnnotationId: (annotationId: string | null) => void;
28
31
  setReferenceId: (uuid: string) => void;
29
32
  setStaged: (staged: CreatorItemDrawing | null) => void;
@@ -45,8 +48,10 @@ const DrawingAnnotations = (props: Props): JSX.Element => {
45
48
  drawnPathGroups,
46
49
  isCreating,
47
50
  location,
51
+ popupPortalEl,
48
52
  redoDrawingPathGroup,
49
53
  resetDrawing,
54
+ rotation,
50
55
  setActiveAnnotationId,
51
56
  setReferenceId,
52
57
  setStaged,
@@ -170,23 +175,27 @@ const DrawingAnnotations = (props: Props): JSX.Element => {
170
175
  color={color}
171
176
  onStart={handleStart}
172
177
  onStop={handleStop}
178
+ rotation={rotation}
173
179
  />
174
180
  )}
175
181
 
176
- {isCreating && hasPathGroups && drawingSVGGroup && canShowPopupToolbar && (
177
- <PopupDrawingToolbar
178
- ref={popupDrawingToolbarRef}
179
- canComment={hasDrawnPathGroups}
180
- canRedo={hasStashedPathGroups}
181
- canUndo={hasDrawnPathGroups}
182
- className={classNames('ba-DrawingAnnotations-toolbar', { 'ba-is-drawing': isDrawing })}
183
- onDelete={handleDelete}
184
- onRedo={handleRedo}
185
- onReply={handleReply}
186
- onUndo={handleUndo}
187
- reference={drawingSVGGroup}
188
- />
189
- )}
182
+ {isCreating && hasPathGroups && drawingSVGGroup && canShowPopupToolbar && (() => {
183
+ const toolbar = (
184
+ <PopupDrawingToolbar
185
+ ref={popupDrawingToolbarRef}
186
+ canComment={hasDrawnPathGroups}
187
+ canRedo={hasStashedPathGroups}
188
+ canUndo={hasDrawnPathGroups}
189
+ className={classNames('ba-DrawingAnnotations-toolbar', { 'ba-is-drawing': isDrawing })}
190
+ onDelete={handleDelete}
191
+ onRedo={handleRedo}
192
+ onReply={handleReply}
193
+ onUndo={handleUndo}
194
+ reference={drawingSVGGroup}
195
+ />
196
+ );
197
+ return popupPortalEl ? ReactDOM.createPortal(toolbar, popupPortalEl) : toolbar;
198
+ })()}
190
199
  </>
191
200
  );
192
201
  };
@@ -18,6 +18,7 @@ import {
18
18
  getAnnotationsForLocation,
19
19
  getColor,
20
20
  getCreatorStatus,
21
+ getRotation,
21
22
  Mode,
22
23
  setActiveAnnotationIdAction,
23
24
  setReferenceIdAction,
@@ -34,6 +35,7 @@ export type Props = {
34
35
  canShowPopupToolbar: boolean;
35
36
  drawnPathGroups: Array<PathGroup>;
36
37
  isCreating: boolean;
38
+ rotation: number;
37
39
  stashedPathGroups: Array<PathGroup>;
38
40
  };
39
41
 
@@ -47,6 +49,7 @@ export const mapStateToProps = (state: AppState, { location }: { location: numbe
47
49
  canShowPopupToolbar: creatorStatus === CreatorStatus.started,
48
50
  drawnPathGroups: getDrawingDrawnPathGroupsForLocation(state, location),
49
51
  isCreating: getAnnotationMode(state) === Mode.DRAWING && creatorStatus !== CreatorStatus.pending,
52
+ rotation: getRotation(state),
50
53
  stashedPathGroups: getStashedDrawnPathGroupsForLocation(state, location),
51
54
  };
52
55
  };
@@ -7,6 +7,7 @@ import DrawingSVG, { DrawingSVGRef } from './DrawingSVG';
7
7
  import PointerCapture, { PointerCaptureRef, Status as DrawingStatus } from '../components/PointerCapture';
8
8
  import { getDrawingCursor } from './DrawingCursor';
9
9
  import { getPathCommands } from './drawingUtil';
10
+ import { getElementLocalPosition } from '../utils/rotate';
10
11
  import { PathGroup, Position } from '../@types';
11
12
  import './DrawingCreator.scss';
12
13
 
@@ -15,6 +16,7 @@ export type Props = {
15
16
  color?: string;
16
17
  onStart: () => void;
17
18
  onStop: (pathGroup: PathGroup) => void;
19
+ rotation?: number;
18
20
  size?: number;
19
21
  };
20
22
 
@@ -26,6 +28,7 @@ export default function DrawingCreator({
26
28
  color = defaultStrokeColor,
27
29
  onStart,
28
30
  onStop,
31
+ rotation = 0,
29
32
  size = defaultStrokeSize,
30
33
  }: Props): JSX.Element {
31
34
  const [drawingStatus, setDrawingStatus] = React.useState<DrawingStatus>(DrawingStatus.init);
@@ -45,7 +48,9 @@ export default function DrawingCreator({
45
48
  return [];
46
49
  }
47
50
 
48
- const { height, width } = creatorEl.getBoundingClientRect();
51
+ // Get the element's dimensions (before any rotation is applied)
52
+ const width = creatorEl.offsetWidth;
53
+ const height = creatorEl.offsetHeight;
49
54
  const { size: minSize } = stroke;
50
55
  const MAX_X = width - minSize;
51
56
  const MAX_Y = height - minSize;
@@ -56,21 +61,9 @@ export default function DrawingCreator({
56
61
  }));
57
62
  }, [stroke]);
58
63
 
59
- const getPosition = (x: number, y: number): [number, number] => {
60
- const { current: creatorEl } = creatorElRef;
61
-
62
- if (!creatorEl) {
63
- return [x, y];
64
- }
65
-
66
- // Calculate the new position based on the mouse position less the page offset
67
- const { left, top } = creatorEl.getBoundingClientRect();
68
- return [x - left, y - top];
69
- };
70
-
71
64
  // Drawing Lifecycle Callbacks
72
65
  const startDraw = (x: number, y: number): void => {
73
- const [x1, y1] = getPosition(x, y);
66
+ const [x1, y1] = getElementLocalPosition(x, y, creatorElRef.current, rotation);
74
67
 
75
68
  setDrawingStatus(DrawingStatus.dragging);
76
69
 
@@ -100,7 +93,7 @@ export default function DrawingCreator({
100
93
 
101
94
  const updateDraw = React.useCallback(
102
95
  (x: number, y: number): void => {
103
- const [x2, y2] = getPosition(x, y);
96
+ const [x2, y2] = getElementLocalPosition(x, y, creatorElRef.current, rotation);
104
97
  const { current: points } = capturedPointsRef;
105
98
 
106
99
  points.push({ x: x2, y: y2 });
@@ -111,7 +104,7 @@ export default function DrawingCreator({
111
104
  onStart();
112
105
  }
113
106
  },
114
- [drawingStatus, onStart, setDrawingStatus],
107
+ [drawingStatus, onStart, rotation, setDrawingStatus],
115
108
  );
116
109
 
117
110
  // Event Handlers
@@ -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 DrawingAnnotationsContainer from './DrawingAnnotationsContainer';
5
5
 
6
+ export type Options = BaseOptions & {
7
+ popupPortalEl?: HTMLElement | null;
8
+ };
9
+
6
10
  export default class DrawingListManager 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--drawing');
9
20
  this.reactEl.dataset.testid = 'ba-Layer--drawing';
@@ -14,6 +25,6 @@ export default class DrawingListManager extends BaseManager {
14
25
  this.root = ReactDOM.createRoot(this.reactEl);
15
26
  }
16
27
 
17
- this.root.render(<DrawingAnnotationsContainer referenceEl={this.referenceEl} targetType={this.targetType} location={this.location} {...props} />);
28
+ this.root.render(<DrawingAnnotationsContainer referenceEl={this.referenceEl} targetType={this.targetType} location={this.location} popupPortalEl={this.popupPortalEl} {...props} />);
18
29
  }
19
30
  }
@@ -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);