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
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div
|
|
3
3
|
class="kviewer-form-signature"
|
|
4
|
-
:class="{
|
|
4
|
+
:class="{
|
|
5
|
+
'kviewer-form-signature--filled': hasSigned,
|
|
6
|
+
'kviewer-form-signature--static': !clickToSignEnabled
|
|
7
|
+
}"
|
|
5
8
|
@click="onClickSign"
|
|
6
9
|
>
|
|
7
10
|
<img
|
|
@@ -10,13 +13,21 @@
|
|
|
10
13
|
class="kviewer-form-signature__preview"
|
|
11
14
|
:alt="t('signature')"
|
|
12
15
|
>
|
|
13
|
-
<span v-else class="kviewer-form-signature__placeholder">
|
|
16
|
+
<span v-else-if="clickToSignEnabled" class="kviewer-form-signature__placeholder">
|
|
14
17
|
{{ props.field.promptText || t("clickToSign") }}
|
|
15
18
|
</span>
|
|
19
|
+
<!-- click-to-sign off: passive marker — the host owns the signing flow.
|
|
20
|
+
promptText is a call to action ("Sign here"), so it only applies to
|
|
21
|
+
the click-to-sign placeholder; the marker stays a neutral label. -->
|
|
22
|
+
<span v-else class="kviewer-form-signature__placeholder kviewer-form-signature__marker">
|
|
23
|
+
<UIcon name="i-lucide-signature" class="kviewer-form-signature__marker-icon" />
|
|
24
|
+
{{ t("signature") }}
|
|
25
|
+
</span>
|
|
16
26
|
</div>
|
|
17
27
|
|
|
18
28
|
<ClientOnly>
|
|
19
29
|
<SignatureDrawModal
|
|
30
|
+
v-if="clickToSignEnabled"
|
|
20
31
|
v-model:open="drawModalOpen"
|
|
21
32
|
@submit="onDrawSubmit"
|
|
22
33
|
@cancel="drawModalOpen = false"
|
|
@@ -43,7 +54,9 @@ const signatureUrl = computed(() => {
|
|
|
43
54
|
return fv.value;
|
|
44
55
|
});
|
|
45
56
|
const hasSigned = computed(() => !!signatureUrl.value);
|
|
57
|
+
const clickToSignEnabled = computed(() => state.clickToSign.value);
|
|
46
58
|
async function onClickSign() {
|
|
59
|
+
if (!clickToSignEnabled.value) return;
|
|
47
60
|
if (props.field.readOnly || state.readonly.value) return;
|
|
48
61
|
if (formFields.isFieldLocked(props.field.id)) return;
|
|
49
62
|
if (state.signatures.value.length > 0) {
|
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<UModal v-model:open="isOpen" :title="t('
|
|
2
|
+
<UModal v-model:open="isOpen" :title="t('addSignature')" :ui="{ content: 'sm:max-w-2xl' }">
|
|
3
3
|
<template #body>
|
|
4
4
|
<div class="flex flex-col gap-3">
|
|
5
|
+
<div class="flex gap-1" role="tablist">
|
|
6
|
+
<button
|
|
7
|
+
v-for="tab in tabs"
|
|
8
|
+
:key="tab"
|
|
9
|
+
role="tab"
|
|
10
|
+
:aria-selected="mode === tab"
|
|
11
|
+
class="px-3 py-1.5 text-sm rounded cursor-pointer"
|
|
12
|
+
:class="
|
|
13
|
+
mode === tab ? 'bg-elevated text-highlighted font-medium' : 'text-muted hover:text-default'
|
|
14
|
+
"
|
|
15
|
+
@click="mode = tab"
|
|
16
|
+
>
|
|
17
|
+
{{ tab === "draw" ? t("drawSignature") : t("uploadSignature") }}
|
|
18
|
+
</button>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
5
21
|
<div
|
|
22
|
+
v-show="mode === 'draw'"
|
|
6
23
|
ref="canvasWrapEl"
|
|
7
24
|
class="border border-default rounded bg-white relative w-full"
|
|
8
25
|
:style="{ aspectRatio: `${canvasAspect}` }"
|
|
@@ -16,7 +33,8 @@
|
|
|
16
33
|
@pointerleave="onPointerUp"
|
|
17
34
|
/>
|
|
18
35
|
</div>
|
|
19
|
-
|
|
36
|
+
|
|
37
|
+
<div v-if="mode === 'draw'" class="flex items-center gap-3">
|
|
20
38
|
<div class="flex gap-1">
|
|
21
39
|
<button
|
|
22
40
|
v-for="c in signatureColors"
|
|
@@ -33,6 +51,50 @@
|
|
|
33
51
|
{{ t("clear") }}
|
|
34
52
|
</UButton>
|
|
35
53
|
</div>
|
|
54
|
+
|
|
55
|
+
<template v-if="mode === 'upload'">
|
|
56
|
+
<!-- The checkerboard makes transparency visible, so users can
|
|
57
|
+
tell whether background removal actually worked before
|
|
58
|
+
placing the signature on the page. -->
|
|
59
|
+
<div
|
|
60
|
+
class="border border-dashed border-default rounded relative w-full flex items-center justify-center bg-checkerboard"
|
|
61
|
+
:style="{ aspectRatio: `${canvasAspect}` }"
|
|
62
|
+
@dragover.prevent
|
|
63
|
+
@drop.prevent="onDrop"
|
|
64
|
+
>
|
|
65
|
+
<img
|
|
66
|
+
v-if="uploadedUrl"
|
|
67
|
+
:src="uploadedUrl"
|
|
68
|
+
class="max-w-full max-h-full object-contain"
|
|
69
|
+
:alt="t('uploadSignature')"
|
|
70
|
+
>
|
|
71
|
+
<button
|
|
72
|
+
v-else
|
|
73
|
+
class="w-full h-full flex flex-col items-center justify-center gap-1 text-muted text-sm cursor-pointer"
|
|
74
|
+
@click="fileInputEl?.click()"
|
|
75
|
+
>
|
|
76
|
+
<span>{{ t("dropSignatureImage") }}</span>
|
|
77
|
+
<span class="text-xs">{{ acceptHint }}</span>
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<input
|
|
82
|
+
ref="fileInputEl"
|
|
83
|
+
type="file"
|
|
84
|
+
:accept="accept"
|
|
85
|
+
class="hidden"
|
|
86
|
+
@change="onFileChange"
|
|
87
|
+
>
|
|
88
|
+
|
|
89
|
+
<p v-if="uploadError" class="text-sm text-error">{{ uploadError }}</p>
|
|
90
|
+
|
|
91
|
+
<div class="flex items-center gap-3">
|
|
92
|
+
<UButton variant="ghost" color="neutral" size="xs" @click="fileInputEl?.click()">
|
|
93
|
+
{{ t("chooseImage") }}
|
|
94
|
+
</UButton>
|
|
95
|
+
<UCheckbox v-model="removeBackground" size="sm" :label="t('removeWhiteBackground')" />
|
|
96
|
+
</div>
|
|
97
|
+
</template>
|
|
36
98
|
</div>
|
|
37
99
|
</template>
|
|
38
100
|
<template #footer>
|
|
@@ -40,7 +102,7 @@
|
|
|
40
102
|
<UButton variant="ghost" color="neutral" @click="cancel">
|
|
41
103
|
{{ t("cancel") }}
|
|
42
104
|
</UButton>
|
|
43
|
-
<UButton :disabled="!
|
|
105
|
+
<UButton :disabled="!hasContent" @click="submit">
|
|
44
106
|
{{ t("save") }}
|
|
45
107
|
</UButton>
|
|
46
108
|
</div>
|
|
@@ -49,8 +111,9 @@
|
|
|
49
111
|
</template>
|
|
50
112
|
|
|
51
113
|
<script setup>
|
|
52
|
-
import { ref, watch, nextTick, onBeforeUnmount } from "vue";
|
|
114
|
+
import { ref, computed, watch, nextTick, onBeforeUnmount } from "vue";
|
|
53
115
|
import { defaultOptions } from "../../annotation/engine/config";
|
|
116
|
+
import { fitWithin, knockOutWhite } from "../../annotation/utils/signature_image";
|
|
54
117
|
import { useKviewerI18n } from "../../composables/useKviewerI18n";
|
|
55
118
|
const { t } = useKviewerI18n();
|
|
56
119
|
const props = defineProps({
|
|
@@ -64,6 +127,8 @@ watch(
|
|
|
64
127
|
isOpen.value = v;
|
|
65
128
|
if (v) {
|
|
66
129
|
hasDrawn.value = false;
|
|
130
|
+
mode.value = "draw";
|
|
131
|
+
resetUpload();
|
|
67
132
|
await nextTick();
|
|
68
133
|
syncCanvasSize();
|
|
69
134
|
clearCanvas();
|
|
@@ -71,6 +136,8 @@ watch(
|
|
|
71
136
|
}
|
|
72
137
|
);
|
|
73
138
|
watch(isOpen, (v) => emit("update:open", v));
|
|
139
|
+
const tabs = ["draw", "upload"];
|
|
140
|
+
const mode = ref("draw");
|
|
74
141
|
const canvasAspect = `${defaultOptions.signature.WIDTH} / ${defaultOptions.signature.HEIGHT}`;
|
|
75
142
|
const signatureColors = defaultOptions.signature.COLORS;
|
|
76
143
|
const canvasRef = ref(null);
|
|
@@ -165,7 +232,102 @@ function clearCanvas() {
|
|
|
165
232
|
ctx.restore();
|
|
166
233
|
hasDrawn.value = false;
|
|
167
234
|
}
|
|
235
|
+
const accept = defaultOptions.signature.ACCEPT;
|
|
236
|
+
const maxSize = defaultOptions.signature.MAX_SIZE;
|
|
237
|
+
const fileInputEl = ref(null);
|
|
238
|
+
const uploadedUrl = ref(null);
|
|
239
|
+
const uploadError = ref(null);
|
|
240
|
+
const removeBackground = ref(true);
|
|
241
|
+
let sourceImage = null;
|
|
242
|
+
const acceptHint = accept.replace(/\./g, "").toUpperCase().replace(/,/g, ", ");
|
|
243
|
+
const maxSizeMb = Math.round(maxSize / (1024 * 1024));
|
|
244
|
+
const hasContent = computed(
|
|
245
|
+
() => mode.value === "draw" ? hasDrawn.value : !!uploadedUrl.value
|
|
246
|
+
);
|
|
247
|
+
function resetUpload() {
|
|
248
|
+
uploadedUrl.value = null;
|
|
249
|
+
uploadError.value = null;
|
|
250
|
+
sourceImage = null;
|
|
251
|
+
if (fileInputEl.value) fileInputEl.value.value = "";
|
|
252
|
+
}
|
|
253
|
+
function onDrop(e) {
|
|
254
|
+
const file = e.dataTransfer?.files?.[0];
|
|
255
|
+
if (file) void handleFile(file);
|
|
256
|
+
}
|
|
257
|
+
function onFileChange(e) {
|
|
258
|
+
const file = e.target.files?.[0];
|
|
259
|
+
if (file) void handleFile(file);
|
|
260
|
+
}
|
|
261
|
+
async function handleFile(file) {
|
|
262
|
+
uploadError.value = null;
|
|
263
|
+
const extension = `.${file.name.split(".").pop()?.toLowerCase() ?? ""}`;
|
|
264
|
+
if (!accept.split(",").includes(extension)) {
|
|
265
|
+
uploadError.value = t("unsupportedImageType", { accept: acceptHint });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (file.size > maxSize) {
|
|
269
|
+
uploadError.value = t("imageTooLarge", { size: maxSizeMb });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
sourceImage = await loadImage(file);
|
|
274
|
+
renderUpload();
|
|
275
|
+
} catch {
|
|
276
|
+
sourceImage = null;
|
|
277
|
+
uploadError.value = t("imageReadFailed");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function loadImage(file) {
|
|
281
|
+
return new Promise((resolve, reject) => {
|
|
282
|
+
const reader = new FileReader();
|
|
283
|
+
reader.onerror = () => reject(new Error("read failed"));
|
|
284
|
+
reader.onload = () => {
|
|
285
|
+
const img = new Image();
|
|
286
|
+
img.onload = () => resolve(img);
|
|
287
|
+
img.onerror = () => reject(new Error("decode failed"));
|
|
288
|
+
img.src = reader.result;
|
|
289
|
+
};
|
|
290
|
+
reader.readAsDataURL(file);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
const MAX_OUTPUT_SCALE = 3;
|
|
294
|
+
function renderUpload() {
|
|
295
|
+
const img = sourceImage;
|
|
296
|
+
if (!img) return;
|
|
297
|
+
const { width, height } = fitWithin(
|
|
298
|
+
img.naturalWidth,
|
|
299
|
+
img.naturalHeight,
|
|
300
|
+
defaultOptions.signature.WIDTH * MAX_OUTPUT_SCALE,
|
|
301
|
+
defaultOptions.signature.HEIGHT * MAX_OUTPUT_SCALE
|
|
302
|
+
);
|
|
303
|
+
const canvas = document.createElement("canvas");
|
|
304
|
+
canvas.width = width;
|
|
305
|
+
canvas.height = height;
|
|
306
|
+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
307
|
+
if (!ctx) {
|
|
308
|
+
uploadError.value = t("imageReadFailed");
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
312
|
+
if (removeBackground.value) {
|
|
313
|
+
try {
|
|
314
|
+
const imageData = ctx.getImageData(0, 0, width, height);
|
|
315
|
+
knockOutWhite(imageData.data);
|
|
316
|
+
ctx.putImageData(imageData, 0, 0);
|
|
317
|
+
} catch {
|
|
318
|
+
uploadError.value = t("backgroundRemovalFailed");
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
uploadedUrl.value = canvas.toDataURL("image/png");
|
|
322
|
+
}
|
|
323
|
+
watch(removeBackground, () => {
|
|
324
|
+
if (sourceImage) renderUpload();
|
|
325
|
+
});
|
|
168
326
|
function submit() {
|
|
327
|
+
if (mode.value === "upload") {
|
|
328
|
+
if (uploadedUrl.value) emit("submit", uploadedUrl.value);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
169
331
|
const canvas = canvasRef.value;
|
|
170
332
|
if (!canvas) return;
|
|
171
333
|
const imageUrl = canvas.toDataURL("image/png");
|
|
@@ -175,3 +337,7 @@ function cancel() {
|
|
|
175
337
|
emit("cancel");
|
|
176
338
|
}
|
|
177
339
|
</script>
|
|
340
|
+
|
|
341
|
+
<style scoped>
|
|
342
|
+
.bg-checkerboard{background-color:#fff;background-image:linear-gradient(45deg,#e5e5e5 25%,transparent 0),linear-gradient(-45deg,#e5e5e5 25%,transparent 0),linear-gradient(45deg,transparent 75%,#e5e5e5 0),linear-gradient(-45deg,transparent 75%,#e5e5e5 0);background-position:0 0,0 8px,8px -8px,-8px 0;background-size:16px 16px}
|
|
343
|
+
</style>
|
|
@@ -1,110 +1,107 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
<UIcon name="i-lucide-minus" class="text-[10px] text-muted" />
|
|
22
|
-
<USlider
|
|
23
|
-
:model-value="freehandStrokeWidth"
|
|
24
|
-
:min="1"
|
|
25
|
-
:max="24"
|
|
26
|
-
:step="1"
|
|
27
|
-
size="sm"
|
|
28
|
-
class="w-28"
|
|
29
|
-
@update:model-value="setFreehandSize"
|
|
30
|
-
/>
|
|
31
|
-
<UIcon name="i-lucide-plus" class="text-[10px] text-muted" />
|
|
32
|
-
<span class="w-9 text-[11px] text-muted text-right tabular-nums">
|
|
33
|
-
{{ freehandStrokeWidth }}px
|
|
34
|
-
</span>
|
|
35
|
-
</div>
|
|
2
|
+
<!-- Rendered only while a tool with editable style is active — an empty
|
|
3
|
+
"no properties" pill would just be toolbar noise the rest of the
|
|
4
|
+
time. The second row is a grid, so the center column stays put. -->
|
|
5
|
+
<div
|
|
6
|
+
v-if="supportsProperties"
|
|
7
|
+
class="flex items-center gap-2 px-2 py-1 rounded-md border border-default bg-default/60 lg:min-w-64"
|
|
8
|
+
>
|
|
9
|
+
<!-- ── Desktop: inline color buttons + slider (md and above) ── -->
|
|
10
|
+
<div class="hidden lg:flex items-center gap-1">
|
|
11
|
+
<button
|
|
12
|
+
v-for="color in colors"
|
|
13
|
+
:key="color"
|
|
14
|
+
class="w-4 h-4 rounded-full border-2 cursor-pointer transition-transform hover:scale-110"
|
|
15
|
+
:class="color === currentColor ? 'border-white ring-1 ring-primary' : 'border-transparent'"
|
|
16
|
+
:style="{ backgroundColor: color }"
|
|
17
|
+
:title="`Color: ${color}`"
|
|
18
|
+
@click="setColor(color)"
|
|
19
|
+
/>
|
|
20
|
+
</div>
|
|
36
21
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
class="w-7 h-7 rounded-full border-2 cursor-pointer transition-transform hover:scale-110"
|
|
57
|
-
:class="color === currentColor ? 'border-white ring-2 ring-primary' : 'border-transparent'"
|
|
58
|
-
:style="{ backgroundColor: color }"
|
|
59
|
-
@click="setColorAndClose(color)"
|
|
60
|
-
/>
|
|
61
|
-
</div>
|
|
62
|
-
</div>
|
|
63
|
-
</template>
|
|
64
|
-
</UPopover>
|
|
22
|
+
<div
|
|
23
|
+
v-if="activeType === AnnotationType.FREEHAND"
|
|
24
|
+
class="hidden lg:flex items-center gap-2 min-w-40"
|
|
25
|
+
>
|
|
26
|
+
<UIcon name="i-lucide-minus" class="text-[10px] text-muted" />
|
|
27
|
+
<USlider
|
|
28
|
+
:model-value="freehandStrokeWidth"
|
|
29
|
+
:min="1"
|
|
30
|
+
:max="24"
|
|
31
|
+
:step="1"
|
|
32
|
+
size="sm"
|
|
33
|
+
class="w-28"
|
|
34
|
+
@update:model-value="setFreehandSize"
|
|
35
|
+
/>
|
|
36
|
+
<UIcon name="i-lucide-plus" class="text-[10px] text-muted" />
|
|
37
|
+
<span class="w-9 text-[11px] text-muted text-right tabular-nums">
|
|
38
|
+
{{ freehandStrokeWidth }}px
|
|
39
|
+
</span>
|
|
40
|
+
</div>
|
|
65
41
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
>
|
|
83
|
-
<UIcon name="i-lucide-minus" class="text-sm" />
|
|
84
|
-
</button>
|
|
85
|
-
<span class="w-9 text-center text-sm tabular-nums">
|
|
86
|
-
{{ freehandStrokeWidth }}px
|
|
87
|
-
</span>
|
|
42
|
+
<!-- ── Mobile: two popover buttons (below md) ── -->
|
|
43
|
+
<div class="flex lg:hidden items-center gap-1">
|
|
44
|
+
<!-- Color popover -->
|
|
45
|
+
<UPopover v-model:open="colorPopoverOpen">
|
|
46
|
+
<button
|
|
47
|
+
class="flex items-center justify-center w-7 h-7 rounded-md hover:bg-default cursor-pointer"
|
|
48
|
+
:title="t('color')"
|
|
49
|
+
>
|
|
50
|
+
<span
|
|
51
|
+
class="w-4 h-4 rounded-full border border-white/30"
|
|
52
|
+
:style="{ backgroundColor: currentColor ?? colors[0] }"
|
|
53
|
+
/>
|
|
54
|
+
</button>
|
|
55
|
+
<template #content>
|
|
56
|
+
<div class="p-3">
|
|
57
|
+
<div class="grid grid-cols-4 gap-1.5">
|
|
88
58
|
<button
|
|
89
|
-
|
|
90
|
-
:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
59
|
+
v-for="color in colors"
|
|
60
|
+
:key="color"
|
|
61
|
+
class="w-7 h-7 rounded-full border-2 cursor-pointer transition-transform hover:scale-110"
|
|
62
|
+
:class="color === currentColor ? 'border-white ring-2 ring-primary' : 'border-transparent'"
|
|
63
|
+
:style="{ backgroundColor: color }"
|
|
64
|
+
@click="setColorAndClose(color)"
|
|
65
|
+
/>
|
|
96
66
|
</div>
|
|
97
|
-
</
|
|
98
|
-
</
|
|
99
|
-
</
|
|
100
|
-
</template>
|
|
67
|
+
</div>
|
|
68
|
+
</template>
|
|
69
|
+
</UPopover>
|
|
101
70
|
|
|
102
|
-
|
|
103
|
-
<
|
|
104
|
-
<
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
71
|
+
<!-- Stroke width popover (freehand only) -->
|
|
72
|
+
<UPopover v-if="activeType === AnnotationType.FREEHAND">
|
|
73
|
+
<button
|
|
74
|
+
class="flex items-center justify-center h-7 px-1.5 gap-0.5 rounded-md hover:bg-default cursor-pointer"
|
|
75
|
+
:title="t('strokeWidth')"
|
|
76
|
+
>
|
|
77
|
+
<UIcon name="i-lucide-pencil" class="text-sm text-muted" />
|
|
78
|
+
<span class="text-[10px] text-muted tabular-nums">{{ freehandStrokeWidth }}</span>
|
|
79
|
+
</button>
|
|
80
|
+
<template #content>
|
|
81
|
+
<div class="p-3 flex items-center gap-3">
|
|
82
|
+
<button
|
|
83
|
+
class="flex items-center justify-center w-8 h-8 rounded-md hover:bg-default cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
|
84
|
+
:disabled="freehandStrokeWidth <= 1"
|
|
85
|
+
:title="t('decreaseStrokeWidth')"
|
|
86
|
+
@click="decrementStrokeWidth"
|
|
87
|
+
>
|
|
88
|
+
<UIcon name="i-lucide-minus" class="text-sm" />
|
|
89
|
+
</button>
|
|
90
|
+
<span class="w-9 text-center text-sm tabular-nums">
|
|
91
|
+
{{ freehandStrokeWidth }}px
|
|
92
|
+
</span>
|
|
93
|
+
<button
|
|
94
|
+
class="flex items-center justify-center w-8 h-8 rounded-md hover:bg-default cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
|
95
|
+
:disabled="freehandStrokeWidth >= 24"
|
|
96
|
+
:title="t('increaseStrokeWidth')"
|
|
97
|
+
@click="incrementStrokeWidth"
|
|
98
|
+
>
|
|
99
|
+
<UIcon name="i-lucide-plus" class="text-sm" />
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
</template>
|
|
103
|
+
</UPopover>
|
|
104
|
+
</div>
|
|
108
105
|
</div>
|
|
109
106
|
</template>
|
|
110
107
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Ref, type ShallowRef } from 'vue';
|
|
2
|
-
import type { AddFormFieldPayload, CheckboxStyle, DetectedShape, FormFieldDefinition, FormFieldValue, PlaceFieldPayload } from '../annotation/engine/types.js';
|
|
2
|
+
import type { AddFormFieldPayload, CheckboxStyle, DetectedShape, FormFieldDefinition, FormFieldValue, PlaceFieldPayload, PlacedFieldDefaults, SignatureFieldStatus } from '../annotation/engine/types.js';
|
|
3
3
|
export interface FormFieldsState {
|
|
4
4
|
/** Field definitions grouped by page number */
|
|
5
5
|
fieldDefinitions: ShallowRef<Map<number, FormFieldDefinition[]>>;
|
|
@@ -35,6 +35,10 @@ export interface FormFieldsState {
|
|
|
35
35
|
getAllFields: () => FormFieldDefinition[];
|
|
36
36
|
/** All user-placed field definitions (used by export). */
|
|
37
37
|
getPlacedFields: () => FormFieldDefinition[];
|
|
38
|
+
/** Signed-state snapshot of every signature widget (any origin).
|
|
39
|
+
* Reads the reactive defs/values, so computeds over it re-run on
|
|
40
|
+
* every signing or field change. */
|
|
41
|
+
getSignatureFieldStatus: () => SignatureFieldStatus[];
|
|
38
42
|
/** Look up a field definition by id (any origin). */
|
|
39
43
|
getFieldById: (id: string) => FormFieldDefinition | undefined;
|
|
40
44
|
/** Id of the currently selected placed field (null if none). */
|
|
@@ -54,6 +58,18 @@ export interface FormFieldsState {
|
|
|
54
58
|
/** Replace the host-provided color map. Called by KViewer when the
|
|
55
59
|
* prop changes. */
|
|
56
60
|
setRoleColors: (colors: Record<string, string>) => void;
|
|
61
|
+
/** Per-field-type presets merged into every tool placement. Host-owned
|
|
62
|
+
* via the KViewer `fieldDefaults` prop or the bridge; programmatic
|
|
63
|
+
* `addFormField` calls are NOT affected (the payload is explicit). */
|
|
64
|
+
placedFieldDefaults: ShallowRef<PlacedFieldDefaults>;
|
|
65
|
+
/** Replace the placement presets. */
|
|
66
|
+
setPlacedFieldDefaults: (defaults: PlacedFieldDefaults) => void;
|
|
67
|
+
/** When true, user-placed fields show selection chrome and can be
|
|
68
|
+
* moved/resized/deleted in hand mode even outside form-edit mode.
|
|
69
|
+
* Parsed/detected fields are unaffected. */
|
|
70
|
+
editablePlacedFields: Ref<boolean>;
|
|
71
|
+
/** Enable or disable placed-field editing outside form-edit mode. */
|
|
72
|
+
setEditablePlacedFields: (enabled: boolean) => void;
|
|
57
73
|
/** Apply checkbox-style hints (extracted from `/MK /CA` in the source PDF)
|
|
58
74
|
* to existing parsed checkbox defs and store the map for any defs
|
|
59
75
|
* parsed afterwards. Keyed by checkbox fieldName. */
|
|
@@ -14,12 +14,20 @@ export function provideFormFields() {
|
|
|
14
14
|
const selectedPlacedFieldId = ref(null);
|
|
15
15
|
const activeRoleId = ref(null);
|
|
16
16
|
const roleColors = shallowRef({});
|
|
17
|
+
const placedFieldDefaults = shallowRef({});
|
|
18
|
+
const editablePlacedFields = ref(false);
|
|
17
19
|
function setActiveRole(id) {
|
|
18
20
|
activeRoleId.value = id;
|
|
19
21
|
}
|
|
20
22
|
function setRoleColors(colors) {
|
|
21
23
|
roleColors.value = colors;
|
|
22
24
|
}
|
|
25
|
+
function setPlacedFieldDefaults(defaults) {
|
|
26
|
+
placedFieldDefaults.value = defaults;
|
|
27
|
+
}
|
|
28
|
+
function setEditablePlacedFields(enabled) {
|
|
29
|
+
editablePlacedFields.value = enabled;
|
|
30
|
+
}
|
|
23
31
|
function selectPlacedField(id) {
|
|
24
32
|
selectedPlacedFieldId.value = id;
|
|
25
33
|
}
|
|
@@ -261,10 +269,6 @@ export function provideFormFields() {
|
|
|
261
269
|
while (existing.has(`${base}_${n}`)) n += 1;
|
|
262
270
|
return `${base}_${n}`;
|
|
263
271
|
}
|
|
264
|
-
function placedDefaultValue(fieldType) {
|
|
265
|
-
if (fieldType === "checkbox") return false;
|
|
266
|
-
return "";
|
|
267
|
-
}
|
|
268
272
|
function addPlacedField(payload) {
|
|
269
273
|
const id = `placed-${generateUUID()}`;
|
|
270
274
|
let fieldName;
|
|
@@ -284,6 +288,15 @@ export function provideFormFields() {
|
|
|
284
288
|
required: false,
|
|
285
289
|
origin: "placed"
|
|
286
290
|
};
|
|
291
|
+
const defaults = placedFieldDefaults.value[payload.fieldType];
|
|
292
|
+
if (defaults) {
|
|
293
|
+
Object.assign(
|
|
294
|
+
def,
|
|
295
|
+
Object.fromEntries(
|
|
296
|
+
Object.entries(defaults).filter(([, value]) => value !== void 0)
|
|
297
|
+
)
|
|
298
|
+
);
|
|
299
|
+
}
|
|
287
300
|
if (activeRoleId.value) {
|
|
288
301
|
def.roleId = activeRoleId.value;
|
|
289
302
|
}
|
|
@@ -291,11 +304,11 @@ export function provideFormFields() {
|
|
|
291
304
|
def.buttonValue = nextRadioOptionValue(fieldName);
|
|
292
305
|
}
|
|
293
306
|
if (payload.fieldType === "checkbox") {
|
|
294
|
-
def.checkboxStyle
|
|
307
|
+
def.checkboxStyle ??= "check";
|
|
295
308
|
}
|
|
296
309
|
if (payload.fieldType === "dropdown") {
|
|
297
|
-
def.combo
|
|
298
|
-
def.options
|
|
310
|
+
def.combo ??= true;
|
|
311
|
+
def.options ??= [{ exportValue: "Option 1", displayValue: "Option 1" }];
|
|
299
312
|
}
|
|
300
313
|
const existingDefs = fieldDefinitions.value.get(payload.pageNumber) ?? [];
|
|
301
314
|
fieldDefinitions.value.set(payload.pageNumber, [...existingDefs, def]);
|
|
@@ -304,7 +317,9 @@ export function provideFormFields() {
|
|
|
304
317
|
fieldId: id,
|
|
305
318
|
fieldName: def.fieldName,
|
|
306
319
|
fieldType: def.fieldType,
|
|
307
|
-
|
|
320
|
+
// Honors a `defaultValue` preset from placedFieldDefaults, matching
|
|
321
|
+
// how addFormField initializes values.
|
|
322
|
+
value: getDefaultValue(def)
|
|
308
323
|
});
|
|
309
324
|
triggerRef(fieldValues);
|
|
310
325
|
if (payload.fieldType === "radio") {
|
|
@@ -455,6 +470,25 @@ export function provideFormFields() {
|
|
|
455
470
|
}
|
|
456
471
|
return false;
|
|
457
472
|
}
|
|
473
|
+
function getSignatureFieldStatus() {
|
|
474
|
+
const out = [];
|
|
475
|
+
for (const defs of fieldDefinitions.value.values()) {
|
|
476
|
+
for (const d of defs) {
|
|
477
|
+
if (d.fieldType !== "signature") continue;
|
|
478
|
+
const fv = fieldValues.value.get(d.id);
|
|
479
|
+
const signed = typeof fv?.value === "string" && fv.value !== "" || d.signedInSource === true;
|
|
480
|
+
const status = {
|
|
481
|
+
id: d.id,
|
|
482
|
+
fieldName: d.fieldName,
|
|
483
|
+
pageNumber: d.pageNumber,
|
|
484
|
+
signed
|
|
485
|
+
};
|
|
486
|
+
if (d.roleId) status.roleId = d.roleId;
|
|
487
|
+
out.push(status);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return out;
|
|
491
|
+
}
|
|
458
492
|
function isFieldLocked(fieldId) {
|
|
459
493
|
const target = getFieldById(fieldId);
|
|
460
494
|
if (!target) return false;
|
|
@@ -511,6 +545,7 @@ export function provideFormFields() {
|
|
|
511
545
|
removePlacedField,
|
|
512
546
|
getAllFields,
|
|
513
547
|
getPlacedFields,
|
|
548
|
+
getSignatureFieldStatus,
|
|
514
549
|
getFieldById,
|
|
515
550
|
selectedPlacedFieldId,
|
|
516
551
|
selectPlacedField,
|
|
@@ -525,7 +560,11 @@ export function provideFormFields() {
|
|
|
525
560
|
activeRoleId,
|
|
526
561
|
setActiveRole,
|
|
527
562
|
roleColors,
|
|
528
|
-
setRoleColors
|
|
563
|
+
setRoleColors,
|
|
564
|
+
placedFieldDefaults,
|
|
565
|
+
setPlacedFieldDefaults,
|
|
566
|
+
editablePlacedFields,
|
|
567
|
+
setEditablePlacedFields
|
|
529
568
|
};
|
|
530
569
|
provide(FORM_FIELDS_KEY, state);
|
|
531
570
|
return state;
|
|
@@ -156,7 +156,7 @@ export function provideViewerSearch() {
|
|
|
156
156
|
const end = match.end;
|
|
157
157
|
const isSelected = isSelectedPage && i === selectedMatchIndex;
|
|
158
158
|
const highlightSuffix = isSelected ? " selected" : "";
|
|
159
|
-
let selectedSpan
|
|
159
|
+
let selectedSpan;
|
|
160
160
|
if (!prevEnd || begin.divIdx !== prevEnd.divIdx) {
|
|
161
161
|
if (prevEnd) {
|
|
162
162
|
appendTextToDiv(binding, prevEnd.divIdx, prevEnd.offset, void 0);
|