@servicetitan/dte-pdf-editor 1.94.0 → 1.95.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 (52) hide show
  1. package/README.md +37 -2
  2. package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.d.ts.map +1 -1
  3. package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.js +4 -6
  4. package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.js.map +1 -1
  5. package/dist/components/pdf-view/pdf-view-fillable.d.ts.map +1 -1
  6. package/dist/components/pdf-view/pdf-view-fillable.js +27 -4
  7. package/dist/components/pdf-view/pdf-view-fillable.js.map +1 -1
  8. package/dist/components/shared/char-limit-indicator.d.ts +8 -0
  9. package/dist/components/shared/char-limit-indicator.d.ts.map +1 -0
  10. package/dist/components/shared/char-limit-indicator.js +9 -0
  11. package/dist/components/shared/char-limit-indicator.js.map +1 -0
  12. package/dist/components/shared/index.d.ts +1 -0
  13. package/dist/components/shared/index.d.ts.map +1 -1
  14. package/dist/components/shared/index.js +1 -0
  15. package/dist/components/shared/index.js.map +1 -1
  16. package/dist/styles/pdf-field-overlay.css +9 -2
  17. package/dist/styles/pdf-field.css +22 -1
  18. package/dist/utils/field/field-text-box.utils.d.ts +31 -0
  19. package/dist/utils/field/field-text-box.utils.d.ts.map +1 -0
  20. package/dist/utils/field/field-text-box.utils.js +102 -0
  21. package/dist/utils/field/field-text-box.utils.js.map +1 -0
  22. package/dist/utils/field/fit-field-text.utils.d.ts +13 -0
  23. package/dist/utils/field/fit-field-text.utils.d.ts.map +1 -0
  24. package/dist/utils/field/fit-field-text.utils.js +44 -0
  25. package/dist/utils/field/fit-field-text.utils.js.map +1 -0
  26. package/dist/utils/field/get-field-max-length.utils.d.ts +38 -0
  27. package/dist/utils/field/get-field-max-length.utils.d.ts.map +1 -0
  28. package/dist/utils/field/get-field-max-length.utils.js +166 -0
  29. package/dist/utils/field/get-field-max-length.utils.js.map +1 -0
  30. package/dist/utils/field/index.d.ts +2 -0
  31. package/dist/utils/field/index.d.ts.map +1 -1
  32. package/dist/utils/field/index.js +2 -0
  33. package/dist/utils/field/index.js.map +1 -1
  34. package/dist/utils/field/text-wrap.utils.d.ts +26 -0
  35. package/dist/utils/field/text-wrap.utils.d.ts.map +1 -0
  36. package/dist/utils/field/text-wrap.utils.js +53 -0
  37. package/dist/utils/field/text-wrap.utils.js.map +1 -0
  38. package/package.json +1 -1
  39. package/src/components/pdf-fields-overlay/pdf-overlay-field-fillable.tsx +41 -28
  40. package/src/components/pdf-view/pdf-view-fillable.tsx +82 -36
  41. package/src/components/shared/char-limit-indicator.tsx +23 -0
  42. package/src/components/shared/index.ts +1 -0
  43. package/src/styles/pdf-field-overlay.css +9 -2
  44. package/src/styles/pdf-field.css +22 -1
  45. package/src/utils/field/__tests__/fit-field-text.utils.test.ts +224 -0
  46. package/src/utils/field/__tests__/get-field-max-length.utils.test.ts +751 -0
  47. package/src/utils/field/__tests__/text-wrap.utils.test.ts +181 -0
  48. package/src/utils/field/field-text-box.utils.ts +143 -0
  49. package/src/utils/field/fit-field-text.utils.ts +61 -0
  50. package/src/utils/field/get-field-max-length.utils.ts +211 -0
  51. package/src/utils/field/index.ts +2 -0
  52. package/src/utils/field/text-wrap.utils.ts +78 -0
@@ -47,7 +47,8 @@
47
47
  background-color: var(--color-neutral-0, #ffffff);
48
48
  border-radius: 4px;
49
49
  padding: 1px var(--spacing-half, 4px);
50
- top: -10px;
50
+ top: 0;
51
+ transform: translateY(-50%);
51
52
  left: 3px;
52
53
  font-size: var(--typescale-small, 10px);
53
54
  line-height: 1;
@@ -63,7 +64,27 @@
63
64
  }
64
65
  }
65
66
 
67
+ .dte-pdf-field-char-count {
68
+ position: absolute;
69
+ right: 3px;
70
+ bottom: 0;
71
+ transform: translateY(50%);
72
+ padding: 1px var(--spacing-half, 4px);
73
+ border-radius: 4px;
74
+ background-color: var(--color-neutral-0, #ffffff);
75
+ box-shadow: 0 0 0 1px var(--pdf-field-color);
76
+ color: var(--pdf-field-color-dark);
77
+ font-size: var(--typescale-small, 10px);
78
+ user-select: none;
79
+
80
+ &.--at-limit {
81
+ color: var(--danger, #dc3545);
82
+ box-shadow: 0 0 0 1px var(--danger, #dc3545);
83
+ }
84
+ }
85
+
66
86
  input::placeholder,
87
+ textarea::placeholder,
67
88
  input[type='date'],
68
89
  input[type='date']::-webkit-calendar-picker-indicator {
69
90
  color: var(--pdf-field-color);
@@ -0,0 +1,224 @@
1
+ import { FieldTypeEnum, type PdfField } from '../../../interface/types';
2
+ import { pointsToPixels } from '../../pdf';
3
+ import { fitFieldTextChange } from '../fit-field-text.utils';
4
+ import { countWrappedLines, type TextMeasurer } from '../text-wrap.utils';
5
+
6
+ /*
7
+ * A fake canvas with easy arithmetic, whatever the font: `i` is 2px, `W` is 10px, anything else
8
+ * 5px. A 100px field then holds 50 `i`, 10 `W` or 20 other characters on a line.
9
+ */
10
+ const glyphWidth = (char: string) => {
11
+ if (char === 'i') {
12
+ return 2;
13
+ }
14
+ return char === 'W' ? 10 : 5;
15
+ };
16
+
17
+ const measure: TextMeasurer = text => {
18
+ return Array.from(text).reduce((sum, char) => sum + glyphWidth(char), 0);
19
+ };
20
+
21
+ const buildField = (overrides: Partial<PdfField> = {}): PdfField => ({
22
+ id: 'field-1',
23
+ type: FieldTypeEnum.fillable,
24
+ subType: 'text',
25
+ x: 0,
26
+ y: 0,
27
+ page: 1,
28
+ label: 'Label',
29
+ width: 100,
30
+ height: 25,
31
+ ...overrides,
32
+ });
33
+
34
+ /** A textarea 100px wide with room for exactly three lines. */
35
+ const multilineField = () => {
36
+ return buildField({ multiline: true, height: Math.ceil(3 * pointsToPixels(8)) + 1 });
37
+ };
38
+
39
+ describe('fitFieldTextChange', () => {
40
+ let getContext: jest.SpyInstance;
41
+
42
+ const subject = (field: PdfField, previous: string, next: string) => {
43
+ return fitFieldTextChange(field, previous, next);
44
+ };
45
+
46
+ beforeAll(() => {
47
+ getContext = jest
48
+ .spyOn(HTMLCanvasElement.prototype, 'getContext')
49
+ .mockImplementation(() => {
50
+ return {
51
+ font: '',
52
+ measureText: (text: string) => ({ width: measure(text) }),
53
+ } as unknown as RenderingContext;
54
+ });
55
+ });
56
+
57
+ afterAll(() => {
58
+ getContext.mockRestore();
59
+ });
60
+
61
+ describe('single-line text', () => {
62
+ test('accepts text that fits', () => {
63
+ expect(subject(buildField(), 'abc', 'abcd')).toBe('abcd');
64
+ });
65
+
66
+ test('accepts text that exactly fills the width', () => {
67
+ expect(subject(buildField(), 'W'.repeat(9), 'W'.repeat(10))).toBe('W'.repeat(10));
68
+ });
69
+
70
+ test('refuses a character that no longer fits', () => {
71
+ const full = 'W'.repeat(10);
72
+
73
+ expect(subject(buildField(), full, `${full}W`)).toBe(full);
74
+ });
75
+
76
+ test('narrow text keeps fitting long after wide text has run out of room', () => {
77
+ const field = buildField();
78
+
79
+ expect(subject(field, 'i'.repeat(49), 'i'.repeat(50))).toBe('i'.repeat(50));
80
+ expect(subject(field, 'W'.repeat(10), `${'W'.repeat(10)}W`)).toBe('W'.repeat(10));
81
+ });
82
+
83
+ test('a narrow character fits in room a wide one does not', () => {
84
+ const value = 'W'.repeat(9);
85
+
86
+ expect(subject(buildField(), value, `${value}i`)).toBe(`${value}i`);
87
+ // 10px are free: two of the 5px characters fit, the third does not
88
+ expect(subject(buildField(), value, `${value}abc`)).toBe(`${value}ab`);
89
+ });
90
+
91
+ test('cuts a paste back to the characters that fit', () => {
92
+ expect(subject(buildField(), '', 'a'.repeat(50))).toBe('a'.repeat(20));
93
+ });
94
+
95
+ test('cuts the inserted text, never the text that was already there', () => {
96
+ const previous = 'W'.repeat(9);
97
+
98
+ // 'abc' is 15px but only 10px are free: 'ab' goes in, the tail stays untouched
99
+ expect(subject(buildField(), previous, 'WWWWabcWWWWW')).toBe('WWWWabWWWWW');
100
+ });
101
+
102
+ test('refuses an insert in the middle when nothing of it fits', () => {
103
+ const previous = 'W'.repeat(10);
104
+
105
+ expect(subject(buildField(), previous, 'WWWWWaWWWWW')).toBe(previous);
106
+ });
107
+
108
+ test('refuses a replacement that would make the text overflow', () => {
109
+ const previous = 'W'.repeat(10);
110
+
111
+ // one W selected and replaced by two
112
+ expect(subject(buildField(), previous, `WW${previous.slice(1)}`)).toBe(previous);
113
+ });
114
+
115
+ test('always allows deleting, even from a value that already overflows', () => {
116
+ const overflowing = 'W'.repeat(15);
117
+
118
+ expect(subject(buildField(), overflowing, 'W'.repeat(14))).toBe('W'.repeat(14));
119
+ expect(subject(buildField(), overflowing, '')).toBe('');
120
+ });
121
+
122
+ test('never leaves a field impossible to type in: one character always fits', () => {
123
+ expect(subject(buildField({ width: 3 }), '', 'W')).toBe('W');
124
+ expect(subject(buildField({ width: 3 }), 'W', 'WW')).toBe('W');
125
+ });
126
+
127
+ test('never splits a surrogate pair when cutting a paste', () => {
128
+ const result = subject(buildField(), '', '😀'.repeat(30));
129
+
130
+ expect(Array.from(result)).toHaveLength(20);
131
+ expect(result).toHaveLength(40);
132
+ expect(result).toBe('😀'.repeat(20));
133
+ });
134
+
135
+ test('is fine with an empty value', () => {
136
+ expect(subject(buildField(), '', '')).toBe('');
137
+ });
138
+ });
139
+
140
+ describe('number', () => {
141
+ // 100px field minus the 15px spin button gutter leaves 85px: 17 digits
142
+ const numberField = () => buildField({ subType: 'number' });
143
+
144
+ test('accepts digits up to the room left after the spin button gutter', () => {
145
+ expect(subject(numberField(), '1'.repeat(16), '1'.repeat(17))).toBe('1'.repeat(17));
146
+ expect(subject(numberField(), '1'.repeat(17), '1'.repeat(18))).toBe('1'.repeat(17));
147
+ });
148
+
149
+ test('cuts a pasted number to what fits', () => {
150
+ expect(subject(numberField(), '', '9'.repeat(40))).toBe('9'.repeat(17));
151
+ });
152
+
153
+ test('treats the sign and decimal point as ordinary characters of the width', () => {
154
+ expect(subject(numberField(), '', '-123.456')).toBe('-123.456');
155
+ });
156
+ });
157
+
158
+ describe('multiline text', () => {
159
+ test('accepts text within the lines available', () => {
160
+ expect(subject(multilineField(), '', 'a'.repeat(60))).toBe('a'.repeat(60));
161
+ });
162
+
163
+ test('refuses text that needs another line', () => {
164
+ const full = 'a'.repeat(60);
165
+
166
+ expect(subject(multilineField(), full, `${full}a`)).toBe(full);
167
+ });
168
+
169
+ test('counts explicit newlines against the lines available', () => {
170
+ const threeLines = 'a\nb\nc';
171
+
172
+ expect(subject(multilineField(), threeLines, `${threeLines}\n`)).toBe(threeLines);
173
+ expect(subject(multilineField(), 'a\nb', 'a\nb\n')).toBe('a\nb\n');
174
+ });
175
+
176
+ test('lets a blank line through only while there are lines left', () => {
177
+ expect(subject(multilineField(), 'a\n', 'a\n\n')).toBe('a\n\n');
178
+ expect(subject(multilineField(), 'a\n\n', 'a\n\n\n')).toBe('a\n\n');
179
+ });
180
+
181
+ test('wraps by words, so a word that no longer fits moves down and can use up the lines', () => {
182
+ const threeWordLines = `${'a'.repeat(16)} ${'b'.repeat(16)} ${'c'.repeat(16)}`;
183
+
184
+ expect(countWrappedLines(threeWordLines, 100, measure)).toBe(3);
185
+ // only the first three letters of the next word still fit after the last line's 85px
186
+ expect(
187
+ subject(multilineField(), threeWordLines, `${threeWordLines} ${'d'.repeat(16)}`),
188
+ ).toBe(`${threeWordLines} ddd`);
189
+ });
190
+
191
+ test('breaks a word that is longer than a whole line across lines', () => {
192
+ expect(subject(multilineField(), '', 'a'.repeat(100))).toBe('a'.repeat(60));
193
+ });
194
+
195
+ test('cuts a paste back to the longest text that fits the lines', () => {
196
+ const paste = 'aaaaaaaaaaaaaaaa '.repeat(10);
197
+ const result = subject(multilineField(), '', paste);
198
+
199
+ expect(paste.startsWith(result)).toBe(true);
200
+ expect(countWrappedLines(result, 100, measure)).toBeLessThanOrEqual(3);
201
+ expect(countWrappedLines(paste.slice(0, result.length + 1), 100, measure)).toBe(4);
202
+ });
203
+
204
+ test('cuts the inserted text, never the text that was already there', () => {
205
+ const previous = 'a\nb\nc';
206
+ const result = subject(multilineField(), previous, 'a\nX\nY\nZ\nb\nc');
207
+
208
+ expect(result.endsWith('b\nc')).toBe(true);
209
+ expect(countWrappedLines(result, 100, measure)).toBeLessThanOrEqual(3);
210
+ });
211
+ });
212
+
213
+ describe('fields it does not limit', () => {
214
+ test.each(['checkbox', 'radio', 'date', 'dropdown'] as const)('%s', subType => {
215
+ expect(subject(buildField({ subType }), '', 'W'.repeat(100))).toBe('W'.repeat(100));
216
+ });
217
+
218
+ test('non-fillable fields', () => {
219
+ expect(subject(buildField({ type: FieldTypeEnum.generic }), '', 'W'.repeat(100))).toBe(
220
+ 'W'.repeat(100),
221
+ );
222
+ });
223
+ });
224
+ });