box-annotations 5.2.1-beta.7 → 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 (56) 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 +14 -1
  16. package/src/common/BaseAnnotator.ts +46 -2
  17. package/src/common/BaseManager.ts +2 -0
  18. package/src/common/DeselectListener.tsx +3 -1
  19. package/src/common/withProviders.tsx +6 -2
  20. package/src/components/Popups/PopupDrawingToolbar.tsx +0 -7
  21. package/src/components/Popups/PopupV2.scss +4 -1
  22. package/src/components/Popups/PopupV2.tsx +122 -47
  23. package/src/document/DocumentAnnotator.ts +8 -4
  24. package/src/document/docUtil.ts +2 -1
  25. package/src/drawing/DrawingAnnotations.tsx +23 -14
  26. package/src/drawing/DrawingAnnotationsContainer.tsx +3 -0
  27. package/src/drawing/DrawingCreator.tsx +9 -16
  28. package/src/drawing/DrawingManager.tsx +13 -2
  29. package/src/highlight/HighlightAnnotations.tsx +27 -14
  30. package/src/highlight/HighlightContainer.tsx +3 -0
  31. package/src/highlight/HighlightCreatorManager.ts +8 -5
  32. package/src/highlight/HighlightListener.ts +4 -2
  33. package/src/highlight/HighlightManager.tsx +13 -2
  34. package/src/image/ImageAnnotator.ts +3 -2
  35. package/src/media/MediaAnnotator.ts +2 -1
  36. package/src/popup/PopupContainer.tsx +8 -1
  37. package/src/popup/PopupLayer.tsx +41 -6
  38. package/src/popup/PopupManager.tsx +24 -4
  39. package/src/region/RegionCreation.tsx +11 -8
  40. package/src/region/RegionCreationContainer.tsx +2 -2
  41. package/src/region/RegionCreator.tsx +9 -17
  42. package/src/store/annotations/actions.ts +89 -0
  43. package/src/store/annotations/reducer.ts +48 -0
  44. package/src/store/eventing/create.ts +3 -3
  45. package/src/store/eventing/delete.ts +27 -0
  46. package/src/store/eventing/middleware.ts +40 -4
  47. package/src/store/eventing/replyCreate.ts +34 -0
  48. package/src/store/eventing/replyDelete.ts +35 -0
  49. package/src/store/eventing/replyUpdate.ts +39 -0
  50. package/src/store/eventing/types.ts +4 -4
  51. package/src/store/eventing/update.ts +29 -0
  52. package/src/store/highlight/actions.ts +32 -3
  53. package/src/store/highlight/types.ts +1 -0
  54. package/src/store/options/selectors.ts +2 -2
  55. package/src/store/options/types.ts +2 -2
  56. package/src/utils/rotate.ts +43 -0
@@ -33,12 +33,6 @@ export type Props = {
33
33
 
34
34
  const options: Partial<Options> = {
35
35
  modifiers: [
36
- {
37
- name: 'eventListeners',
38
- options: {
39
- scroll: false,
40
- },
41
- },
42
36
  {
43
37
  name: 'offset',
44
38
  options: {
@@ -48,7 +42,6 @@ const options: Partial<Options> = {
48
42
  {
49
43
  name: 'preventOverflow',
50
44
  options: {
51
- altAxis: true,
52
45
  padding: 50,
53
46
  },
54
47
  },
@@ -1,5 +1,8 @@
1
1
  .ba-PopupV2 {
2
- z-index: 1;
2
+ position: absolute;
3
+ top: 0;
4
+ left: 0;
5
+ z-index: 150;
3
6
  width: 348px;
4
7
  }
5
8
 
@@ -1,13 +1,16 @@
1
- import React from 'react';
2
1
  import noop from 'lodash/noop';
3
- import { useDispatch, useSelector } from 'react-redux';
2
+ import React from 'react';
3
+ import ReactDOM from 'react-dom';
4
4
  import { useIntl } from 'react-intl';
5
- import { BlueprintModernizationProvider } from '@box/blueprint-web';
5
+ import { useDispatch, useSelector } from 'react-redux';
6
+
7
+ import { BlueprintModernizationProvider, TooltipProvider } from '@box/blueprint-web';
6
8
  import {
7
9
  MentionContextProvider,
8
10
  MessageEditorV2,
9
11
  ThreadedAnnotationsV2,
10
12
  serializeMentionMarkup,
13
+ serializeMessageToMarkdown,
11
14
  } from '@box/threaded-annotations';
12
15
  import type { DocumentNodeV2, TextMessageTypeV2 } from '@box/threaded-annotations';
13
16
  import type { FetchedAvatarUrls, UserContactType } from '@box/user-selector';
@@ -15,16 +18,20 @@ import type { JSONContent } from '@tiptap/core';
15
18
  import FocusTrap from 'box-ui-elements/es/components/focus-trap/FocusTrap';
16
19
 
17
20
  import { annotationToMessages, collaboratorToUserContact } from '../../adapters/threadedAnnotationsAdapters';
21
+ import AnnotationCallbacksContext from '../../common/AnnotationCallbacksContext';
18
22
  import {
19
23
  createReplyAction,
20
24
  deleteAnnotationAction,
25
+ deleteReplyAction,
21
26
  setActiveAnnotationIdAction,
22
27
  updateAnnotationAction,
28
+ updateReplyAction,
23
29
  } from '../../store/annotations/actions';
24
30
  import { getAnnotation } from '../../store/annotations/selectors';
25
- import { getApiHost, getToken } from '../../store/options';
31
+ import { getApiHost, getFileId, getFileVersionId, getToken, isFeatureEnabled } from '../../store/options';
26
32
  import { fetchCollaboratorsAction } from '../../store/users/actions';
27
33
 
34
+ import type { Token, TokenLiteral, TokenMap } from '../../@types';
28
35
  import type { AppState, AppThunkDispatch } from '../../store/types';
29
36
 
30
37
  import createPopper, { PopupReference } from './Popper';
@@ -35,6 +42,7 @@ import './PopupV2.scss';
35
42
  export type Props = {
36
43
  annotationId?: string;
37
44
  onSubmit: (text: string) => void;
45
+ popupPortalEl?: HTMLElement | null;
38
46
  reference: PopupReference;
39
47
  };
40
48
 
@@ -59,12 +67,35 @@ const createDocumentNode = (content: JSONContent | null): DocumentNodeV2 => {
59
67
  return { type: 'doc', content: [content] } as DocumentNodeV2;
60
68
  };
61
69
 
62
- // Callers render initials as a fallback on null.
63
- // A persistent null across all users usually indicates a stale token.
64
- 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> => {
65
93
  try {
94
+ if (!fileId) return null;
95
+ const stringToken = await resolveStringToken(token, `file_${fileId}`);
96
+ if (!stringToken) return null;
66
97
  const response = await fetch(`${apiHost}/2.0/users/${userId}/avatar?pic_type=large`, {
67
- headers: { Authorization: `Bearer ${token}` },
98
+ headers: { Authorization: `Bearer ${stringToken}` },
68
99
  });
69
100
  if (!response.ok) return null;
70
101
  const blob = await response.blob();
@@ -74,15 +105,26 @@ const fetchAvatarBlob = async (apiHost: string, token: string, userId: string):
74
105
  }
75
106
  };
76
107
 
77
- const PopupV2 = ({ annotationId, onSubmit, reference }: Props): JSX.Element => {
108
+ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): JSX.Element | null => {
78
109
  const intl = useIntl();
79
110
  const dispatch = useDispatch<AppThunkDispatch>();
111
+ const { onCopyLink: consumerOnCopyLink } = React.useContext(AnnotationCallbacksContext);
80
112
  const popupRef = React.useRef<HTMLDivElement>(null);
81
113
  const popperRef = React.useRef<Instance>();
82
114
  const optionsRef = React.useRef<Partial<Options>>(getPopupOptions());
83
115
 
84
116
  const apiHost = useSelector(getApiHost);
117
+ const fileId = useSelector(getFileId);
118
+ const fileVersionId = useSelector(getFileVersionId);
119
+ const isRichTextEnabled = useSelector((state: AppState) => isFeatureEnabled(state, 'isRichTextEnabled'));
85
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
+ );
86
128
  const annotation = useSelector((state: AppState) =>
87
129
  annotationId ? getAnnotation(state, annotationId) : undefined,
88
130
  );
@@ -98,7 +140,7 @@ const PopupV2 = ({ annotationId, onSubmit, reference }: Props): JSX.Element => {
98
140
  if (cached) return cached;
99
141
  const capturedApiHost = apiHost;
100
142
  const capturedToken = token;
101
- const url = await fetchAvatarBlob(capturedApiHost, capturedToken, userId);
143
+ const url = await fetchAvatarBlob(capturedApiHost, capturedToken, fileId, userId);
102
144
  if (!url) return null;
103
145
  if (
104
146
  credentialsRef.current.apiHost !== capturedApiHost ||
@@ -115,7 +157,7 @@ const PopupV2 = ({ annotationId, onSubmit, reference }: Props): JSX.Element => {
115
157
  avatarCacheRef.current.set(userId, url);
116
158
  return url;
117
159
  },
118
- [apiHost, token],
160
+ [apiHost, fileId, token],
119
161
  );
120
162
 
121
163
  React.useEffect(() => {
@@ -150,7 +192,7 @@ const PopupV2 = ({ annotationId, onSubmit, reference }: Props): JSX.Element => {
150
192
  if (!annotation) return undefined;
151
193
 
152
194
  const userIds = Array.from(
153
- new Set(annotationToMessages(annotation).map(msg => String(msg.author.id))),
195
+ new Set(annotationToMessages(annotation, isRichTextEnabled).map(msg => String(msg.author.id))),
154
196
  );
155
197
  let cancelled = false;
156
198
 
@@ -168,18 +210,18 @@ const PopupV2 = ({ annotationId, onSubmit, reference }: Props): JSX.Element => {
168
210
  return () => {
169
211
  cancelled = true;
170
212
  };
171
- }, [annotation, getOrFetchAvatarBlob]);
213
+ }, [annotation, getOrFetchAvatarBlob, isRichTextEnabled]);
172
214
 
173
215
  const threadMessages: TextMessageTypeV2[] = React.useMemo(() => {
174
216
  if (!annotation) return [];
175
- return annotationToMessages(annotation).map(msg => ({
217
+ return annotationToMessages(annotation, isRichTextEnabled).map(msg => ({
176
218
  ...msg,
177
219
  author: {
178
220
  ...msg.author,
179
221
  avatarUrl: avatarBlobs[String(msg.author.id)],
180
222
  },
181
223
  }));
182
- }, [annotation, avatarBlobs]);
224
+ }, [annotation, avatarBlobs, isRichTextEnabled]);
183
225
 
184
226
  const isResolved = annotation?.status === 'resolved';
185
227
  const resolvedBy = isResolved
@@ -222,14 +264,28 @@ const PopupV2 = ({ annotationId, onSubmit, reference }: Props): JSX.Element => {
222
264
  const handlePost = React.useCallback(
223
265
  async (content: JSONContent | null): Promise<void> => {
224
266
  const doc = createDocumentNode(content);
225
- const { text } = serializeMentionMarkup(doc);
267
+ const text = serializePopupMessage(doc, isRichTextEnabled);
226
268
  if (annotationId) {
227
269
  await dispatch(createReplyAction({ annotationId, message: text }));
228
270
  } else {
229
271
  onSubmit(text);
230
272
  }
231
273
  },
232
- [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],
233
289
  );
234
290
 
235
291
  const handleThreadDelete = React.useCallback(
@@ -241,6 +297,14 @@ const PopupV2 = ({ annotationId, onSubmit, reference }: Props): JSX.Element => {
241
297
  [annotationId, dispatch],
242
298
  );
243
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
+
244
308
  const handleResolve = React.useCallback(
245
309
  async (): Promise<void> => {
246
310
  if (!annotationId) return;
@@ -260,7 +324,9 @@ const PopupV2 = ({ annotationId, onSubmit, reference }: Props): JSX.Element => {
260
324
  [annotationId, dispatch],
261
325
  );
262
326
 
263
- return (
327
+ if (!popupPortalEl) return null;
328
+
329
+ return ReactDOM.createPortal(
264
330
  <FocusTrap>
265
331
  <div
266
332
  ref={popupRef}
@@ -276,38 +342,47 @@ const PopupV2 = ({ annotationId, onSubmit, reference }: Props): JSX.Element => {
276
342
  role="presentation"
277
343
  >
278
344
  <BlueprintModernizationProvider enableModernizedComponents>
279
- <MentionContextProvider value={{
280
- collaborationPopoverProps: {
281
- onSubmit: () => Promise.resolve(),
282
- },
283
- }}>
284
- {annotationId ? (
285
- <ThreadedAnnotationsV2
286
- isAnnotations
287
- isResolved={isResolved}
288
- messages={threadMessages}
289
- onAvatarClick={noop}
290
- onDelete={noop}
291
- onPost={handlePost}
292
- onResolve={handleResolve}
293
- onThreadDelete={handleThreadDelete}
294
- onUnresolve={handleUnresolve}
295
- resolvedAt={resolvedAt}
296
- resolvedBy={resolvedBy}
297
- userSelectorProps={userSelectorProps}
298
- />
299
- ) : (
300
- <MessageEditorV2
301
- isFirstAnnotation
302
- onPost={handlePost}
303
- userSelectorProps={userSelectorProps}
304
- />
305
- )}
306
- </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>
307
381
  </BlueprintModernizationProvider>
308
382
  <div data-threaded-annotations-portal />
309
383
  </div>
310
- </FocusTrap>
384
+ </FocusTrap>,
385
+ popupPortalEl,
311
386
  );
312
387
  };
313
388
 
@@ -24,6 +24,9 @@ import type { ViewMode } from '../store/options/types';
24
24
  // Pdf.js textLayer enhancement requires waiting for 300ms (they hardcode this magic number)
25
25
  const TEXT_LAYER_ENHANCEMENT = 300;
26
26
 
27
+ // Vertical padding on each page element as provided by Preview SDK (see DocumentAnnotator.scss)
28
+ const PAGE_PADDING = 15;
29
+
27
30
  export default class DocumentAnnotator extends BaseAnnotator {
28
31
  annotatedEl?: HTMLElement;
29
32
 
@@ -99,8 +102,8 @@ export default class DocumentAnnotator extends BaseAnnotator {
99
102
  }
100
103
 
101
104
  // Annotations mode
102
- managers.add(new PopupManager({ location: pageNumber, referenceEl: pageReferenceEl, resinTags }));
103
- managers.add(new DrawingManager({ location: pageNumber, referenceEl: pageReferenceEl, resinTags }));
105
+ managers.add(new PopupManager({ location: pageNumber, popupPortalEl: this.popupPortalEl, referenceEl: pageReferenceEl, resinTags }));
106
+ managers.add(new DrawingManager({ location: pageNumber, popupPortalEl: this.popupPortalEl, referenceEl: pageReferenceEl, resinTags }));
104
107
 
105
108
  const textLayer = pageEl.querySelector('.textLayer') as HTMLElement;
106
109
 
@@ -115,7 +118,7 @@ export default class DocumentAnnotator extends BaseAnnotator {
115
118
  );
116
119
  }
117
120
 
118
- managers.add(new HighlightManager({ location: pageNumber, referenceEl: pageReferenceEl, resinTags }));
121
+ managers.add(new HighlightManager({ location: pageNumber, popupPortalEl: this.popupPortalEl, referenceEl: pageReferenceEl, resinTags }));
119
122
  managers.add(new RegionManager({ location: pageNumber, referenceEl: pageReferenceEl, resinTags }));
120
123
 
121
124
  const canvasLayerEl = pageEl.querySelector<HTMLElement>('.canvasWrapper');
@@ -200,7 +203,7 @@ export default class DocumentAnnotator extends BaseAnnotator {
200
203
 
201
204
  // Calculate original (unrotated) page dimensions for annotation layer sizing
202
205
  const pageWidth = pageEl.clientWidth;
203
- const pageHeight = pageEl.clientHeight;
206
+ const pageHeight = pageEl.clientHeight - PAGE_PADDING * 2;
204
207
  const isOrthogonal = rotation % 180 !== 0;
205
208
  const origWidth = isOrthogonal ? pageHeight : pageWidth;
206
209
  const origHeight = isOrthogonal ? pageWidth : pageHeight;
@@ -221,6 +224,7 @@ export default class DocumentAnnotator extends BaseAnnotator {
221
224
  }
222
225
 
223
226
  manager.render({
227
+ callbacks: { onCopyLink: this.onCopyLink },
224
228
  intl: this.intl,
225
229
  store: this.store,
226
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
  };