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
@@ -1,7 +1,10 @@
1
1
  <template>
2
2
  <div
3
3
  class="kviewer-form-signature"
4
- :class="{ 'kviewer-form-signature--filled': hasSigned }"
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) {
@@ -2,7 +2,7 @@
2
2
  <UModal
3
3
  v-model:open="isOpen"
4
4
  :title="props.initialText !== void 0 ? t('editText') : t('freeText')"
5
- :ui="{ width: 'sm:max-w-md' }"
5
+ :ui="{ content: 'sm:max-w-md' }"
6
6
  >
7
7
  <template #body>
8
8
  <div class="flex flex-col gap-3">
@@ -1,8 +1,25 @@
1
1
  <template>
2
- <UModal v-model:open="isOpen" :title="t('drawSignature')" :ui="{ content: 'sm:max-w-2xl' }">
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
- <div class="flex items-center gap-3">
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="!hasDrawn" @click="submit">
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
- <div class="flex items-center gap-2 px-2 py-1 rounded-md border border-default bg-default/60 lg:min-w-64">
3
- <template v-if="supportsProperties">
4
- <!-- ── Desktop: inline color buttons + slider (md and above) ── -->
5
- <div class="hidden lg:flex items-center gap-1">
6
- <button
7
- v-for="color in colors"
8
- :key="color"
9
- class="w-4 h-4 rounded-full border-2 cursor-pointer transition-transform hover:scale-110"
10
- :class="color === currentColor ? 'border-white ring-1 ring-primary' : 'border-transparent'"
11
- :style="{ backgroundColor: color }"
12
- :title="`Color: ${color}`"
13
- @click="setColor(color)"
14
- />
15
- </div>
16
-
17
- <div
18
- v-if="activeType === AnnotationType.FREEHAND"
19
- class="hidden lg:flex items-center gap-2 min-w-40"
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
- <!-- ── Mobile: two popover buttons (below md) ── -->
38
- <div class="flex lg:hidden items-center gap-1">
39
- <!-- Color popover -->
40
- <UPopover v-model:open="colorPopoverOpen">
41
- <button
42
- class="flex items-center justify-center w-7 h-7 rounded-md hover:bg-default cursor-pointer"
43
- :title="t('color')"
44
- >
45
- <span
46
- class="w-4 h-4 rounded-full border border-white/30"
47
- :style="{ backgroundColor: currentColor ?? colors[0] }"
48
- />
49
- </button>
50
- <template #content>
51
- <div class="p-3">
52
- <div class="grid grid-cols-4 gap-1.5">
53
- <button
54
- v-for="color in colors"
55
- :key="color"
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
- <!-- Stroke width popover (freehand only) -->
67
- <UPopover v-if="activeType === AnnotationType.FREEHAND">
68
- <button
69
- class="flex items-center justify-center h-7 px-1.5 gap-0.5 rounded-md hover:bg-default cursor-pointer"
70
- :title="t('strokeWidth')"
71
- >
72
- <UIcon name="i-lucide-pencil" class="text-sm text-muted" />
73
- <span class="text-[10px] text-muted tabular-nums">{{ freehandStrokeWidth }}</span>
74
- </button>
75
- <template #content>
76
- <div class="p-3 flex items-center gap-3">
77
- <button
78
- class="flex items-center justify-center w-8 h-8 rounded-md hover:bg-default cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
79
- :disabled="freehandStrokeWidth <= 1"
80
- :title="t('decreaseStrokeWidth')"
81
- @click="decrementStrokeWidth"
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
- class="flex items-center justify-center w-8 h-8 rounded-md hover:bg-default cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
90
- :disabled="freehandStrokeWidth >= 24"
91
- :title="t('increaseStrokeWidth')"
92
- @click="incrementStrokeWidth"
93
- >
94
- <UIcon name="i-lucide-plus" class="text-sm" />
95
- </button>
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
- </template>
98
- </UPopover>
99
- </div>
100
- </template>
67
+ </div>
68
+ </template>
69
+ </UPopover>
101
70
 
102
- <template v-else>
103
- <div class="flex items-center gap-1.5 text-xs text-muted">
104
- <UIcon name="i-lucide-sliders-horizontal" class="text-sm" />
105
- <span>{{ t("noProperties") }}</span>
106
- </div>
107
- </template>
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 = "check";
307
+ def.checkboxStyle ??= "check";
295
308
  }
296
309
  if (payload.fieldType === "dropdown") {
297
- def.combo = true;
298
- def.options = [{ exportValue: "Option 1", displayValue: "Option 1" }];
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
- value: placedDefaultValue(def.fieldType)
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 = null;
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);