@lateralus-ai/shipping-ui 1.1.2 → 1.3.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/dist/components/DocumentEditor/DocumentEditor.d.ts +4 -0
- package/dist/components/DocumentEditor/index.d.ts +1 -0
- package/dist/components/ModalPanel.d.ts +4 -0
- package/dist/components/PdfViewer/ImageViewer.d.ts +1 -1
- package/dist/components/PdfViewer/PdfViewer.d.ts +1 -1
- package/dist/components/PdfViewer/usePageManagement.d.ts +5 -1
- package/dist/components/SearchModal.d.ts +24 -0
- package/dist/components/Tabs.d.ts +13 -0
- package/dist/components/icons/SettingsIcon.d.ts +3 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/defect-report.pdf +0 -0
- package/dist/example.pdf +0 -0
- package/dist/index.cjs +272 -10
- package/dist/index.esm.js +35960 -1124
- package/dist/material-theme.d.ts +26 -0
- package/dist/sample-document.docx +0 -0
- package/dist/stories/SearchModal.d.ts +1 -0
- package/dist/style.css +1 -0
- package/dist/tailwind-theme.d.ts +12 -0
- package/dist/types/documentEditor.d.ts +47 -0
- package/dist/utils/checkboxModule.d.ts +54 -0
- package/package.json +5 -1
- package/src/components/DocumentEditor/DocumentEditor.tsx +872 -0
- package/src/components/DocumentEditor/index.ts +1 -0
- package/src/components/ModalPanel.tsx +13 -0
- package/src/components/PdfViewer/ImageViewer.tsx +8 -3
- package/src/components/PdfViewer/PdfViewer.tsx +138 -17
- package/src/components/PdfViewer/usePageManagement.ts +22 -7
- package/src/components/SearchModal.tsx +320 -0
- package/src/components/Tabs.tsx +43 -0
- package/src/components/icons/SettingsIcon.tsx +33 -0
- package/src/components/index.ts +2 -0
- package/src/material-theme.ts +30 -0
- package/src/stories/DocumentEditor.stories.tsx +287 -0
- package/src/stories/ModalHeader.stories.tsx +36 -0
- package/src/stories/PDFViewer.stories.tsx +1 -1
- package/src/stories/SearchModal.stories.tsx +132 -0
- package/src/stories/SearchModal.tsx +82 -0
- package/src/stories/Tabs.stories.tsx +51 -0
- package/src/styles/tabs.css +55 -0
- package/src/tailwind-theme.ts +12 -0
- package/src/types/documentEditor.ts +56 -0
- package/src/utils/checkboxModule.ts +242 -0
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import Docxtemplater from "docxtemplater";
|
|
3
|
+
import PizZip from "pizzip";
|
|
4
|
+
import * as docxPreview from "docx-preview";
|
|
5
|
+
import CheckboxModule from "../../utils/checkboxModule";
|
|
6
|
+
import type {
|
|
7
|
+
DocumentEditorProps,
|
|
8
|
+
Schema,
|
|
9
|
+
SchemaProperty,
|
|
10
|
+
} from "../../types/documentEditor";
|
|
11
|
+
|
|
12
|
+
// Store form data outside of React state to prevent rerenders
|
|
13
|
+
let globalFormData: Record<string, any> = {};
|
|
14
|
+
let originalData: Record<string, any> = {};
|
|
15
|
+
|
|
16
|
+
const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|
17
|
+
docxUrl,
|
|
18
|
+
schema,
|
|
19
|
+
onDataChange,
|
|
20
|
+
initialData,
|
|
21
|
+
onSaveRef,
|
|
22
|
+
readonly = false,
|
|
23
|
+
}) => {
|
|
24
|
+
const [placeholders, setPlaceholders] = useState<string[]>([]);
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
const [error, setError] = useState<string | null>(null);
|
|
27
|
+
const [saveFlag, setSaveFlag] = useState(0); // Used to trigger saves
|
|
28
|
+
const previewRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
|
|
30
|
+
const extractPlaceholders = useCallback((text: string): string[] => {
|
|
31
|
+
const regex = /\{([^}]+)\}/g;
|
|
32
|
+
const matches = [];
|
|
33
|
+
let match;
|
|
34
|
+
while ((match = regex.exec(text)) !== null) {
|
|
35
|
+
matches.push(match[1]);
|
|
36
|
+
}
|
|
37
|
+
return [...new Set(matches)];
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
const handleFieldChange = useCallback((fieldName: string, value: any) => {
|
|
41
|
+
// Update global form data without triggering React rerenders
|
|
42
|
+
globalFormData = { ...globalFormData, [fieldName]: value };
|
|
43
|
+
|
|
44
|
+
// Update color coding for this field
|
|
45
|
+
updateFieldColor(fieldName, value);
|
|
46
|
+
|
|
47
|
+
// Call onDataChange callback immediately on field change
|
|
48
|
+
onDataChange?.(globalFormData);
|
|
49
|
+
}, [onDataChange]);
|
|
50
|
+
|
|
51
|
+
const handleCheckboxChange = useCallback(
|
|
52
|
+
(checkboxName: string, checked: boolean) => {
|
|
53
|
+
// Find the field name and update based on schema mapping
|
|
54
|
+
const foundMapping = Object.entries(schema.properties || {}).find(([fieldName, fieldConfig]) => {
|
|
55
|
+
const config = fieldConfig as SchemaProperty;
|
|
56
|
+
const docxMapping = config.docx_mapping;
|
|
57
|
+
|
|
58
|
+
// Pattern 1: Single checkbox with "name" property
|
|
59
|
+
const isSingleCheckbox = docxMapping?.type === "checkbox" && docxMapping.name === checkboxName;
|
|
60
|
+
isSingleCheckbox && handleFieldChange(fieldName, checked);
|
|
61
|
+
|
|
62
|
+
// Pattern 2: Dual checkbox with "true_name" and "false_name" properties
|
|
63
|
+
const isTrueCheckbox = docxMapping?.type === "checkbox" && docxMapping.true_name === checkboxName;
|
|
64
|
+
const isFalseCheckbox = docxMapping?.type === "checkbox" && docxMapping.false_name === checkboxName;
|
|
65
|
+
|
|
66
|
+
isTrueCheckbox && handleFieldChange(fieldName, checked);
|
|
67
|
+
isFalseCheckbox && handleFieldChange(fieldName, !checked);
|
|
68
|
+
|
|
69
|
+
// Pattern 3: Radio button mapping
|
|
70
|
+
const radioMapping = docxMapping?.type === "radio" && docxMapping.mapping?.find(
|
|
71
|
+
(m) => m.name === checkboxName,
|
|
72
|
+
);
|
|
73
|
+
radioMapping && handleFieldChange(fieldName, radioMapping.value);
|
|
74
|
+
|
|
75
|
+
return isSingleCheckbox || isTrueCheckbox || isFalseCheckbox || !!radioMapping;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Fallback to direct field name mapping
|
|
79
|
+
!foundMapping && handleFieldChange(checkboxName, checked);
|
|
80
|
+
},
|
|
81
|
+
[handleFieldChange, schema],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const isFieldEdited = useCallback(
|
|
85
|
+
(fieldName: string, value: any): boolean => {
|
|
86
|
+
const originalValue = originalData[fieldName];
|
|
87
|
+
|
|
88
|
+
// Handle different data types
|
|
89
|
+
return !(value === originalValue ||
|
|
90
|
+
(value === "" && (originalValue === null || originalValue === undefined)) ||
|
|
91
|
+
(originalValue === "" && (value === null || value === undefined)));
|
|
92
|
+
},
|
|
93
|
+
[],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const updateFieldColor = useCallback(
|
|
97
|
+
(fieldName: string, value: any) => {
|
|
98
|
+
const hasPreviewRef = !!previewRef.current;
|
|
99
|
+
const isEdited = isFieldEdited(fieldName, value);
|
|
100
|
+
|
|
101
|
+
hasPreviewRef && (() => {
|
|
102
|
+
const elements = previewRef.current!.querySelectorAll(
|
|
103
|
+
`[data-field-path="${fieldName}"]`,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
elements.forEach((element) => {
|
|
107
|
+
const editedClass = isEdited ? "field-edited" : "field-original";
|
|
108
|
+
const oppositeClass = isEdited ? "field-original" : "field-edited";
|
|
109
|
+
|
|
110
|
+
element.classList.remove(oppositeClass);
|
|
111
|
+
element.classList.add(editedClass);
|
|
112
|
+
});
|
|
113
|
+
})();
|
|
114
|
+
},
|
|
115
|
+
[isFieldEdited],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const updateAllFieldColors = useCallback(() => {
|
|
119
|
+
const hasPreviewRef = !!previewRef.current;
|
|
120
|
+
|
|
121
|
+
hasPreviewRef && (() => {
|
|
122
|
+
Object.entries(globalFormData).forEach(([fieldName, value]) => {
|
|
123
|
+
updateFieldColor(fieldName, value);
|
|
124
|
+
});
|
|
125
|
+
})();
|
|
126
|
+
}, [updateFieldColor]);
|
|
127
|
+
|
|
128
|
+
const handleSave = useCallback(async () => {
|
|
129
|
+
// Only call onDataChange when user explicitly saves
|
|
130
|
+
onDataChange?.(globalFormData);
|
|
131
|
+
setSaveFlag((prev) => prev + 1);
|
|
132
|
+
}, [onDataChange]);
|
|
133
|
+
|
|
134
|
+
// Expose save function to parent component
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (onSaveRef) {
|
|
137
|
+
onSaveRef.current = handleSave;
|
|
138
|
+
}
|
|
139
|
+
}, [handleSave, onSaveRef]);
|
|
140
|
+
|
|
141
|
+
const processDocument = useCallback(
|
|
142
|
+
async (data: Record<string, any>) => {
|
|
143
|
+
try {
|
|
144
|
+
const response = await fetch(docxUrl);
|
|
145
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
146
|
+
|
|
147
|
+
const zip = new PizZip(arrayBuffer);
|
|
148
|
+
|
|
149
|
+
// Create checkbox data from form data
|
|
150
|
+
const checkboxData: Record<string, boolean> = {};
|
|
151
|
+
Object.entries(data).forEach(([key, value]) => {
|
|
152
|
+
if (
|
|
153
|
+
typeof value === "boolean" ||
|
|
154
|
+
value === "true" ||
|
|
155
|
+
value === "false"
|
|
156
|
+
) {
|
|
157
|
+
checkboxData[key] = value === true || value === "true";
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const doc = new Docxtemplater(zip, {
|
|
162
|
+
paragraphLoop: true,
|
|
163
|
+
linebreaks: true,
|
|
164
|
+
nullGetter: (e) => {
|
|
165
|
+
const fieldPath = e.value || "unknown_field";
|
|
166
|
+
return `||| |||${fieldPath}|||`;
|
|
167
|
+
},
|
|
168
|
+
modules: [
|
|
169
|
+
new CheckboxModule({
|
|
170
|
+
checkboxData,
|
|
171
|
+
schema,
|
|
172
|
+
finalGeneration: false,
|
|
173
|
+
}),
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Create data with special markers for filled fields
|
|
178
|
+
const markedData: Record<string, any> = {};
|
|
179
|
+
const newlinePlaceholder = "__NEWLINE__";
|
|
180
|
+
Object.entries(data).forEach(([key, value]) => {
|
|
181
|
+
if (value !== null && value !== undefined && value !== "") {
|
|
182
|
+
let processedValue = value;
|
|
183
|
+
if (typeof value === "string") {
|
|
184
|
+
processedValue = value.replace(/\n/g, newlinePlaceholder);
|
|
185
|
+
}
|
|
186
|
+
// Generate marker for filled fields: |||value|||field_path|||
|
|
187
|
+
markedData[key] = `|||${processedValue}|||${key}|||`;
|
|
188
|
+
} else {
|
|
189
|
+
// This will trigger nullGetter
|
|
190
|
+
markedData[key] = null;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Render the document with the marked data
|
|
195
|
+
doc.render(markedData);
|
|
196
|
+
|
|
197
|
+
// Generate the document blob
|
|
198
|
+
const generatedBlob = doc.getZip().generate({
|
|
199
|
+
type: "blob",
|
|
200
|
+
mimeType:
|
|
201
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return generatedBlob;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
console.error("Error processing document:", error);
|
|
207
|
+
throw new Error(
|
|
208
|
+
error instanceof Error ? error.message : "Failed to process document",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
[docxUrl, schema],
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const createInlineInput = useCallback(
|
|
216
|
+
(
|
|
217
|
+
fieldPath: string,
|
|
218
|
+
value: string,
|
|
219
|
+
fieldConfig: SchemaProperty,
|
|
220
|
+
data: Record<string, any>,
|
|
221
|
+
) => {
|
|
222
|
+
const container = document.createElement("span");
|
|
223
|
+
container.className = "inline-field-container";
|
|
224
|
+
container.setAttribute("data-field-path", fieldPath);
|
|
225
|
+
|
|
226
|
+
const isFieldReadOnly = readonly;
|
|
227
|
+
const displayValue = value || globalFormData[fieldPath] || "";
|
|
228
|
+
|
|
229
|
+
// Handle readonly mode
|
|
230
|
+
const createReadonlyElement = () => {
|
|
231
|
+
const isBooleanField = fieldConfig.type === "boolean";
|
|
232
|
+
|
|
233
|
+
const element = isBooleanField ? (() => {
|
|
234
|
+
const checkboxSymbol = document.createElement("span");
|
|
235
|
+
checkboxSymbol.textContent =
|
|
236
|
+
displayValue === "true" || globalFormData[fieldPath] === true
|
|
237
|
+
? "☑"
|
|
238
|
+
: "☐";
|
|
239
|
+
checkboxSymbol.className = "inline-checkbox-readonly";
|
|
240
|
+
return checkboxSymbol;
|
|
241
|
+
})() : (() => {
|
|
242
|
+
const newlinePlaceholder = "__NEWLINE__";
|
|
243
|
+
const restoredValue = String(displayValue).replace(
|
|
244
|
+
new RegExp(newlinePlaceholder, "g"),
|
|
245
|
+
"\n",
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const textNode = document.createElement("span");
|
|
249
|
+
textNode.textContent = restoredValue;
|
|
250
|
+
textNode.className = "inline-field-readonly";
|
|
251
|
+
textNode.style.whiteSpace = "pre-wrap";
|
|
252
|
+
return textNode;
|
|
253
|
+
})();
|
|
254
|
+
|
|
255
|
+
container.appendChild(element);
|
|
256
|
+
return container;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// Handle editable boolean fields
|
|
260
|
+
const createBooleanInput = () => {
|
|
261
|
+
const checkbox = document.createElement("input");
|
|
262
|
+
checkbox.type = "checkbox";
|
|
263
|
+
checkbox.checked =
|
|
264
|
+
value === "true" || globalFormData[fieldPath] === true;
|
|
265
|
+
checkbox.className =
|
|
266
|
+
"inline-checkbox rounded border-gray-300 text-green-600 focus:ring-green-500";
|
|
267
|
+
checkbox.setAttribute("data-field-path", fieldPath);
|
|
268
|
+
checkbox.addEventListener("change", (e) => {
|
|
269
|
+
handleFieldChange(fieldPath, (e.target as HTMLInputElement).checked);
|
|
270
|
+
});
|
|
271
|
+
container.appendChild(checkbox);
|
|
272
|
+
return container;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// Handle enum select fields
|
|
276
|
+
const createSelectInput = () => {
|
|
277
|
+
const select = document.createElement("select");
|
|
278
|
+
select.className =
|
|
279
|
+
"inline-select border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-500";
|
|
280
|
+
select.setAttribute("data-field-path", fieldPath);
|
|
281
|
+
|
|
282
|
+
// Add empty option
|
|
283
|
+
const emptyOption = document.createElement("option");
|
|
284
|
+
emptyOption.value = "";
|
|
285
|
+
emptyOption.textContent = "Select...";
|
|
286
|
+
select.appendChild(emptyOption);
|
|
287
|
+
|
|
288
|
+
// Add enum options - sanitize option values
|
|
289
|
+
fieldConfig.enum!.forEach((option) => {
|
|
290
|
+
const optionEl = document.createElement("option");
|
|
291
|
+
optionEl.value = String(option);
|
|
292
|
+
optionEl.textContent = String(option);
|
|
293
|
+
optionEl.selected = value === option || globalFormData[fieldPath] === option;
|
|
294
|
+
select.appendChild(optionEl);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
select.addEventListener("change", (e) => {
|
|
298
|
+
handleFieldChange(fieldPath, (e.target as HTMLSelectElement).value);
|
|
299
|
+
});
|
|
300
|
+
container.appendChild(select);
|
|
301
|
+
return container;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// Handle date input fields
|
|
305
|
+
const createDateInput = () => {
|
|
306
|
+
const input = document.createElement("input");
|
|
307
|
+
input.type = fieldConfig.format === "date" ? "date" : "datetime-local";
|
|
308
|
+
input.value = String(value || globalFormData[fieldPath] || "");
|
|
309
|
+
input.className = "inline-date-input text-sm focus:outline-none";
|
|
310
|
+
input.setAttribute("data-field-path", fieldPath);
|
|
311
|
+
input.style.width = "100%";
|
|
312
|
+
input.style.maxWidth = "200px";
|
|
313
|
+
input.placeholder = String(fieldConfig.description || fieldPath);
|
|
314
|
+
|
|
315
|
+
input.addEventListener("input", (e) => {
|
|
316
|
+
handleFieldChange(fieldPath, (e.target as HTMLInputElement).value);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
container.appendChild(input);
|
|
320
|
+
return container;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// Handle text/textarea input fields
|
|
324
|
+
const createTextInput = () => {
|
|
325
|
+
const newlinePlaceholder = "__NEWLINE__";
|
|
326
|
+
const restoredValue = String(
|
|
327
|
+
value || globalFormData[fieldPath] || "",
|
|
328
|
+
).replace(new RegExp(newlinePlaceholder, "g"), "\n");
|
|
329
|
+
|
|
330
|
+
const textarea = document.createElement("textarea");
|
|
331
|
+
textarea.value = restoredValue;
|
|
332
|
+
textarea.className =
|
|
333
|
+
"inline-textarea text-sm focus:outline-none resize-none";
|
|
334
|
+
textarea.setAttribute("data-field-path", fieldPath);
|
|
335
|
+
textarea.style.width = "100%";
|
|
336
|
+
textarea.style.minHeight = "17px";
|
|
337
|
+
textarea.style.overflow = "hidden";
|
|
338
|
+
textarea.placeholder = String(fieldConfig.description || fieldPath);
|
|
339
|
+
textarea.rows = 1;
|
|
340
|
+
|
|
341
|
+
// Auto-resize textarea
|
|
342
|
+
const autoResize = () => {
|
|
343
|
+
textarea.style.height = "auto";
|
|
344
|
+
const shouldExpand = textarea.value.includes("\n") || textarea.scrollHeight > 25;
|
|
345
|
+
textarea.style.height = shouldExpand
|
|
346
|
+
? Math.max(17, textarea.scrollHeight) + "px"
|
|
347
|
+
: "17px";
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
textarea.addEventListener("input", (e) => {
|
|
351
|
+
handleFieldChange(
|
|
352
|
+
fieldPath,
|
|
353
|
+
(e.target as HTMLTextAreaElement).value,
|
|
354
|
+
);
|
|
355
|
+
autoResize();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Initial resize
|
|
359
|
+
setTimeout(autoResize, 0);
|
|
360
|
+
|
|
361
|
+
container.appendChild(textarea);
|
|
362
|
+
return container;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// Main logic using separate conditionals
|
|
366
|
+
const renderReadonly = () => isFieldReadOnly && createReadonlyElement();
|
|
367
|
+
const renderBoolean = () => !isFieldReadOnly && fieldConfig.type === "boolean" && createBooleanInput();
|
|
368
|
+
const renderSelect = () => !isFieldReadOnly && fieldConfig.enum && createSelectInput();
|
|
369
|
+
const renderDate = () => !isFieldReadOnly && (fieldConfig.format === "date" || fieldConfig.format === "date-time") && createDateInput();
|
|
370
|
+
const renderText = () => !isFieldReadOnly && fieldConfig.type !== "boolean" && !fieldConfig.enum && !(fieldConfig.format === "date" || fieldConfig.format === "date-time") && createTextInput();
|
|
371
|
+
|
|
372
|
+
return renderReadonly() || renderBoolean() || renderSelect() || renderDate() || createTextInput();
|
|
373
|
+
},
|
|
374
|
+
[handleFieldChange, readonly],
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const replaceMarkersWithInputs = useCallback(
|
|
378
|
+
(container: HTMLElement, data: Record<string, any>) => {
|
|
379
|
+
const walker = document.createTreeWalker(
|
|
380
|
+
container,
|
|
381
|
+
NodeFilter.SHOW_TEXT,
|
|
382
|
+
null,
|
|
383
|
+
false,
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
const textNodes: Text[] = [];
|
|
387
|
+
let node;
|
|
388
|
+
while ((node = walker.nextNode())) {
|
|
389
|
+
textNodes.push(node as Text);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
textNodes.forEach((textNode) => {
|
|
393
|
+
const hasTextContent = !!textNode.textContent;
|
|
394
|
+
const markerRegex = /\|\|\|([^|]*)\|\|\|([^|]+)\|\|\|/g;
|
|
395
|
+
const text = textNode.textContent || "";
|
|
396
|
+
const hasMarkers = markerRegex.test(text);
|
|
397
|
+
const parent = textNode.parentNode;
|
|
398
|
+
|
|
399
|
+
hasTextContent && hasMarkers && parent && (() => {
|
|
400
|
+
// Split the text content and replace markers
|
|
401
|
+
let lastIndex = 0;
|
|
402
|
+
const fragment = document.createDocumentFragment();
|
|
403
|
+
|
|
404
|
+
textNode.textContent!.replace(
|
|
405
|
+
markerRegex,
|
|
406
|
+
(match, value, fieldPath, offset) => {
|
|
407
|
+
// Add text before the marker
|
|
408
|
+
(offset > lastIndex) && fragment.appendChild(
|
|
409
|
+
document.createTextNode(text.slice(lastIndex, offset)),
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
const isCheckboxMarker = fieldPath.startsWith("checkbox:");
|
|
413
|
+
|
|
414
|
+
// Handle checkbox markers
|
|
415
|
+
const createCheckboxElement = () => {
|
|
416
|
+
const checkboxName = fieldPath.replace("checkbox:", "");
|
|
417
|
+
|
|
418
|
+
// Find radio button configuration
|
|
419
|
+
const radioConfig = Object.entries(schema.properties || {}).find(([fname, fieldConfig]) => {
|
|
420
|
+
const config = fieldConfig as SchemaProperty;
|
|
421
|
+
const docxMapping = config.docx_mapping;
|
|
422
|
+
const isDualCheckbox = docxMapping?.type === "checkbox" &&
|
|
423
|
+
docxMapping.true_name &&
|
|
424
|
+
docxMapping.false_name;
|
|
425
|
+
|
|
426
|
+
return isDualCheckbox &&
|
|
427
|
+
(docxMapping.true_name === checkboxName || docxMapping.false_name === checkboxName);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
const [fieldName, fieldConfig] = radioConfig || [];
|
|
431
|
+
const config = fieldConfig as SchemaProperty;
|
|
432
|
+
const docxMapping = config?.docx_mapping;
|
|
433
|
+
const isRadioButton = !!radioConfig;
|
|
434
|
+
const isYesOption = docxMapping?.true_name === checkboxName;
|
|
435
|
+
|
|
436
|
+
// Create readonly checkbox symbol
|
|
437
|
+
const createReadonlyCheckbox = () => {
|
|
438
|
+
const checkboxSymbol = document.createElement("span");
|
|
439
|
+
checkboxSymbol.textContent = value;
|
|
440
|
+
checkboxSymbol.className = "inline-checkbox-readonly";
|
|
441
|
+
return checkboxSymbol;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// Create radio button
|
|
445
|
+
const createRadioButton = () => {
|
|
446
|
+
const radioContainer = document.createElement("span");
|
|
447
|
+
radioContainer.className = "inline-radio-container";
|
|
448
|
+
|
|
449
|
+
const radio = document.createElement("input");
|
|
450
|
+
radio.type = "radio";
|
|
451
|
+
radio.name = `radio-group-${fieldName}`;
|
|
452
|
+
radio.value = isYesOption ? "true" : "false";
|
|
453
|
+
|
|
454
|
+
const currentValue = data[fieldName!];
|
|
455
|
+
radio.checked = isYesOption
|
|
456
|
+
? (currentValue === true || currentValue === "true")
|
|
457
|
+
: (currentValue === false || currentValue === "false");
|
|
458
|
+
|
|
459
|
+
radio.className = "text-green-600 focus:ring-green-500 border-gray-300";
|
|
460
|
+
radio.setAttribute("data-field-path", checkboxName);
|
|
461
|
+
radio.addEventListener("change", (e) => {
|
|
462
|
+
const isChecked = (e.target as HTMLInputElement).checked;
|
|
463
|
+
isChecked && handleCheckboxChange(checkboxName, true);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
radioContainer.appendChild(radio);
|
|
467
|
+
return radioContainer;
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// Create regular checkbox
|
|
471
|
+
const createRegularCheckbox = () => {
|
|
472
|
+
const checkbox = document.createElement("input");
|
|
473
|
+
checkbox.type = "checkbox";
|
|
474
|
+
checkbox.checked = value === "☑";
|
|
475
|
+
checkbox.className =
|
|
476
|
+
"inline-checkbox rounded border-gray-300 text-green-600 focus:ring-green-500";
|
|
477
|
+
checkbox.setAttribute("data-field-path", checkboxName);
|
|
478
|
+
checkbox.addEventListener("change", (e) => {
|
|
479
|
+
handleCheckboxChange(
|
|
480
|
+
checkboxName,
|
|
481
|
+
(e.target as HTMLInputElement).checked,
|
|
482
|
+
);
|
|
483
|
+
});
|
|
484
|
+
return checkbox;
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const renderReadonlyCheckbox = () => readonly && createReadonlyCheckbox();
|
|
488
|
+
const renderRadioButton = () => !readonly && isRadioButton && createRadioButton();
|
|
489
|
+
const renderRegularCheckbox = () => !readonly && !isRadioButton && createRegularCheckbox();
|
|
490
|
+
|
|
491
|
+
return renderReadonlyCheckbox() || renderRadioButton() || renderRegularCheckbox();
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
// Handle regular input elements
|
|
495
|
+
const createRegularInput = () => {
|
|
496
|
+
const fieldConfig = schema.properties?.[fieldPath] || {};
|
|
497
|
+
return createInlineInput(fieldPath, value, fieldConfig, data);
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const element = isCheckboxMarker
|
|
501
|
+
? createCheckboxElement()
|
|
502
|
+
: createRegularInput();
|
|
503
|
+
|
|
504
|
+
fragment.appendChild(element);
|
|
505
|
+
lastIndex = offset + match.length;
|
|
506
|
+
return match;
|
|
507
|
+
},
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
// Add remaining text after the last marker
|
|
511
|
+
(lastIndex < text.length) && fragment.appendChild(
|
|
512
|
+
document.createTextNode(text.slice(lastIndex)),
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
parent.replaceChild(fragment, textNode);
|
|
516
|
+
})();
|
|
517
|
+
});
|
|
518
|
+
},
|
|
519
|
+
[schema, createInlineInput, handleCheckboxChange, readonly],
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
const updatePreview = useCallback(
|
|
523
|
+
async (data: Record<string, any>) => {
|
|
524
|
+
const hasPreviewRef = !!previewRef.current;
|
|
525
|
+
|
|
526
|
+
hasPreviewRef && await (async () => {
|
|
527
|
+
try {
|
|
528
|
+
const blob = await processDocument(data);
|
|
529
|
+
|
|
530
|
+
// Clear container and render preview using the npm package
|
|
531
|
+
while (previewRef.current!.firstChild) {
|
|
532
|
+
previewRef.current!.removeChild(previewRef.current!.firstChild);
|
|
533
|
+
}
|
|
534
|
+
await docxPreview.renderAsync(blob, previewRef.current!, undefined, {
|
|
535
|
+
className: "docx-preview",
|
|
536
|
+
inWrapper: true,
|
|
537
|
+
hideWrapperOnPrint: true,
|
|
538
|
+
ignoreWidth: false,
|
|
539
|
+
ignoreHeight: false,
|
|
540
|
+
ignoreFonts: false,
|
|
541
|
+
breakPages: true,
|
|
542
|
+
debug: false,
|
|
543
|
+
experimental: true,
|
|
544
|
+
trimXmlDeclaration: true,
|
|
545
|
+
renderHeaders: false,
|
|
546
|
+
renderFooters: false,
|
|
547
|
+
renderFootnotes: true,
|
|
548
|
+
renderEndnotes: true,
|
|
549
|
+
ignoreLastRenderedPageBreak: true,
|
|
550
|
+
useBase64URL: false,
|
|
551
|
+
renderChanges: false,
|
|
552
|
+
renderComments: false,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// Replace markers with input fields after rendering
|
|
556
|
+
setTimeout(() => {
|
|
557
|
+
const stillHasPreviewRef = !!previewRef.current;
|
|
558
|
+
stillHasPreviewRef && (() => {
|
|
559
|
+
replaceMarkersWithInputs(previewRef.current!, data);
|
|
560
|
+
// Initialize field colors after inputs are created
|
|
561
|
+
updateAllFieldColors();
|
|
562
|
+
})();
|
|
563
|
+
}, 100); // Small delay to ensure DOM is ready
|
|
564
|
+
} catch (error) {
|
|
565
|
+
console.error("Error updating preview:", error);
|
|
566
|
+
const stillHasPreviewRef = !!previewRef.current;
|
|
567
|
+
|
|
568
|
+
stillHasPreviewRef && (() => {
|
|
569
|
+
// Clear existing content safely
|
|
570
|
+
while (previewRef.current!.firstChild) {
|
|
571
|
+
previewRef.current!.removeChild(previewRef.current!.firstChild);
|
|
572
|
+
}
|
|
573
|
+
// Create error message element safely
|
|
574
|
+
const errorDiv = document.createElement("div");
|
|
575
|
+
errorDiv.className = "p-4 text-red-600";
|
|
576
|
+
errorDiv.textContent = `Error updating preview: ${error instanceof Error ? error.message : "Unknown error"}`;
|
|
577
|
+
previewRef.current!.appendChild(errorDiv);
|
|
578
|
+
})();
|
|
579
|
+
}
|
|
580
|
+
})();
|
|
581
|
+
},
|
|
582
|
+
[processDocument, replaceMarkersWithInputs, updateAllFieldColors],
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
const loadDocx = useCallback(async () => {
|
|
586
|
+
try {
|
|
587
|
+
setLoading(true);
|
|
588
|
+
const response = await fetch(docxUrl);
|
|
589
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
590
|
+
|
|
591
|
+
const zip = new PizZip(arrayBuffer);
|
|
592
|
+
const doc = new Docxtemplater(zip);
|
|
593
|
+
|
|
594
|
+
// Extract placeholders from the document text
|
|
595
|
+
const text = doc.getFullText();
|
|
596
|
+
const extractedPlaceholders = extractPlaceholders(text);
|
|
597
|
+
setPlaceholders(extractedPlaceholders);
|
|
598
|
+
|
|
599
|
+
// Initialize form data with defaults, merging with provided initialData
|
|
600
|
+
const defaultData: Record<string, any> = {};
|
|
601
|
+
extractedPlaceholders.forEach((placeholder) => {
|
|
602
|
+
const fieldConfig = schema.properties?.[placeholder];
|
|
603
|
+
const hasFieldConfig = !!fieldConfig;
|
|
604
|
+
|
|
605
|
+
const setBooleanDefault = () => hasFieldConfig && fieldConfig.type === "boolean" && false;
|
|
606
|
+
const setDateDefault = () => hasFieldConfig && fieldConfig.type === "string" && fieldConfig.format === "date" && "";
|
|
607
|
+
const setStringDefault = () => hasFieldConfig && fieldConfig.type !== "boolean" && !(fieldConfig.type === "string" && fieldConfig.format === "date") && "";
|
|
608
|
+
const setEmptyDefault = () => !hasFieldConfig && "";
|
|
609
|
+
|
|
610
|
+
defaultData[placeholder] = setBooleanDefault() || setDateDefault() || setStringDefault() || "";
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// Merge default data with provided initial data
|
|
614
|
+
const mergedData = { ...defaultData, ...initialData };
|
|
615
|
+
globalFormData = mergedData;
|
|
616
|
+
originalData = { ...mergedData }; // Store original data for comparison
|
|
617
|
+
setError(null);
|
|
618
|
+
} catch (err) {
|
|
619
|
+
setError(
|
|
620
|
+
`Failed to load document: ${err instanceof Error ? err.message : "Unknown error"}`,
|
|
621
|
+
);
|
|
622
|
+
} finally {
|
|
623
|
+
setLoading(false);
|
|
624
|
+
}
|
|
625
|
+
}, [docxUrl, schema, extractPlaceholders, initialData]);
|
|
626
|
+
|
|
627
|
+
useEffect(() => {
|
|
628
|
+
loadDocx();
|
|
629
|
+
}, [loadDocx]);
|
|
630
|
+
|
|
631
|
+
// Generate initial preview after component mounts and data is loaded
|
|
632
|
+
useEffect(() => {
|
|
633
|
+
const shouldUpdatePreview = !loading &&
|
|
634
|
+
Object.keys(globalFormData).length > 0 &&
|
|
635
|
+
!!previewRef.current;
|
|
636
|
+
|
|
637
|
+
shouldUpdatePreview && updatePreview(globalFormData);
|
|
638
|
+
}, [loading, updatePreview]);
|
|
639
|
+
|
|
640
|
+
// Update preview when save is triggered
|
|
641
|
+
useEffect(() => {
|
|
642
|
+
const shouldUpdate = saveFlag > 0 && !!previewRef.current;
|
|
643
|
+
shouldUpdate && updatePreview(globalFormData);
|
|
644
|
+
}, [saveFlag, updatePreview]);
|
|
645
|
+
|
|
646
|
+
// Early return components
|
|
647
|
+
const LoadingComponent = () => (
|
|
648
|
+
<div className="flex items-center justify-center p-8">
|
|
649
|
+
<div className="text-body">Loading document...</div>
|
|
650
|
+
</div>
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
const ErrorComponent = () => (
|
|
654
|
+
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
|
655
|
+
<div className="text-red-800 text-body-em">Error</div>
|
|
656
|
+
<div className="text-red-700 text-body mt-1">{error}</div>
|
|
657
|
+
</div>
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
// Return early states using separate conditionals
|
|
661
|
+
const isLoading = loading;
|
|
662
|
+
const hasError = !loading && !!error;
|
|
663
|
+
|
|
664
|
+
const renderLoading = () => isLoading && <LoadingComponent />;
|
|
665
|
+
const renderError = () => hasError && <ErrorComponent />;
|
|
666
|
+
const renderDocument = () => !isLoading && !hasError && (
|
|
667
|
+
<div>
|
|
668
|
+
<style>{`
|
|
669
|
+
.inline-field-container {
|
|
670
|
+
display: block;
|
|
671
|
+
margin: 0;
|
|
672
|
+
padding: 0;
|
|
673
|
+
width: 100%;
|
|
674
|
+
}
|
|
675
|
+
.inline-input, .inline-select {
|
|
676
|
+
background: rgba(255, 255, 255, 0.9);
|
|
677
|
+
border: 1px solid #d1d5db;
|
|
678
|
+
border-radius: 4px;
|
|
679
|
+
padding: 4px 8px;
|
|
680
|
+
font-size: 13px;
|
|
681
|
+
line-height: 1.4;
|
|
682
|
+
font-family: inherit;
|
|
683
|
+
height: 17px;
|
|
684
|
+
}
|
|
685
|
+
.inline-input:focus, .inline-select:focus {
|
|
686
|
+
outline: none;
|
|
687
|
+
border-color: #10b981;
|
|
688
|
+
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
|
|
689
|
+
}
|
|
690
|
+
.inline-textarea {
|
|
691
|
+
background: transparent;
|
|
692
|
+
border: none;
|
|
693
|
+
border-radius: 0;
|
|
694
|
+
padding: 2px 4px;
|
|
695
|
+
font-size: 13px;
|
|
696
|
+
line-height: 1.4;
|
|
697
|
+
font-family: inherit;
|
|
698
|
+
overflow: hidden;
|
|
699
|
+
word-wrap: break-word;
|
|
700
|
+
display: inline-block;
|
|
701
|
+
vertical-align: top;
|
|
702
|
+
}
|
|
703
|
+
.inline-textarea:focus {
|
|
704
|
+
outline: none;
|
|
705
|
+
background: transparent;
|
|
706
|
+
}
|
|
707
|
+
.inline-textarea:hover {
|
|
708
|
+
background: rgba(0, 0, 0, 0.02);
|
|
709
|
+
}
|
|
710
|
+
.inline-date-input {
|
|
711
|
+
background: transparent;
|
|
712
|
+
border: none;
|
|
713
|
+
border-radius: 0;
|
|
714
|
+
padding: 2px 4px;
|
|
715
|
+
font-size: 13px;
|
|
716
|
+
line-height: 1.4;
|
|
717
|
+
font-family: inherit;
|
|
718
|
+
color: inherit;
|
|
719
|
+
height: 17px;
|
|
720
|
+
}
|
|
721
|
+
.inline-date-input:focus {
|
|
722
|
+
outline: none;
|
|
723
|
+
background: transparent;
|
|
724
|
+
}
|
|
725
|
+
.inline-date-input:hover {
|
|
726
|
+
background: rgba(0, 0, 0, 0.02);
|
|
727
|
+
}
|
|
728
|
+
/* Style the date picker icon */
|
|
729
|
+
.inline-date-input::-webkit-calendar-picker-indicator {
|
|
730
|
+
opacity: 0.6;
|
|
731
|
+
cursor: pointer;
|
|
732
|
+
}
|
|
733
|
+
.inline-date-input::-webkit-calendar-picker-indicator:hover {
|
|
734
|
+
opacity: 1;
|
|
735
|
+
}
|
|
736
|
+
.inline-checkbox {
|
|
737
|
+
margin: 0 4px;
|
|
738
|
+
transform: scale(0.9);
|
|
739
|
+
}
|
|
740
|
+
/* Original field styles */
|
|
741
|
+
.field-original {
|
|
742
|
+
background: rgba(229, 231, 235, 0.3) !important;
|
|
743
|
+
}
|
|
744
|
+
.field-original:focus {
|
|
745
|
+
background: rgba(229, 231, 235, 0.4) !important;
|
|
746
|
+
}
|
|
747
|
+
/* Edited field styles */
|
|
748
|
+
.field-edited {
|
|
749
|
+
background: rgba(254, 240, 138, 0.4) !important;
|
|
750
|
+
}
|
|
751
|
+
.field-edited:focus {
|
|
752
|
+
background: rgba(254, 240, 138, 0.5) !important;
|
|
753
|
+
}
|
|
754
|
+
/* Textarea color coding */
|
|
755
|
+
.inline-textarea.field-original {
|
|
756
|
+
background: rgba(229, 231, 235, 0.2) !important;
|
|
757
|
+
border-radius: 3px !important;
|
|
758
|
+
}
|
|
759
|
+
.inline-textarea.field-edited {
|
|
760
|
+
background: rgba(254, 240, 138, 0.3) !important;
|
|
761
|
+
border-radius: 3px !important;
|
|
762
|
+
}
|
|
763
|
+
/* Date input color coding */
|
|
764
|
+
.inline-date-input.field-original {
|
|
765
|
+
background: rgba(229, 231, 235, 0.2) !important;
|
|
766
|
+
border-radius: 3px !important;
|
|
767
|
+
}
|
|
768
|
+
.inline-date-input.field-edited {
|
|
769
|
+
background: rgba(254, 240, 138, 0.3) !important;
|
|
770
|
+
border-radius: 3px !important;
|
|
771
|
+
}
|
|
772
|
+
/* Checkbox color coding */
|
|
773
|
+
.inline-checkbox.field-original {
|
|
774
|
+
accent-color: #9ca3af;
|
|
775
|
+
}
|
|
776
|
+
.inline-checkbox.field-edited {
|
|
777
|
+
accent-color: #f59e0b;
|
|
778
|
+
}
|
|
779
|
+
.docx-preview .inline-field-container {
|
|
780
|
+
background: rgba(255, 247, 230, 0.5);
|
|
781
|
+
border-radius: 3px;
|
|
782
|
+
padding: 1px 2px;
|
|
783
|
+
}
|
|
784
|
+
/* Readonly containers should be transparent */
|
|
785
|
+
.docx-preview .inline-field-container:has(.inline-field-readonly),
|
|
786
|
+
.docx-preview .inline-field-container:has(.inline-checkbox-readonly) {
|
|
787
|
+
background: transparent !important;
|
|
788
|
+
border-radius: 0 !important;
|
|
789
|
+
padding: 0 !important;
|
|
790
|
+
}
|
|
791
|
+
/* Container color coding */
|
|
792
|
+
.docx-preview .inline-field-container.field-original {
|
|
793
|
+
background: rgba(229, 231, 235, 0.3) !important;
|
|
794
|
+
}
|
|
795
|
+
.docx-preview .inline-field-container.field-edited {
|
|
796
|
+
background: rgba(254, 240, 138, 0.4) !important;
|
|
797
|
+
}
|
|
798
|
+
/* Readonly mode: remove all background colors */
|
|
799
|
+
.docx-preview .inline-field-container.field-original:has(.inline-field-readonly) {
|
|
800
|
+
background: transparent !important;
|
|
801
|
+
}
|
|
802
|
+
.docx-preview .inline-field-container.field-edited:has(.inline-field-readonly) {
|
|
803
|
+
background: transparent !important;
|
|
804
|
+
}
|
|
805
|
+
/* Remove header/footer padding from docx-preview */
|
|
806
|
+
.docx-preview .docx-wrapper .docx-document {
|
|
807
|
+
padding-top: 0 !important;
|
|
808
|
+
padding-bottom: 0 !important;
|
|
809
|
+
}
|
|
810
|
+
.docx-preview .docx-wrapper section {
|
|
811
|
+
padding-top: 0 !important;
|
|
812
|
+
padding-bottom: 0 !important;
|
|
813
|
+
}
|
|
814
|
+
.docx-preview .docx-wrapper .docx-page {
|
|
815
|
+
padding-top: 0 !important;
|
|
816
|
+
padding-bottom: 0 !important;
|
|
817
|
+
}
|
|
818
|
+
/* Override inline padding styles on section elements */
|
|
819
|
+
section.docx-preview {
|
|
820
|
+
padding-top: 20pt !important;
|
|
821
|
+
padding-bottom: 20pt !important;
|
|
822
|
+
}
|
|
823
|
+
/* Override docx-preview-wrapper styles */
|
|
824
|
+
.docx-preview-wrapper {
|
|
825
|
+
background: transparent !important;
|
|
826
|
+
padding: 0 !important;
|
|
827
|
+
}
|
|
828
|
+
.docx-preview-wrapper>section.docx-preview {
|
|
829
|
+
margin-bottom: 0 !important;
|
|
830
|
+
box-shadow: none !important;
|
|
831
|
+
}
|
|
832
|
+
/* Scale down all text in the document by 20% */
|
|
833
|
+
.docx-preview {
|
|
834
|
+
font-size: 80% !important;
|
|
835
|
+
}
|
|
836
|
+
@media (max-width: 1024px) {
|
|
837
|
+
.docx-preview {
|
|
838
|
+
width: auto !important;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
.docx-preview * {
|
|
842
|
+
font-size: inherit !important;
|
|
843
|
+
}
|
|
844
|
+
/* Readonly field styles */
|
|
845
|
+
.inline-field-readonly {
|
|
846
|
+
color: inherit;
|
|
847
|
+
font-family: inherit;
|
|
848
|
+
font-size: 13px;
|
|
849
|
+
line-height: 1.4;
|
|
850
|
+
background: transparent;
|
|
851
|
+
border-radius: 0;
|
|
852
|
+
padding: 2px 4px;
|
|
853
|
+
border: none;
|
|
854
|
+
}
|
|
855
|
+
.inline-checkbox-readonly {
|
|
856
|
+
color: inherit;
|
|
857
|
+
font-family: inherit;
|
|
858
|
+
font-size: inherit;
|
|
859
|
+
margin: 0 2px;
|
|
860
|
+
}
|
|
861
|
+
`}</style>
|
|
862
|
+
|
|
863
|
+
<div className="bg-white max-w-full">
|
|
864
|
+
<div ref={previewRef} className="bg-white" />
|
|
865
|
+
</div>
|
|
866
|
+
</div>
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
return renderLoading() || renderError() || renderDocument();
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
export default DocumentEditor;
|