@brftech/filex-core 0.42.2 → 0.43.0

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 (234) hide show
  1. package/README.md +14 -2
  2. package/dist/ArchiveViewer-BqU761Wk.js +149 -0
  3. package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
  4. package/dist/CsvViewer-ByHVK5ga.js +172 -0
  5. package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
  6. package/dist/{DrawioViewer-CC6rJXeP.js → DrawioViewer-Bgrj8-zG.js} +45 -44
  7. package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
  8. package/dist/{EpubViewer-CAoTSXTO.js → EpubViewer-DJwUKlHW.js} +50 -50
  9. package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
  10. package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
  11. package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
  12. package/dist/MermaidViewer-CEDTD_pp.js +136 -0
  13. package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
  14. package/dist/PsdViewer-CZcxUpZO.js +121 -0
  15. package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
  16. package/dist/TiffViewer-BwI62CbQ.js +141 -0
  17. package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
  18. package/dist/{Viewer3D-CdZeK4j4.js → Viewer3D-DoeZcuMm.js} +16 -16
  19. package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
  20. package/dist/filex-core.js +475 -219
  21. package/dist/filex-core.umd.cjs +165 -108
  22. package/dist/filex-core.umd.cjs.map +1 -1
  23. package/dist/fonts/caveat-latin-ext.woff2 +0 -0
  24. package/dist/fonts/caveat-latin.woff2 +0 -0
  25. package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
  26. package/dist/fonts/dancing-script-latin.woff2 +0 -0
  27. package/dist/fonts/homemade-apple-latin.woff2 +0 -0
  28. package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
  29. package/dist/fonts/source-serif-4-latin.woff2 +0 -0
  30. package/dist/index-DuWnGuv5.js +35901 -0
  31. package/dist/index-DuWnGuv5.js.map +1 -0
  32. package/dist/index.d.ts +5227 -553
  33. package/dist/pdf-CaPYsOUo.js +21302 -0
  34. package/dist/pdf-CaPYsOUo.js.map +1 -0
  35. package/dist/style.css +1 -1
  36. package/package.json +1 -1
  37. package/src/FileExplorer.vue +1055 -253
  38. package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
  39. package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
  40. package/src/assets/fonts/README.md +58 -0
  41. package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
  42. package/src/assets/fonts/caveat-latin.woff2 +0 -0
  43. package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
  44. package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
  45. package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
  46. package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
  47. package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
  48. package/src/components/AdvancedSearch.vue +49 -0
  49. package/src/components/Breadcrumb.vue +5 -5
  50. package/src/components/ChoiceButtons.vue +152 -0
  51. package/src/components/CommandPalette.vue +5 -5
  52. package/src/components/ConnectionGuideView.vue +1 -1
  53. package/src/components/ConnectionNotice.vue +59 -0
  54. package/src/components/ConnectionsPanel.vue +30 -568
  55. package/src/components/ContextMenu.vue +31 -5
  56. package/src/components/DataTable.vue +1331 -0
  57. package/src/components/FilePane.vue +3 -0
  58. package/src/components/FilterBar.vue +12 -5
  59. package/src/components/GalleryView.vue +50 -2
  60. package/src/components/GridView.vue +51 -3
  61. package/src/components/HomeView.vue +18 -6
  62. package/src/components/InspectorPanel.vue +134 -17
  63. package/src/components/ListView.vue +366 -900
  64. package/src/components/LogoMark.vue +57 -0
  65. package/src/components/NFSExportsPanel.vue +76 -28
  66. package/src/components/OnboardingTour.vue +19 -7
  67. package/src/components/OperationsCenter.vue +77 -1
  68. package/src/components/PendingOpsTray.vue +59 -5
  69. package/src/components/PresenceBar.vue +7 -7
  70. package/src/components/ProductVersion.vue +31 -0
  71. package/src/components/QuickLook.vue +10 -14
  72. package/src/components/RecentlyOpened.vue +31 -15
  73. package/src/components/RecoveryKeyModal.vue +1 -1
  74. package/src/components/RowActions.vue +134 -0
  75. package/src/components/S3KeysPanel.vue +98 -62
  76. package/src/components/SSHKeysPanel.vue +79 -30
  77. package/src/components/SideNav.vue +260 -96
  78. package/src/components/StarButton.vue +1 -0
  79. package/src/components/StorageFields.vue +74 -6
  80. package/src/components/StorageTags.vue +62 -0
  81. package/src/components/TabBar.vue +1 -1
  82. package/src/components/TagKindIcon.vue +56 -0
  83. package/src/components/TagPicker.vue +226 -40
  84. package/src/components/ThemePalette.vue +4 -4
  85. package/src/components/TimeZonePicker.vue +29 -10
  86. package/src/components/TokensPanel.vue +82 -31
  87. package/src/components/Toolbar.vue +28 -6
  88. package/src/components/plugin/PluginConfirmModal.vue +54 -0
  89. package/src/components/plugin/PluginInspectorSection.vue +149 -0
  90. package/src/components/plugin/PluginPageView.vue +339 -0
  91. package/src/components/plugin/PluginViewModal.vue +159 -0
  92. package/src/components/plugin/SurfaceConversation.vue +76 -0
  93. package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
  94. package/src/components/plugin/SurfaceRenderer.vue +299 -0
  95. package/src/components/plugin/SurfaceSections.vue +91 -0
  96. package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
  97. package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
  98. package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
  99. package/src/components/plugin/nodes/SurfaceList.vue +127 -0
  100. package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
  101. package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
  102. package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
  103. package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
  104. package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
  105. package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
  106. package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
  107. package/src/components/public/PublicLanguagePicker.vue +58 -0
  108. package/src/components/public/PublicLinkPage.vue +286 -0
  109. package/src/components/public/PublicLinkPreview.vue +85 -0
  110. package/src/components/public/PublicPinGate.vue +108 -0
  111. package/src/components/public/PublicRequestBody.vue +204 -0
  112. package/src/components/public/PublicShareBody.vue +188 -0
  113. package/src/components/public/PublicShell.vue +332 -0
  114. package/src/composables/useConnections.ts +8 -2
  115. package/src/composables/useFileApi.ts +183 -44
  116. package/src/composables/useLocale.ts +277 -41
  117. package/src/composables/useNFSExports.ts +8 -10
  118. package/src/composables/useOperations.ts +44 -3
  119. package/src/composables/usePendingOps.ts +59 -8
  120. package/src/composables/usePluginActions.ts +121 -0
  121. package/src/composables/usePluginSurface.ts +223 -0
  122. package/src/composables/usePublicBranding.ts +172 -0
  123. package/src/composables/usePublicLink.ts +573 -0
  124. package/src/composables/usePublicPage.ts +61 -0
  125. package/src/composables/useS3Keys.ts +17 -12
  126. package/src/composables/useSSHKeys.ts +8 -10
  127. package/src/composables/useTokens.ts +8 -12
  128. package/src/composables/useUploadChunked.ts +18 -8
  129. package/src/composables/useViewerFetch.ts +6 -3
  130. package/src/index.ts +404 -8
  131. package/src/lib/actionIcons.ts +46 -1
  132. package/src/lib/advSearch.ts +14 -7
  133. package/src/lib/appLock.ts +130 -0
  134. package/src/lib/connection.ts +110 -0
  135. package/src/lib/destinationTree.ts +19 -3
  136. package/src/lib/direction.ts +353 -0
  137. package/src/lib/dragGhost.ts +6 -0
  138. package/src/lib/dropLimits.ts +69 -0
  139. package/src/lib/errorWords.ts +394 -0
  140. package/src/lib/externalReach.ts +5 -1
  141. package/src/lib/fileFilters.ts +7 -2
  142. package/src/lib/fileIcons.ts +4 -0
  143. package/src/lib/internalPaths.ts +77 -0
  144. package/src/lib/listInput.ts +64 -0
  145. package/src/lib/listing.ts +58 -23
  146. package/src/lib/modalStack.ts +26 -0
  147. package/src/lib/nodeRow.ts +18 -3
  148. package/src/lib/pdfFieldRules.ts +126 -0
  149. package/src/lib/pdfFields.ts +589 -0
  150. package/src/lib/pdfFieldsGeom.ts +203 -0
  151. package/src/lib/pdfjsLoader.ts +51 -0
  152. package/src/lib/personName.ts +47 -0
  153. package/src/lib/pluginApplies.ts +123 -0
  154. package/src/lib/pluginLabel.ts +72 -0
  155. package/src/lib/pluginMenu.ts +253 -0
  156. package/src/lib/pluginPage.ts +58 -0
  157. package/src/lib/plural.ts +181 -0
  158. package/src/lib/popupLayer.ts +103 -0
  159. package/src/lib/prefs.ts +563 -0
  160. package/src/lib/productVersion.ts +35 -0
  161. package/src/lib/publicLayout.ts +39 -0
  162. package/src/lib/serviceGate.ts +104 -0
  163. package/src/lib/shareTtl.ts +25 -11
  164. package/src/lib/signFonts.ts +85 -0
  165. package/src/lib/signaturePad.ts +111 -0
  166. package/src/lib/sortOrder.ts +100 -38
  167. package/src/lib/star.ts +7 -1
  168. package/src/lib/surfaceCell.ts +41 -0
  169. package/src/lib/surfaceConditions.ts +156 -0
  170. package/src/lib/surfaceOpen.ts +106 -0
  171. package/src/lib/surfaceSteps.ts +68 -0
  172. package/src/lib/surfaceValues.ts +191 -0
  173. package/src/lib/symlink.ts +150 -0
  174. package/src/lib/tableColumns.ts +493 -0
  175. package/src/lib/tableEnv.ts +34 -0
  176. package/src/lib/tablePrefs.ts +84 -0
  177. package/src/lib/tags.ts +121 -12
  178. package/src/lib/themes.ts +286 -18
  179. package/src/lib/tour.ts +152 -0
  180. package/src/lib/trashEmpty.ts +111 -0
  181. package/src/lib/trashTimeLeft.ts +23 -0
  182. package/src/lib/uiLocales.ts +460 -0
  183. package/src/lib/viewPrefs.ts +696 -660
  184. package/src/lib/viewPrefsHttp.ts +78 -0
  185. package/src/locales/en.ts +515 -117
  186. package/src/locales/resolve.ts +17 -8
  187. package/src/locales/tr.ts +536 -152
  188. package/src/modals/ConvertModal.vue +62 -11
  189. package/src/modals/DestinationPickerModal.vue +59 -16
  190. package/src/modals/Modal.vue +113 -23
  191. package/src/modals/NewDocumentModal.vue +16 -6
  192. package/src/modals/NewFolderModal.vue +8 -0
  193. package/src/modals/PermissionsModal.vue +272 -163
  194. package/src/modals/PreviewModal.vue +119 -20
  195. package/src/modals/RenameModal.vue +37 -1
  196. package/src/styles/base.css +2890 -260
  197. package/src/styles/sign-fonts.css +118 -0
  198. package/src/styles/variables.css +60 -0
  199. package/src/types/Connections.ts +17 -0
  200. package/src/types/ExplorerConfig.ts +117 -1
  201. package/src/types/FileNode.ts +50 -0
  202. package/src/types/Plugins.ts +494 -0
  203. package/src/types/Public.ts +212 -0
  204. package/src/viewers/ArchiveViewer.vue +93 -48
  205. package/src/viewers/CsvViewer.vue +99 -53
  206. package/src/viewers/DrawioViewer.vue +35 -9
  207. package/src/viewers/EpubViewer.vue +7 -8
  208. package/src/viewers/IpynbViewer.vue +3 -1
  209. package/src/viewers/MermaidViewer.vue +4 -5
  210. package/src/viewers/PdfViewer.vue +8 -17
  211. package/src/viewers/PsdViewer.vue +2 -4
  212. package/src/viewers/TiffViewer.vue +4 -5
  213. package/src/viewers/Viewer3D.vue +1 -1
  214. package/LICENSE +0 -21
  215. package/dist/ArchiveViewer-BUQWLhwX.js +0 -124
  216. package/dist/ArchiveViewer-BUQWLhwX.js.map +0 -1
  217. package/dist/CsvViewer-Bg2_aqzc.js +0 -141
  218. package/dist/CsvViewer-Bg2_aqzc.js.map +0 -1
  219. package/dist/DrawioViewer-CC6rJXeP.js.map +0 -1
  220. package/dist/EpubViewer-CAoTSXTO.js.map +0 -1
  221. package/dist/IpynbViewer-D0GwGpnE.js +0 -184
  222. package/dist/IpynbViewer-D0GwGpnE.js.map +0 -1
  223. package/dist/MermaidViewer-DGwAvRZg.js +0 -137
  224. package/dist/MermaidViewer-DGwAvRZg.js.map +0 -1
  225. package/dist/PsdViewer-DhGweA0m.js +0 -122
  226. package/dist/PsdViewer-DhGweA0m.js.map +0 -1
  227. package/dist/TiffViewer-D0AurvAh.js +0 -142
  228. package/dist/TiffViewer-D0AurvAh.js.map +0 -1
  229. package/dist/Viewer3D-CdZeK4j4.js.map +0 -1
  230. package/dist/index-BArI-B1M.js +0 -26911
  231. package/dist/index-BArI-B1M.js.map +0 -1
  232. package/dist/useViewerFetch-czqbd2Lj.js +0 -25
  233. package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
  234. package/src/composables/useScrolledX.ts +0 -21
@@ -0,0 +1,1285 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * SurfacePdfFields — a plugin surface's `pdf-fields` node: a PDF drawn by
4
+ * pdf.js with signature / initials / date / text / checkbox boxes on top.
5
+ *
6
+ * Four modes, one drawing.
7
+ *
8
+ * ⚠⚠ `define` and `place` are the author's two JOBS, split (v3 §3.3).
9
+ * Naming a box and finding a place for it were one screen — a palette of
10
+ * types, a rectangle under the pointer, and a strip of properties for
11
+ * whichever box happened to be selected — and the owner's words for it were
12
+ * "you are still doing both at once". `define` shows the boxes as cards and
13
+ * no document at all; `place` shows the document and hands out the boxes
14
+ * that still need a place, one tap each. `edit` keeps the one-screen form
15
+ * for a plugin that wants it. All three send the WHOLE `fields[]` back under
16
+ * the node's id; an unplaced box travels as `placed: false`.
17
+ *
18
+ * `fill` is a signer filling their own boxes (the pad opens inline for a signature, text is typed in place
19
+ * under its rule, a checkbox toggles, a date takes today on a tap) and sends
20
+ * `{fields: [{id, value, font?, rule?}]}`; the other signers' boxes are grey
21
+ * outlines carrying that signer's name.
22
+ *
23
+ * ⚠⚠ A `text` field's rule is enforced WHILE the person types, not after
24
+ * they submit: an invalid signature page that only says so on the way out is
25
+ * a page that gets signed twice. `lib/pdfFieldRules` holds the shaping and
26
+ * the verdict; the rule then travels back with the value so the stamping
27
+ * plugin formats what it is handed instead of guessing from the string
28
+ * (`01/02/2026` is a date to a rule and ambiguous text to a parser).
29
+ *
30
+ * ⚠ The FACE travels for the same reason, and it is one of five shipped
31
+ * with the package (`lib/signFonts`) rather than a CSS name the machine might
32
+ * happen to have — the same five the signature pad offers, so a document
33
+ * signed on a phone and filled on a desktop matches.
34
+ *
35
+ * Geometry is fractions of the rendered page (`lib/pdfFieldsGeom`), so the
36
+ * boxes are CSS percentages of the page element and survive every zoom
37
+ * without being recomputed; the canvas underneath is re-rendered lazily
38
+ * per page (IntersectionObserver) at the device's pixel ratio. The rules —
39
+ * who may touch what, what goes on the wire — are `lib/pdfFields`.
40
+ *
41
+ * Bytes: `src.path` through the explorer's authenticated preview fetch
42
+ * (like SurfacePreview), `src.ref` through the host's `fileUrl` resolver
43
+ * (a public page's exposed copy), `src.url` as given. A document that will
44
+ * not load is an error node, never a blank.
45
+ */
46
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
47
+ import type { LocaleCode, ThemeMode } from '../../../types/ExplorerConfig';
48
+ import type { FileApi } from '../../../composables/useFileApi';
49
+ import type { FileRefResolver, PdfFieldsSource } from '../../../types/Plugins';
50
+ import { useLocale } from '../../../composables/useLocale';
51
+ import { fetchViewerArrayBuffer } from '../../../composables/useViewerFetch';
52
+ import { labelOf } from '../../../lib/pluginLabel';
53
+ import { loadPdfjs } from '../../../lib/pdfjsLoader';
54
+ import { actionIconSvg } from '../../../lib/actionIcons';
55
+ import {
56
+ DEFAULT_FIELD_SIZE,
57
+ allowedTypes,
58
+ applyFillValues,
59
+ copyToPage,
60
+ fillValues,
61
+ hasValue,
62
+ isFieldOf,
63
+ defineField,
64
+ isPlaced,
65
+ normalizeFields,
66
+ normalizeSigners,
67
+ placeField,
68
+ unplacedFields,
69
+ signerColor,
70
+ todayIso,
71
+ withFont,
72
+ withValue,
73
+ type PdfField,
74
+ type PdfFieldType,
75
+ } from '../../../lib/pdfFields';
76
+ import {
77
+ PDF_RULE_KINDS,
78
+ applyRule,
79
+ ruleError,
80
+ ruleInputMode,
81
+ ruleMaxLength,
82
+ type PdfFieldRule,
83
+ type PdfRuleKind,
84
+ } from '../../../lib/pdfFieldRules';
85
+ import { SIGN_FONTS, signFont, type SignFontKey } from '../../../lib/signFonts';
86
+ import { clampFrac, fitPageWidth, pointToFrac, rectFromPoints, type PageBox } from '../../../lib/pdfFieldsGeom';
87
+ import type { SignatureValue } from '../../../lib/signaturePad';
88
+ import PdfFieldEditor from './PdfFieldEditor.vue';
89
+ import SurfaceSignaturePad from './SurfaceSignaturePad.vue';
90
+ import ChoiceButtons, { type ChoiceOption } from '../../ChoiceButtons.vue';
91
+
92
+ const props = defineProps<{
93
+ id: string;
94
+ src?: PdfFieldsSource;
95
+ mode?: string;
96
+ fields?: unknown;
97
+ signers?: unknown;
98
+ signer?: string;
99
+ types?: unknown;
100
+ /** `define` / `edit`: the date layouts a `date` box may be written in. */
101
+ formats?: unknown;
102
+ /** The date control's three captions, as the plugin words them (`date_labels`). */
103
+ dateLabels?: unknown;
104
+ /** Edit: the `fields[]` the host holds; fill: `{fields: [{id, value}]}`. */
105
+ modelValue?: unknown;
106
+ locale: LocaleCode;
107
+ theme?: ThemeMode;
108
+ disabled?: boolean;
109
+ invalid?: boolean;
110
+ api?: Pick<FileApi, 'fetchBlob'>;
111
+ fileUrl?: FileRefResolver;
112
+ pdfWorkerUrl?: string | null;
113
+ /**
114
+ * v3 §3.2 — `page`: this node owns the screen, so the document is fitted
115
+ * to the BOX rather than to the text column, and the person never scrolls
116
+ * in two directions to reach a signature box. The zoom controls stay:
117
+ * fitting is the starting point, not a cage.
118
+ */
119
+ layout?: 'inline' | 'page';
120
+ /** The height a `page` layout may fill (a CSS length). Default: the frame's. */
121
+ pageHeight?: string;
122
+ /**
123
+ * The lines the plugin can print under a signature, offered per signature
124
+ * box on the author's screens (`lib/pdfFields.PdfStampLine`).
125
+ */
126
+ stampLines?: unknown;
127
+ }>();
128
+
129
+ const emit = defineEmits<{
130
+ (e: 'update:modelValue', v: unknown): void;
131
+ }>();
132
+
133
+ const { t } = useLocale(() => props.locale);
134
+
135
+ /** Every author mode; `fill` is the signer's. */
136
+ const editing = computed(() => props.mode !== 'fill');
137
+ /** The boxes as cards, with no document on screen. */
138
+ const defining = computed(() => props.mode === 'define');
139
+ /** The document, with the defined boxes waiting to be put on it. */
140
+ const placingStep = computed(() => props.mode === 'place');
141
+ const signers = computed(() => normalizeSigners(props.signers));
142
+ const palette = computed(() => allowedTypes(props.types));
143
+
144
+ /* ── the fields ───────────────────────────────────────────────────────── */
145
+
146
+ const local = ref<PdfField[]>([]);
147
+ let lastEmitted: unknown = undefined;
148
+
149
+ function seed(): void {
150
+ if (editing.value) {
151
+ local.value = Array.isArray(props.modelValue) ? normalizeFields(props.modelValue) : normalizeFields(props.fields);
152
+ } else {
153
+ local.value = applyFillValues(normalizeFields(props.fields), props.modelValue);
154
+ }
155
+ }
156
+
157
+ function publish(): void {
158
+ const v = editing.value ? local.value.map((f) => ({ ...f })) : fillValues(local.value, props.signer);
159
+ lastEmitted = v;
160
+ emit('update:modelValue', v);
161
+ }
162
+
163
+ /**
164
+ * A seed that arrives WHILE a box is being dragged waits for the drag to end.
165
+ *
166
+ * ⚠⚠ Every `change` answer re-sends the surface, so `fields` arrives as a new
167
+ * array (and, for an echo the person has already overtaken, with the box
168
+ * where it was BEFORE the gesture in progress). Seeding then replaced the
169
+ * list under the pointer: the dragged box snapped back for a frame and was
170
+ * pulled forward again on the next move — part of the flicker the owner saw
171
+ * while placing and moving boxes (2026-09-21, item 6). The drag's own
172
+ * `publish()` at the end carries the newer list anyway, so the deferred seed
173
+ * is usually dropped rather than run.
174
+ */
175
+ let seedLater = false;
176
+ function reseed(): void {
177
+ if (drag) {
178
+ seedLater = true;
179
+ return;
180
+ }
181
+ seed();
182
+ }
183
+
184
+ seed();
185
+ watch([() => props.fields, () => props.mode, () => props.signer], () => reseed());
186
+ watch(
187
+ () => props.modelValue,
188
+ (v) => {
189
+ if (v !== lastEmitted) reseed();
190
+ },
191
+ );
192
+
193
+ const byPage = computed(() => {
194
+ const m = new Map<number, PdfField[]>();
195
+ for (const f of local.value) {
196
+ // A defined box that nobody has placed is not anywhere yet.
197
+ if (!isPlaced(f)) continue;
198
+ const arr = m.get(f.page) ?? [];
199
+ arr.push(f);
200
+ m.set(f.page, arr);
201
+ }
202
+ return m;
203
+ });
204
+
205
+ function fieldColor(f: PdfField): string {
206
+ return signerColor(signers.value, f.assignee);
207
+ }
208
+
209
+ function signerLabel(assignee: string | undefined): string {
210
+ const s = signers.value.find((x) => x.id === assignee);
211
+ return s ? labelOf(s.label, props.locale) || s.id : assignee || '';
212
+ }
213
+
214
+ function mine(f: PdfField): boolean {
215
+ return !editing.value && isFieldOf(f, props.signer);
216
+ }
217
+
218
+ function typeLabel(type: PdfFieldType): string {
219
+ return t(`plugin.pdf.type_${type}`);
220
+ }
221
+
222
+ /**
223
+ * What this field is CALLED — its own name, or the type's as a stand-in.
224
+ *
225
+ * ⚠ v3 §3.1. Three signature boxes on one page were three identical grey
226
+ * rectangles saying "Signature"; a signer could only tell them apart by
227
+ * where they were. The name is shown in the box, in the pad's heading and
228
+ * on the text box's own label, so the same word identifies the field
229
+ * wherever the person meets it.
230
+ */
231
+ function fieldLabel(f: PdfField): string {
232
+ const own = (f.label ?? '').trim();
233
+ if (own) return own;
234
+ // An unnamed box is its kind's name, NUMBERED among the unnamed boxes of
235
+ // that kind when there are several — the same rule the signing app uses
236
+ // on its own screens (views.NameIn), so "Signature 2" on the document is
237
+ // "Signature 2" in the form that asks for it.
238
+ const same = local.value.filter((g) => g.type === f.type && !(g.label ?? '').trim());
239
+ if (same.length < 2) return typeLabel(f.type);
240
+ return `${typeLabel(f.type)} ${same.findIndex((g) => g.id === f.id) + 1}`;
241
+ }
242
+
243
+ /* ── the document ─────────────────────────────────────────────────────── */
244
+
245
+ const status = ref<'loading' | 'ok' | 'error'>('loading');
246
+ const pages = ref<Array<PageBox & { num: number }>>([]);
247
+ const zoom = ref(1);
248
+ const containerEl = ref<HTMLDivElement | null>(null);
249
+ const containerW = ref(640);
250
+ const containerH = ref(0);
251
+
252
+ /** This node has the screen to itself. */
253
+ const pageLayout = computed(() => props.layout === 'page');
254
+
255
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
256
+ let pdfDoc: any = null;
257
+ let seq = 0;
258
+ let observer: IntersectionObserver | null = null;
259
+ let resizeObs: ResizeObserver | null = null;
260
+ const pageEls = new Map<number, HTMLDivElement>();
261
+ const rendered = new Map<number, number>();
262
+ const canvases = new Map<number, HTMLCanvasElement>();
263
+
264
+ /**
265
+ * The width a page is drawn at before zoom.
266
+ *
267
+ * ⚠⚠ In `page` layout it is whichever of the two constraints binds: the
268
+ * column's width, or the width at which the WHOLE page still fits the
269
+ * height. Fitting to width alone is what produced the complaint that
270
+ * started this — a portrait page in a landscape window is taller than the
271
+ * screen, so signing meant scrolling down to find the box and sideways to
272
+ * read the line it sits on. A margin is taken off the height so the page
273
+ * does not touch the toolbar above it.
274
+ */
275
+ const fitWidth = computed(() => {
276
+ if (!pageLayout.value) return containerW.value;
277
+ const first = pages.value[0];
278
+ if (!first) return containerW.value;
279
+ return fitPageWidth({ width: containerW.value, height: containerH.value }, first);
280
+ });
281
+
282
+ /** The page's drawn width in CSS px: the fitted width, times the zoom. */
283
+ const pageWidth = computed(() => Math.max(120, Math.round(fitWidth.value * zoom.value)));
284
+
285
+ async function bytes(): Promise<ArrayBuffer> {
286
+ const s = props.src ?? {};
287
+ if (s.path) {
288
+ if (!props.api) throw new Error('no api');
289
+ const r = await props.api.fetchBlob(s.path);
290
+ URL.revokeObjectURL(r.url);
291
+ return r.blob.arrayBuffer();
292
+ }
293
+ const url = s.url || (s.ref ? props.fileUrl?.(s.ref)?.url : '');
294
+ if (!url) throw new Error('no source');
295
+ return fetchViewerArrayBuffer({ url, headers: {}, credentials: 'same-origin' });
296
+ }
297
+
298
+ async function load(): Promise<void> {
299
+ const run = ++seq;
300
+ status.value = 'loading';
301
+ pages.value = [];
302
+ disconnect();
303
+ try {
304
+ pdfDoc?.destroy?.();
305
+ } catch {
306
+ /* ignore */
307
+ }
308
+ pdfDoc = null;
309
+ try {
310
+ const lib = await loadPdfjs(props.pdfWorkerUrl ?? undefined);
311
+ if (!lib) throw new Error('pdfjs unavailable');
312
+ const data = await bytes();
313
+ if (run !== seq) return;
314
+ const doc = await lib.getDocument({ data }).promise;
315
+ if (run !== seq) {
316
+ doc.destroy?.();
317
+ return;
318
+ }
319
+ pdfDoc = doc;
320
+ const list: Array<PageBox & { num: number }> = [];
321
+ for (let n = 1; n <= doc.numPages; n++) {
322
+ const page = await doc.getPage(n);
323
+ const vp = page.getViewport({ scale: 1 });
324
+ list.push({ num: n, width: vp.width, height: vp.height, rotation: vp.rotation ?? 0 });
325
+ }
326
+ if (run !== seq) return;
327
+ pages.value = list;
328
+ status.value = 'ok';
329
+ await nextTick();
330
+ observe();
331
+ } catch {
332
+ if (run !== seq) return;
333
+ status.value = 'error';
334
+ }
335
+ }
336
+
337
+ function disconnect(): void {
338
+ observer?.disconnect();
339
+ observer = null;
340
+ pageEls.clear();
341
+ rendered.clear();
342
+ canvases.clear();
343
+ }
344
+
345
+ /**
346
+ * A page element arrived (or was replaced): remember it, and have it drawn.
347
+ *
348
+ * ⚠⚠ Observed HERE, not only in `observe()` after a load. The same node
349
+ * lives through the wizard's `define` step, where no page is drawn at all,
350
+ * and its document finishes loading there — `observe()` then finds no page
351
+ * to watch, and when `place` draws the pages a moment later nothing ever
352
+ * renders them. Measured 2026-09-21: the observer was made at t=2.7 s, the
353
+ * page element arrived at t=3.7 s, and the place step showed a blank page.
354
+ * (It had been hidden by the reload-on-every-surface bug this round fixed:
355
+ * the reload happened to observe the pages the second time.)
356
+ */
357
+ function setPageEl(num: number, el: unknown): void {
358
+ if (!(el instanceof HTMLDivElement)) return;
359
+ const before = pageEls.get(num);
360
+ if (before === el) return;
361
+ if (before) observer?.unobserve(before);
362
+ pageEls.set(num, el);
363
+ if (!pdfDoc) return;
364
+ if (observer) observer.observe(el);
365
+ else if (typeof IntersectionObserver === 'undefined') void renderPage(num);
366
+ // A replaced element holds no canvas: draw it again at the current width.
367
+ if (before) {
368
+ rendered.delete(num);
369
+ canvases.delete(num);
370
+ }
371
+ }
372
+
373
+ function observe(): void {
374
+ if (typeof IntersectionObserver === 'undefined') {
375
+ for (const n of pageEls.keys()) void renderPage(n);
376
+ return;
377
+ }
378
+ observer = new IntersectionObserver(
379
+ (entries) => {
380
+ for (const e of entries) {
381
+ const n = Number((e.target as HTMLElement).dataset.page);
382
+ if (e.isIntersecting) void renderPage(n);
383
+ }
384
+ },
385
+ { rootMargin: '200px 0px' },
386
+ );
387
+ for (const el of pageEls.values()) observer.observe(el);
388
+ }
389
+
390
+ /**
391
+ * Draws page `num` at the current width × DPR, once per width.
392
+ *
393
+ * ⚠⚠ Into a FRESH canvas, swapped in only once pdf.js has finished. Writing
394
+ * `canvas.width` on the canvas already on screen clears it on the spot, and
395
+ * the page stayed white until the asynchronous render came back — every
396
+ * zoom, every re-fit (a strip appearing above the document is enough to
397
+ * re-fit a `page` layout) blinked the whole document. The old bitmap now
398
+ * stays up, stretched by CSS to the new size, until its replacement is
399
+ * ready.
400
+ */
401
+ async function renderPage(num: number): Promise<void> {
402
+ if (!pdfDoc) return;
403
+ const el = pageEls.get(num);
404
+ if (!el) return;
405
+ const wantW = pageWidth.value;
406
+ if (rendered.get(num) === wantW) return;
407
+ rendered.set(num, wantW);
408
+ const meta = pages.value.find((p) => p.num === num);
409
+ if (!meta) return;
410
+ const k = typeof window !== 'undefined' && window.devicePixelRatio > 0 ? Math.min(3, window.devicePixelRatio) : 1;
411
+ const scale = (wantW / meta.width) * k;
412
+ const doc = pdfDoc;
413
+ try {
414
+ const page = await doc.getPage(num);
415
+ const viewport = page.getViewport({ scale });
416
+ const fresh = document.createElement('canvas');
417
+ fresh.className = 'fe-spdf__canvas';
418
+ fresh.width = Math.round(viewport.width);
419
+ fresh.height = Math.round(viewport.height);
420
+ const ctx = fresh.getContext('2d');
421
+ if (!ctx) return;
422
+ await page.render({ canvasContext: ctx, viewport }).promise;
423
+ // A newer width (or another document) overtook this render: its own
424
+ // render will swap in, and this bitmap is already stale.
425
+ if (doc !== pdfDoc || rendered.get(num) !== wantW) return;
426
+ const live = pageEls.get(num);
427
+ if (!live) return;
428
+ const old = canvases.get(num);
429
+ if (old && old.parentNode === live) live.replaceChild(fresh, old);
430
+ else live.prepend(fresh);
431
+ canvases.set(num, fresh);
432
+ } catch {
433
+ if (rendered.get(num) === wantW) rendered.delete(num);
434
+ }
435
+ }
436
+
437
+ function rerenderVisible(): void {
438
+ for (const n of pageEls.keys()) {
439
+ if (rendered.has(n)) {
440
+ rendered.delete(n);
441
+ void renderPage(n);
442
+ }
443
+ }
444
+ }
445
+
446
+ watch(pageWidth, () => rerenderVisible());
447
+
448
+ function zoomIn(): void {
449
+ zoom.value = Math.min(3, Math.round(zoom.value * 1.25 * 100) / 100);
450
+ }
451
+ function zoomOut(): void {
452
+ zoom.value = Math.max(0.5, Math.round((zoom.value / 1.25) * 100) / 100);
453
+ }
454
+ function zoomFit(): void {
455
+ zoom.value = 1;
456
+ }
457
+
458
+ /**
459
+ * Measure the pane the document is drawn into, and keep measuring it.
460
+ *
461
+ * ⚠⚠ Called on mount AND whenever the pane appears, because it does not
462
+ * always exist when this component mounts: the wizard draws the same node in
463
+ * `define` first (cards, no document) and the pane arrives only on the next
464
+ * step. Measured once at mount, `containerEl` was null, no observer was
465
+ * attached, and the width kept its 640px default — so the "fit the page to
466
+ * the window" rule was computed against a box that had nothing to do with
467
+ * the window, and an A4 page ran off the bottom of the screen.
468
+ */
469
+ function measurePane(): void {
470
+ const el = containerEl.value;
471
+ if (!el) return;
472
+ containerW.value = el.clientWidth || containerW.value;
473
+ containerH.value = el.clientHeight || containerH.value;
474
+ if (typeof ResizeObserver === 'undefined') return;
475
+ resizeObs?.disconnect();
476
+ resizeObs = new ResizeObserver(() => applyPaneSize());
477
+ resizeObs.observe(el);
478
+ }
479
+
480
+ /**
481
+ * Take the pane's current size — unless a box is being dragged.
482
+ *
483
+ * ⚠⚠ A re-fit DURING a drag changes the page's size under the pointer: the
484
+ * fractions the drag is computing are then fractions of a different page,
485
+ * and the box slides away from the finger. Measured 2026-09-21: selecting
486
+ * the box on pointer-down brought a strip in above the document, the pane
487
+ * lost 34px of height, and the fitted page shrank from 383px to 349px wide
488
+ * mid-gesture. The size is taken again the moment the drag ends.
489
+ */
490
+ function applyPaneSize(): void {
491
+ if (drag) {
492
+ sizeLater = true;
493
+ return;
494
+ }
495
+ const w = containerEl.value?.clientWidth ?? 0;
496
+ const h = containerEl.value?.clientHeight ?? 0;
497
+ if (w > 0 && Math.abs(w - containerW.value) > 2) containerW.value = w;
498
+ // ⚠ The height matters: in `page` layout it is half of what decides the
499
+ // drawn width, so a window resized shorter must re-fit rather than keep
500
+ // a page that no longer fits it.
501
+ if (h > 0 && Math.abs(h - containerH.value) > 2) containerH.value = h;
502
+ }
503
+ let sizeLater = false;
504
+
505
+ watch(containerEl, () => {
506
+ void nextTick(measurePane);
507
+ });
508
+
509
+ onMounted(() => {
510
+ measurePane();
511
+ void load();
512
+ });
513
+
514
+ /**
515
+ * Which document this is, as a VALUE.
516
+ *
517
+ * ⚠⚠ Not the `src` object. Every `change` answer re-sends the surface, so
518
+ * this node is handed a NEW `src` object carrying the same three strings,
519
+ * and the deep watcher that stood here fired on every such arrival and
520
+ * reloaded the document from scratch. Measured 2026-09-21 (the owner's
521
+ * "the screen flickers while I place and drag boxes"): each placement's
522
+ * debounced `change` answer tore the page down — "Loading…", every canvas
523
+ * thrown away, redrawn — and a drag that was in progress when it landed kept
524
+ * a DETACHED page element, whose zero-size rectangle made every later
525
+ * pointer position read as the bottom-right corner: "sometimes the box goes
526
+ * to the edge of the document". Only a different document reloads now.
527
+ */
528
+ const srcKey = computed(() => {
529
+ const s = props.src ?? {};
530
+ return [s.ref ?? '', s.path ?? '', s.url ?? ''].join('\u0000');
531
+ });
532
+ watch(srcKey, () => void load());
533
+
534
+ onBeforeUnmount(() => {
535
+ seq++;
536
+ disconnect();
537
+ resizeObs?.disconnect();
538
+ try {
539
+ pdfDoc?.destroy?.();
540
+ } catch {
541
+ /* ignore */
542
+ }
543
+ pdfDoc = null;
544
+ });
545
+
546
+ /* ── edit mode: place, move, resize ───────────────────────────────────── */
547
+
548
+ const selected = ref('');
549
+ const placing = ref<PdfFieldType | ''>('');
550
+
551
+ interface Drag {
552
+ kind: 'place' | 'move' | 'resize';
553
+ page: number;
554
+ el: HTMLElement;
555
+ start: { x: number; y: number };
556
+ id?: string;
557
+ orig?: PdfField;
558
+ /** The box being drawn, while placing. */
559
+ ghost?: { x: number; y: number; w: number; h: number };
560
+ }
561
+ let drag: Drag | null = null;
562
+ const ghost = ref<{ page: number; x: number; y: number; w: number; h: number } | null>(null);
563
+
564
+ function fracAt(ev: PointerEvent, el: HTMLElement): { x: number; y: number } {
565
+ const r = el.getBoundingClientRect();
566
+ return pointToFrac({ x: ev.clientX - r.left, y: ev.clientY - r.top }, { width: r.width, height: r.height });
567
+ }
568
+
569
+ /**
570
+ * The rectangle of the page a drag is on, read FRESH, or null when there is
571
+ * no page to read it from.
572
+ *
573
+ * ⚠⚠ The element captured at pointer-down is only a fallback. If the page
574
+ * list is re-rendered mid-gesture the captured element is detached, and a
575
+ * detached element measures 0×0 — `pointToFrac` then turns ANY pointer into
576
+ * the corner (it clamps `x / 1` to 1), which is exactly how a dragged box
577
+ * ended up pinned to the document's edge. A rectangle that is not a real
578
+ * page is refused, and the box simply stays where it was for that frame.
579
+ */
580
+ function dragRect(d: { page: number; el: HTMLElement }): DOMRect | null {
581
+ const live = pageEls.get(d.page);
582
+ const el = live && live.isConnected ? live : d.el;
583
+ if (!el || !el.isConnected) return null;
584
+ const r = el.getBoundingClientRect();
585
+ return r.width >= 1 && r.height >= 1 ? r : null;
586
+ }
587
+
588
+ function fracIn(ev: PointerEvent, r: DOMRect): { x: number; y: number } {
589
+ return pointToFrac({ x: ev.clientX - r.left, y: ev.clientY - r.top }, { width: r.width, height: r.height });
590
+ }
591
+
592
+ function pickType(type: PdfFieldType): void {
593
+ placing.value = placing.value === type ? '' : type;
594
+ selected.value = '';
595
+ }
596
+
597
+ /* ── define: the boxes, as cards ──────────────────────────────────────── */
598
+
599
+ /**
600
+ * Add a box of this type. It has an owner; it has no place — and no name
601
+ * until somebody types one.
602
+ *
603
+ * ⚠ Not pre-filled with the type's name any more. That default was written
604
+ * into the request in the REQUESTER's language, so a Turkish signer of an
605
+ * English requester's document was asked for "Signature", "Date" and "Text"
606
+ * (2026-09-21). An unnamed box is shown under its kind's name — here as the
607
+ * input's placeholder, and on every other screen in that reader's language.
608
+ */
609
+ function addField(type: PdfFieldType): void {
610
+ const f = defineField(local.value, type, {
611
+ assignee: defaultAssignee(),
612
+ required: true,
613
+ });
614
+ local.value = [...local.value, f];
615
+ publish();
616
+ }
617
+
618
+ /** One card changed. */
619
+ function patchField(next: PdfField): void {
620
+ local.value = local.value.map((f) => (f.id === next.id ? next : f));
621
+ publish();
622
+ }
623
+
624
+ function removeField(id: string): void {
625
+ local.value = local.value.filter((f) => f.id !== id);
626
+ if (selected.value === id) selected.value = '';
627
+ publish();
628
+ }
629
+
630
+ /* ── place: hand the defined boxes out, one at a time ─────────────────── */
631
+
632
+ /** The boxes still waiting for a place, in the order they were defined. */
633
+ const pending = computed(() => unplacedFields(local.value));
634
+ /** Which of them the next tap on the page will put down. */
635
+ const placingId = ref('');
636
+
637
+ function pickPending(id: string): void {
638
+ placingId.value = placingId.value === id ? '' : id;
639
+ selected.value = '';
640
+ }
641
+
642
+ /** Put the chosen box on this page, at this rectangle. */
643
+ function placePending(page: number, rect: { x: number; y: number; w: number; h: number }): void {
644
+ const id = placingId.value;
645
+ if (!id) return;
646
+ local.value = local.value.map((f) => {
647
+ if (f.id !== id) return f;
648
+ const next: PdfField = { ...f, page, ...clampFrac(rect) };
649
+ delete next.placed;
650
+ return next;
651
+ });
652
+ placingId.value = '';
653
+ selected.value = id;
654
+ publish();
655
+ }
656
+
657
+ /** Take a box off the page again: it goes back to the waiting list. */
658
+ function unplace(): void {
659
+ const id = selected.value;
660
+ if (!id) return;
661
+ local.value = local.value.map((f) => (f.id === id ? { ...f, placed: false } : f));
662
+ selected.value = '';
663
+ publish();
664
+ }
665
+
666
+ /** The name a waiting box is offered under, with whose it is. */
667
+ function pendingLabel(f: PdfField): string {
668
+ const who = signerLabel(f.assignee);
669
+ const name = fieldLabel(f);
670
+ return who ? `${name} · ${who}` : name;
671
+ }
672
+
673
+ function onPageDown(ev: PointerEvent, num: number): void {
674
+ if (!editing.value || props.disabled) return;
675
+ const el = ev.currentTarget as HTMLElement;
676
+ if (placingStep.value && placingId.value) {
677
+ drag = { kind: 'place', page: num, el, start: fracAt(ev, el) };
678
+ ghost.value = null;
679
+ bindWindow();
680
+ ev.preventDefault?.();
681
+ return;
682
+ }
683
+ if (placing.value) {
684
+ drag = { kind: 'place', page: num, el, start: fracAt(ev, el) };
685
+ ghost.value = null;
686
+ bindWindow();
687
+ ev.preventDefault?.();
688
+ return;
689
+ }
690
+ selected.value = '';
691
+ }
692
+
693
+ function onFieldDown(ev: PointerEvent, f: PdfField, num: number): void {
694
+ if (!editing.value || props.disabled) return;
695
+ const el = (ev.currentTarget as HTMLElement).closest('.fe-spdf__page') as HTMLElement | null;
696
+ if (!el) return;
697
+ selected.value = f.id;
698
+ placing.value = '';
699
+ drag = { kind: 'move', page: num, el, start: fracAt(ev, el), id: f.id, orig: { ...f } };
700
+ bindWindow();
701
+ ev.stopPropagation();
702
+ ev.preventDefault?.();
703
+ }
704
+
705
+ function onHandleDown(ev: PointerEvent, f: PdfField, num: number): void {
706
+ if (!editing.value || props.disabled) return;
707
+ const el = (ev.currentTarget as HTMLElement).closest('.fe-spdf__page') as HTMLElement | null;
708
+ if (!el) return;
709
+ selected.value = f.id;
710
+ drag = { kind: 'resize', page: num, el, start: fracAt(ev, el), id: f.id, orig: { ...f } };
711
+ bindWindow();
712
+ ev.stopPropagation();
713
+ ev.preventDefault?.();
714
+ }
715
+
716
+ function onWindowMove(ev: PointerEvent): void {
717
+ if (!drag) return;
718
+ const r = dragRect(drag);
719
+ if (!r) return;
720
+ const p = fracIn(ev, r);
721
+ if (drag.kind === 'place') {
722
+ const r = rectFromPoints(drag.start, p);
723
+ ghost.value = { page: drag.page, ...r };
724
+ return;
725
+ }
726
+ if (!drag.id || !drag.orig) return;
727
+ const dx = p.x - drag.start.x;
728
+ const dy = p.y - drag.start.y;
729
+ const o = drag.orig;
730
+ const next = drag.kind === 'move' ? clampFrac({ x: o.x + dx, y: o.y + dy, w: o.w, h: o.h }) : clampFrac({ x: o.x, y: o.y, w: o.w + dx, h: o.h + dy });
731
+ const id = drag.id;
732
+ local.value = local.value.map((f) => (f.id === id ? { ...f, ...next } : f));
733
+ }
734
+
735
+ function onWindowUp(ev: PointerEvent): void {
736
+ if (!drag) return;
737
+ const d = drag;
738
+ drag = null;
739
+ unbindWindow();
740
+ const r = dragRect(d);
741
+ // The pane may have changed size while the gesture held it still.
742
+ if (sizeLater) {
743
+ sizeLater = false;
744
+ applyPaneSize();
745
+ }
746
+ // A seed that waited for the gesture is overtaken by what the gesture
747
+ // publishes below; one that nothing will overtake runs now.
748
+ const publishes = d.kind === 'place' || d.kind === 'move' || d.kind === 'resize';
749
+ if (seedLater) {
750
+ seedLater = false;
751
+ if (!publishes) seed();
752
+ }
753
+ if (!r) {
754
+ // No page to measure against (it was re-drawn under the gesture): keep
755
+ // what the last good frame put down rather than inventing a place.
756
+ ghost.value = null;
757
+ if (d.kind === 'move' || d.kind === 'resize') publish();
758
+ return;
759
+ }
760
+ if (d.kind === 'place' && placingStep.value && placingId.value) {
761
+ const p = fracIn(ev, r);
762
+ const box = local.value.find((f) => f.id === placingId.value);
763
+ const dragged = Math.abs(p.x - d.start.x) > 0.01 || Math.abs(p.y - d.start.y) > 0.01;
764
+ // ⚠ A tap is enough. The box already knows how big it wants to be (it
765
+ // was defined with a size), so placing is one gesture, not a drawing
766
+ // exercise; a drag is still honoured for somebody who wants a different
767
+ // size straight away.
768
+ const rect = dragged
769
+ ? rectFromPoints(d.start, p)
770
+ : { x: p.x - (box?.w ?? 0.2) / 2, y: p.y - (box?.h ?? 0.05) / 2, w: box?.w ?? 0.2, h: box?.h ?? 0.05 };
771
+ placePending(d.page, rect);
772
+ ghost.value = null;
773
+ return;
774
+ }
775
+ if (d.kind === 'place' && placing.value) {
776
+ const p = fracIn(ev, r);
777
+ const dragged = Math.abs(p.x - d.start.x) > 0.01 || Math.abs(p.y - d.start.y) > 0.01;
778
+ const type = placing.value;
779
+ const f = dragged
780
+ ? placeField(local.value, type, d.page, rectFromPoints(d.start, p), rectFromPoints(d.start, p), defaultAssignee())
781
+ : placeField(local.value, type, d.page, { x: p.x - DEFAULT_FIELD_SIZE[type].w / 2, y: p.y - DEFAULT_FIELD_SIZE[type].h / 2 }, undefined, defaultAssignee());
782
+ // Born with a name (the type's) rather than nameless: the placer
783
+ // renames it if they want to, and a field nobody renamed still reads as
784
+ // something in the signer's form.
785
+ local.value = [...local.value, { ...f, label: typeLabel(type) }];
786
+ selected.value = f.id;
787
+ ghost.value = null;
788
+ publish();
789
+ return;
790
+ }
791
+ ghost.value = null;
792
+ if (d.kind === 'move' || d.kind === 'resize') {
793
+ // A tap on a placed box selects it and moves nothing: no round trip for
794
+ // a list that did not change.
795
+ const now = local.value.find((f) => f.id === d.id);
796
+ const o = d.orig;
797
+ if (!now || !o || now.x !== o.x || now.y !== o.y || now.w !== o.w || now.h !== o.h) publish();
798
+ }
799
+ }
800
+
801
+ function defaultAssignee(): string | undefined {
802
+ return signers.value.length === 1 ? signers.value[0].id : undefined;
803
+ }
804
+
805
+ /**
806
+ * Reactive twin of `drag` for the template: the one-screen editor waits for
807
+ * the gesture to end before it appears, so selecting a box by dragging it
808
+ * does not push the document down under the pointer mid-drag.
809
+ */
810
+ const dragging = ref(false);
811
+
812
+ function bindWindow(): void {
813
+ dragging.value = true;
814
+ window.addEventListener('pointermove', onWindowMove);
815
+ window.addEventListener('pointerup', onWindowUp);
816
+ window.addEventListener('pointercancel', onWindowUp);
817
+ }
818
+ function unbindWindow(): void {
819
+ dragging.value = false;
820
+ window.removeEventListener('pointermove', onWindowMove);
821
+ window.removeEventListener('pointerup', onWindowUp);
822
+ window.removeEventListener('pointercancel', onWindowUp);
823
+ }
824
+
825
+ const selectedField = computed(() => local.value.find((f) => f.id === selected.value) ?? null);
826
+
827
+ const copyTarget = ref(1);
828
+ function copy(): void {
829
+ if (!selectedField.value) return;
830
+ const before = local.value.length;
831
+ local.value = copyToPage(local.value, selected.value, copyTarget.value);
832
+ if (local.value.length === before) return;
833
+ // The copy is the box you are now working on, and it is on another page —
834
+ // which in a tall document is usually off the screen. Select it and go
835
+ // there, or the button answers by appearing to do nothing.
836
+ selected.value = local.value[local.value.length - 1].id;
837
+ publish();
838
+ }
839
+
840
+ /**
841
+ * Bring the selected box into view inside the document pane.
842
+ *
843
+ * ⚠ The pane scrolls, the page does not: `.fe-spdf__scroll` is the window
844
+ * on to a column of pages, so "scroll to the box" is arithmetic on ITS
845
+ * scrollTop, not `el.scrollIntoView()` (which would also scroll the app
846
+ * frame, whose whole point in this layout is that it does not move).
847
+ * Nothing happens mid-gesture: a drag selects the box it picks up, and a
848
+ * pane that scrolled under the pointer would take the box with it.
849
+ */
850
+ function revealSelected(): void {
851
+ const pane = containerEl.value;
852
+ const f = selectedField.value;
853
+ if (!pane || !f || drag || !isPlaced(f)) return;
854
+ const pageEl = pageEls.get(f.page);
855
+ if (!pageEl) return;
856
+ const page = pageEl.getBoundingClientRect();
857
+ const view = pane.getBoundingClientRect();
858
+ if (!page.height || !view.height) return;
859
+ const top = page.top - view.top + pane.scrollTop + f.y * page.height;
860
+ const bottom = top + f.h * page.height;
861
+ const pad = 24;
862
+ if (top < pane.scrollTop + pad) pane.scrollTop = Math.max(0, top - pad);
863
+ else if (bottom > pane.scrollTop + pane.clientHeight - pad) pane.scrollTop = bottom - pane.clientHeight + pad;
864
+ }
865
+ watch(selected, () => void nextTick(revealSelected));
866
+
867
+ /* ── fill mode ────────────────────────────────────────────────────────── */
868
+
869
+ /** The field whose signature pad is open. */
870
+ const padFor = ref('');
871
+
872
+ function setValue(id: string, value: unknown): void {
873
+ local.value = withValue(local.value, id, value);
874
+ publish();
875
+ }
876
+
877
+ function onFieldTap(f: PdfField): void {
878
+ if (editing.value || props.disabled || !mine(f)) return;
879
+ switch (f.type) {
880
+ case 'signature':
881
+ case 'initials':
882
+ padFor.value = padFor.value === f.id ? '' : f.id;
883
+ break;
884
+ case 'checkbox':
885
+ setValue(f.id, f.value === true ? false : true);
886
+ break;
887
+ case 'date':
888
+ setValue(f.id, hasValue(f) ? '' : todayIso());
889
+ break;
890
+ default:
891
+ break;
892
+ }
893
+ }
894
+
895
+ function onTextInput(f: PdfField, ev: Event): void {
896
+ const el = ev.target as HTMLInputElement;
897
+ // ⚠ Shaped as it is typed (digits only, the GG/AA/YYYY mask, …) and the
898
+ // INPUT is written back, because a value the box refused has to leave the
899
+ // box too — otherwise the person sees what they typed and the document
900
+ // gets something else.
901
+ const shaped = applyRule(el.value, ruleOf(f));
902
+ if (shaped !== el.value) el.value = shaped;
903
+ setValue(f.id, shaped);
904
+ }
905
+
906
+ const padField = computed(() => local.value.find((f) => f.id === padFor.value) ?? null);
907
+
908
+ /**
909
+ * What the pad offers for this box: the requester chose drawn or typed, so
910
+ * the signer gets exactly that — a drawn box draws (or takes a picture of a
911
+ * signature), a typed one types, in the face that was chosen for it.
912
+ */
913
+ const padModes = computed(() => (padField.value?.style === 'typed' ? ['type'] : ['draw', 'upload']));
914
+ const padFonts = computed(() =>
915
+ padField.value?.style === 'typed' && padField.value.font ? [padField.value.font] : undefined,
916
+ );
917
+ const padValue = computed<SignatureValue | null>(() => {
918
+ const v = padField.value?.value;
919
+ return typeof v === 'string' && v ? { png_b64: v, mode: 'draw' } : null;
920
+ });
921
+
922
+ function onPadValue(v: SignatureValue | null): void {
923
+ if (!padField.value) return;
924
+ setValue(padField.value.id, v?.png_b64 ?? '');
925
+ }
926
+
927
+ function sigSrc(f: PdfField): string {
928
+ return typeof f.value === 'string' && f.value ? `data:image/png;base64,${f.value}` : '';
929
+ }
930
+
931
+ /* ── rules and faces ─────────────────────────────────────── */
932
+
933
+ /** A field's rule, or `null` — only `text` carries one. */
934
+ function ruleOf(f: PdfField): PdfFieldRule | null {
935
+ return f.type === 'text' ? (f.rule ?? null) : null;
936
+ }
937
+
938
+ /** The words under a text box: what it will accept. `''` when anything goes. */
939
+ function ruleHint(f: PdfField): string {
940
+ const r = ruleOf(f);
941
+ if (!r) return '';
942
+ const parts: string[] = [];
943
+ if (r.kind === 'number') parts.push(t('plugin.pdf.rule_number'));
944
+ else if (r.kind === 'email') parts.push(t('plugin.pdf.rule_email'));
945
+ if (r.min && r.max) parts.push(t('plugin.pdf.rule_length', { min: r.min, max: r.max }));
946
+ else if (r.min) parts.push(t('plugin.pdf.rule_min', { min: r.min }));
947
+ else if (r.max) parts.push(t('plugin.pdf.rule_max', { max: r.max }));
948
+ return parts.join(' · ');
949
+ }
950
+
951
+ /** What is wrong with what is in the box right now. `''` when nothing is. */
952
+ function ruleMessage(f: PdfField): string {
953
+ const r = ruleOf(f);
954
+ const code = ruleError(f.value, r);
955
+ if (!code || !r) return '';
956
+ if (code === 'min') return t('plugin.pdf.err_min', { min: r.min ?? 0 });
957
+ if (code === 'max') return t('plugin.pdf.err_max', { max: r.max ?? 0 });
958
+ return t(`plugin.pdf.err_${code}`);
959
+ }
960
+
961
+ /** The face a field's words are drawn in — the field's, else the default. */
962
+ function fontClassOf(f: PdfField): string {
963
+ return signFont(f.font).className;
964
+ }
965
+
966
+ const checkIcon = actionIconSvg('check');
967
+ </script>
968
+
969
+ <template>
970
+ <div
971
+ class="fe-spdf"
972
+ data-testid="surface-pdf-fields"
973
+ :class="{
974
+ 'is-edit': editing,
975
+ 'is-fill': !editing,
976
+ 'is-invalid': invalid,
977
+ 'is-disabled': disabled,
978
+ 'is-placing': !!placing || !!placingId,
979
+ 'is-define': defining,
980
+ 'fe-spdf--page': pageLayout && !defining,
981
+ }"
982
+ :style="pageLayout ? { '--spdf-h': pageHeight || '100%' } : undefined"
983
+ :data-state="status"
984
+ :data-layout="pageLayout ? 'page' : 'inline'"
985
+ >
986
+ <div v-if="!defining" class="fe-spdf__bar">
987
+ <!-- ⚠ In `place` the toolbar is not a palette of TYPES but the boxes
988
+ that were defined a step earlier and still have nowhere to go.
989
+ Choosing one and tapping the page is the whole gesture. -->
990
+ <div v-if="placingStep" class="fe-spdf__palette" role="toolbar" :aria-label="t('plugin.pdf.pending')">
991
+ <button
992
+ v-for="f in pending"
993
+ :key="f.id"
994
+ type="button"
995
+ class="fe-btn fe-btn--sm"
996
+ :class="{ 'fe-btn--primary': placingId === f.id }"
997
+ :style="{ '--spdf-color': fieldColor(f) }"
998
+ :disabled="disabled || status !== 'ok'"
999
+ :aria-pressed="placingId === f.id ? 'true' : 'false'"
1000
+ :data-testid="`surface-pdf-pending-${f.id}`"
1001
+ @click="pickPending(f.id)"
1002
+ >
1003
+ {{ pendingLabel(f) }}
1004
+ </button>
1005
+ <span v-if="!pending.length" class="fe-spdf__done" data-testid="surface-pdf-all-placed">
1006
+ {{ t('plugin.pdf.all_placed') }}
1007
+ </span>
1008
+ </div>
1009
+ <div v-else-if="editing" class="fe-spdf__palette" role="toolbar" :aria-label="t('plugin.pdf.palette')">
1010
+ <button
1011
+ v-for="ty in palette"
1012
+ :key="ty"
1013
+ type="button"
1014
+ class="fe-btn fe-btn--sm"
1015
+ :class="{ 'fe-btn--primary': placing === ty }"
1016
+ :disabled="disabled || status !== 'ok'"
1017
+ :aria-pressed="placing === ty ? 'true' : 'false'"
1018
+ :data-testid="`surface-pdf-type-${ty}`"
1019
+ @click="pickType(ty)"
1020
+ >
1021
+ {{ typeLabel(ty) }}
1022
+ </button>
1023
+ </div>
1024
+ <div class="fe-spdf__zoom">
1025
+ <button type="button" class="fe-btn fe-btn--sm" :aria-label="t('plugin.pdf.zoom_out')" :disabled="status !== 'ok'" @click="zoomOut">−</button>
1026
+ <button type="button" class="fe-btn fe-btn--sm fe-spdf__zoomval" :aria-label="t(pageLayout ? 'plugin.pdf.zoom_fit_page' : 'plugin.pdf.zoom_fit')" :disabled="status !== 'ok'" @click="zoomFit">
1027
+ {{ Math.round(zoom * 100) }}%
1028
+ </button>
1029
+ <button type="button" class="fe-btn fe-btn--sm" :aria-label="t('plugin.pdf.zoom_in')" :disabled="status !== 'ok'" @click="zoomIn">+</button>
1030
+ </div>
1031
+ </div>
1032
+ <p v-if="editing && !defining && !placingStep && placing" class="fe-surface__text fe-surface__text--info fe-spdf__hint">{{ t('plugin.pdf.place_hint', { type: typeLabel(placing) }) }}</p>
1033
+
1034
+ <!-- ⚠⚠ DEFINE: the boxes as cards, and no document anywhere on the
1035
+ screen. This step answers "what is being asked of whom"; where each
1036
+ box goes is the next one's question. -->
1037
+ <div v-if="defining" class="fe-spdf__define" data-testid="surface-pdf-define">
1038
+ <div v-if="!local.length" class="fe-spdf__empty" data-testid="surface-pdf-no-boxes">
1039
+ {{ t('plugin.pdf.no_boxes') }}
1040
+ </div>
1041
+ <div
1042
+ v-for="(f, i) in local"
1043
+ :key="f.id"
1044
+ class="fe-spdf__card"
1045
+ :style="{ '--spdf-color': fieldColor(f) }"
1046
+ :data-testid="`surface-pdf-card-${f.id}`"
1047
+ >
1048
+ <span class="fe-spdf__cardno" aria-hidden="true">{{ i + 1 }}</span>
1049
+ <PdfFieldEditor
1050
+ :field="f"
1051
+ :signers="signers"
1052
+ :formats="formats"
1053
+ :date-labels="dateLabels"
1054
+ :stamp-lines="stampLines"
1055
+ :locale="locale"
1056
+ :disabled="disabled"
1057
+ :testid-prefix="`surface-pdf-card-${f.id}`"
1058
+ @patch="patchField"
1059
+ @remove="removeField(f.id)"
1060
+ />
1061
+ </div>
1062
+ <div class="fe-spdf__add" role="toolbar" :aria-label="t('plugin.pdf.add_box')">
1063
+ <span class="fe-spdf__addlbl">{{ t('plugin.pdf.add_box') }}</span>
1064
+ <button
1065
+ v-for="ty in palette"
1066
+ :key="ty"
1067
+ type="button"
1068
+ class="fe-btn fe-btn--sm"
1069
+ :disabled="disabled"
1070
+ :data-testid="`surface-pdf-add-${ty}`"
1071
+ @click="addField(ty)"
1072
+ >
1073
+ + {{ typeLabel(ty) }}
1074
+ </button>
1075
+ </div>
1076
+ </div>
1077
+
1078
+ <!-- ⚠⚠ PLACE asks WHERE, and only where. The box was named a step ago;
1079
+ putting the naming controls back on this screen would be "both at
1080
+ once" again, which is the thing this split exists to end. What a
1081
+ selected box offers here is about its PLACE: take it off, put a
1082
+ copy on another page, or drop it. -->
1083
+ <!-- ⚠⚠ ONE strip of constant height in `place`, whatever it is saying.
1084
+ It used to be three things that came and went — a hint while a box
1085
+ was chosen, a property strip once one was selected, nothing in
1086
+ between — and each arrival took height from the document pane. In
1087
+ a `page` layout the page is fitted to that pane, so every tap
1088
+ re-fitted (and redrew) the whole document: measured 2026-09-21, the
1089
+ page shrank from 383px to 349px wide the moment the first box was
1090
+ selected, in the middle of the gesture that selected it. -->
1091
+ <div v-else-if="placingStep" class="fe-spdf__strip" data-testid="surface-pdf-strip">
1092
+ <div v-if="selectedField" class="fe-spdf__props" data-testid="surface-pdf-selected">
1093
+ <span class="fe-spdf__propname" :style="{ '--spdf-color': fieldColor(selectedField) }">
1094
+ {{ fieldLabel(selectedField) }}
1095
+ </span>
1096
+ <span v-if="signerLabel(selectedField.assignee)" class="fe-spdf__propwho">
1097
+ {{ signerLabel(selectedField.assignee) }}
1098
+ </span>
1099
+ <button type="button" class="fe-btn fe-btn--sm" data-testid="surface-pdf-unplace" @click="unplace">
1100
+ {{ t('plugin.pdf.unplace') }}
1101
+ </button>
1102
+ <!-- ⚠ A number, not a list. The rule is "no dropdown in a surface",
1103
+ and a 200-page contract would answer it with 200 buttons — the
1104
+ same complaint (a control you have to hunt through) wearing the
1105
+ fix's clothes. -->
1106
+ <label v-if="pages.length > 1" class="fe-spdf__prop">
1107
+ <span>{{ t('plugin.pdf.copy_to_page') }}</span>
1108
+ <input
1109
+ v-model.number="copyTarget"
1110
+ class="fe-cfield__input fe-spdf__num"
1111
+ type="number"
1112
+ min="1"
1113
+ :max="pages.length"
1114
+ data-testid="surface-pdf-copy-page"
1115
+ />
1116
+ <button type="button" class="fe-btn fe-btn--sm" data-testid="surface-pdf-copy" @click="copy">{{ t('plugin.pdf.copy') }}</button>
1117
+ </label>
1118
+ <button
1119
+ type="button"
1120
+ class="fe-btn fe-btn--sm fe-btn--danger"
1121
+ data-testid="surface-pdf-delete"
1122
+ @click="removeField(selectedField.id)"
1123
+ >
1124
+ {{ t('plugin.pdf.delete') }}
1125
+ </button>
1126
+ </div>
1127
+ <p
1128
+ v-else-if="placingId"
1129
+ class="fe-surface__text fe-surface__text--info fe-spdf__hint"
1130
+ data-testid="surface-pdf-place-hint"
1131
+ >
1132
+ {{ t('plugin.pdf.place_box_hint', { name: pendingLabel(local.find((f) => f.id === placingId) ?? local[0]) }) }}
1133
+ </p>
1134
+ <p v-else class="fe-surface__text fe-surface__text--muted fe-spdf__hint" data-testid="surface-pdf-idle-hint">
1135
+ {{ t(pending.length ? 'plugin.pdf.place_idle' : 'plugin.pdf.place_idle_done') }}
1136
+ </p>
1137
+ </div>
1138
+
1139
+ <!-- The one-screen `edit` mode keeps the whole editor for whichever box
1140
+ is selected: a plugin that asks for both jobs at once gets both.
1141
+ ⚠ Not while a box is being dragged: the editor arriving above the
1142
+ document on pointer-down pushed the page down under the pointer. -->
1143
+ <div v-else-if="editing && selectedField && !dragging" data-testid="surface-pdf-selected">
1144
+ <PdfFieldEditor
1145
+ :field="selectedField"
1146
+ :signers="signers"
1147
+ :formats="formats"
1148
+ :date-labels="dateLabels"
1149
+ :stamp-lines="stampLines"
1150
+ :locale="locale"
1151
+ :disabled="disabled"
1152
+ testid-prefix="surface-pdf"
1153
+ @patch="patchField"
1154
+ @remove="removeField(selectedField.id)"
1155
+ >
1156
+ <template #extra>
1157
+ <label v-if="pages.length > 1" class="fe-spdf__prop">
1158
+ <span>{{ t('plugin.pdf.copy_to_page') }}</span>
1159
+ <input
1160
+ v-model.number="copyTarget"
1161
+ class="fe-cfield__input fe-spdf__num"
1162
+ type="number"
1163
+ min="1"
1164
+ :max="pages.length"
1165
+ data-testid="surface-pdf-copy-page"
1166
+ />
1167
+ <button type="button" class="fe-btn fe-btn--sm" data-testid="surface-pdf-copy" @click="copy">{{ t('plugin.pdf.copy') }}</button>
1168
+ </label>
1169
+ </template>
1170
+ </PdfFieldEditor>
1171
+ </div>
1172
+
1173
+ <div v-if="!defining" ref="containerEl" class="fe-spdf__scroll">
1174
+ <p v-if="status === 'loading'" class="fe-surface__text fe-surface__text--muted">{{ t('plugin.view.loading') }}</p>
1175
+ <p v-else-if="status === 'error'" class="fe-surface__unknown" role="alert" data-testid="surface-pdf-error">{{ t('plugin.pdf.load_failed') }}</p>
1176
+ <div v-else class="fe-spdf__pages">
1177
+ <div
1178
+ v-for="p in pages"
1179
+ :key="p.num"
1180
+ :ref="(el) => setPageEl(p.num, el)"
1181
+ class="fe-spdf__page"
1182
+ :data-page="p.num"
1183
+ :style="{ width: `${pageWidth}px`, aspectRatio: `${p.width} / ${p.height}` }"
1184
+ @pointerdown="onPageDown($event, p.num)"
1185
+ >
1186
+ <div
1187
+ v-for="f in byPage.get(p.num) ?? []"
1188
+ :key="f.id"
1189
+ class="fe-spdf__field"
1190
+ :class="[
1191
+ `fe-spdf__field--${f.type}`,
1192
+ {
1193
+ 'is-selected': editing && selected === f.id,
1194
+ 'is-mine': mine(f),
1195
+ 'is-foreign': !editing && !mine(f),
1196
+ 'is-filled': hasValue(f),
1197
+ 'is-required': f.required === true,
1198
+ },
1199
+ ]"
1200
+ :style="{ left: `${f.x * 100}%`, top: `${f.y * 100}%`, width: `${f.w * 100}%`, height: `${f.h * 100}%`, '--spdf-color': fieldColor(f) }"
1201
+ :data-testid="`surface-pdf-field-${f.id}`"
1202
+ :data-field-type="f.type"
1203
+ :role="mine(f) ? 'button' : undefined"
1204
+ :tabindex="mine(f) ? 0 : undefined"
1205
+ :title="editing || !mine(f) ? signerLabel(f.assignee) : undefined"
1206
+ @pointerdown="onFieldDown($event, f, p.num)"
1207
+ @click="onFieldTap(f)"
1208
+ @keydown.enter.prevent="onFieldTap(f)"
1209
+ >
1210
+ <img v-if="(f.type === 'signature' || f.type === 'initials') && sigSrc(f)" class="fe-spdf__sig" :src="sigSrc(f)" alt="" />
1211
+ <input
1212
+ v-else-if="f.type === 'text' && mine(f)"
1213
+ class="fe-spdf__text"
1214
+ :class="fontClassOf(f)"
1215
+ type="text"
1216
+ :value="typeof f.value === 'string' ? f.value : ''"
1217
+ :placeholder="fieldLabel(f)"
1218
+ :aria-label="fieldLabel(f)"
1219
+ :inputmode="ruleInputMode(ruleOf(f))"
1220
+ :maxlength="ruleMaxLength(ruleOf(f))"
1221
+ :aria-invalid="ruleMessage(f) ? 'true' : undefined"
1222
+ :aria-describedby="ruleHint(f) ? `${id}-${f.id}-rule` : undefined"
1223
+ :title="ruleHint(f) || undefined"
1224
+ :disabled="disabled"
1225
+ :data-testid="`surface-pdf-text-${f.id}`"
1226
+ @pointerdown.stop
1227
+ @click.stop
1228
+ @input="onTextInput(f, $event)"
1229
+ />
1230
+ <!-- eslint-disable-next-line vue/no-v-html -- static markup from lib/actionIcons -->
1231
+ <span v-else-if="f.type === 'checkbox' && f.value === true" class="fe-spdf__check" aria-hidden="true" v-html="checkIcon"></span>
1232
+ <span
1233
+ v-else-if="f.type === 'date' && typeof f.value === 'string' && f.value"
1234
+ class="fe-spdf__val"
1235
+ :class="fontClassOf(f)"
1236
+ >{{ f.value }}</span>
1237
+ <!-- ⚠ The NAME, then what to do about it. A box called "Yetkili
1238
+ imzası" says more than a third grey rectangle reading
1239
+ "Signature", and the person filling it is looking for the
1240
+ name they were told, not for a type. -->
1241
+ <span v-else class="fe-spdf__label">
1242
+ {{ fieldLabel(f) }}
1243
+ <small v-if="mine(f)" class="fe-spdf__who">{{ t(`plugin.pdf.tap_${f.type}`) }}</small>
1244
+ <small v-else-if="f.assignee && (editing || !mine(f))" class="fe-spdf__who">{{ signerLabel(f.assignee) }}</small>
1245
+ </span>
1246
+ <!-- ⚠ The rule's words sit OUTSIDE the box, not inside it: a
1247
+ field is a few millimetres of a page, and a hint drawn in it
1248
+ would cover the value it is about. -->
1249
+ <span
1250
+ v-if="!editing && mine(f) && f.type === 'text' && (ruleMessage(f) || ruleHint(f))"
1251
+ :id="`${id}-${f.id}-rule`"
1252
+ class="fe-spdf__rule"
1253
+ :class="{ 'is-bad': !!ruleMessage(f) }"
1254
+ :role="ruleMessage(f) ? 'alert' : undefined"
1255
+ :data-testid="`surface-pdf-rule-${f.id}`"
1256
+ >{{ ruleMessage(f) || ruleHint(f) }}</span>
1257
+ <span v-if="editing" class="fe-spdf__handle" data-testid="surface-pdf-handle" @pointerdown="onHandleDown($event, f, p.num)"></span>
1258
+ </div>
1259
+ <div
1260
+ v-if="ghost && ghost.page === p.num"
1261
+ class="fe-spdf__field fe-spdf__field--ghost"
1262
+ :style="{ left: `${ghost.x * 100}%`, top: `${ghost.y * 100}%`, width: `${ghost.w * 100}%`, height: `${ghost.h * 100}%` }"
1263
+ ></div>
1264
+ </div>
1265
+ </div>
1266
+ </div>
1267
+
1268
+ <div v-if="!editing && padField" class="fe-spdf__pad" data-testid="surface-pdf-pad">
1269
+ <p class="fe-surface__text fe-surface__text--muted">{{ t('plugin.pdf.pad_for', { type: fieldLabel(padField) }) }}</p>
1270
+ <SurfaceSignaturePad
1271
+ :id="`${id}-${padField.id}`"
1272
+ :model-value="padValue"
1273
+ :modes="padModes"
1274
+ :font="padField.font"
1275
+ :fonts="padFonts"
1276
+ :locale="locale"
1277
+ :disabled="disabled"
1278
+ :width="padField.type === 'initials' ? 240 : undefined"
1279
+ :height="padField.type === 'initials' ? 120 : undefined"
1280
+ @update:model-value="onPadValue"
1281
+ />
1282
+ <button type="button" class="fe-btn fe-btn--sm" data-testid="surface-pdf-pad-done" @click="padFor = ''">{{ t('plugin.pdf.pad_done') }}</button>
1283
+ </div>
1284
+ </div>
1285
+ </template>