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.
- package/README.md +13 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +9 -0
- package/dist/runtime/annotation/engine/config.d.ts +1 -0
- package/dist/runtime/annotation/engine/config.js +4 -0
- package/dist/runtime/annotation/engine/cursor-preview.d.ts +14 -1
- package/dist/runtime/annotation/engine/cursor-preview.js +32 -6
- package/dist/runtime/annotation/engine/editor/editor.d.ts +17 -0
- package/dist/runtime/annotation/engine/editor/editor.js +44 -15
- package/dist/runtime/annotation/engine/tools/signature.js +30 -5
- package/dist/runtime/annotation/engine/tools/stamp.d.ts +9 -0
- package/dist/runtime/annotation/engine/tools/stamp.js +67 -15
- package/dist/runtime/annotation/engine/types.d.ts +25 -0
- package/dist/runtime/annotation/engine/utils.d.ts +1 -4
- package/dist/runtime/annotation/engine/utils.js +1 -10
- package/dist/runtime/annotation/pdf-export/export.js +0 -3
- package/dist/runtime/annotation/utils/placement_size.d.ts +27 -0
- package/dist/runtime/annotation/utils/placement_size.js +26 -0
- package/dist/runtime/annotation/utils/signature_image.d.ts +26 -0
- package/dist/runtime/annotation/utils/signature_image.js +20 -0
- package/dist/runtime/assets/kviewer.css +1 -1
- package/dist/runtime/components/FormFieldLayer.vue +6 -1
- package/dist/runtime/components/Viewer.d.vue.ts +56 -2
- package/dist/runtime/components/Viewer.vue +68 -2
- package/dist/runtime/components/Viewer.vue.d.ts +56 -2
- package/dist/runtime/components/ViewerTabs.d.vue.ts +29 -9
- package/dist/runtime/components/ViewerTabs.vue +10 -2
- package/dist/runtime/components/ViewerTabs.vue.d.ts +29 -9
- package/dist/runtime/components/form-fields/FormFieldWrapper.vue +4 -2
- package/dist/runtime/components/form-fields/FormSignatureField.vue +15 -2
- package/dist/runtime/components/modals/FreeTextModal.vue +1 -1
- package/dist/runtime/components/modals/SignatureDrawModal.vue +170 -4
- package/dist/runtime/components/tools/ToolProperties.vue +98 -101
- package/dist/runtime/composables/useFormFields.d.ts +17 -1
- package/dist/runtime/composables/useFormFields.js +48 -9
- package/dist/runtime/composables/useViewerSearch.js +1 -1
- package/dist/runtime/composables/useViewerState.d.ts +1 -0
- package/dist/runtime/composables/useViewerState.js +12 -13
- package/dist/runtime/embed/bridge-client.d.ts +19 -3
- package/dist/runtime/embed/bridge-client.js +24 -0
- package/dist/runtime/embed/bridge-host.d.ts +10 -1
- package/dist/runtime/embed/bridge-host.js +20 -0
- package/dist/runtime/embed/protocol.d.ts +28 -1
- package/dist/runtime/i18n/messages.d.ts +16 -2
- package/dist/runtime/i18n/messages.js +16 -2
- package/dist/runtime/public-types.d.ts +1 -1
- package/dist/runtime/toolbar-tools.d.ts +1 -1
- package/dist/runtime/toolbar-tools.js +8 -0
- 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://
|
|
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
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
|
{
|
|
@@ -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
|
|
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,
|
|
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: `${
|
|
33
|
-
height: `${
|
|
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: `${
|
|
45
|
-
height: `${
|
|
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
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 } =
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
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
|
|
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
|
|
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");
|
|
@@ -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
|
+
};
|