@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.
- package/README.md +14 -2
- package/dist/ArchiveViewer-BqU761Wk.js +149 -0
- package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
- package/dist/CsvViewer-ByHVK5ga.js +172 -0
- package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
- package/dist/{DrawioViewer-CC6rJXeP.js → DrawioViewer-Bgrj8-zG.js} +45 -44
- package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
- package/dist/{EpubViewer-CAoTSXTO.js → EpubViewer-DJwUKlHW.js} +50 -50
- package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
- package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
- package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
- package/dist/MermaidViewer-CEDTD_pp.js +136 -0
- package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
- package/dist/PsdViewer-CZcxUpZO.js +121 -0
- package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
- package/dist/TiffViewer-BwI62CbQ.js +141 -0
- package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
- package/dist/{Viewer3D-CdZeK4j4.js → Viewer3D-DoeZcuMm.js} +16 -16
- package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
- package/dist/filex-core.js +475 -219
- package/dist/filex-core.umd.cjs +165 -108
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/fonts/caveat-latin-ext.woff2 +0 -0
- package/dist/fonts/caveat-latin.woff2 +0 -0
- package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
- package/dist/fonts/dancing-script-latin.woff2 +0 -0
- package/dist/fonts/homemade-apple-latin.woff2 +0 -0
- package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
- package/dist/fonts/source-serif-4-latin.woff2 +0 -0
- package/dist/index-DuWnGuv5.js +35901 -0
- package/dist/index-DuWnGuv5.js.map +1 -0
- package/dist/index.d.ts +5227 -553
- package/dist/pdf-CaPYsOUo.js +21302 -0
- package/dist/pdf-CaPYsOUo.js.map +1 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +1055 -253
- package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
- package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
- package/src/assets/fonts/README.md +58 -0
- package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
- package/src/assets/fonts/caveat-latin.woff2 +0 -0
- package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
- package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
- package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
- package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
- package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
- package/src/components/AdvancedSearch.vue +49 -0
- package/src/components/Breadcrumb.vue +5 -5
- package/src/components/ChoiceButtons.vue +152 -0
- package/src/components/CommandPalette.vue +5 -5
- package/src/components/ConnectionGuideView.vue +1 -1
- package/src/components/ConnectionNotice.vue +59 -0
- package/src/components/ConnectionsPanel.vue +30 -568
- package/src/components/ContextMenu.vue +31 -5
- package/src/components/DataTable.vue +1331 -0
- package/src/components/FilePane.vue +3 -0
- package/src/components/FilterBar.vue +12 -5
- package/src/components/GalleryView.vue +50 -2
- package/src/components/GridView.vue +51 -3
- package/src/components/HomeView.vue +18 -6
- package/src/components/InspectorPanel.vue +134 -17
- package/src/components/ListView.vue +366 -900
- package/src/components/LogoMark.vue +57 -0
- package/src/components/NFSExportsPanel.vue +76 -28
- package/src/components/OnboardingTour.vue +19 -7
- package/src/components/OperationsCenter.vue +77 -1
- package/src/components/PendingOpsTray.vue +59 -5
- package/src/components/PresenceBar.vue +7 -7
- package/src/components/ProductVersion.vue +31 -0
- package/src/components/QuickLook.vue +10 -14
- package/src/components/RecentlyOpened.vue +31 -15
- package/src/components/RecoveryKeyModal.vue +1 -1
- package/src/components/RowActions.vue +134 -0
- package/src/components/S3KeysPanel.vue +98 -62
- package/src/components/SSHKeysPanel.vue +79 -30
- package/src/components/SideNav.vue +260 -96
- package/src/components/StarButton.vue +1 -0
- package/src/components/StorageFields.vue +74 -6
- package/src/components/StorageTags.vue +62 -0
- package/src/components/TabBar.vue +1 -1
- package/src/components/TagKindIcon.vue +56 -0
- package/src/components/TagPicker.vue +226 -40
- package/src/components/ThemePalette.vue +4 -4
- package/src/components/TimeZonePicker.vue +29 -10
- package/src/components/TokensPanel.vue +82 -31
- package/src/components/Toolbar.vue +28 -6
- package/src/components/plugin/PluginConfirmModal.vue +54 -0
- package/src/components/plugin/PluginInspectorSection.vue +149 -0
- package/src/components/plugin/PluginPageView.vue +339 -0
- package/src/components/plugin/PluginViewModal.vue +159 -0
- package/src/components/plugin/SurfaceConversation.vue +76 -0
- package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
- package/src/components/plugin/SurfaceRenderer.vue +299 -0
- package/src/components/plugin/SurfaceSections.vue +91 -0
- package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
- package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
- package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
- package/src/components/plugin/nodes/SurfaceList.vue +127 -0
- package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
- package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
- package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
- package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
- package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
- package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
- package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
- package/src/components/public/PublicLanguagePicker.vue +58 -0
- package/src/components/public/PublicLinkPage.vue +286 -0
- package/src/components/public/PublicLinkPreview.vue +85 -0
- package/src/components/public/PublicPinGate.vue +108 -0
- package/src/components/public/PublicRequestBody.vue +204 -0
- package/src/components/public/PublicShareBody.vue +188 -0
- package/src/components/public/PublicShell.vue +332 -0
- package/src/composables/useConnections.ts +8 -2
- package/src/composables/useFileApi.ts +183 -44
- package/src/composables/useLocale.ts +277 -41
- package/src/composables/useNFSExports.ts +8 -10
- package/src/composables/useOperations.ts +44 -3
- package/src/composables/usePendingOps.ts +59 -8
- package/src/composables/usePluginActions.ts +121 -0
- package/src/composables/usePluginSurface.ts +223 -0
- package/src/composables/usePublicBranding.ts +172 -0
- package/src/composables/usePublicLink.ts +573 -0
- package/src/composables/usePublicPage.ts +61 -0
- package/src/composables/useS3Keys.ts +17 -12
- package/src/composables/useSSHKeys.ts +8 -10
- package/src/composables/useTokens.ts +8 -12
- package/src/composables/useUploadChunked.ts +18 -8
- package/src/composables/useViewerFetch.ts +6 -3
- package/src/index.ts +404 -8
- package/src/lib/actionIcons.ts +46 -1
- package/src/lib/advSearch.ts +14 -7
- package/src/lib/appLock.ts +130 -0
- package/src/lib/connection.ts +110 -0
- package/src/lib/destinationTree.ts +19 -3
- package/src/lib/direction.ts +353 -0
- package/src/lib/dragGhost.ts +6 -0
- package/src/lib/dropLimits.ts +69 -0
- package/src/lib/errorWords.ts +394 -0
- package/src/lib/externalReach.ts +5 -1
- package/src/lib/fileFilters.ts +7 -2
- package/src/lib/fileIcons.ts +4 -0
- package/src/lib/internalPaths.ts +77 -0
- package/src/lib/listInput.ts +64 -0
- package/src/lib/listing.ts +58 -23
- package/src/lib/modalStack.ts +26 -0
- package/src/lib/nodeRow.ts +18 -3
- package/src/lib/pdfFieldRules.ts +126 -0
- package/src/lib/pdfFields.ts +589 -0
- package/src/lib/pdfFieldsGeom.ts +203 -0
- package/src/lib/pdfjsLoader.ts +51 -0
- package/src/lib/personName.ts +47 -0
- package/src/lib/pluginApplies.ts +123 -0
- package/src/lib/pluginLabel.ts +72 -0
- package/src/lib/pluginMenu.ts +253 -0
- package/src/lib/pluginPage.ts +58 -0
- package/src/lib/plural.ts +181 -0
- package/src/lib/popupLayer.ts +103 -0
- package/src/lib/prefs.ts +563 -0
- package/src/lib/productVersion.ts +35 -0
- package/src/lib/publicLayout.ts +39 -0
- package/src/lib/serviceGate.ts +104 -0
- package/src/lib/shareTtl.ts +25 -11
- package/src/lib/signFonts.ts +85 -0
- package/src/lib/signaturePad.ts +111 -0
- package/src/lib/sortOrder.ts +100 -38
- package/src/lib/star.ts +7 -1
- package/src/lib/surfaceCell.ts +41 -0
- package/src/lib/surfaceConditions.ts +156 -0
- package/src/lib/surfaceOpen.ts +106 -0
- package/src/lib/surfaceSteps.ts +68 -0
- package/src/lib/surfaceValues.ts +191 -0
- package/src/lib/symlink.ts +150 -0
- package/src/lib/tableColumns.ts +493 -0
- package/src/lib/tableEnv.ts +34 -0
- package/src/lib/tablePrefs.ts +84 -0
- package/src/lib/tags.ts +121 -12
- package/src/lib/themes.ts +286 -18
- package/src/lib/tour.ts +152 -0
- package/src/lib/trashEmpty.ts +111 -0
- package/src/lib/trashTimeLeft.ts +23 -0
- package/src/lib/uiLocales.ts +460 -0
- package/src/lib/viewPrefs.ts +696 -660
- package/src/lib/viewPrefsHttp.ts +78 -0
- package/src/locales/en.ts +515 -117
- package/src/locales/resolve.ts +17 -8
- package/src/locales/tr.ts +536 -152
- package/src/modals/ConvertModal.vue +62 -11
- package/src/modals/DestinationPickerModal.vue +59 -16
- package/src/modals/Modal.vue +113 -23
- package/src/modals/NewDocumentModal.vue +16 -6
- package/src/modals/NewFolderModal.vue +8 -0
- package/src/modals/PermissionsModal.vue +272 -163
- package/src/modals/PreviewModal.vue +119 -20
- package/src/modals/RenameModal.vue +37 -1
- package/src/styles/base.css +2890 -260
- package/src/styles/sign-fonts.css +118 -0
- package/src/styles/variables.css +60 -0
- package/src/types/Connections.ts +17 -0
- package/src/types/ExplorerConfig.ts +117 -1
- package/src/types/FileNode.ts +50 -0
- package/src/types/Plugins.ts +494 -0
- package/src/types/Public.ts +212 -0
- package/src/viewers/ArchiveViewer.vue +93 -48
- package/src/viewers/CsvViewer.vue +99 -53
- package/src/viewers/DrawioViewer.vue +35 -9
- package/src/viewers/EpubViewer.vue +7 -8
- package/src/viewers/IpynbViewer.vue +3 -1
- package/src/viewers/MermaidViewer.vue +4 -5
- package/src/viewers/PdfViewer.vue +8 -17
- package/src/viewers/PsdViewer.vue +2 -4
- package/src/viewers/TiffViewer.vue +4 -5
- package/src/viewers/Viewer3D.vue +1 -1
- package/LICENSE +0 -21
- package/dist/ArchiveViewer-BUQWLhwX.js +0 -124
- package/dist/ArchiveViewer-BUQWLhwX.js.map +0 -1
- package/dist/CsvViewer-Bg2_aqzc.js +0 -141
- package/dist/CsvViewer-Bg2_aqzc.js.map +0 -1
- package/dist/DrawioViewer-CC6rJXeP.js.map +0 -1
- package/dist/EpubViewer-CAoTSXTO.js.map +0 -1
- package/dist/IpynbViewer-D0GwGpnE.js +0 -184
- package/dist/IpynbViewer-D0GwGpnE.js.map +0 -1
- package/dist/MermaidViewer-DGwAvRZg.js +0 -137
- package/dist/MermaidViewer-DGwAvRZg.js.map +0 -1
- package/dist/PsdViewer-DhGweA0m.js +0 -122
- package/dist/PsdViewer-DhGweA0m.js.map +0 -1
- package/dist/TiffViewer-D0AurvAh.js +0 -142
- package/dist/TiffViewer-D0AurvAh.js.map +0 -1
- package/dist/Viewer3D-CdZeK4j4.js.map +0 -1
- package/dist/index-BArI-B1M.js +0 -26911
- package/dist/index-BArI-B1M.js.map +0 -1
- package/dist/useViewerFetch-czqbd2Lj.js +0 -25
- package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
- 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>
|