kviewer 0.0.9 → 0.0.10

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 (26) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/runtime/annotation/engine/editor/editor.d.ts +6 -3
  3. package/dist/runtime/annotation/engine/editor/editor.js +22 -9
  4. package/dist/runtime/annotation/engine/editor/selector.d.ts +9 -1
  5. package/dist/runtime/annotation/engine/editor/selector.js +72 -30
  6. package/dist/runtime/annotation/engine/painter.d.ts +2 -1
  7. package/dist/runtime/annotation/engine/painter.js +16 -4
  8. package/dist/runtime/components/PdfPage.d.vue.ts +6 -1
  9. package/dist/runtime/components/PdfPage.vue +51 -18
  10. package/dist/runtime/components/PdfPage.vue.d.ts +6 -1
  11. package/dist/runtime/components/ToolButton.d.vue.ts +4 -2
  12. package/dist/runtime/components/ToolButton.vue.d.ts +4 -2
  13. package/dist/runtime/components/Viewer.vue +213 -164
  14. package/dist/runtime/components/modals/FreeTextModal.vue +27 -3
  15. package/dist/runtime/components/tools/ActionTools.vue +4 -5
  16. package/dist/runtime/composables/useAnnotationEngine.d.ts +1 -0
  17. package/dist/runtime/composables/useAnnotationEngine.js +4 -1
  18. package/dist/runtime/composables/useAnnotationHistory.d.ts +1 -0
  19. package/dist/runtime/composables/useAnnotationHistory.js +10 -3
  20. package/dist/runtime/composables/useInertiaPanzoom.d.ts +40 -0
  21. package/dist/runtime/composables/useInertiaPanzoom.js +472 -0
  22. package/dist/runtime/composables/usePageVirtualization.d.ts +3 -0
  23. package/dist/runtime/composables/usePageVirtualization.js +23 -0
  24. package/dist/runtime/composables/useViewerSearch.d.ts +1 -0
  25. package/dist/runtime/composables/useViewerSearch.js +15 -5
  26. package/package.json +1 -1
@@ -8,8 +8,8 @@
8
8
  <div class="relative flex-1 min-h-0">
9
9
  <div
10
10
  ref="scrollContainer"
11
- class="absolute inset-0 overflow-auto bg-muted/50"
12
- :class="{ 'overflow-hidden': isSinglePageMode }"
11
+ class="absolute inset-0 overflow-hidden bg-[#F0F3F5] dark:bg-muted/50"
12
+ style="overscroll-behavior: none"
13
13
  data-testid="viewer-scroll"
14
14
  >
15
15
  <!-- Error state -->
@@ -39,24 +39,29 @@
39
39
  </div>
40
40
 
41
41
  <ClientOnly v-else>
42
- <!-- Single-page-at-a-time mode -->
42
+ <!-- Single-page mode with panzoom for pinch-to-zoom -->
43
43
  <div
44
44
  v-if="isSinglePageMode && singlePageMeta"
45
- class="flex flex-col items-center py-4"
46
- :style="{ minHeight: '100%' }"
45
+ class="panzoom-viewport"
47
46
  >
48
47
  <div
49
- :ref="(el) => onPageRef(singlePageMeta.pageNumber, el)"
50
- :style="pageSlotStyle(singlePageMeta)"
51
- class="kviewer-page-slot"
48
+ ref="zoomWrapper"
49
+ class="flex flex-col items-center justify-center p-4"
50
+ :style="{ minHeight: '100%' }"
52
51
  >
53
- <div :style="pageRotationStyle(singlePageMeta)">
54
- <PdfPage
55
- :key="singlePageMeta.pageNumber"
56
- :page-number="singlePageMeta.pageNumber"
57
- :page-meta="singlePageMeta"
58
- :scale="pageScale"
59
- />
52
+ <div
53
+ :ref="(el) => onPageRef(singlePageMeta.pageNumber, el)"
54
+ :style="pageSlotStyle(singlePageMeta)"
55
+ class="kviewer-page-slot"
56
+ >
57
+ <div :style="pageRotationStyle(singlePageMeta)">
58
+ <PdfPage
59
+ :key="singlePageMeta.pageNumber"
60
+ :page-number="singlePageMeta.pageNumber"
61
+ :page-meta="singlePageMeta"
62
+ :scale="pageScale"
63
+ />
64
+ </div>
60
65
  </div>
61
66
  </div>
62
67
  </div>
@@ -64,38 +69,14 @@
64
69
  <!-- Continuous scroll — single-page layout -->
65
70
  <div
66
71
  v-else-if="pageSettings.layoutMode.value === 'single'"
67
- class="flex flex-col items-center gap-4 py-4"
72
+ class="panzoom-viewport"
68
73
  >
69
74
  <div
70
- v-for="meta in pageMetas"
71
- :key="meta.pageNumber"
72
- :ref="(el) => onPageRef(meta.pageNumber, el)"
73
- :style="pageSlotStyle(meta)"
74
- class="kviewer-page-slot"
75
- >
76
- <div :style="pageRotationStyle(meta)">
77
- <PdfPage
78
- v-if="isPageRendered(meta.pageNumber)"
79
- :page-number="meta.pageNumber"
80
- :page-meta="meta"
81
- :scale="pageScale"
82
- />
83
- </div>
84
- </div>
85
- </div>
86
-
87
- <!-- Continuous scroll — two-page (side-by-side) layout -->
88
- <div
89
- v-else
90
- class="flex flex-col items-center gap-4 py-4"
91
- >
92
- <div
93
- v-for="(pair, index) in pagedPairs"
94
- :key="index"
95
- class="flex gap-4"
75
+ ref="zoomWrapper"
76
+ class="flex flex-col gap-4 p-4"
96
77
  >
97
78
  <div
98
- v-for="meta in pair"
79
+ v-for="meta in pageMetas"
99
80
  :key="meta.pageNumber"
100
81
  :ref="(el) => onPageRef(meta.pageNumber, el)"
101
82
  :style="pageSlotStyle(meta)"
@@ -112,6 +93,40 @@
112
93
  </div>
113
94
  </div>
114
95
  </div>
96
+
97
+ <!-- Continuous scroll — two-page (side-by-side) layout -->
98
+ <div
99
+ v-else
100
+ class="panzoom-viewport"
101
+ >
102
+ <div
103
+ ref="zoomWrapper"
104
+ class="flex flex-col gap-4 p-4"
105
+ >
106
+ <div
107
+ v-for="(pair, index) in pagedPairs"
108
+ :key="index"
109
+ class="flex gap-4"
110
+ >
111
+ <div
112
+ v-for="meta in pair"
113
+ :key="meta.pageNumber"
114
+ :ref="(el) => onPageRef(meta.pageNumber, el)"
115
+ :style="pageSlotStyle(meta)"
116
+ class="kviewer-page-slot"
117
+ >
118
+ <div :style="pageRotationStyle(meta)">
119
+ <PdfPage
120
+ v-if="isPageRendered(meta.pageNumber)"
121
+ :page-number="meta.pageNumber"
122
+ :page-meta="meta"
123
+ :scale="pageScale"
124
+ />
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ </div>
115
130
  </ClientOnly>
116
131
  </div>
117
132
 
@@ -136,6 +151,16 @@
136
151
  @submit="onFreeTextSubmit"
137
152
  @cancel="onFreeTextCancel"
138
153
  />
154
+ <!-- iOS focus bridge: focused synchronously in the tap handler so the
155
+ keyboard pops up; modal textarea takes over on mount without
156
+ closing the keyboard. -->
157
+ <input
158
+ ref="iosFocusBridge"
159
+ type="text"
160
+ aria-hidden="true"
161
+ tabindex="-1"
162
+ class="ios-focus-bridge"
163
+ >
139
164
  </ClientOnly>
140
165
  </div>
141
166
  </template>
@@ -174,7 +199,8 @@ import { createPageProxyCache } from "../composables/usePageProxyCache";
174
199
  import { createSearchIndex } from "../composables/useSearchIndex";
175
200
  import { createShapeDetection } from "../composables/useShapeDetection";
176
201
  import { provideFormFields } from "../composables/useFormFields";
177
- import { isIPad, getTouchDistance, getTouchCenter, clampScale } from "../annotation/engine/input-device";
202
+ import { isIPad } from "../annotation/engine/input-device";
203
+ import { useInertiaPanzoom } from "../composables/useInertiaPanzoom";
178
204
  const props = defineProps({
179
205
  source: { type: null, required: true },
180
206
  textLayer: { type: Boolean, required: false },
@@ -204,8 +230,12 @@ const virtualization = createPageVirtualization();
204
230
  setScrollToPageFn((pageNumber) => {
205
231
  if (isSinglePageMode.value) {
206
232
  viewerState.currentPage.value = pageNumber;
207
- } else {
208
- virtualization.scrollToPage(pageNumber);
233
+ return;
234
+ }
235
+ const el = virtualization.getPageElement(pageNumber);
236
+ if (el) {
237
+ inertiaPanzoom.scrollToElement(el, { block: "start" });
238
+ viewerState.currentPage.value = pageNumber;
209
239
  }
210
240
  });
211
241
  const proxyCache = createPageProxyCache();
@@ -241,6 +271,8 @@ const isRotatedSideways = computed(() => {
241
271
  const r = pageSettings.rotation.value;
242
272
  return r === 90 || r === 270;
243
273
  });
274
+ const zoomWrapper = ref(null);
275
+ const inertiaPanzoom = useInertiaPanzoom({ minScale: 0.5, maxScale: 4 });
244
276
  function pageSlotStyle(meta) {
245
277
  const s = pageScale.value;
246
278
  if (isRotatedSideways.value) {
@@ -297,8 +329,10 @@ const pagedPairs = computed(() => {
297
329
  });
298
330
  const freeTextModalOpen = ref(false);
299
331
  const freeTextDefaults = ref({ color: "#ff0000", fontSize: 18 });
332
+ const iosFocusBridge = ref(null);
300
333
  let freeTextResolve = null;
301
334
  function onRequestTextInput(opts) {
335
+ iosFocusBridge.value?.focus({ preventScroll: true });
302
336
  return new Promise((resolve) => {
303
337
  freeTextDefaults.value = {
304
338
  color: opts.defaultColor,
@@ -342,6 +376,12 @@ async function loadDocument() {
342
376
  viewerState.doc.value = pdfDoc;
343
377
  viewerState.totalPages.value = pdfDoc.numPages;
344
378
  proxyCache.setDocument(pdfDoc);
379
+ if (!scrollContainer.value) {
380
+ await nextTick();
381
+ }
382
+ if (!scrollContainer.value) {
383
+ throw new Error("Scroll container not available");
384
+ }
345
385
  await virtualization.init(pdfDoc, scrollContainer.value);
346
386
  const metas = virtualization.pageMetas.value;
347
387
  if (metas.length > 0) {
@@ -378,7 +418,17 @@ async function loadDocument() {
378
418
  );
379
419
  }
380
420
  viewerSearch.setScrollToPage((pageNumber) => {
381
- virtualization.scrollToPage(pageNumber);
421
+ if (isSinglePageMode.value) {
422
+ viewerState.currentPage.value = pageNumber;
423
+ return;
424
+ }
425
+ const el = virtualization.getPageElement(pageNumber);
426
+ if (el) inertiaPanzoom.scrollToElement(el, { block: "start" });
427
+ });
428
+ viewerSearch.setScrollToElement((el) => {
429
+ if (!isSinglePageMode.value) {
430
+ inertiaPanzoom.scrollToElement(el, { block: "center" });
431
+ }
382
432
  });
383
433
  searchIndex.buildIndex(pdfDoc).then(() => {
384
434
  viewerSearch.setSearchIndex({
@@ -481,116 +531,97 @@ async function importAnnotations(annotations, options) {
481
531
  function getKonvaCanvasState() {
482
532
  return painter?.getCanvasState() ?? {};
483
533
  }
484
- let pinchActive = false;
485
- let pinchStartDistance = 0;
486
- let pinchStartScale = 1;
487
- let pinchCurrentRatio = 1;
488
- let pinchOriginX = 0;
489
- let pinchOriginY = 0;
490
- let pinchStartViewportX = 0;
491
- let pinchStartViewportY = 0;
492
- let pinchCurrentViewportX = 0;
493
- let pinchCurrentViewportY = 0;
494
- function getPinchTarget() {
495
- return scrollContainer.value?.firstElementChild;
496
- }
497
- function onPinchTouchStart(e) {
498
- if (!viewerState.stylusMode.value) return;
499
- if (e.touches.length === 2) {
500
- const sc = scrollContainer.value;
501
- const scRect = sc.getBoundingClientRect();
502
- const center = getTouchCenter(e.touches[0], e.touches[1]);
503
- pinchActive = true;
504
- pinchStartDistance = getTouchDistance(e.touches[0], e.touches[1]);
505
- pinchStartScale = viewerState.scale.value;
506
- pinchCurrentRatio = 1;
507
- pinchStartViewportX = center.x - scRect.left;
508
- pinchStartViewportY = center.y - scRect.top;
509
- pinchCurrentViewportX = pinchStartViewportX;
510
- pinchCurrentViewportY = pinchStartViewportY;
511
- pinchOriginX = pinchStartViewportX + sc.scrollLeft;
512
- pinchOriginY = pinchStartViewportY + sc.scrollTop;
513
- const target = getPinchTarget();
514
- if (target) {
515
- target.style.transformOrigin = `${pinchOriginX}px ${pinchOriginY}px`;
516
- target.style.willChange = "transform";
517
- }
518
- }
519
- }
520
- function onPinchTouchMove(e) {
521
- if (!viewerState.stylusMode.value || !pinchActive || e.touches.length < 2) return;
522
- e.preventDefault();
523
- const sc = scrollContainer.value;
524
- if (!sc) return;
525
- const scRect = sc.getBoundingClientRect();
526
- const currentDistance = getTouchDistance(e.touches[0], e.touches[1]);
527
- pinchCurrentRatio = currentDistance / pinchStartDistance;
528
- const clamped = clampScale(pinchStartScale * pinchCurrentRatio);
529
- pinchCurrentRatio = clamped / pinchStartScale;
530
- const center = getTouchCenter(e.touches[0], e.touches[1]);
531
- pinchCurrentViewportX = center.x - scRect.left;
532
- pinchCurrentViewportY = center.y - scRect.top;
533
- const target = getPinchTarget();
534
- if (target) target.style.transform = `scale(${pinchCurrentRatio})`;
535
- sc.scrollLeft = pinchOriginX * pinchCurrentRatio - pinchCurrentViewportX;
536
- sc.scrollTop = pinchOriginY * pinchCurrentRatio - pinchCurrentViewportY;
537
- }
538
- function onPinchTouchEnd(e) {
539
- if (e.touches.length < 2 && pinchActive) {
540
- pinchActive = false;
541
- const target = getPinchTarget();
542
- if (target) {
543
- target.style.transform = "";
544
- target.style.willChange = "";
545
- }
546
- const finalScale = clampScale(pinchStartScale * pinchCurrentRatio);
547
- if (Math.abs(finalScale - pinchStartScale) > 0.01) {
548
- const layoutRatio = finalScale / pinchStartScale;
549
- const newScrollLeft = pinchOriginX * layoutRatio - pinchCurrentViewportX;
550
- const newScrollTop = pinchOriginY * layoutRatio - pinchCurrentViewportY;
551
- viewerState.setScale(finalScale);
552
- nextTick(() => {
553
- const sc = scrollContainer.value;
554
- if (sc) {
555
- sc.scrollLeft = newScrollLeft;
556
- sc.scrollTop = newScrollTop;
557
- }
558
- });
559
- }
560
- pinchCurrentRatio = 1;
561
- }
534
+ function initPanzoom() {
535
+ inertiaPanzoom.detach();
536
+ if (!zoomWrapper.value) return;
537
+ inertiaPanzoom.attach(zoomWrapper.value);
562
538
  }
563
539
  let wheelCooldown = false;
564
- function onSinglePageWheel(event) {
565
- if (!isSinglePageMode.value) return;
566
- event.preventDefault();
567
- if (wheelCooldown) return;
568
- wheelCooldown = true;
569
- setTimeout(() => {
570
- wheelCooldown = false;
571
- }, 300);
572
- const total = viewerState.totalPages.value;
573
- const cur = viewerState.currentPage.value;
574
- if (event.deltaY > 0 && cur < total) {
575
- viewerState.currentPage.value = cur + 1;
576
- } else if (event.deltaY < 0 && cur > 1) {
577
- viewerState.currentPage.value = cur - 1;
578
- }
579
- }
580
- function onGlobalKeydown(event) {
581
- if (!props.active) return;
540
+ function onWheel(event) {
541
+ if (event.ctrlKey || event.metaKey) return;
582
542
  if (isSinglePageMode.value) {
543
+ event.preventDefault();
544
+ if (wheelCooldown) return;
545
+ wheelCooldown = true;
546
+ setTimeout(() => {
547
+ wheelCooldown = false;
548
+ }, 300);
583
549
  const total = viewerState.totalPages.value;
584
550
  const cur = viewerState.currentPage.value;
585
- if ((event.key === "ArrowDown" || event.key === "PageDown") && cur < total) {
586
- event.preventDefault();
551
+ if (event.deltaY > 0 && cur < total) {
587
552
  viewerState.currentPage.value = cur + 1;
588
- return;
589
- }
590
- if ((event.key === "ArrowUp" || event.key === "PageUp") && cur > 1) {
591
- event.preventDefault();
553
+ } else if (event.deltaY < 0 && cur > 1) {
592
554
  viewerState.currentPage.value = cur - 1;
593
- return;
555
+ }
556
+ return;
557
+ }
558
+ event.preventDefault();
559
+ const dx = event.shiftKey ? event.deltaY : event.deltaX;
560
+ const dy = event.shiftKey ? 0 : event.deltaY;
561
+ inertiaPanzoom.scrollBy(dx, dy);
562
+ }
563
+ function isTextInputTarget(target) {
564
+ if (!(target instanceof HTMLElement)) return false;
565
+ const tag = target.tagName;
566
+ return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable;
567
+ }
568
+ function onGlobalKeydown(event) {
569
+ if (!props.active) return;
570
+ const typing = isTextInputTarget(event.target);
571
+ if (!typing) {
572
+ if (isSinglePageMode.value) {
573
+ const total = viewerState.totalPages.value;
574
+ const cur = viewerState.currentPage.value;
575
+ if ((event.key === "ArrowDown" || event.key === "PageDown") && cur < total) {
576
+ event.preventDefault();
577
+ viewerState.currentPage.value = cur + 1;
578
+ return;
579
+ }
580
+ if ((event.key === "ArrowUp" || event.key === "PageUp") && cur > 1) {
581
+ event.preventDefault();
582
+ viewerState.currentPage.value = cur - 1;
583
+ return;
584
+ }
585
+ } else {
586
+ const vh = scrollContainer.value?.clientHeight ?? 600;
587
+ const arrowStep = 40;
588
+ const pageStep = Math.max(100, vh * 0.9);
589
+ const BIG = 1e9;
590
+ switch (event.key) {
591
+ case "ArrowDown":
592
+ event.preventDefault();
593
+ inertiaPanzoom.scrollBy(0, arrowStep);
594
+ return;
595
+ case "ArrowUp":
596
+ event.preventDefault();
597
+ inertiaPanzoom.scrollBy(0, -arrowStep);
598
+ return;
599
+ case "ArrowRight":
600
+ event.preventDefault();
601
+ inertiaPanzoom.scrollBy(arrowStep, 0);
602
+ return;
603
+ case "ArrowLeft":
604
+ event.preventDefault();
605
+ inertiaPanzoom.scrollBy(-arrowStep, 0);
606
+ return;
607
+ case "PageDown":
608
+ case " ":
609
+ event.preventDefault();
610
+ inertiaPanzoom.scrollBy(0, pageStep);
611
+ return;
612
+ case "PageUp":
613
+ event.preventDefault();
614
+ inertiaPanzoom.scrollBy(0, -pageStep);
615
+ return;
616
+ case "Home":
617
+ event.preventDefault();
618
+ inertiaPanzoom.scrollBy(0, -BIG);
619
+ return;
620
+ case "End":
621
+ event.preventDefault();
622
+ inertiaPanzoom.scrollBy(0, BIG);
623
+ return;
624
+ }
594
625
  }
595
626
  }
596
627
  const isFindShortcut = (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "f";
@@ -611,7 +642,12 @@ onMounted(() => {
611
642
  painter = createAnnotationEngine(viewerState, {
612
643
  userName: props.userName,
613
644
  onRequestTextInput,
614
- freehandGroupingDelay: props.freehandGroupingDelay
645
+ freehandGroupingDelay: props.freehandGroupingDelay,
646
+ // Suspend panzoom gestures while an annotation is being dragged or
647
+ // transformed. Otherwise single-finger pan runs in parallel with the
648
+ // Konva shape drag on mobile and the two CSS transforms fight every
649
+ // frame, making text drag feel laggy.
650
+ setExternalGesturesEnabled: (enabled) => inertiaPanzoom.setGesturesEnabled(enabled)
615
651
  });
616
652
  viewerState.selectTool(viewerState.activeTool.value);
617
653
  if (props.stamps) viewerState.stamps.value = props.stamps;
@@ -630,14 +666,26 @@ onMounted(() => {
630
666
  }
631
667
  });
632
668
  resizeObserver.observe(scrollContainer.value);
633
- scrollContainer.value.addEventListener("scroll", () => {
634
- viewerState.selectionRect.value = null;
635
- }, { passive: true });
636
- scrollContainer.value.addEventListener("wheel", onSinglePageWheel, { passive: false });
637
- scrollContainer.value.addEventListener("touchstart", onPinchTouchStart, { passive: true });
638
- scrollContainer.value.addEventListener("touchmove", onPinchTouchMove, { passive: false });
639
- scrollContainer.value.addEventListener("touchend", onPinchTouchEnd, { passive: true });
640
- scrollContainer.value.addEventListener("touchcancel", onPinchTouchEnd, { passive: true });
669
+ scrollContainer.value.addEventListener("wheel", onWheel, { passive: false });
670
+ inertiaPanzoom.onChange(() => {
671
+ if (viewerState.selectionRect.value) viewerState.selectionRect.value = null;
672
+ if (!isSinglePageMode.value) virtualization.updateCurrentPageByRect();
673
+ });
674
+ nextTick(() => initPanzoom());
675
+ watch(
676
+ [isSinglePageMode, () => pageSettings.layoutMode.value],
677
+ () => nextTick(() => initPanzoom())
678
+ );
679
+ watch(
680
+ [() => viewerState.activeTool.value, () => viewerState.stylusMode.value],
681
+ ([tool, stylusMode]) => {
682
+ const enable = stylusMode || tool === "hand";
683
+ inertiaPanzoom.setGesturesEnabled(enable);
684
+ },
685
+ { immediate: true }
686
+ );
687
+ watch(() => viewerState.scale.value, () => inertiaPanzoom.reset());
688
+ watch(() => viewerState.currentPage.value, () => inertiaPanzoom.reset());
641
689
  }
642
690
  if (isIPad()) {
643
691
  viewerState.setStylusMode(true);
@@ -687,11 +735,8 @@ watch(
687
735
  );
688
736
  onBeforeUnmount(() => {
689
737
  window.removeEventListener("keydown", onGlobalKeydown);
690
- scrollContainer.value?.removeEventListener("wheel", onSinglePageWheel);
691
- scrollContainer.value?.removeEventListener("touchstart", onPinchTouchStart);
692
- scrollContainer.value?.removeEventListener("touchmove", onPinchTouchMove);
693
- scrollContainer.value?.removeEventListener("touchend", onPinchTouchEnd);
694
- scrollContainer.value?.removeEventListener("touchcancel", onPinchTouchEnd);
738
+ scrollContainer.value?.removeEventListener("wheel", onWheel);
739
+ inertiaPanzoom.detach();
695
740
  resizeObserver?.disconnect();
696
741
  viewerSearch.reset();
697
742
  shapeDetectionCache.clearAllShapeCache();
@@ -710,3 +755,7 @@ defineExpose({
710
755
  setFormFieldValue: (fieldName, value) => formFieldsState.setFieldValueByName(fieldName, value)
711
756
  });
712
757
  </script>
758
+
759
+ <style scoped>
760
+ .kviewer-page-slot{margin-left:auto;margin-right:auto}.panzoom-viewport{height:100%;overflow:hidden;position:relative;width:100%}.ios-focus-bridge{border:0;font-size:16px;height:1px;left:0;opacity:0;padding:0;pointer-events:none;position:fixed;top:0;width:1px;z-index:-1}
761
+ </style>
@@ -7,10 +7,10 @@
7
7
  <template #body>
8
8
  <div class="flex flex-col gap-3">
9
9
  <UTextarea
10
+ ref="textareaRef"
10
11
  v-model="inputValue"
11
12
  placeholder="Start typing..."
12
13
  :rows="3"
13
- autofocus
14
14
  />
15
15
  <div class="flex items-center justify-between">
16
16
  <div class="flex gap-1">
@@ -22,6 +22,7 @@
22
22
  c === currentColor ? 'border-white ring-2 ring-blue-500' : 'border-transparent'
23
23
  "
24
24
  :style="{ backgroundColor: c }"
25
+ @pointerdown="onPreserveFocus"
25
26
  @click="currentColor = c"
26
27
  />
27
28
  </div>
@@ -39,14 +40,19 @@
39
40
  <UButton variant="ghost" color="neutral" @click="cancel">
40
41
  Cancel
41
42
  </UButton>
42
- <UButton :disabled="!inputValue.trim()" @click="submit">OK</UButton>
43
+ <UButton
44
+ :disabled="!inputValue.trim()"
45
+ @click="submit"
46
+ >
47
+ OK
48
+ </UButton>
43
49
  </div>
44
50
  </template>
45
51
  </UModal>
46
52
  </template>
47
53
 
48
54
  <script setup>
49
- import { ref, watch } from "vue";
55
+ import { ref, watch, nextTick } from "vue";
50
56
  import { defaultOptions } from "../../annotation/engine/config";
51
57
  const props = defineProps({
52
58
  open: { type: Boolean, required: true },
@@ -55,6 +61,20 @@ const props = defineProps({
55
61
  initialText: { type: String, required: false }
56
62
  });
57
63
  const emit = defineEmits(["update:open", "submit", "cancel"]);
64
+ const textareaRef = ref(null);
65
+ function focusTextarea() {
66
+ const instance = textareaRef.value;
67
+ if (!instance) return;
68
+ const el = instance.inputRef ?? instance.$el?.querySelector?.("textarea") ?? instance;
69
+ if (el && typeof el.focus === "function") {
70
+ el.focus();
71
+ const len = el.value?.length ?? 0;
72
+ try {
73
+ el.setSelectionRange(len, len);
74
+ } catch {
75
+ }
76
+ }
77
+ }
58
78
  const isOpen = ref(props.open);
59
79
  watch(
60
80
  () => props.open,
@@ -64,6 +84,7 @@ watch(
64
84
  inputValue.value = props.initialText ?? "";
65
85
  currentColor.value = props.defaultColor;
66
86
  currentFontSize.value = String(props.defaultFontSize);
87
+ nextTick(() => requestAnimationFrame(() => focusTextarea()));
67
88
  }
68
89
  }
69
90
  );
@@ -86,4 +107,7 @@ function submit() {
86
107
  function cancel() {
87
108
  emit("cancel");
88
109
  }
110
+ function onPreserveFocus(e) {
111
+ if (e.pointerType === "mouse") e.preventDefault();
112
+ }
89
113
  </script>
@@ -18,11 +18,9 @@
18
18
  :disabled="!state.history.canRedo.value"
19
19
  @click="state.history.redo"
20
20
  />
21
- <UButton
22
- icon="i-lucide-eraser"
23
- :variant="state.activeTool.value === 'eraser' ? 'soft' : 'ghost'"
24
- color="neutral"
25
- size="sm"
21
+ <ToolButton
22
+ :tool="{ name: 'Eraser', icon: 'i-lucide-eraser' }"
23
+ :active="state.activeTool.value === 'eraser'"
26
24
  data-testid="action-eraser"
27
25
  @click="state.selectTool('eraser')"
28
26
  />
@@ -31,5 +29,6 @@
31
29
 
32
30
  <script setup>
33
31
  import { useViewerState } from "../../composables/useViewerState";
32
+ import ToolButton from "../ToolButton.vue";
34
33
  const state = useViewerState();
35
34
  </script>
@@ -5,4 +5,5 @@ export declare function createAnnotationEngine(viewerState: ViewerState, options
5
5
  onRequestTextInput: PainterCallbacks['onRequestTextInput'];
6
6
  onRequestDeleteConfirm?: PainterCallbacks['onRequestDeleteConfirm'];
7
7
  freehandGroupingDelay?: number;
8
+ setExternalGesturesEnabled?: (enabled: boolean) => void;
8
9
  }): Painter;
@@ -21,6 +21,8 @@ export function createAnnotationEngine(viewerState, options) {
21
21
  if (annotationStore.type === AnnotationType.FREETEXT) {
22
22
  viewerState.painter.value?.selectAnnotation(annotationStore.id);
23
23
  }
24
+ } else {
25
+ viewerState.history.rebaseline();
24
26
  }
25
27
  },
26
28
  onStoreDelete: (id) => {
@@ -65,7 +67,8 @@ export function createAnnotationEngine(viewerState, options) {
65
67
  callbacks,
66
68
  getStylusModeEnabled: () => viewerState.stylusMode.value,
67
69
  getReadonly: () => viewerState.readonly.value,
68
- freehandGroupingDelay: options.freehandGroupingDelay
70
+ freehandGroupingDelay: options.freehandGroupingDelay,
71
+ setExternalGesturesEnabled: options.setExternalGesturesEnabled
69
72
  });
70
73
  viewerState.painter.value = painter;
71
74
  return painter;
@@ -6,6 +6,7 @@ export interface AnnotationHistory {
6
6
  undo: () => void;
7
7
  redo: () => void;
8
8
  reset: () => void;
9
+ rebaseline: () => void;
9
10
  canUndo: ComputedRef<boolean>;
10
11
  canRedo: ComputedRef<boolean>;
11
12
  }
@@ -6,15 +6,19 @@ function cloneAnnotations(map) {
6
6
  export function createAnnotationHistory(annotations, painter) {
7
7
  const undoStack = ref([]);
8
8
  const redoStack = ref([]);
9
+ let baseline = cloneAnnotations(annotations.value);
9
10
  const canUndo = computed(() => undoStack.value.length > 0);
10
11
  const canRedo = computed(() => redoStack.value.length > 0);
11
12
  function commit() {
12
- const snapshot = cloneAnnotations(annotations.value);
13
- undoStack.value.push(snapshot);
13
+ undoStack.value.push(baseline);
14
14
  if (undoStack.value.length > MAX_HISTORY) {
15
15
  undoStack.value.shift();
16
16
  }
17
17
  redoStack.value = [];
18
+ baseline = cloneAnnotations(annotations.value);
19
+ }
20
+ function rebaseline() {
21
+ baseline = cloneAnnotations(annotations.value);
18
22
  }
19
23
  function restoreSnapshot(target) {
20
24
  const p = painter.value;
@@ -53,6 +57,7 @@ export function createAnnotationHistory(annotations, painter) {
53
57
  redoStack.value.push(currentSnapshot);
54
58
  const target = undoStack.value.pop();
55
59
  restoreSnapshot(target);
60
+ baseline = cloneAnnotations(annotations.value);
56
61
  }
57
62
  function redo() {
58
63
  if (!canRedo.value) return;
@@ -60,10 +65,12 @@ export function createAnnotationHistory(annotations, painter) {
60
65
  undoStack.value.push(currentSnapshot);
61
66
  const target = redoStack.value.pop();
62
67
  restoreSnapshot(target);
68
+ baseline = cloneAnnotations(annotations.value);
63
69
  }
64
70
  function reset() {
65
71
  undoStack.value = [];
66
72
  redoStack.value = [];
73
+ baseline = cloneAnnotations(annotations.value);
67
74
  }
68
- return { commit, undo, redo, reset, canUndo, canRedo };
75
+ return { commit, undo, redo, reset, rebaseline, canUndo, canRedo };
69
76
  }