kviewer 0.2.4 → 0.3.1

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 (49) hide show
  1. package/README.md +13 -1
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +9 -0
  4. package/dist/runtime/annotation/engine/config.d.ts +1 -0
  5. package/dist/runtime/annotation/engine/config.js +4 -0
  6. package/dist/runtime/annotation/engine/cursor-preview.d.ts +14 -1
  7. package/dist/runtime/annotation/engine/cursor-preview.js +32 -6
  8. package/dist/runtime/annotation/engine/editor/editor.d.ts +17 -0
  9. package/dist/runtime/annotation/engine/editor/editor.js +44 -15
  10. package/dist/runtime/annotation/engine/tools/signature.js +30 -5
  11. package/dist/runtime/annotation/engine/tools/stamp.d.ts +9 -0
  12. package/dist/runtime/annotation/engine/tools/stamp.js +67 -15
  13. package/dist/runtime/annotation/engine/types.d.ts +25 -0
  14. package/dist/runtime/annotation/engine/utils.d.ts +1 -4
  15. package/dist/runtime/annotation/engine/utils.js +1 -10
  16. package/dist/runtime/annotation/pdf-export/export.js +0 -3
  17. package/dist/runtime/annotation/utils/placement_size.d.ts +27 -0
  18. package/dist/runtime/annotation/utils/placement_size.js +26 -0
  19. package/dist/runtime/annotation/utils/signature_image.d.ts +26 -0
  20. package/dist/runtime/annotation/utils/signature_image.js +20 -0
  21. package/dist/runtime/assets/kviewer.css +1 -1
  22. package/dist/runtime/components/FormFieldLayer.vue +6 -1
  23. package/dist/runtime/components/Viewer.d.vue.ts +56 -2
  24. package/dist/runtime/components/Viewer.vue +68 -2
  25. package/dist/runtime/components/Viewer.vue.d.ts +56 -2
  26. package/dist/runtime/components/ViewerTabs.d.vue.ts +29 -9
  27. package/dist/runtime/components/ViewerTabs.vue +10 -2
  28. package/dist/runtime/components/ViewerTabs.vue.d.ts +29 -9
  29. package/dist/runtime/components/form-fields/FormFieldWrapper.vue +4 -2
  30. package/dist/runtime/components/form-fields/FormSignatureField.vue +15 -2
  31. package/dist/runtime/components/modals/FreeTextModal.vue +1 -1
  32. package/dist/runtime/components/modals/SignatureDrawModal.vue +170 -4
  33. package/dist/runtime/components/tools/ToolProperties.vue +98 -101
  34. package/dist/runtime/composables/useFormFields.d.ts +17 -1
  35. package/dist/runtime/composables/useFormFields.js +48 -9
  36. package/dist/runtime/composables/useViewerSearch.js +1 -1
  37. package/dist/runtime/composables/useViewerState.d.ts +1 -0
  38. package/dist/runtime/composables/useViewerState.js +12 -13
  39. package/dist/runtime/embed/bridge-client.d.ts +19 -3
  40. package/dist/runtime/embed/bridge-client.js +24 -0
  41. package/dist/runtime/embed/bridge-host.d.ts +10 -1
  42. package/dist/runtime/embed/bridge-host.js +20 -0
  43. package/dist/runtime/embed/protocol.d.ts +28 -1
  44. package/dist/runtime/i18n/messages.d.ts +16 -2
  45. package/dist/runtime/i18n/messages.js +16 -2
  46. package/dist/runtime/public-types.d.ts +1 -1
  47. package/dist/runtime/toolbar-tools.d.ts +1 -1
  48. package/dist/runtime/toolbar-tools.js +8 -0
  49. package/package.json +18 -18
package/README.md CHANGED
@@ -88,7 +88,7 @@ By default the toolbar is fully built-in. Pass `tools` to choose which buttons r
88
88
  </template>
89
89
  ```
90
90
 
91
- `separator`/`spacer` are layout primitives (`spacer` right-aligns what follows). `tools` controls buttons only — it never gates programmatic `state.selectTool(...)` access. Form-edit mode is API-only (`v-model:form-edit-mode` / `setFormEditMode()` / `toggleFormEditMode()`); there is no built-in toggle button. See the [toolbar configuration docs](https://github.com/uppt/nuxt-kviewer) for the full tool-ID list and the array that reproduces the default toolbar.
91
+ `separator`/`spacer` are layout primitives (`spacer` right-aligns what follows). `tools` controls buttons only — it never gates programmatic `state.selectTool(...)` access. Form-edit mode is API-only (`v-model:form-edit-mode` / `setFormEditMode()` / `toggleFormEditMode()`); there is no built-in toggle button. See the [toolbar configuration docs](https://kviewer.wrench.kabema-digital.de/api/kviewer#configuring-the-toolbar) for the full tool-ID list and the array that reproduces the default toolbar.
92
92
 
93
93
  ## Viewer Ref API
94
94
 
@@ -185,6 +185,18 @@ A page counts as read once its top edge scrolls into view (computed from geometr
185
185
 
186
186
  The same surface is available over the [embed bridge](src/runtime/embed): `client.allPagesRead()`, `client.getViewedPages()`, `client.resetViewedPages()`, and the `all-pages-read` / `viewedPages-changed` events via `client.on(...)`.
187
187
 
188
+ ## Migrating to v0.3.0
189
+
190
+ The built-in **click-to-sign** flow on signature fields is now **opt-in** (previously always on). Without it, signature fields render as passive markers (signature icon + a localized "Signature" label) and clicking them does nothing — so an embedding e-sign product's own signing process stays the only entry point.
191
+
192
+ To restore the previous behavior:
193
+
194
+ ```vue
195
+ <KViewer :source="pdfUrl" click-to-sign />
196
+ ```
197
+
198
+ or at runtime via `viewer.setClickToSign(true)` / the embed bridge's `client.setClickToSign(true)`. A field's `promptText` now only applies to the click-to-sign placeholder; signature values set programmatically always render.
199
+
188
200
  ## Development
189
201
 
190
202
  <details>
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kviewer",
3
3
  "configKey": "kviewer",
4
- "version": "0.2.4",
4
+ "version": "0.3.1",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -72,6 +72,15 @@ const module$1 = defineNuxtModule({
72
72
  const require_ = createRequire(import.meta.url);
73
73
  const pdfjsDir = dirname(require_.resolve("pdfjs-dist/package.json"));
74
74
  nuxt.hook("nitro:config", (nitroConfig) => {
75
+ nitroConfig.externals ||= {};
76
+ nitroConfig.externals.inline ||= [];
77
+ if (Array.isArray(nitroConfig.externals.inline)) {
78
+ for (const dependency of ["unhead", "consola"]) {
79
+ if (!nitroConfig.externals.inline.includes(dependency)) {
80
+ nitroConfig.externals.inline.push(dependency);
81
+ }
82
+ }
83
+ }
75
84
  nitroConfig.publicAssets ||= [];
76
85
  nitroConfig.publicAssets.push(
77
86
  {
@@ -44,6 +44,7 @@ export declare const defaultOptions: {
44
44
  STROKE_WIDTH: number;
45
45
  OPACITY: number;
46
46
  MAX_CURSOR_SIZE: number;
47
+ MAX_PLACEMENT_SIZE: number;
47
48
  MAX_UPLOAD_IMAGE_SIZE: number;
48
49
  LOAD_PDF_ANNOTATION: boolean;
49
50
  DB_CLICK_DELETE: boolean;
@@ -74,6 +74,10 @@ export const defaultOptions = {
74
74
  STROKE_WIDTH: 2,
75
75
  OPACITY: 1,
76
76
  MAX_CURSOR_SIZE: 96,
77
+ // Bound for stamps/signatures placed without explicit dimensions. Shared
78
+ // by the placement path and the cursor preview so the ghost cannot drift
79
+ // from what actually lands on the page.
80
+ MAX_PLACEMENT_SIZE: 120,
77
81
  MAX_UPLOAD_IMAGE_SIZE: 800,
78
82
  LOAD_PDF_ANNOTATION: true,
79
83
  DB_CLICK_DELETE: false
@@ -1,2 +1,15 @@
1
- export declare function showCursorPreview(imageUrl: string, size?: number): void;
1
+ export interface ImageSize {
2
+ width: number;
3
+ height: number;
4
+ }
5
+ /**
6
+ * Bumped whenever the preview is torn down or replaced. Callers that size the
7
+ * preview asynchronously capture it first and bail if it moved, so a slow
8
+ * measurement cannot resurrect a ghost for a tool the user already left.
9
+ */
10
+ export declare function cursorPreviewToken(): number;
11
+ /** Synchronous hit for an already-measured image, so repeat picks don't flash. */
12
+ export declare function peekImageSize(imageUrl: string): ImageSize | null;
13
+ export declare function measureImage(imageUrl: string): Promise<ImageSize | null>;
14
+ export declare function showCursorPreview(imageUrl: string, width?: number, height?: number): void;
2
15
  export declare function hideCursorPreview(): void;
@@ -4,6 +4,32 @@ const PADDING = 6;
4
4
  const CROSSHAIR_SIZE = 12;
5
5
  let previewEl = null;
6
6
  let mouseMoveHandler = null;
7
+ let previewToken = 0;
8
+ const naturalSizeCache = /* @__PURE__ */ new Map();
9
+ export function cursorPreviewToken() {
10
+ return previewToken;
11
+ }
12
+ export function peekImageSize(imageUrl) {
13
+ return naturalSizeCache.get(imageUrl) ?? null;
14
+ }
15
+ export function measureImage(imageUrl) {
16
+ const cached = naturalSizeCache.get(imageUrl);
17
+ if (cached) return Promise.resolve(cached);
18
+ return new Promise((resolve) => {
19
+ const img = new Image();
20
+ img.addEventListener("load", () => {
21
+ const size = { width: img.naturalWidth, height: img.naturalHeight };
22
+ if (!size.width || !size.height) {
23
+ resolve(null);
24
+ return;
25
+ }
26
+ naturalSizeCache.set(imageUrl, size);
27
+ resolve(size);
28
+ });
29
+ img.addEventListener("error", () => resolve(null));
30
+ img.src = imageUrl;
31
+ });
32
+ }
7
33
  function isOverPage(e) {
8
34
  const target = e.target;
9
35
  if (!target) return false;
@@ -21,16 +47,15 @@ function onMouseMove(e) {
21
47
  document.body.style.cursor = "";
22
48
  }
23
49
  }
24
- export function showCursorPreview(imageUrl, size = 80) {
50
+ export function showCursorPreview(imageUrl, width = 80, height = width) {
25
51
  hideCursorPreview();
26
- const outerSize = size + PADDING * 2;
27
52
  previewEl = document.createElement("div");
28
53
  Object.assign(previewEl.style, {
29
54
  position: "fixed",
30
55
  zIndex: "9999",
31
56
  pointerEvents: "none",
32
- width: `${outerSize}px`,
33
- height: `${outerSize}px`,
57
+ width: `${width + PADDING * 2}px`,
58
+ height: `${height + PADDING * 2}px`,
34
59
  transform: "translate(-50%, -50%)",
35
60
  border: `2px solid ${PRIMARY_COLOR}`,
36
61
  borderRadius: "3px",
@@ -41,8 +66,8 @@ export function showCursorPreview(imageUrl, size = 80) {
41
66
  const img = document.createElement("img");
42
67
  img.src = imageUrl;
43
68
  Object.assign(img.style, {
44
- width: `${size}px`,
45
- height: `${size}px`,
69
+ width: `${width}px`,
70
+ height: `${height}px`,
46
71
  objectFit: "contain",
47
72
  display: "block",
48
73
  pointerEvents: "none"
@@ -76,6 +101,7 @@ export function showCursorPreview(imageUrl, size = 80) {
76
101
  document.addEventListener("mousemove", mouseMoveHandler);
77
102
  }
78
103
  export function hideCursorPreview() {
104
+ previewToken++;
79
105
  if (previewEl) {
80
106
  previewEl.remove();
81
107
  previewEl = null;
@@ -33,6 +33,8 @@ export declare abstract class Editor {
33
33
  currentShapeGroup: IShapeGroup | null;
34
34
  protected getStylusModeEnabled?: () => boolean;
35
35
  protected freehandGroupingDelay: number;
36
+ private lastPointerDownAt;
37
+ private lastPointerDownPos;
36
38
  static MinSize: number;
37
39
  constructor({ userName, konvaStage, pageNumber, annotation, onAdd, editorType, onChange, getStylusModeEnabled, freehandGroupingDelay, }: IEditorOptions & {
38
40
  editorType: AnnotationType;
@@ -40,6 +42,13 @@ export declare abstract class Editor {
40
42
  private dispatchAddEvent;
41
43
  private dispatchChangedEvent;
42
44
  protected enableEditMode(): void;
45
+ /**
46
+ * Returns true when this pointer-down is a duplicate of the immediately
47
+ * preceding one (same spot, within {@link DUPLICATE_DOWN_MS}). See the
48
+ * constant docs: this catches the iOS ghost click and stylus contact
49
+ * bounce that would otherwise commit two overlapping annotations per tap.
50
+ */
51
+ private isDuplicatePointerDown;
43
52
  protected disableEditMode(): void;
44
53
  protected getBgLayer(konvaStage?: Konva.Stage): Konva.Layer;
45
54
  protected delShapeGroup(id: string): void;
@@ -62,6 +71,14 @@ export declare abstract class Editor {
62
71
  activate(konvaStage: Konva.Stage, annotation: IAnnotationType): void;
63
72
  rebindEvents(): void;
64
73
  addSerializedGroupToLayer(konvaStage: Konva.Stage, konvaString: string): void;
74
+ /**
75
+ * Attach a rehydrated group to the page layer AND register it in
76
+ * shapeGroupStore. Registration is what lets the painter resolve this
77
+ * editor as the group's owner (findEditorForGroupId) — without it,
78
+ * drag/transform changes to the group are silently never persisted.
79
+ * Subclasses that override addSerializedGroupToLayer must call this.
80
+ */
81
+ protected adoptHydratedGroup(konvaStage: Konva.Stage, ghostGroup: Konva.Group): void;
65
82
  deleteGroup(id: string, konvaStage: Konva.Stage): void;
66
83
  updateStyle(annotationStore: IAnnotationStore, style: IAnnotationStyle): void;
67
84
  static Timer: Map<number, {
@@ -2,6 +2,8 @@ import Konva from "konva";
2
2
  import { formatTimestamp, generateUUID } from "../utils.js";
3
3
  import { SHAPE_GROUP_NAME } from "../const.js";
4
4
  import { isStylusTouch } from "../input-device.js";
5
+ const DUPLICATE_DOWN_MS = 400;
6
+ const DUPLICATE_DOWN_DISTANCE = 12;
5
7
  export class Editor {
6
8
  userName;
7
9
  id;
@@ -15,6 +17,8 @@ export class Editor {
15
17
  currentShapeGroup;
16
18
  getStylusModeEnabled;
17
19
  freehandGroupingDelay;
20
+ lastPointerDownAt = 0;
21
+ lastPointerDownPos = null;
18
22
  static MinSize = 8;
19
23
  constructor({
20
24
  userName,
@@ -87,7 +91,9 @@ export class Editor {
87
91
  enableEditMode() {
88
92
  const stylusEnabled = this.getStylusModeEnabled?.() ?? false;
89
93
  this.konvaStage.on("mousedown", (e) => {
90
- if (e.evt.button === 0) this.mouseDownHandler(e);
94
+ if (e.evt.button !== 0) return;
95
+ if (this.isDuplicatePointerDown()) return;
96
+ this.mouseDownHandler(e);
91
97
  });
92
98
  this.konvaStage.on("mousemove", (e) => {
93
99
  this.mouseMoveHandler(e);
@@ -97,29 +103,42 @@ export class Editor {
97
103
  });
98
104
  this.konvaStage.on("touchstart", (e) => {
99
105
  if (e.evt.touches.length !== 1) return;
100
- if (stylusEnabled) {
101
- if (!isStylusTouch(e.evt)) return;
102
- e.evt.preventDefault();
103
- }
106
+ if (stylusEnabled && !isStylusTouch(e.evt)) return;
107
+ e.evt.preventDefault();
108
+ if (this.isDuplicatePointerDown()) return;
104
109
  this.mouseDownHandler(e);
105
110
  });
106
111
  this.konvaStage.on("touchmove", (e) => {
107
112
  if (e.evt.touches.length !== 1) return;
108
- if (stylusEnabled) {
109
- if (!isStylusTouch(e.evt)) return;
110
- if (this.isPainting) e.evt.preventDefault();
111
- }
113
+ if (stylusEnabled && !isStylusTouch(e.evt)) return;
114
+ if (this.isPainting) e.evt.preventDefault();
112
115
  this.mouseMoveHandler(e);
113
116
  });
114
117
  this.konvaStage.on("touchend", (e) => {
115
- if (stylusEnabled) {
116
- const touch = e.evt.changedTouches?.[0];
117
- const isStylus = touch && "touchType" in touch && touch.touchType === "stylus";
118
- if (!isStylus) return;
119
- }
118
+ if (stylusEnabled && !isStylusTouch(e.evt)) return;
119
+ e.evt.preventDefault();
120
120
  this.mouseUpHandler(e);
121
121
  });
122
122
  }
123
+ /**
124
+ * Returns true when this pointer-down is a duplicate of the immediately
125
+ * preceding one (same spot, within {@link DUPLICATE_DOWN_MS}). See the
126
+ * constant docs: this catches the iOS ghost click and stylus contact
127
+ * bounce that would otherwise commit two overlapping annotations per tap.
128
+ */
129
+ isDuplicatePointerDown() {
130
+ const pos = this.konvaStage.getRelativePointerPosition();
131
+ const now = Date.now();
132
+ const prevAt = this.lastPointerDownAt;
133
+ const prevPos = this.lastPointerDownPos;
134
+ this.lastPointerDownAt = now;
135
+ this.lastPointerDownPos = pos ? { x: pos.x, y: pos.y } : null;
136
+ if (!pos || !prevPos) return false;
137
+ if (now - prevAt > DUPLICATE_DOWN_MS) return false;
138
+ const dx = pos.x - prevPos.x;
139
+ const dy = pos.y - prevPos.y;
140
+ return dx * dx + dy * dy <= DUPLICATE_DOWN_DISTANCE * DUPLICATE_DOWN_DISTANCE;
141
+ }
123
142
  disableEditMode() {
124
143
  this.isPainting = false;
125
144
  this.konvaStage.off("click mousedown mousemove mouseup touchstart touchmove touchend");
@@ -211,11 +230,21 @@ export class Editor {
211
230
  this.enableEditMode();
212
231
  }
213
232
  addSerializedGroupToLayer(konvaStage, konvaString) {
214
- this.konvaStage = konvaStage;
215
233
  const ghostGroup = Konva.Node.create(konvaString);
216
234
  if (ghostGroup instanceof Konva.Container) {
217
235
  ghostGroup.find((node) => node instanceof Konva.Shape).forEach((shape) => shape.strokeScaleEnabled(true));
218
236
  }
237
+ this.adoptHydratedGroup(konvaStage, ghostGroup);
238
+ }
239
+ /**
240
+ * Attach a rehydrated group to the page layer AND register it in
241
+ * shapeGroupStore. Registration is what lets the painter resolve this
242
+ * editor as the group's owner (findEditorForGroupId) — without it,
243
+ * drag/transform changes to the group are silently never persisted.
244
+ * Subclasses that override addSerializedGroupToLayer must call this.
245
+ */
246
+ adoptHydratedGroup(konvaStage, ghostGroup) {
247
+ this.konvaStage = konvaStage;
219
248
  const id = ghostGroup.id();
220
249
  this.getBgLayer(konvaStage).add(ghostGroup);
221
250
  if (this.shapeGroupStore.has(id)) return;
@@ -1,9 +1,14 @@
1
1
  import Konva from "konva";
2
2
  import { AnnotationType } from "../types.js";
3
- import { resizeImage } from "../utils.js";
4
3
  import { Editor } from "../editor/editor.js";
5
4
  import { defaultOptions } from "../config.js";
6
- import { showCursorPreview } from "../cursor-preview.js";
5
+ import {
6
+ cursorPreviewToken,
7
+ measureImage,
8
+ peekImageSize,
9
+ showCursorPreview
10
+ } from "../cursor-preview.js";
11
+ import { cursorPreviewSize, placementSize } from "../../utils/placement_size.js";
7
12
  export class EditorSignature extends Editor {
8
13
  signatureUrl;
9
14
  signatureImage;
@@ -15,7 +20,23 @@ export class EditorSignature extends Editor {
15
20
  }
16
21
  }
17
22
  createCursorImg() {
18
- showCursorPreview(this.signatureUrl, defaultOptions.setting.MAX_CURSOR_SIZE);
23
+ const scale = this.konvaStage?.scale()?.x ?? 1;
24
+ const max = defaultOptions.setting.MAX_PLACEMENT_SIZE;
25
+ const cached = cursorPreviewSize(null, peekImageSize(this.signatureUrl), max, scale);
26
+ if (cached) {
27
+ showCursorPreview(this.signatureUrl, cached.width, cached.height);
28
+ return;
29
+ }
30
+ const token = cursorPreviewToken();
31
+ void measureImage(this.signatureUrl).then((natural) => {
32
+ if (cursorPreviewToken() !== token) return;
33
+ const size = cursorPreviewSize(null, natural, max, scale);
34
+ if (size) {
35
+ showCursorPreview(this.signatureUrl, size.width, size.height);
36
+ } else {
37
+ showCursorPreview(this.signatureUrl, defaultOptions.setting.MAX_CURSOR_SIZE);
38
+ }
39
+ });
19
40
  }
20
41
  mouseDownHandler(e) {
21
42
  if (e.currentTarget !== this.konvaStage) return;
@@ -25,7 +46,11 @@ export class EditorSignature extends Editor {
25
46
  const pos = this.konvaStage.getRelativePointerPosition();
26
47
  Konva.Image.fromURL(this.signatureUrl, async (image) => {
27
48
  const { width: width_rec, height: height_rec } = image.getClientRect();
28
- const { newWidth, newHeight } = resizeImage(width_rec, height_rec, 120);
49
+ const { width: newWidth, height: newHeight } = placementSize(
50
+ null,
51
+ { width: width_rec, height: height_rec },
52
+ defaultOptions.setting.MAX_PLACEMENT_SIZE
53
+ ) ?? { width: width_rec, height: height_rec };
29
54
  const crosshair = { x: newWidth / 2, y: newHeight / 2 };
30
55
  this.signatureImage = image;
31
56
  this.signatureImage.setAttrs({
@@ -63,7 +88,7 @@ export class EditorSignature extends Editor {
63
88
  oldImage.destroy();
64
89
  ghostGroup.add(image);
65
90
  });
66
- this.getBgLayer(konvaStage).add(ghostGroup);
91
+ this.adoptHydratedGroup(konvaStage, ghostGroup);
67
92
  }
68
93
  mouseMoveHandler() {
69
94
  }
@@ -8,7 +8,16 @@ export declare class EditorStamp extends Editor {
8
8
  private stampWidth;
9
9
  private stampHeight;
10
10
  constructor(editorOptions: IEditorOptions, defaultStampUrl: string | null);
11
+ private get explicitSize();
11
12
  private createCursorImg;
13
+ /**
14
+ * True when an identical stamp (same source image) already sits under the
15
+ * tap point. Guards against dropping a second, overlapping copy on a spot
16
+ * that's already stamped — e.g. a flaky stylus misses a tap, the user
17
+ * re-sweeps the column, and re-hits cells that already carry a cross.
18
+ * Distance is compared in stage-content units so it is zoom-independent.
19
+ */
20
+ private hasIdenticalStampNear;
12
21
  protected mouseDownHandler(e: KonvaEventObject<MouseEvent | TouchEvent>): void;
13
22
  activateWithStamp(konvaStage: Konva.Stage, annotation: IAnnotationType, stampUrl: string | null, stampWidth?: number, stampHeight?: number): void;
14
23
  addSerializedGroupToLayer(konvaStage: Konva.Stage, konvaString: string): void;
@@ -1,9 +1,17 @@
1
1
  import Konva from "konva";
2
2
  import { AnnotationType } from "../types.js";
3
- import { resizeImage } from "../utils.js";
4
3
  import { Editor } from "../editor/editor.js";
5
4
  import { defaultOptions } from "../config.js";
6
- import { showCursorPreview } from "../cursor-preview.js";
5
+ import {
6
+ cursorPreviewToken,
7
+ measureImage,
8
+ peekImageSize,
9
+ showCursorPreview
10
+ } from "../cursor-preview.js";
11
+ import {
12
+ cursorPreviewSize,
13
+ placementSize
14
+ } from "../../utils/placement_size.js";
7
15
  export class EditorStamp extends Editor {
8
16
  stampUrl;
9
17
  stampImage;
@@ -16,27 +24,71 @@ export class EditorStamp extends Editor {
16
24
  this.createCursorImg();
17
25
  }
18
26
  }
27
+ get explicitSize() {
28
+ return { width: this.stampWidth ?? 0, height: this.stampHeight ?? 0 };
29
+ }
19
30
  createCursorImg() {
20
- const baseSize = this.stampWidth ?? this.stampHeight ?? defaultOptions.setting.MAX_CURSOR_SIZE;
21
31
  const scale = this.konvaStage?.scale()?.x ?? 1;
22
- showCursorPreview(this.stampUrl, Math.round(baseSize * scale));
32
+ const max = defaultOptions.setting.MAX_PLACEMENT_SIZE;
33
+ const immediate = cursorPreviewSize(
34
+ this.explicitSize,
35
+ peekImageSize(this.stampUrl),
36
+ max,
37
+ scale
38
+ );
39
+ if (immediate) {
40
+ showCursorPreview(this.stampUrl, immediate.width, immediate.height);
41
+ return;
42
+ }
43
+ const token = cursorPreviewToken();
44
+ void measureImage(this.stampUrl).then((natural) => {
45
+ if (cursorPreviewToken() !== token) return;
46
+ const size = cursorPreviewSize(this.explicitSize, natural, max, scale);
47
+ if (size) {
48
+ showCursorPreview(this.stampUrl, size.width, size.height);
49
+ } else {
50
+ showCursorPreview(this.stampUrl, defaultOptions.setting.MAX_CURSOR_SIZE);
51
+ }
52
+ });
53
+ }
54
+ /**
55
+ * True when an identical stamp (same source image) already sits under the
56
+ * tap point. Guards against dropping a second, overlapping copy on a spot
57
+ * that's already stamped — e.g. a flaky stylus misses a tap, the user
58
+ * re-sweeps the column, and re-hits cells that already carry a cross.
59
+ * Distance is compared in stage-content units so it is zoom-independent.
60
+ */
61
+ hasIdenticalStampNear(pos, size) {
62
+ const threshold = Math.max(8, size * 0.5);
63
+ const t2 = threshold * threshold;
64
+ const images = this.konvaStage.find("Image");
65
+ for (const img of images) {
66
+ if (img.getAttr("base64") !== this.stampUrl) continue;
67
+ const rect = img.getClientRect({ relativeTo: this.konvaStage });
68
+ const cx = rect.x + rect.width / 2;
69
+ const cy = rect.y + rect.height / 2;
70
+ const dx = cx - pos.x;
71
+ const dy = cy - pos.y;
72
+ if (dx * dx + dy * dy <= t2) return true;
73
+ }
74
+ return false;
23
75
  }
24
76
  mouseDownHandler(e) {
25
77
  if (e.currentTarget !== this.konvaStage) return;
78
+ const pos = this.konvaStage.getRelativePointerPosition();
79
+ if (!pos) return;
80
+ const estimatedSize = this.stampWidth ?? this.stampHeight ?? defaultOptions.setting.MAX_PLACEMENT_SIZE;
81
+ if (this.hasIdenticalStampNear(pos, estimatedSize)) return;
26
82
  this.stampImage = null;
27
83
  this.currentShapeGroup = this.createShapeGroup();
28
84
  this.getBgLayer().add(this.currentShapeGroup.konvaGroup);
29
- const pos = this.konvaStage.getRelativePointerPosition();
30
85
  Konva.Image.fromURL(this.stampUrl, async (image) => {
31
- let newWidth;
32
- let newHeight;
33
- if (this.stampWidth && this.stampHeight) {
34
- newWidth = this.stampWidth;
35
- newHeight = this.stampHeight;
36
- } else {
37
- const { width: width_rec, height: height_rec } = image.getClientRect();
38
- ({ newWidth, newHeight } = resizeImage(width_rec, height_rec, 120));
39
- }
86
+ const { width: width_rec, height: height_rec } = image.getClientRect();
87
+ const { width: newWidth, height: newHeight } = placementSize(
88
+ this.explicitSize,
89
+ { width: width_rec, height: height_rec },
90
+ defaultOptions.setting.MAX_PLACEMENT_SIZE
91
+ ) ?? { width: width_rec, height: height_rec };
40
92
  const crosshair = { x: newWidth / 2, y: newHeight / 2 };
41
93
  this.stampImage = image;
42
94
  this.stampImage.setAttrs({
@@ -83,7 +135,7 @@ export class EditorStamp extends Editor {
83
135
  oldText.moveToTop();
84
136
  }
85
137
  });
86
- this.getBgLayer(konvaStage).add(ghostGroup);
138
+ this.adoptHydratedGroup(konvaStage, ghostGroup);
87
139
  }
88
140
  mouseMoveHandler() {
89
141
  }
@@ -294,6 +294,31 @@ export interface AddFormFieldPayload {
294
294
  /** 0 = left, 1 = center, 2 = right. */
295
295
  textAlignment?: number;
296
296
  }
297
+ /**
298
+ * Preset properties applied to fields the user places via the placement
299
+ * tools. `pageNumber`/`rect`/`fieldType` come from the gesture;
300
+ * `fieldName`/`buttonValue` stay auto-generated so name uniqueness and
301
+ * radio grouping keep working — rename via the `field-placed` event and
302
+ * `updateFormField()` instead.
303
+ */
304
+ export type PlacedFieldDefaultProps = Partial<Omit<AddFormFieldPayload, 'pageNumber' | 'rect' | 'fieldType' | 'fieldName' | 'buttonValue'>>;
305
+ /** Per-field-type presets for tool placements, keyed by {@link FormFieldType}. */
306
+ export type PlacedFieldDefaults = Partial<Record<FormFieldType, PlacedFieldDefaultProps>>;
307
+ /**
308
+ * Signed-state snapshot of one signature widget. `signed` is true when the
309
+ * widget holds a signature image applied in this session OR the source PDF
310
+ * already carries a digital signature for the field (`signedInSource`).
311
+ * Lets a host (e.g. an e-sign app) gate finalization on all fields being
312
+ * signed — see `getSignedStatus()` / the `signedStatus-changed` event.
313
+ */
314
+ export interface SignatureFieldStatus {
315
+ id: string;
316
+ fieldName: string;
317
+ pageNumber: number;
318
+ signed: boolean;
319
+ /** Present when the field is tagged with a host role. */
320
+ roleId?: string;
321
+ }
297
322
  export interface DetectedShapeRect {
298
323
  x: number;
299
324
  y: number;
@@ -7,10 +7,7 @@ export declare function generateUUID(): string;
7
7
  export declare function setCssCustomProperty(propertyName: string, value: string): void;
8
8
  export declare function removeCssCustomProperty(propertyName: string): void;
9
9
  export declare function base64ToImageBitmap(base64: string): Promise<ImageBitmap>;
10
- export declare function resizeImage(width: number, height: number, max: number): {
11
- newWidth: number;
12
- newHeight: number;
13
- };
10
+ export { resizeImage } from '../utils/placement_size.js';
14
11
  export declare function convertToRGB(array: number[], index?: number): string;
15
12
  export declare function formatTimestamp(timestamp: number): string;
16
13
  export declare function formatPDFDate(dateString: string | null, full?: boolean): string;
@@ -67,16 +67,7 @@ export async function base64ToImageBitmap(base64) {
67
67
  const blob = new Blob([bytes], { type: "image/png" });
68
68
  return await createImageBitmap(blob);
69
69
  }
70
- export function resizeImage(width, height, max) {
71
- if (width <= max && height <= max) {
72
- return { newWidth: width, newHeight: height };
73
- }
74
- const scaleFactor = Math.min(max / width, max / height);
75
- return {
76
- newWidth: width * scaleFactor,
77
- newHeight: height * scaleFactor
78
- };
79
- }
70
+ export { resizeImage } from "../utils/placement_size.js";
80
71
  export function convertToRGB(array, index = 0) {
81
72
  if (index < 0 || index * 3 + 2 >= array.length) {
82
73
  throw new Error("Index out of bounds");
@@ -224,9 +224,6 @@ async function flattenPageAnnotations(pdfDoc, page, annotations) {
224
224
  drawX = 0;
225
225
  drawY = H_u;
226
226
  break;
227
- default:
228
- drawX = 0;
229
- drawY = 0;
230
227
  }
231
228
  page.drawImage(image, {
232
229
  x: drawX,
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Sizing rules shared by stamp/signature placement and the drag cursor
3
+ * preview. They live apart from the engine so both paths derive their size
4
+ * from one implementation — the ghost drifting from what actually lands on
5
+ * the page was a real bug, not a hypothetical one.
6
+ */
7
+ export interface ImageSize {
8
+ width: number;
9
+ height: number;
10
+ }
11
+ /** Aspect-preserving downscale. Never upscales. */
12
+ export declare function resizeImage(width: number, height: number, max: number): {
13
+ newWidth: number;
14
+ newHeight: number;
15
+ };
16
+ /**
17
+ * The size an annotation will occupy in stage-content units once placed.
18
+ * Explicit dimensions (a stamp carrying width/height metadata) win outright;
19
+ * otherwise the image's own aspect ratio is bounded by `max`.
20
+ */
21
+ export declare function placementSize(explicit: Partial<ImageSize> | null, natural: ImageSize | null, max: number): ImageSize | null;
22
+ /**
23
+ * The same size expressed in CSS pixels, for the cursor ghost. The Konva stage
24
+ * scale is what converts content units to what the user actually sees, so a
25
+ * ghost that ignores it is wrong at every zoom level but 100%.
26
+ */
27
+ export declare function cursorPreviewSize(explicit: Partial<ImageSize> | null, natural: ImageSize | null, max: number, scale: number): ImageSize | null;
@@ -0,0 +1,26 @@
1
+ export function resizeImage(width, height, max) {
2
+ if (width <= max && height <= max) {
3
+ return { newWidth: width, newHeight: height };
4
+ }
5
+ const scaleFactor = Math.min(max / width, max / height);
6
+ return {
7
+ newWidth: width * scaleFactor,
8
+ newHeight: height * scaleFactor
9
+ };
10
+ }
11
+ export function placementSize(explicit, natural, max) {
12
+ if (explicit?.width && explicit?.height) {
13
+ return { width: explicit.width, height: explicit.height };
14
+ }
15
+ if (!natural?.width || !natural?.height) return null;
16
+ const { newWidth, newHeight } = resizeImage(natural.width, natural.height, max);
17
+ return { width: newWidth, height: newHeight };
18
+ }
19
+ export function cursorPreviewSize(explicit, natural, max, scale) {
20
+ const size = placementSize(explicit, natural, max);
21
+ if (!size) return null;
22
+ return {
23
+ width: Math.round(size.width * scale),
24
+ height: Math.round(size.height * scale)
25
+ };
26
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Pixel helpers for signature images uploaded from disk.
3
+ *
4
+ * Kept free of DOM APIs (they take plain pixel buffers, not canvases) so
5
+ * the behaviour is unit-testable without a headless browser — the rest
6
+ * of the upload flow lives in SignatureDrawModal.vue.
7
+ */
8
+ /**
9
+ * Scanned or photographed signatures arrive on opaque white paper,
10
+ * which would cover page content once stamped onto a PDF. Map luminance
11
+ * to alpha so near-white goes fully transparent and the anti-aliased
12
+ * edges of the strokes fade out instead of leaving a hard halo.
13
+ */
14
+ export declare const WHITE_CUTOFF = 240;
15
+ export declare const WHITE_SOFT_EDGE = 180;
16
+ /** Mutates `data` (RGBA, as returned by ImageData) in place. */
17
+ export declare function knockOutWhite(data: Uint8ClampedArray, cutoff?: number, softEdge?: number): void;
18
+ /**
19
+ * Scale `width`×`height` down to fit inside `maxWidth`×`maxHeight`,
20
+ * preserving aspect ratio. Never scales up: a signature smaller than the
21
+ * box is left at its natural size rather than being blown up and blurred.
22
+ */
23
+ export declare function fitWithin(width: number, height: number, maxWidth: number, maxHeight: number): {
24
+ width: number;
25
+ height: number;
26
+ };