@immense/vue-pom-generator 1.0.53 → 1.0.55

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.
@@ -1,68 +1,27 @@
1
- import { computePosition, limitShift, offset, shift } from "./floating-ui";
2
1
  import type { PwLocator, PwPage } from "./playwright-types";
3
2
 
4
- const __PW_CURSOR_ID__ = "__pw_cursor__";
5
- const __PW_CURSOR_ANNOTATION_ID__ = "__pw_cursor_annotation__";
6
- const __PW_CURSOR_ANNOTATION_CONTENT_ID__ = "__pw_cursor_annotation_content__";
7
- const __PW_CURSOR_ANNOTATION_ARROW_ID__ = "__pw_cursor_annotation_arrow__";
8
- const __PW_CURSOR_ANNOTATION_AVOID_SELECTOR__
9
- = [
10
- "[data-callout-avoid]",
11
- "button",
12
- "input",
13
- "textarea",
14
- "select",
15
- "summary",
16
- "a[href]",
17
- "[role='button']",
18
- "[role='link']",
19
- "[role='textbox']",
20
- "[role='combobox']",
21
- "[role='option']",
22
- "[role='tab']",
23
- "[role='menuitem']",
24
- "[contenteditable='']",
25
- "[contenteditable='true']",
26
- "[contenteditable]:not([contenteditable='false'])",
27
- ].join(",");
28
- const __PW_CURSOR_ANNOTATION_MARGIN__ = 18;
29
- const __PW_CURSOR_ANNOTATION_GAP__ = 18;
30
- const __PW_CURSOR_ANNOTATION_ARROW_SIZE__ = 14;
31
- const __PW_CURSOR_ANNOTATION_AVOID_PADDING__ = 12;
32
- const __PW_CURSOR_ANNOTATION_BACKGROUND__ = "#dc2626";
33
- const __PW_CURSOR_ANNOTATION_BORDER__ = "0px solid transparent";
34
- const __PW_CURSOR_ANNOTATION_BOX_SHADOW__ = "0 20px 44px rgba(127, 29, 29, 0.32)";
35
- const __PW_CURSOR_ANNOTATION_TEXT_COLOR__ = "#f8fafc";
36
- const __PW_CURSOR_ANNOTATION_RADIUS__ = 0;
37
-
38
- type Placement =
39
- | "top"
40
- | "top-start"
41
- | "top-end"
42
- | "right"
43
- | "right-start"
44
- | "right-end"
45
- | "bottom"
46
- | "bottom-start"
47
- | "bottom-end"
48
- | "left"
49
- | "left-start"
50
- | "left-end";
51
-
52
- const __PW_CURSOR_ALLOWED_PLACEMENTS__: Placement[] = [
53
- "top-start",
54
- "top",
55
- "top-end",
56
- "right-start",
57
- "right",
58
- "right-end",
59
- "bottom-start",
60
- "bottom",
61
- "bottom-end",
62
- "left-start",
63
- "left",
64
- "left-end",
65
- ];
3
+ export const POINTER_CALLOUT_IDS = {
4
+ annotation: "__pw_pointer_callout__",
5
+ arrow: "__pw_pointer_callout_arrow__",
6
+ content: "__pw_pointer_callout_content__",
7
+ } as const;
8
+
9
+ export const POINTER_CALLOUT_THEME = {
10
+ arrowPadding: 10,
11
+ arrowSize: 14,
12
+ avoidPadding: 12,
13
+ background: "#dc2626",
14
+ border: "0px solid transparent",
15
+ borderRadius: 0,
16
+ boxShadow: "0 20px 44px rgba(127, 29, 29, 0.32)",
17
+ charsPerLine: 28,
18
+ gap: 18,
19
+ margin: 18,
20
+ maxWidth: 320,
21
+ minHeight: 52,
22
+ minWidth: 180,
23
+ textColor: "#f8fafc",
24
+ } as const;
66
25
 
67
26
  export type ElementTarget = string | PwLocator;
68
27
 
@@ -73,354 +32,50 @@ export interface CalloutTargetBox {
73
32
  height: number;
74
33
  }
75
34
 
76
- interface CalloutContext {
77
- avoidRects: CalloutTargetBox[];
78
- protectedTargetRects: CalloutTargetBox[];
79
- viewportHeight: number;
80
- viewportWidth: number;
81
- }
82
-
83
- interface FloatingVirtualElement extends CalloutTargetBox {
84
- kind: "arrow" | "floating" | "reference";
85
- }
86
-
87
- interface CalloutLayout {
88
- arrowX: number | null;
89
- arrowY: number | null;
90
- placement: Placement;
91
- staticSide: "bottom" | "left" | "right" | "top";
92
- x: number;
93
- y: number;
94
- }
95
-
96
35
  export interface ShowCalloutOptions {
97
36
  skipScroll?: boolean;
98
37
  targetBox?: CalloutTargetBox;
99
38
  }
100
39
 
101
- function __pw_overlap_area__(first: CalloutTargetBox, second: CalloutTargetBox): number {
102
- const horizontal = Math.max(0, Math.min(first.x + first.width, second.x + second.width) - Math.max(first.x, second.x));
103
- const vertical = Math.max(0, Math.min(first.y + first.height, second.y + second.height) - Math.max(first.y, second.y));
104
- return horizontal * vertical;
105
- }
106
-
107
- function __pw_rect_center_distance__(first: CalloutTargetBox, second: CalloutTargetBox): number {
108
- const firstCenterX = first.x + first.width / 2;
109
- const firstCenterY = first.y + first.height / 2;
110
- const secondCenterX = second.x + second.width / 2;
111
- const secondCenterY = second.y + second.height / 2;
112
- return Math.hypot(firstCenterX - secondCenterX, firstCenterY - secondCenterY);
113
- }
114
-
115
- function __pw_rect_gap__(first: CalloutTargetBox, second: CalloutTargetBox): number {
116
- const horizontalGap = Math.max(0, Math.max(second.x - (first.x + first.width), first.x - (second.x + second.width)));
117
- const verticalGap = Math.max(0, Math.max(second.y - (first.y + first.height), first.y - (second.y + second.height)));
118
- return Math.max(horizontalGap, verticalGap);
119
- }
120
-
121
- function __pw_expand_rect__(rect: CalloutTargetBox, padding: number): CalloutTargetBox {
122
- return {
123
- height: rect.height + (padding * 2),
124
- width: rect.width + (padding * 2),
125
- x: rect.x - padding,
126
- y: rect.y - padding,
127
- };
40
+ export interface CalloutRenderRequest {
41
+ overlayIds: string[];
42
+ target: ElementTarget;
43
+ targetBox: CalloutTargetBox;
44
+ text: string;
128
45
  }
129
46
 
130
- function __pw_parse_placement__(placement: Placement): {
131
- align: "center" | "end" | "start";
132
- side: "bottom" | "left" | "right" | "top";
133
- } {
134
- const [side, align] = placement.split("-") as [Placement extends `${infer Side}-${string}` ? Side : never, "end" | "start" | undefined];
135
- return {
136
- align: align ?? "center",
137
- side,
138
- };
47
+ export interface CalloutRenderer {
48
+ readonly overlayIds?: string[];
49
+ hide: (page: PwPage) => Promise<void>;
50
+ show: (page: PwPage, request: CalloutRenderRequest) => Promise<void>;
139
51
  }
140
52
 
141
- function __pw_to_client_rect__(rect: CalloutTargetBox) {
142
- return {
143
- bottom: rect.y + rect.height,
144
- height: rect.height,
145
- left: rect.x,
146
- right: rect.x + rect.width,
147
- top: rect.y,
148
- width: rect.width,
149
- x: rect.x,
150
- y: rect.y,
151
- };
152
- }
153
-
154
- function __pw_create_virtual_element__(rect: CalloutTargetBox, kind: FloatingVirtualElement["kind"]): FloatingVirtualElement {
155
- return {
156
- height: rect.height,
157
- kind,
158
- width: rect.width,
159
- x: rect.x,
160
- y: rect.y,
161
- };
162
- }
163
-
164
- function __pw_create_platform__(viewportWidth: number, viewportHeight: number) {
165
- const offsetParent = {
166
- clientHeight: viewportHeight,
167
- clientLeft: 0,
168
- clientTop: 0,
169
- clientWidth: viewportWidth,
170
- };
171
-
172
- return {
173
- convertOffsetParentRelativeRectToViewportRelativeRect: ({ rect }: { rect: CalloutTargetBox }) => rect,
174
- getClientRects: (element: FloatingVirtualElement) => [__pw_to_client_rect__(element)],
175
- getClippingRect: () => ({
176
- height: viewportHeight,
177
- width: viewportWidth,
178
- x: 0,
179
- y: 0,
180
- }),
181
- getDimensions: (element: FloatingVirtualElement) => ({
182
- height: element.height,
183
- width: element.width,
184
- }),
185
- getDocumentElement: () => ({
186
- clientHeight: viewportHeight,
187
- clientWidth: viewportWidth,
188
- }),
189
- getElementRects: ({
190
- floating,
191
- reference,
192
- }: {
193
- floating: FloatingVirtualElement;
194
- reference: FloatingVirtualElement;
195
- strategy: string;
196
- }) => ({
197
- floating: {
198
- height: floating.height,
199
- width: floating.width,
200
- x: 0,
201
- y: 0,
202
- },
203
- reference: {
204
- height: reference.height,
205
- width: reference.width,
206
- x: reference.x,
207
- y: reference.y,
208
- },
209
- }),
210
- getOffsetParent: () => offsetParent,
211
- getScale: () => ({ x: 1, y: 1 }),
212
- isElement: () => false,
213
- isRTL: () => false,
214
- };
53
+ export interface CalloutOptions {
54
+ extraOverlayIds?: string[];
55
+ renderer?: CalloutRenderer;
215
56
  }
216
57
 
217
- async function __pw_compute_shifted_position__(
218
- referenceRect: CalloutTargetBox,
219
- floatingRect: CalloutTargetBox,
220
- placement: Placement,
221
- protectedRects: CalloutTargetBox[],
222
- viewportWidth: number,
223
- viewportHeight: number,
224
- ): Promise<{
225
- adjustmentDistance: number;
226
- arrowX: number | null;
227
- arrowY: number | null;
228
- placement: Placement;
229
- x: number;
230
- y: number;
231
- }> {
232
- const platform = __pw_create_platform__(viewportWidth, viewportHeight);
233
- const floatingElement = __pw_create_virtual_element__(floatingRect, "floating");
234
- const referenceElement = __pw_create_virtual_element__(referenceRect, "reference");
235
- const result = await computePosition(referenceElement, floatingElement, {
236
- middleware: [
237
- offset(__PW_CURSOR_ANNOTATION_GAP__),
238
- shift({
239
- limiter: limitShift({}),
240
- padding: __PW_CURSOR_ANNOTATION_MARGIN__,
241
- }),
242
- ],
243
- placement,
244
- platform,
245
- strategy: "fixed",
246
- });
247
- const resolvedPlacement = result.placement as Placement;
248
- const { side } = __pw_parse_placement__(resolvedPlacement);
249
- let x = Math.round(result.x);
250
- let y = Math.round(result.y);
251
- const baseX = x;
252
- const baseY = y;
253
- let layoutAdjustedForProtectedRect = false;
254
-
255
- for (const protectedRect of protectedRects) {
256
- const horizontalOverlap = x < protectedRect.x + protectedRect.width && x + floatingRect.width > protectedRect.x;
257
- const verticalOverlap = y < protectedRect.y + protectedRect.height && y + floatingRect.height > protectedRect.y;
258
-
259
- if ((side === "top" || side === "bottom") && horizontalOverlap) {
260
- if (side === "top" && verticalOverlap) {
261
- y = Math.min(y, protectedRect.y - floatingRect.height);
262
- layoutAdjustedForProtectedRect = true;
263
- }
264
- if (side === "bottom" && verticalOverlap) {
265
- y = Math.max(y, protectedRect.y + protectedRect.height);
266
- layoutAdjustedForProtectedRect = true;
267
- }
268
- }
269
-
270
- if ((side === "left" || side === "right") && verticalOverlap) {
271
- if (side === "left" && horizontalOverlap) {
272
- x = Math.min(x, protectedRect.x - floatingRect.width);
273
- layoutAdjustedForProtectedRect = true;
274
- }
275
- if (side === "right" && horizontalOverlap) {
276
- x = Math.max(x, protectedRect.x + protectedRect.width);
277
- layoutAdjustedForProtectedRect = true;
278
- }
279
- }
280
- }
281
-
282
- x = Math.min(
283
- Math.max(x, __PW_CURSOR_ANNOTATION_MARGIN__),
284
- Math.max(__PW_CURSOR_ANNOTATION_MARGIN__, viewportWidth - floatingRect.width - __PW_CURSOR_ANNOTATION_MARGIN__),
285
- );
286
- y = Math.min(
287
- Math.max(y, __PW_CURSOR_ANNOTATION_MARGIN__),
288
- Math.max(__PW_CURSOR_ANNOTATION_MARGIN__, viewportHeight - floatingRect.height - __PW_CURSOR_ANNOTATION_MARGIN__),
289
- );
290
- const adjustmentDistance = Math.abs(x - baseX) + Math.abs(y - baseY);
291
- const referenceCenterX = referenceRect.x + referenceRect.width / 2;
292
- const referenceCenterY = referenceRect.y + referenceRect.height / 2;
293
- const arrowPadding = 10;
294
- const arrowHalf = __PW_CURSOR_ANNOTATION_ARROW_SIZE__ / 2;
295
- const middlewareData = result.middlewareData as { arrow?: { x?: number; y?: number } };
296
- const baseArrowData = middlewareData.arrow;
297
- const arrowX = side === "top" || side === "bottom"
298
- ? !layoutAdjustedForProtectedRect && typeof baseArrowData?.x === "number"
299
- ? Math.round(baseArrowData.x)
300
- : Math.min(
301
- Math.max(referenceCenterX - x - arrowHalf, arrowPadding),
302
- Math.max(arrowPadding, floatingRect.width - __PW_CURSOR_ANNOTATION_ARROW_SIZE__ - arrowPadding),
303
- )
304
- : null;
305
- const arrowY = side === "left" || side === "right"
306
- ? !layoutAdjustedForProtectedRect && typeof baseArrowData?.y === "number"
307
- ? Math.round(baseArrowData.y)
308
- : Math.min(
309
- Math.max(referenceCenterY - y - arrowHalf, arrowPadding),
310
- Math.max(arrowPadding, floatingRect.height - __PW_CURSOR_ANNOTATION_ARROW_SIZE__ - arrowPadding),
311
- )
312
- : null;
313
-
58
+ export function measureCalloutBubble(text: string): { bubbleHeight: number; bubbleWidth: number } {
314
59
  return {
315
- adjustmentDistance,
316
- arrowX,
317
- arrowY,
318
- placement: resolvedPlacement,
319
- x,
320
- y,
60
+ bubbleWidth: Math.min(
61
+ POINTER_CALLOUT_THEME.maxWidth,
62
+ Math.max(
63
+ POINTER_CALLOUT_THEME.minWidth,
64
+ Math.min(text.length, POINTER_CALLOUT_THEME.charsPerLine) * 7 + 44,
65
+ ),
66
+ ),
67
+ bubbleHeight: Math.max(
68
+ POINTER_CALLOUT_THEME.minHeight,
69
+ Math.ceil(Math.max(text.length, 1) / POINTER_CALLOUT_THEME.charsPerLine) * 20 + 24,
70
+ ),
321
71
  };
322
72
  }
323
73
 
324
- async function __pw_compute_callout_layout__(
325
- targetRect: CalloutTargetBox,
326
- floatingRect: CalloutTargetBox,
327
- context: CalloutContext,
328
- ): Promise<CalloutLayout> {
329
- const referenceRect = targetRect;
330
- let bestLayout: (CalloutLayout & { score: number }) | null = null;
331
-
332
- for (const placement of __PW_CURSOR_ALLOWED_PLACEMENTS__) {
333
- const result = await __pw_compute_shifted_position__(
334
- referenceRect,
335
- floatingRect,
336
- placement,
337
- context.protectedTargetRects,
338
- context.viewportWidth,
339
- context.viewportHeight,
340
- );
341
-
342
- const positionedRect: CalloutTargetBox = {
343
- x: result.x,
344
- y: result.y,
345
- width: floatingRect.width,
346
- height: floatingRect.height,
347
- };
348
- const protectedOverlap = context.protectedTargetRects.reduce(
349
- (sum, avoidRect) => sum + __pw_overlap_area__(positionedRect, avoidRect),
350
- 0,
351
- );
352
- const avoidOverlap = context.avoidRects.reduce(
353
- (sum, avoidRect) => sum + __pw_overlap_area__(positionedRect, __pw_expand_rect__(avoidRect, __PW_CURSOR_ANNOTATION_AVOID_PADDING__)),
354
- 0,
355
- );
356
- const targetGap = __pw_rect_gap__(positionedRect, targetRect);
357
- const score = (protectedOverlap * 10)
358
- + (avoidOverlap * 8)
359
- + (result.adjustmentDistance * 60)
360
- + (targetGap * 40)
361
- + (__pw_rect_center_distance__(positionedRect, referenceRect) * 0.08);
362
-
363
- if (!bestLayout || score < bestLayout.score) {
364
- bestLayout = {
365
- arrowX: result.arrowX,
366
- arrowY: result.arrowY,
367
- placement: result.placement,
368
- score,
369
- staticSide: (() => {
370
- const side = __pw_parse_placement__(result.placement).side;
371
- if (side === "bottom") return "top";
372
- if (side === "left") return "right";
373
- if (side === "right") return "left";
374
- return "bottom";
375
- })(),
376
- x: result.x,
377
- y: result.y,
378
- };
379
- }
380
- }
381
-
382
- if (!bestLayout) {
383
- return {
384
- arrowX: null,
385
- arrowY: null,
386
- placement: "bottom",
387
- staticSide: "top",
388
- x: targetRect.x,
389
- y: targetRect.y,
390
- };
391
- }
392
-
393
- return bestLayout;
394
- }
395
-
396
- function __pw_get_callout_dimensions__(annotationText: string): { bubbleHeight: number; bubbleWidth: number } {
397
- const charsPerLine = 28;
398
- return {
399
- bubbleWidth: Math.min(320, Math.max(180, Math.min(annotationText.length, charsPerLine) * 7 + 44)),
400
- bubbleHeight: Math.max(52, Math.ceil(Math.max(annotationText.length, 1) / charsPerLine) * 20 + 24),
401
- };
402
- }
403
-
404
- async function __pw_ensure_callout__(page: PwPage): Promise<void> {
405
- const exists = await page.evaluate(
406
- ({ contentId, annotationId, arrowId }: { contentId: string; annotationId: string; arrowId: string }) =>
407
- document.getElementById(annotationId) != null
408
- && document.getElementById(contentId) != null
409
- && document.getElementById(arrowId) != null,
410
- {
411
- arrowId: __PW_CURSOR_ANNOTATION_ARROW_ID__,
412
- contentId: __PW_CURSOR_ANNOTATION_CONTENT_ID__,
413
- annotationId: __PW_CURSOR_ANNOTATION_ID__,
414
- },
415
- );
416
- if (exists) return;
417
-
74
+ async function __pw_ensure_simple_callout__(page: PwPage): Promise<void> {
418
75
  await page.evaluate(
419
76
  ({
420
77
  annotationId,
421
78
  contentId,
422
- arrowId,
423
- arrowSize,
424
79
  background,
425
80
  border,
426
81
  borderRadius,
@@ -429,16 +84,23 @@ async function __pw_ensure_callout__(page: PwPage): Promise<void> {
429
84
  }: {
430
85
  annotationId: string;
431
86
  contentId: string;
432
- arrowId: string;
433
- arrowSize: number;
434
87
  background: string;
435
88
  border: string;
436
89
  borderRadius: number;
437
90
  boxShadow: string;
438
91
  textColor: string;
439
92
  }) => {
440
- const annotation = document.createElement("div");
441
- annotation.setAttribute("id", annotationId);
93
+ const ensureElement = <T extends HTMLElement>(id: string, tagName: keyof HTMLElementTagNameMap): T => {
94
+ const existing = document.getElementById(id);
95
+ if (existing instanceof HTMLElement) {
96
+ return existing as T;
97
+ }
98
+ const created = document.createElement(tagName);
99
+ created.id = id;
100
+ return created as T;
101
+ };
102
+
103
+ const annotation = ensureElement<HTMLDivElement>(annotationId, "div");
442
104
  annotation.setAttribute(
443
105
  "style",
444
106
  [
@@ -461,64 +123,39 @@ async function __pw_ensure_callout__(page: PwPage): Promise<void> {
461
123
  "white-space:normal",
462
124
  "transform:translate3d(0,0,0)",
463
125
  "transform-origin:center",
464
- "isolation:isolate",
465
126
  ].join(";"),
466
127
  );
467
128
 
468
- const contentEl = document.createElement("div");
469
- contentEl.setAttribute("id", contentId);
470
- contentEl.setAttribute("style", "position:relative;z-index:1;");
129
+ const content = ensureElement<HTMLDivElement>(contentId, "div");
130
+ content.setAttribute("style", "position:relative;z-index:1;");
471
131
 
472
- const arrowEl = document.createElement("div");
473
- arrowEl.setAttribute("id", arrowId);
474
- arrowEl.setAttribute(
475
- "style",
476
- [
477
- "position:absolute",
478
- "width:" + arrowSize + "px",
479
- "height:" + arrowSize + "px",
480
- "background:" + background,
481
- "transform:rotate(45deg)",
482
- "pointer-events:none",
483
- "left:0",
484
- "top:0",
485
- "box-shadow:0 12px 24px rgba(15,23,42,0.18)",
486
- "z-index:-1",
487
- "opacity:0",
488
- ].join(";"),
489
- );
490
-
491
- annotation.appendChild(contentEl);
492
- annotation.appendChild(arrowEl);
493
- document.body.appendChild(annotation);
132
+ if (!annotation.isConnected) {
133
+ document.body.appendChild(annotation);
134
+ }
135
+ if (content.parentElement !== annotation) {
136
+ annotation.appendChild(content);
137
+ }
494
138
  },
495
139
  {
496
- annotationId: __PW_CURSOR_ANNOTATION_ID__,
497
- contentId: __PW_CURSOR_ANNOTATION_CONTENT_ID__,
498
- arrowId: __PW_CURSOR_ANNOTATION_ARROW_ID__,
499
- arrowSize: __PW_CURSOR_ANNOTATION_ARROW_SIZE__,
500
- background: __PW_CURSOR_ANNOTATION_BACKGROUND__,
501
- border: __PW_CURSOR_ANNOTATION_BORDER__,
502
- borderRadius: __PW_CURSOR_ANNOTATION_RADIUS__,
503
- boxShadow: __PW_CURSOR_ANNOTATION_BOX_SHADOW__,
504
- textColor: __PW_CURSOR_ANNOTATION_TEXT_COLOR__,
140
+ annotationId: POINTER_CALLOUT_IDS.annotation,
141
+ contentId: POINTER_CALLOUT_IDS.content,
142
+ background: POINTER_CALLOUT_THEME.background,
143
+ border: POINTER_CALLOUT_THEME.border,
144
+ borderRadius: POINTER_CALLOUT_THEME.borderRadius,
145
+ boxShadow: POINTER_CALLOUT_THEME.boxShadow,
146
+ textColor: POINTER_CALLOUT_THEME.textColor,
505
147
  },
506
148
  );
507
149
  }
508
150
 
509
- export class Callout {
510
- private readonly page: PwPage;
511
-
512
- public constructor(page: PwPage) {
513
- this.page = page;
514
- }
515
-
516
- private toLocator(target: ElementTarget): PwLocator {
517
- return typeof target === "string" ? this.page.locator(target) : target;
518
- }
519
-
520
- public async hide(): Promise<void> {
521
- await this.page.evaluate(
151
+ const __pw_default_callout_renderer__: CalloutRenderer = {
152
+ overlayIds: [
153
+ POINTER_CALLOUT_IDS.annotation,
154
+ POINTER_CALLOUT_IDS.content,
155
+ POINTER_CALLOUT_IDS.arrow,
156
+ ],
157
+ async hide(page) {
158
+ await page.evaluate(
522
159
  ({ annotationId, contentId, arrowId }: { annotationId: string; contentId: string; arrowId: string }) => {
523
160
  const annotation = document.getElementById(annotationId) as HTMLDivElement | null;
524
161
  const content = document.getElementById(contentId) as HTMLDivElement | null;
@@ -544,231 +181,42 @@ export class Callout {
544
181
  }
545
182
  },
546
183
  {
547
- annotationId: __PW_CURSOR_ANNOTATION_ID__,
548
- contentId: __PW_CURSOR_ANNOTATION_CONTENT_ID__,
549
- arrowId: __PW_CURSOR_ANNOTATION_ARROW_ID__,
184
+ annotationId: POINTER_CALLOUT_IDS.annotation,
185
+ contentId: POINTER_CALLOUT_IDS.content,
186
+ arrowId: POINTER_CALLOUT_IDS.arrow,
550
187
  },
551
188
  );
552
- }
553
-
554
- public async showForElement(
555
- target: ElementTarget,
556
- annotationText: string,
557
- options?: ShowCalloutOptions,
558
- ): Promise<void> {
559
- const text = annotationText.trim();
560
- if (!text) {
561
- await this.hide();
562
- return;
563
- }
564
-
565
- const locator = this.toLocator(target);
566
- if (!options?.skipScroll) {
567
- try {
568
- await locator.first().scrollIntoViewIfNeeded();
569
- }
570
- catch {
571
- // Element may detach during navigation; the bounding-box lookup will surface the failure.
572
- }
573
- }
574
-
575
- const targetBox = options?.targetBox ?? await locator.first().boundingBox();
576
- if (!targetBox) {
577
- throw new Error("Callout.showForElement: target has no bounding box");
578
- }
579
-
580
- await __pw_ensure_callout__(this.page);
581
- const { bubbleHeight, bubbleWidth } = __pw_get_callout_dimensions__(text);
582
- const context = await this.page.evaluate(
189
+ },
190
+ async show(page, request) {
191
+ await __pw_ensure_simple_callout__(page);
192
+ const { bubbleHeight, bubbleWidth } = measureCalloutBubble(request.text);
193
+ await page.evaluate(
583
194
  ({
584
195
  annotationId,
585
- arrowId,
586
- avoidSelector,
587
196
  contentId,
588
- cursorId,
589
- ex,
590
- ey,
591
- }: {
592
- annotationId: string;
593
- arrowId: string;
594
- avoidSelector: string;
595
- contentId: string;
596
- cursorId: string;
597
- ex: number;
598
- ey: number;
599
- }) => {
600
- type BrowserRect = { x: number; y: number; width: number; height: number };
601
-
602
- const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
603
- const viewportWidth = Math.max(window.innerWidth || 0, document.documentElement.clientWidth, 1280);
604
- const viewportHeight = Math.max(window.innerHeight || 0, document.documentElement.clientHeight, 720);
605
- const viewportArea = viewportWidth * viewportHeight;
606
- const overlayIds = new Set([annotationId, arrowId, contentId, cursorId]);
607
-
608
- const toViewportRect = (candidateRect: { left: number; top: number; right: number; bottom: number } | DOMRect): BrowserRect | null => {
609
- const left = clamp(candidateRect.left, 0, viewportWidth);
610
- const top = clamp(candidateRect.top, 0, viewportHeight);
611
- const right = clamp(candidateRect.right, 0, viewportWidth);
612
- const bottom = clamp(candidateRect.bottom, 0, viewportHeight);
613
- const width = right - left;
614
- const height = bottom - top;
615
- if (width < 24 || height < 24) {
616
- return null;
617
- }
618
-
619
- return { x: left, y: top, width, height };
620
- };
621
-
622
- const pushUniqueRect = (rects: BrowserRect[], rect: BrowserRect | null) => {
623
- if (!rect) {
624
- return;
625
- }
626
-
627
- const duplicate = rects.some((existing) =>
628
- Math.abs(existing.x - rect.x) < 1
629
- && Math.abs(existing.y - rect.y) < 1
630
- && Math.abs(existing.width - rect.width) < 1
631
- && Math.abs(existing.height - rect.height) < 1,
632
- );
633
- if (!duplicate) {
634
- rects.push(rect);
635
- }
636
- };
637
-
638
- const collectAvoidRects = (): BrowserRect[] =>
639
- Array.from(document.querySelectorAll<HTMLElement>(avoidSelector))
640
- .filter((candidate) => !overlayIds.has(candidate.id))
641
- .flatMap((candidate) => {
642
- const computedStyle = window.getComputedStyle(candidate);
643
- if (
644
- computedStyle.display === "none"
645
- || computedStyle.visibility === "hidden"
646
- || Number.parseFloat(computedStyle.opacity || "1") <= 0.05
647
- ) {
648
- return [];
649
- }
650
-
651
- const normalizedRect = toViewportRect(candidate.getBoundingClientRect());
652
- if (!normalizedRect) {
653
- return [];
654
- }
655
-
656
- if (!candidate.hasAttribute("data-callout-avoid") && normalizedRect.width * normalizedRect.height > viewportArea * 0.35) {
657
- return [];
658
- }
659
-
660
- return [normalizedRect];
661
- });
662
-
663
- const collectProtectedTargetRects = (): BrowserRect[] => {
664
- const protectedRects: BrowserRect[] = [];
665
- if (typeof document.elementFromPoint !== "function") {
666
- return protectedRects;
667
- }
668
-
669
- const targetElement = document.elementFromPoint(ex, ey);
670
- if (!(targetElement instanceof HTMLElement)) {
671
- return protectedRects;
672
- }
673
-
674
- const targetRect = toViewportRect(targetElement.getBoundingClientRect());
675
- pushUniqueRect(protectedRects, targetRect);
676
- if (!targetRect) {
677
- return protectedRects;
678
- }
679
-
680
- const targetArea = targetRect.width * targetRect.height;
681
- let ancestor = targetElement.parentElement;
682
- while (ancestor && ancestor !== document.body) {
683
- const computedStyle = window.getComputedStyle(ancestor);
684
- if (
685
- computedStyle.display === "none"
686
- || computedStyle.visibility === "hidden"
687
- || Number.parseFloat(computedStyle.opacity || "1") <= 0.05
688
- ) {
689
- ancestor = ancestor.parentElement;
690
- continue;
691
- }
692
-
693
- const ancestorRect = toViewportRect(ancestor.getBoundingClientRect());
694
- if (!ancestorRect) {
695
- ancestor = ancestor.parentElement;
696
- continue;
697
- }
698
-
699
- const ancestorArea = ancestorRect.width * ancestorRect.height;
700
- const clearlyBiggerThanTarget = ancestorArea >= Math.max(targetArea * 1.75, 18_000);
701
- const stillReasonablyLocal = ancestorArea <= viewportArea * 0.28;
702
- const containsTarget = ancestorRect.x <= targetRect.x
703
- && ancestorRect.y <= targetRect.y
704
- && ancestorRect.x + ancestorRect.width >= targetRect.x + targetRect.width
705
- && ancestorRect.y + ancestorRect.height >= targetRect.y + targetRect.height;
706
- if (clearlyBiggerThanTarget && stillReasonablyLocal && containsTarget) {
707
- pushUniqueRect(protectedRects, ancestorRect);
708
- }
709
-
710
- if (protectedRects.length >= 3) {
711
- break;
712
- }
713
-
714
- ancestor = ancestor.parentElement;
715
- }
716
-
717
- return protectedRects;
718
- };
719
-
720
- return {
721
- avoidRects: collectAvoidRects(),
722
- protectedTargetRects: collectProtectedTargetRects(),
723
- viewportHeight,
724
- viewportWidth,
725
- };
726
- },
727
- {
728
- annotationId: __PW_CURSOR_ANNOTATION_ID__,
729
- arrowId: __PW_CURSOR_ANNOTATION_ARROW_ID__,
730
- avoidSelector: __PW_CURSOR_ANNOTATION_AVOID_SELECTOR__,
731
- contentId: __PW_CURSOR_ANNOTATION_CONTENT_ID__,
732
- cursorId: __PW_CURSOR_ID__,
733
- ex: targetBox.x + targetBox.width / 2,
734
- ey: targetBox.y + targetBox.height / 2,
735
- },
736
- );
737
- const layout = await __pw_compute_callout_layout__(
738
- { x: targetBox.x, y: targetBox.y, width: targetBox.width, height: targetBox.height },
739
- { x: 0, y: 0, width: bubbleWidth, height: bubbleHeight },
740
- context,
741
- );
742
-
743
- await this.page.evaluate(
744
- ({
745
- annotationId,
746
197
  arrowId,
747
- arrowSize,
748
- background,
749
- border,
750
- borderRadius,
751
198
  bubbleHeight,
752
199
  bubbleWidth,
753
- contentId,
754
- fallbackX,
755
- fallbackY,
756
- layout,
200
+ border,
201
+ borderRadius,
202
+ background,
203
+ gap,
757
204
  text,
205
+ targetBox,
206
+ margin,
758
207
  }: {
759
208
  annotationId: string;
209
+ contentId: string;
760
210
  arrowId: string;
761
- arrowSize: number;
762
- background: string;
763
- border: string;
764
- borderRadius: number;
765
211
  bubbleHeight: number;
766
212
  bubbleWidth: number;
767
- contentId: string;
768
- fallbackX: number;
769
- fallbackY: number;
770
- layout: CalloutLayout | null;
213
+ border: string;
214
+ borderRadius: number;
215
+ background: string;
216
+ gap: number;
771
217
  text: string;
218
+ targetBox: CalloutTargetBox;
219
+ margin: number;
772
220
  }) => {
773
221
  const annotation = document.getElementById(annotationId) as HTMLDivElement | null;
774
222
  const content = document.getElementById(contentId) as HTMLDivElement | null;
@@ -777,6 +225,52 @@ export class Callout {
777
225
  return;
778
226
  }
779
227
 
228
+ const viewportWidth = Math.max(window.innerWidth || 0, document.documentElement.clientWidth, 1280);
229
+ const viewportHeight = Math.max(window.innerHeight || 0, document.documentElement.clientHeight, 720);
230
+ const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
231
+ const targetCenterX = targetBox.x + targetBox.width / 2;
232
+ const targetCenterY = targetBox.y + targetBox.height / 2;
233
+ const maxX = Math.max(margin, viewportWidth - bubbleWidth - margin);
234
+ const maxY = Math.max(margin, viewportHeight - bubbleHeight - margin);
235
+ const candidates = [
236
+ {
237
+ placement: "right",
238
+ x: targetBox.x + targetBox.width + gap,
239
+ y: targetCenterY - bubbleHeight / 2,
240
+ },
241
+ {
242
+ placement: "bottom",
243
+ x: targetCenterX - bubbleWidth / 2,
244
+ y: targetBox.y + targetBox.height + gap,
245
+ },
246
+ {
247
+ placement: "left",
248
+ x: targetBox.x - bubbleWidth - gap,
249
+ y: targetCenterY - bubbleHeight / 2,
250
+ },
251
+ {
252
+ placement: "top",
253
+ x: targetCenterX - bubbleWidth / 2,
254
+ y: targetBox.y - bubbleHeight - gap,
255
+ },
256
+ ] as const;
257
+
258
+ let placement = "center";
259
+ let resolvedX = clamp(targetCenterX - bubbleWidth / 2, margin, maxX);
260
+ let resolvedY = clamp(targetCenterY - bubbleHeight / 2, margin, maxY);
261
+
262
+ for (const candidate of candidates) {
263
+ const clampedX = clamp(candidate.x, margin, maxX);
264
+ const clampedY = clamp(candidate.y, margin, maxY);
265
+ const fitsWithoutShift = Math.abs(clampedX - candidate.x) < 1 && Math.abs(clampedY - candidate.y) < 1;
266
+ if (fitsWithoutShift) {
267
+ placement = candidate.placement;
268
+ resolvedX = candidate.x;
269
+ resolvedY = candidate.y;
270
+ break;
271
+ }
272
+ }
273
+
780
274
  content.textContent = text;
781
275
  annotation.style.width = `${bubbleWidth}px`;
782
276
  annotation.style.minHeight = `${bubbleHeight}px`;
@@ -785,43 +279,99 @@ export class Callout {
785
279
  annotation.style.borderRadius = `${borderRadius}px`;
786
280
  annotation.style.transition = "opacity 120ms ease-in-out, transform 160ms ease-in-out";
787
281
  annotation.style.willChange = "left, top, opacity, transform";
788
- annotation.style.left = `${layout?.x ?? fallbackX}px`;
789
- annotation.style.top = `${layout?.y ?? fallbackY}px`;
282
+ annotation.style.left = `${Math.round(resolvedX)}px`;
283
+ annotation.style.top = `${Math.round(resolvedY)}px`;
790
284
  annotation.style.opacity = "1";
791
285
  annotation.style.transform = "scale(1)";
792
- annotation.setAttribute("data-placement", layout?.placement ?? "hidden");
793
-
794
- if (arrow && layout) {
286
+ annotation.setAttribute("data-placement", placement);
287
+ if (arrow) {
288
+ arrow.style.opacity = "0";
795
289
  arrow.style.left = "";
796
290
  arrow.style.top = "";
797
291
  arrow.style.right = "";
798
292
  arrow.style.bottom = "";
799
- arrow.style.transform = "rotate(45deg)";
800
- if (layout.arrowX !== null) {
801
- arrow.style.left = `${layout.arrowX}px`;
802
- }
803
- if (layout.arrowY !== null) {
804
- arrow.style.top = `${layout.arrowY}px`;
805
- }
806
- arrow.style.setProperty(layout.staticSide, `${Math.round(arrowSize / -2)}px`);
807
- arrow.style.opacity = "1";
808
293
  }
809
294
  },
810
295
  {
811
- annotationId: __PW_CURSOR_ANNOTATION_ID__,
812
- arrowId: __PW_CURSOR_ANNOTATION_ARROW_ID__,
813
- arrowSize: __PW_CURSOR_ANNOTATION_ARROW_SIZE__,
814
- background: __PW_CURSOR_ANNOTATION_BACKGROUND__,
815
- border: __PW_CURSOR_ANNOTATION_BORDER__,
816
- borderRadius: __PW_CURSOR_ANNOTATION_RADIUS__,
296
+ annotationId: POINTER_CALLOUT_IDS.annotation,
297
+ contentId: POINTER_CALLOUT_IDS.content,
298
+ arrowId: POINTER_CALLOUT_IDS.arrow,
817
299
  bubbleHeight,
818
300
  bubbleWidth,
819
- contentId: __PW_CURSOR_ANNOTATION_CONTENT_ID__,
820
- fallbackX: targetBox.x + (targetBox.width / 2) + __PW_CURSOR_ANNOTATION_GAP__,
821
- fallbackY: targetBox.y + (targetBox.height / 2) + __PW_CURSOR_ANNOTATION_GAP__,
822
- layout,
823
- text,
301
+ border: POINTER_CALLOUT_THEME.border,
302
+ borderRadius: POINTER_CALLOUT_THEME.borderRadius,
303
+ background: POINTER_CALLOUT_THEME.background,
304
+ gap: POINTER_CALLOUT_THEME.gap,
305
+ text: request.text,
306
+ targetBox: request.targetBox,
307
+ margin: POINTER_CALLOUT_THEME.margin,
824
308
  },
825
309
  );
310
+ },
311
+ };
312
+
313
+ export const simpleCalloutRenderer = __pw_default_callout_renderer__;
314
+
315
+ export class Callout {
316
+ private readonly page: PwPage;
317
+ private readonly extraOverlayIds: string[];
318
+ private readonly renderer: CalloutRenderer;
319
+
320
+ public constructor(page: PwPage, options?: CalloutOptions) {
321
+ this.page = page;
322
+ this.extraOverlayIds = options?.extraOverlayIds ?? [];
323
+ this.renderer = options?.renderer ?? __pw_default_callout_renderer__;
324
+ }
325
+
326
+ private toLocator(target: ElementTarget): PwLocator {
327
+ return typeof target === "string" ? this.page.locator(target) : target;
328
+ }
329
+
330
+ public async hide(): Promise<void> {
331
+ await this.renderer.hide(this.page);
332
+ }
333
+
334
+ public async showForElement(
335
+ target: ElementTarget,
336
+ annotationText: string,
337
+ options?: ShowCalloutOptions,
338
+ ): Promise<void> {
339
+ const text = annotationText.trim();
340
+ if (!text) {
341
+ await this.hide();
342
+ return;
343
+ }
344
+
345
+ const locator = this.toLocator(target);
346
+ if (!options?.skipScroll) {
347
+ try {
348
+ await locator.first().scrollIntoViewIfNeeded();
349
+ }
350
+ catch {
351
+ // Element may detach during navigation; the bounding-box lookup will surface the failure.
352
+ }
353
+ }
354
+
355
+ const targetBox = options?.targetBox ?? await locator.first().boundingBox();
356
+ if (!targetBox) {
357
+ throw new Error("Callout.showForElement: target has no bounding box");
358
+ }
359
+
360
+ const overlayIds = Array.from(new Set([
361
+ ...(this.renderer.overlayIds ?? []),
362
+ ...this.extraOverlayIds,
363
+ ]));
364
+
365
+ await this.renderer.show(this.page, {
366
+ overlayIds,
367
+ target,
368
+ targetBox: {
369
+ height: targetBox.height,
370
+ width: targetBox.width,
371
+ x: targetBox.x,
372
+ y: targetBox.y,
373
+ },
374
+ text,
375
+ });
826
376
  }
827
377
  }