@dmitryvim/form-builder 0.1.21 → 0.1.24

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.
@@ -1,240 +1,257 @@
1
1
  // Form Builder Library - Core API
2
2
  // State management
3
- const state = {
4
- schema: null,
5
- formRoot: null,
6
- resourceIndex: new Map(),
7
- version: '1.0.0',
8
- config: {
9
- // File upload configuration
10
- uploadFile: null,
11
- downloadFile: null,
12
- getThumbnail: null,
13
- getDownloadUrl: null,
14
- // Default implementations
15
- enableFilePreview: true,
16
- maxPreviewSize: '200px',
17
- readonly: false
18
- }
3
+ const state = {
4
+ schema: null,
5
+ formRoot: null,
6
+ resourceIndex: new Map(),
7
+ version: "1.0.0",
8
+ config: {
9
+ // File upload configuration
10
+ uploadFile: null,
11
+ downloadFile: null,
12
+ getThumbnail: null,
13
+ getDownloadUrl: null,
14
+ // Default implementations
15
+ enableFilePreview: true,
16
+ maxPreviewSize: "200px",
17
+ readonly: false,
18
+ },
19
19
  };
20
20
 
21
21
  // Utility functions
22
22
  function isPlainObject(obj) {
23
- return obj && typeof obj === 'object' && obj.constructor === Object;
23
+ return obj && typeof obj === "object" && obj.constructor === Object;
24
24
  }
25
25
 
26
26
  function pathJoin(base, key) {
27
- return base ? `${base}.${key}` : key;
27
+ return base ? `${base}.${key}` : key;
28
28
  }
29
29
 
30
30
  function pretty(obj) {
31
- return JSON.stringify(obj, null, 2);
31
+ return JSON.stringify(obj, null, 2);
32
32
  }
33
33
 
34
34
  function clear(node) {
35
- while (node.firstChild) node.removeChild(node.firstChild);
35
+ while (node.firstChild) node.removeChild(node.firstChild);
36
36
  }
37
37
 
38
38
  // Schema validation
39
39
  function validateSchema(schema) {
40
- const errors = [];
41
-
42
- if (!schema || typeof schema !== 'object') {
43
- errors.push('Schema must be an object');
44
- return errors;
45
- }
46
-
47
- if (!schema.version) {
48
- errors.push('Schema missing version');
49
- }
50
-
51
- if (!Array.isArray(schema.elements)) {
52
- errors.push('Schema missing elements array');
53
- return errors;
54
- }
55
-
56
- function validateElements(elements, path) {
57
- elements.forEach((element, index) => {
58
- const elementPath = `${path}[${index}]`;
59
-
60
- if (!element.type) {
61
- errors.push(`${elementPath}: missing type`);
62
- }
63
-
64
- if (!element.key) {
65
- errors.push(`${elementPath}: missing key`);
66
- }
67
-
68
- if (element.type === 'group' && element.elements) {
69
- validateElements(element.elements, `${elementPath}.elements`);
70
- }
71
-
72
- if (element.type === 'select' && element.options) {
73
- const defaultValue = element.default;
74
- if (defaultValue !== undefined && defaultValue !== null && defaultValue !== '') {
75
- const hasMatchingOption = element.options.some(opt => opt.value === defaultValue);
76
- if (!hasMatchingOption) {
77
- errors.push(`${elementPath}: default "${defaultValue}" not in options`);
78
- }
79
- }
80
- }
81
- });
82
- }
83
-
84
- if (Array.isArray(schema.elements)) validateElements(schema.elements, 'elements');
40
+ const errors = [];
41
+
42
+ if (!schema || typeof schema !== "object") {
43
+ errors.push("Schema must be an object");
44
+ return errors;
45
+ }
46
+
47
+ if (!schema.version) {
48
+ errors.push("Schema missing version");
49
+ }
50
+
51
+ if (!Array.isArray(schema.elements)) {
52
+ errors.push("Schema missing elements array");
85
53
  return errors;
54
+ }
55
+
56
+ function validateElements(elements, path) {
57
+ elements.forEach((element, index) => {
58
+ const elementPath = `${path}[${index}]`;
59
+
60
+ if (!element.type) {
61
+ errors.push(`${elementPath}: missing type`);
62
+ }
63
+
64
+ if (!element.key) {
65
+ errors.push(`${elementPath}: missing key`);
66
+ }
67
+
68
+ if (element.type === "group" && element.elements) {
69
+ validateElements(element.elements, `${elementPath}.elements`);
70
+ }
71
+
72
+ if (element.type === "select" && element.options) {
73
+ const defaultValue = element.default;
74
+ if (
75
+ defaultValue !== undefined &&
76
+ defaultValue !== null &&
77
+ defaultValue !== ""
78
+ ) {
79
+ const hasMatchingOption = element.options.some(
80
+ (opt) => opt.value === defaultValue,
81
+ );
82
+ if (!hasMatchingOption) {
83
+ errors.push(
84
+ `${elementPath}: default "${defaultValue}" not in options`,
85
+ );
86
+ }
87
+ }
88
+ }
89
+ });
90
+ }
91
+
92
+ if (Array.isArray(schema.elements))
93
+ validateElements(schema.elements, "elements");
94
+ return errors;
86
95
  }
87
96
 
88
97
  // Form rendering
89
98
  function renderForm(schema, prefill) {
90
-
91
- const errors = validateSchema(schema);
92
- if (errors.length > 0) {
93
- console.error('Schema validation errors:', errors);
94
- return;
95
- }
96
-
97
- state.schema = schema;
98
- if (!state.formRoot) {
99
- console.error('No form root element set. Call setFormRoot() first.');
100
- return;
101
- }
102
-
103
- clear(state.formRoot);
104
-
105
- const formEl = document.createElement('div');
106
- formEl.className = 'space-y-6';
107
-
108
- schema.elements.forEach((element, index) => {
109
- const block = renderElement(element, {
110
- path: '',
111
- prefill: prefill || {}
112
- });
113
- formEl.appendChild(block);
99
+ const errors = validateSchema(schema);
100
+ if (errors.length > 0) {
101
+ console.error("Schema validation errors:", errors);
102
+ return;
103
+ }
104
+
105
+ state.schema = schema;
106
+ if (!state.formRoot) {
107
+ console.error("No form root element set. Call setFormRoot() first.");
108
+ return;
109
+ }
110
+
111
+ clear(state.formRoot);
112
+
113
+ const formEl = document.createElement("div");
114
+ formEl.className = "space-y-6";
115
+
116
+ schema.elements.forEach((element, index) => {
117
+ const block = renderElement(element, {
118
+ path: "",
119
+ prefill: prefill || {},
114
120
  });
121
+ formEl.appendChild(block);
122
+ });
115
123
 
116
- state.formRoot.appendChild(formEl);
124
+ state.formRoot.appendChild(formEl);
117
125
  }
118
126
 
119
127
  function renderElement(element, ctx) {
120
-
121
- const wrapper = document.createElement('div');
122
- wrapper.className = 'mb-6';
123
-
124
- const label = document.createElement('div');
125
- label.className = 'flex items-center mb-2';
126
- const title = document.createElement('label');
127
- title.className = 'text-sm font-medium text-gray-900';
128
- title.textContent = element.label || element.key;
129
- if (element.required) {
130
- const req = document.createElement('span');
131
- req.className = 'text-red-500 ml-1';
132
- req.textContent = '*';
133
- title.appendChild(req);
134
- }
135
- label.appendChild(title);
136
-
137
- // Add info button if there's description or hint
138
- if (element.description || element.hint) {
139
- const infoBtn = document.createElement('button');
140
- infoBtn.type = 'button';
141
- infoBtn.className = 'ml-2 text-gray-400 hover:text-gray-600';
142
- infoBtn.innerHTML = '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>';
143
-
144
- // Create tooltip
145
- const tooltipId = `tooltip-${element.key}-${Math.random().toString(36).substr(2, 9)}`;
146
- const tooltip = document.createElement('div');
147
- tooltip.id = tooltipId;
148
- tooltip.className = 'hidden absolute z-50 bg-gray-200 text-gray-900 text-sm rounded-lg p-3 max-w-sm border border-gray-300 shadow-lg';
149
- tooltip.style.position = 'fixed';
150
- tooltip.textContent = element.description || element.hint || 'Field information';
151
- document.body.appendChild(tooltip);
152
-
153
- infoBtn.onclick = (e) => {
154
- e.preventDefault();
155
- e.stopPropagation();
156
- showTooltip(tooltipId, infoBtn);
157
- };
158
-
159
- label.appendChild(infoBtn);
160
- }
161
-
162
- wrapper.appendChild(label);
128
+ const wrapper = document.createElement("div");
129
+ wrapper.className = "mb-6";
163
130
 
164
- const pathKey = pathJoin(ctx.path, element.key);
131
+ const label = document.createElement("div");
132
+ label.className = "flex items-center mb-2";
133
+ const title = document.createElement("label");
134
+ title.className = "text-sm font-medium text-gray-900";
135
+ title.textContent = element.label || element.key;
136
+ if (element.required) {
137
+ const req = document.createElement("span");
138
+ req.className = "text-red-500 ml-1";
139
+ req.textContent = "*";
140
+ title.appendChild(req);
141
+ }
142
+ label.appendChild(title);
165
143
 
166
- switch (element.type) {
167
- case 'text':
168
- renderTextElement(element, ctx, wrapper, pathKey);
169
- break;
170
-
171
- case 'textarea':
172
- renderTextareaElement(element, ctx, wrapper, pathKey);
173
- break;
174
-
175
- case 'number':
176
- renderNumberElement(element, ctx, wrapper, pathKey);
177
- break;
178
-
179
- case 'select':
180
- renderSelectElement(element, ctx, wrapper, pathKey);
181
- break;
182
-
183
- case 'file':
184
- // TODO: Extract to renderFileElement() function
185
- if (state.config.readonly) {
186
- // Readonly mode: use common preview function
187
- const initial = ctx.prefill[element.key] || element.default;
188
- if (initial) {
189
- const filePreview = renderFilePreviewReadonly(initial);
190
- wrapper.appendChild(filePreview);
191
- } else {
192
- const emptyState = document.createElement('div');
193
- emptyState.className = 'aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500';
194
- emptyState.innerHTML = '<div class="text-center">Нет файла</div>';
195
- wrapper.appendChild(emptyState);
196
- }
197
- } else {
198
- // Edit mode: normal file input
199
- const fileWrapper = document.createElement('div');
200
- fileWrapper.className = 'space-y-2';
201
-
202
- const picker = document.createElement('input');
203
- picker.type = 'file';
204
- picker.name = pathKey;
205
- picker.style.display = 'none'; // Hide default input
206
- if (element.accept) {
207
- if (element.accept.extensions) {
208
- picker.accept = element.accept.extensions.map(ext => `.${ext}`).join(',');
209
- }
210
- }
211
-
212
- const fileContainer = document.createElement('div');
213
- fileContainer.className = 'file-preview-container w-full aspect-square max-w-xs bg-gray-100 rounded-lg overflow-hidden relative group cursor-pointer';
214
-
215
- const initial = ctx.prefill[element.key] || element.default;
216
- if (initial) {
217
- // Add prefill data to resourceIndex so renderFilePreview can use it
218
- if (!state.resourceIndex.has(initial)) {
219
- // Extract filename from URL/path
220
- const filename = initial.split('/').pop() || 'file';
221
- // Determine file type from extension
222
- const extension = filename.split('.').pop()?.toLowerCase();
223
- const fileType = extension && ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(extension)
224
- ? `image/${extension === 'jpg' ? 'jpeg' : extension}`
225
- : 'application/octet-stream';
226
-
227
- state.resourceIndex.set(initial, {
228
- name: filename,
229
- type: fileType,
230
- size: 0,
231
- file: null // No local file for prefill data
232
- });
233
- console.log(`🔧 Added prefill file '${element.key}' to resourceIndex:`, initial);
234
- }
235
- renderFilePreview(fileContainer, initial, initial, '', false).catch(console.error);
236
- } else {
237
- fileContainer.innerHTML = `
144
+ // Add info button if there's description or hint
145
+ if (element.description || element.hint) {
146
+ const infoBtn = document.createElement("button");
147
+ infoBtn.type = "button";
148
+ infoBtn.className = "ml-2 text-gray-400 hover:text-gray-600";
149
+ infoBtn.innerHTML =
150
+ '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>';
151
+
152
+ // Create tooltip
153
+ const tooltipId = `tooltip-${element.key}-${Math.random().toString(36).substr(2, 9)}`;
154
+ const tooltip = document.createElement("div");
155
+ tooltip.id = tooltipId;
156
+ tooltip.className =
157
+ "hidden absolute z-50 bg-gray-200 text-gray-900 text-sm rounded-lg p-3 max-w-sm border border-gray-300 shadow-lg";
158
+ tooltip.style.position = "fixed";
159
+ tooltip.textContent =
160
+ element.description || element.hint || "Field information";
161
+ document.body.appendChild(tooltip);
162
+
163
+ infoBtn.onclick = (e) => {
164
+ e.preventDefault();
165
+ e.stopPropagation();
166
+ showTooltip(tooltipId, infoBtn);
167
+ };
168
+
169
+ label.appendChild(infoBtn);
170
+ }
171
+
172
+ wrapper.appendChild(label);
173
+
174
+ const pathKey = pathJoin(ctx.path, element.key);
175
+
176
+ switch (element.type) {
177
+ case "text":
178
+ renderTextElement(element, ctx, wrapper, pathKey);
179
+ break;
180
+
181
+ case "textarea":
182
+ renderTextareaElement(element, ctx, wrapper, pathKey);
183
+ break;
184
+
185
+ case "number":
186
+ renderNumberElement(element, ctx, wrapper, pathKey);
187
+ break;
188
+
189
+ case "select":
190
+ renderSelectElement(element, ctx, wrapper, pathKey);
191
+ break;
192
+
193
+ case "file":
194
+ // TODO: Extract to renderFileElement() function
195
+ if (state.config.readonly) {
196
+ // Readonly mode: use common preview function
197
+ const initial = ctx.prefill[element.key] || element.default;
198
+ if (initial) {
199
+ const filePreview = renderFilePreviewReadonly(initial);
200
+ wrapper.appendChild(filePreview);
201
+ } else {
202
+ const emptyState = document.createElement("div");
203
+ emptyState.className =
204
+ "aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500";
205
+ emptyState.innerHTML = '<div class="text-center">Нет файла</div>';
206
+ wrapper.appendChild(emptyState);
207
+ }
208
+ } else {
209
+ // Edit mode: normal file input
210
+ const fileWrapper = document.createElement("div");
211
+ fileWrapper.className = "space-y-2";
212
+
213
+ const picker = document.createElement("input");
214
+ picker.type = "file";
215
+ picker.name = pathKey;
216
+ picker.style.display = "none"; // Hide default input
217
+ if (element.accept) {
218
+ if (element.accept.extensions) {
219
+ picker.accept = element.accept.extensions
220
+ .map((ext) => `.${ext}`)
221
+ .join(",");
222
+ }
223
+ }
224
+
225
+ const fileContainer = document.createElement("div");
226
+ fileContainer.className =
227
+ "file-preview-container w-full aspect-square max-w-xs bg-gray-100 rounded-lg overflow-hidden relative group cursor-pointer";
228
+
229
+ const initial = ctx.prefill[element.key] || element.default;
230
+ if (initial) {
231
+ // Add prefill data to resourceIndex so renderFilePreview can use it
232
+ if (!state.resourceIndex.has(initial)) {
233
+ // Extract filename from URL/path
234
+ const filename = initial.split("/").pop() || "file";
235
+ // Determine file type from extension
236
+ const extension = filename.split(".").pop()?.toLowerCase();
237
+ const fileType =
238
+ extension &&
239
+ ["jpg", "jpeg", "png", "gif", "webp"].includes(extension)
240
+ ? `image/${extension === "jpg" ? "jpeg" : extension}`
241
+ : "application/octet-stream";
242
+
243
+ state.resourceIndex.set(initial, {
244
+ name: filename,
245
+ type: fileType,
246
+ size: 0,
247
+ file: null, // No local file for prefill data
248
+ });
249
+ }
250
+ renderFilePreview(fileContainer, initial, initial, "", false).catch(
251
+ console.error,
252
+ );
253
+ } else {
254
+ fileContainer.innerHTML = `
238
255
  <div class="flex flex-col items-center justify-center h-full text-gray-400">
239
256
  <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
240
257
  <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
@@ -242,388 +259,423 @@ function renderElement(element, ctx) {
242
259
  <div class="text-sm text-center">Нажмите или перетащите файл</div>
243
260
  </div>
244
261
  `;
245
- }
246
-
247
- fileContainer.onclick = () => picker.click();
248
- setupDragAndDrop(fileContainer, (files) => {
249
- if (files.length > 0) {
250
- handleFileSelect(files[0], fileContainer, pathKey);
251
- }
252
- });
253
-
254
- picker.onchange = () => {
255
- if (picker.files.length > 0) {
256
- handleFileSelect(picker.files[0], fileContainer, pathKey);
257
- }
258
- };
259
-
260
- fileWrapper.appendChild(fileContainer);
261
- fileWrapper.appendChild(picker);
262
-
263
- // Add upload text
264
- const uploadText = document.createElement('p');
265
- uploadText.className = 'text-xs text-gray-600 mt-2 text-center';
266
- uploadText.innerHTML = `<span class="underline cursor-pointer">Загрузите</span> или перетащите файл`;
267
- uploadText.querySelector('span').onclick = () => picker.click();
268
- fileWrapper.appendChild(uploadText);
269
-
270
- // Add hint
271
- const fileHint = document.createElement('p');
272
- fileHint.className = 'text-xs text-gray-500 mt-1 text-center';
273
- fileHint.textContent = makeFieldHint(element);
274
- fileWrapper.appendChild(fileHint);
275
-
276
- wrapper.appendChild(fileWrapper);
262
+ }
263
+
264
+ fileContainer.onclick = () => picker.click();
265
+ setupDragAndDrop(fileContainer, (files) => {
266
+ if (files.length > 0) {
267
+ handleFileSelect(files[0], fileContainer, pathKey);
268
+ }
269
+ });
270
+
271
+ picker.onchange = () => {
272
+ if (picker.files.length > 0) {
273
+ handleFileSelect(picker.files[0], fileContainer, pathKey);
274
+ }
275
+ };
276
+
277
+ fileWrapper.appendChild(fileContainer);
278
+ fileWrapper.appendChild(picker);
279
+
280
+ // Add upload text
281
+ const uploadText = document.createElement("p");
282
+ uploadText.className = "text-xs text-gray-600 mt-2 text-center";
283
+ uploadText.innerHTML = `<span class="underline cursor-pointer">Загрузите</span> или перетащите файл`;
284
+ uploadText.querySelector("span").onclick = () => picker.click();
285
+ fileWrapper.appendChild(uploadText);
286
+
287
+ // Add hint
288
+ const fileHint = document.createElement("p");
289
+ fileHint.className = "text-xs text-gray-500 mt-1 text-center";
290
+ fileHint.textContent = makeFieldHint(element);
291
+ fileWrapper.appendChild(fileHint);
292
+
293
+ wrapper.appendChild(fileWrapper);
294
+ }
295
+ break;
296
+
297
+ case "files":
298
+ // TODO: Extract to renderFilesElement() function
299
+ if (state.config.readonly) {
300
+ // Readonly mode: render as results list like in workflow-preview.html
301
+ const resultsWrapper = document.createElement("div");
302
+ resultsWrapper.className = "space-y-4";
303
+
304
+ const initialFiles = ctx.prefill[element.key] || [];
305
+
306
+ if (initialFiles.length > 0) {
307
+ initialFiles.forEach((resourceId) => {
308
+ const filePreview = renderFilePreviewReadonly(resourceId);
309
+ resultsWrapper.appendChild(filePreview);
310
+ });
311
+ } else {
312
+ resultsWrapper.innerHTML = `<div class="aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500"><div class="text-center">Нет файлов</div></div>`;
313
+ }
314
+
315
+ wrapper.appendChild(resultsWrapper);
316
+ } else {
317
+ // Edit mode: normal files input
318
+ const filesWrapper = document.createElement("div");
319
+ filesWrapper.className = "space-y-2";
320
+
321
+ const filesPicker = document.createElement("input");
322
+ filesPicker.type = "file";
323
+ filesPicker.name = pathKey;
324
+ filesPicker.multiple = true;
325
+ filesPicker.style.display = "none"; // Hide default input
326
+ if (element.accept) {
327
+ if (element.accept.extensions) {
328
+ filesPicker.accept = element.accept.extensions
329
+ .map((ext) => `.${ext}`)
330
+ .join(",");
331
+ }
332
+ }
333
+
334
+ // Create container with border like in workflow-preview
335
+ const filesContainer = document.createElement("div");
336
+ filesContainer.className =
337
+ "border-2 border-dashed border-gray-300 rounded-lg p-3 hover:border-gray-400 transition-colors";
338
+
339
+ const list = document.createElement("div");
340
+ list.className = "files-list";
341
+
342
+ const initialFiles = ctx.prefill[element.key] || [];
343
+
344
+ // Add prefill files to resourceIndex so renderResourcePills can use them
345
+ if (initialFiles.length > 0) {
346
+ initialFiles.forEach((resourceId) => {
347
+ if (!state.resourceIndex.has(resourceId)) {
348
+ // Extract filename from URL/path
349
+ const filename = resourceId.split("/").pop() || "file";
350
+ // Determine file type from extension
351
+ const extension = filename.split(".").pop()?.toLowerCase();
352
+ const fileType =
353
+ extension &&
354
+ ["jpg", "jpeg", "png", "gif", "webp"].includes(extension)
355
+ ? `image/${extension === "jpg" ? "jpeg" : extension}`
356
+ : "application/octet-stream";
357
+
358
+ state.resourceIndex.set(resourceId, {
359
+ name: filename,
360
+ type: fileType,
361
+ size: 0,
362
+ file: null, // No local file for prefill data
363
+ });
277
364
  }
278
- break;
279
-
280
- case 'files':
281
- // TODO: Extract to renderFilesElement() function
282
- if (state.config.readonly) {
283
- // Readonly mode: render as results list like in workflow-preview.html
284
- const resultsWrapper = document.createElement('div');
285
- resultsWrapper.className = 'space-y-4';
286
-
287
- const initialFiles = ctx.prefill[element.key] || [];
288
-
289
- if (initialFiles.length > 0) {
290
- initialFiles.forEach(resourceId => {
291
- const filePreview = renderFilePreviewReadonly(resourceId);
292
- resultsWrapper.appendChild(filePreview);
293
- });
294
- } else {
295
- resultsWrapper.innerHTML = `<div class="aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500"><div class="text-center">Нет файлов</div></div>`;
365
+ });
366
+ }
367
+
368
+ function updateFilesList() {
369
+ renderResourcePills(list, initialFiles, (ridToRemove) => {
370
+ const index = initialFiles.indexOf(ridToRemove);
371
+ if (index > -1) {
372
+ initialFiles.splice(index, 1);
373
+ }
374
+ updateFilesList(); // Re-render after removal
375
+ });
376
+ }
377
+
378
+ // Initial render
379
+ updateFilesList();
380
+
381
+ setupDragAndDrop(filesContainer, async (files) => {
382
+ const arr = Array.from(files);
383
+ for (const file of arr) {
384
+ let rid;
385
+
386
+ // If uploadHandler is configured, use it to upload the file
387
+ if (state.config.uploadFile) {
388
+ try {
389
+ rid = await state.config.uploadFile(file);
390
+ if (typeof rid !== "string") {
391
+ throw new Error(
392
+ "Upload handler must return a string resource ID",
393
+ );
296
394
  }
297
-
298
- wrapper.appendChild(resultsWrapper);
395
+ } catch (error) {
396
+ throw new Error(`File upload failed: ${error.message}`);
397
+ }
299
398
  } else {
300
- // Edit mode: normal files input
301
- const filesWrapper = document.createElement('div');
302
- filesWrapper.className = 'space-y-2';
303
-
304
- const filesPicker = document.createElement('input');
305
- filesPicker.type = 'file';
306
- filesPicker.name = pathKey;
307
- filesPicker.multiple = true;
308
- filesPicker.style.display = 'none'; // Hide default input
309
- if (element.accept) {
310
- if (element.accept.extensions) {
311
- filesPicker.accept = element.accept.extensions.map(ext => `.${ext}`).join(',');
312
- }
313
- }
314
-
315
- // Create container with border like in workflow-preview
316
- const filesContainer = document.createElement('div');
317
- filesContainer.className = 'border-2 border-dashed border-gray-300 rounded-lg p-3 hover:border-gray-400 transition-colors';
318
-
319
- const list = document.createElement('div');
320
- list.className = 'files-list';
321
-
322
- const initialFiles = ctx.prefill[element.key] || [];
323
-
324
- // Add prefill files to resourceIndex so renderResourcePills can use them
325
- if (initialFiles.length > 0) {
326
- initialFiles.forEach(resourceId => {
327
- if (!state.resourceIndex.has(resourceId)) {
328
- // Extract filename from URL/path
329
- const filename = resourceId.split('/').pop() || 'file';
330
- // Determine file type from extension
331
- const extension = filename.split('.').pop()?.toLowerCase();
332
- const fileType = extension && ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(extension)
333
- ? `image/${extension === 'jpg' ? 'jpeg' : extension}`
334
- : 'application/octet-stream';
335
-
336
- state.resourceIndex.set(resourceId, {
337
- name: filename,
338
- type: fileType,
339
- size: 0,
340
- file: null // No local file for prefill data
341
- });
342
- console.log(`🔧 Added prefill file to resourceIndex:`, resourceId);
343
- }
344
- });
345
- }
346
-
347
- function updateFilesList() {
348
- renderResourcePills(list, initialFiles, (ridToRemove) => {
349
- const index = initialFiles.indexOf(ridToRemove);
350
- if (index > -1) {
351
- initialFiles.splice(index, 1);
352
- }
353
- updateFilesList(); // Re-render after removal
354
- });
355
- }
356
-
357
- // Initial render
358
- updateFilesList();
359
-
360
- setupDragAndDrop(filesContainer, async (files) => {
361
- const arr = Array.from(files);
362
- for (const file of arr) {
363
- let rid;
364
-
365
- // If uploadHandler is configured, use it to upload the file
366
- if (state.config.uploadFile) {
367
- try {
368
- rid = await state.config.uploadFile(file);
369
- if (typeof rid !== 'string') {
370
- throw new Error('Upload handler must return a string resource ID');
371
- }
372
- } catch (error) {
373
- throw new Error(`File upload failed: ${error.message}`);
374
- }
375
- } else {
376
- throw new Error('No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()');
377
- }
378
-
379
- state.resourceIndex.set(rid, {
380
- name: file.name,
381
- type: file.type,
382
- size: file.size,
383
- file: null // Files are always uploaded, never stored locally
384
- });
385
- initialFiles.push(rid);
386
- }
387
- updateFilesList();
388
- });
389
-
390
- filesPicker.onchange = async () => {
391
- for (const file of Array.from(filesPicker.files)) {
392
- let rid;
393
-
394
- // If uploadHandler is configured, use it to upload the file
395
- if (state.config.uploadFile) {
396
- try {
397
- rid = await state.config.uploadFile(file);
398
- if (typeof rid !== 'string') {
399
- throw new Error('Upload handler must return a string resource ID');
400
- }
401
- } catch (error) {
402
- throw new Error(`File upload failed: ${error.message}`);
403
- }
404
- } else {
405
- throw new Error('No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()');
406
- }
407
-
408
- state.resourceIndex.set(rid, {
409
- name: file.name,
410
- type: file.type,
411
- size: file.size,
412
- file: null // Files are always uploaded, never stored locally
413
- });
414
- initialFiles.push(rid);
415
- }
416
- updateFilesList();
417
- // Clear the file input
418
- filesPicker.value = '';
419
- };
420
-
421
- filesContainer.appendChild(list);
422
-
423
- filesWrapper.appendChild(filesContainer);
424
- filesWrapper.appendChild(filesPicker);
425
-
426
- // Add hint
427
- const filesHint = document.createElement('p');
428
- filesHint.className = 'text-xs text-gray-500 mt-1 text-center';
429
- filesHint.textContent = makeFieldHint(element);
430
- filesWrapper.appendChild(filesHint);
431
-
432
- wrapper.appendChild(filesWrapper);
399
+ throw new Error(
400
+ "No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()",
401
+ );
433
402
  }
434
- break;
435
-
436
- case 'group':
437
- // TODO: Extract to renderGroupElement() function
438
- const groupWrap = document.createElement('div');
439
- groupWrap.className = 'border border-gray-200 rounded-lg p-4 bg-gray-50';
440
-
441
- const header = document.createElement('div');
442
- header.className = 'flex justify-between items-center mb-4';
443
-
444
- const left = document.createElement('div');
445
- left.className = 'flex-1';
446
-
447
- const right = document.createElement('div');
448
- right.className = 'flex gap-2';
449
-
450
- const itemsWrap = document.createElement('div');
451
- itemsWrap.className = 'space-y-4';
452
-
453
- groupWrap.appendChild(header);
454
- header.appendChild(left);
455
- header.appendChild(right);
456
-
457
- if (element.repeat && isPlainObject(element.repeat)) {
458
- const min = element.repeat.min ?? 0;
459
- const max = element.repeat.max ?? Infinity;
460
- const pre = Array.isArray(ctx.prefill?.[element.key]) ? ctx.prefill[element.key] : null;
461
-
462
- header.appendChild(right);
463
-
464
- const countItems = () => itemsWrap.querySelectorAll(':scope > .groupItem').length;
465
-
466
- const addItem = (prefillObj) => {
467
- const item = document.createElement('div');
468
- item.className = 'groupItem border border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-3 mb-3 bg-blue-50/30 dark:bg-blue-900/10';
469
- const subCtx = {
470
- path: pathJoin(ctx.path, element.key + `[${countItems()}]`),
471
- prefill: prefillObj || {}
472
- };
473
- element.elements.forEach(child => item.appendChild(renderElement(child, subCtx)));
474
-
475
- const rem = document.createElement('button');
476
- rem.type = 'button';
477
- rem.className = 'bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded text-xs font-medium transition-colors';
478
- rem.textContent = 'Удалить';
479
- rem.addEventListener('click', () => {
480
- if (countItems() <= (element.repeat.min ?? 0)) return;
481
- itemsWrap.removeChild(item);
482
- refreshControls();
483
- });
484
- item.appendChild(rem);
485
- itemsWrap.appendChild(item);
486
- refreshControls();
487
- };
488
-
489
- groupWrap.appendChild(itemsWrap);
490
-
491
- // Add button after items
492
- const addBtn = document.createElement('button');
493
- addBtn.type = 'button';
494
- addBtn.className = 'w-full py-2 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 flex items-center justify-center mt-3';
495
- addBtn.innerHTML = '<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>Добавить элемент';
496
- groupWrap.appendChild(addBtn);
497
-
498
- const refreshControls = () => {
499
- const n = countItems();
500
- addBtn.disabled = n >= max;
501
- left.innerHTML = `<span>${element.label || element.key}</span> <span class="text-slate-500 dark:text-slate-400 text-xs">[${n} / ${max === Infinity ? '∞' : max}, min=${min}]</span>`;
502
- };
503
-
504
- if (pre && pre.length) {
505
- const n = Math.min(max, Math.max(min, pre.length));
506
- for (let i = 0; i < n; i++) addItem(pre[i]);
507
- } else {
508
- const n = Math.max(min, 0);
509
- for (let i = 0; i < n; i++) addItem(null);
403
+
404
+ state.resourceIndex.set(rid, {
405
+ name: file.name,
406
+ type: file.type,
407
+ size: file.size,
408
+ file: null, // Files are always uploaded, never stored locally
409
+ });
410
+ initialFiles.push(rid);
411
+ }
412
+ updateFilesList();
413
+ });
414
+
415
+ filesPicker.onchange = async () => {
416
+ for (const file of Array.from(filesPicker.files)) {
417
+ let rid;
418
+
419
+ // If uploadHandler is configured, use it to upload the file
420
+ if (state.config.uploadFile) {
421
+ try {
422
+ rid = await state.config.uploadFile(file);
423
+ if (typeof rid !== "string") {
424
+ throw new Error(
425
+ "Upload handler must return a string resource ID",
426
+ );
510
427
  }
511
-
512
- addBtn.addEventListener('click', () => addItem(null));
428
+ } catch (error) {
429
+ throw new Error(`File upload failed: ${error.message}`);
430
+ }
513
431
  } else {
514
- // Single object group
515
- const subCtx = {
516
- path: pathJoin(ctx.path, element.key),
517
- prefill: ctx.prefill?.[element.key] || {}
518
- };
519
- element.elements.forEach(child => itemsWrap.appendChild(renderElement(child, subCtx)));
520
- groupWrap.appendChild(itemsWrap);
521
- left.innerHTML = `<span>${element.label || element.key}</span>`;
432
+ throw new Error(
433
+ "No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()",
434
+ );
522
435
  }
523
-
524
- wrapper.appendChild(groupWrap);
525
- break;
526
-
527
- default:
528
- const unsupported = document.createElement('div');
529
- unsupported.className = 'text-red-500 text-sm';
530
- unsupported.textContent = `Unsupported field type: ${element.type}`;
531
- wrapper.appendChild(unsupported);
532
- }
533
436
 
534
- return wrapper;
437
+ state.resourceIndex.set(rid, {
438
+ name: file.name,
439
+ type: file.type,
440
+ size: file.size,
441
+ file: null, // Files are always uploaded, never stored locally
442
+ });
443
+ initialFiles.push(rid);
444
+ }
445
+ updateFilesList();
446
+ // Clear the file input
447
+ filesPicker.value = "";
448
+ };
449
+
450
+ filesContainer.appendChild(list);
451
+
452
+ filesWrapper.appendChild(filesContainer);
453
+ filesWrapper.appendChild(filesPicker);
454
+
455
+ // Add hint
456
+ const filesHint = document.createElement("p");
457
+ filesHint.className = "text-xs text-gray-500 mt-1 text-center";
458
+ filesHint.textContent = makeFieldHint(element);
459
+ filesWrapper.appendChild(filesHint);
460
+
461
+ wrapper.appendChild(filesWrapper);
462
+ }
463
+ break;
464
+
465
+ case "group":
466
+ // TODO: Extract to renderGroupElement() function
467
+ const groupWrap = document.createElement("div");
468
+ groupWrap.className = "border border-gray-200 rounded-lg p-4 bg-gray-50";
469
+
470
+ const header = document.createElement("div");
471
+ header.className = "flex justify-between items-center mb-4";
472
+
473
+ const left = document.createElement("div");
474
+ left.className = "flex-1";
475
+
476
+ const right = document.createElement("div");
477
+ right.className = "flex gap-2";
478
+
479
+ const itemsWrap = document.createElement("div");
480
+ itemsWrap.className = "space-y-4";
481
+
482
+ groupWrap.appendChild(header);
483
+ header.appendChild(left);
484
+ header.appendChild(right);
485
+
486
+ if (element.repeat && isPlainObject(element.repeat)) {
487
+ const min = element.repeat.min ?? 0;
488
+ const max = element.repeat.max ?? Infinity;
489
+ const pre = Array.isArray(ctx.prefill?.[element.key])
490
+ ? ctx.prefill[element.key]
491
+ : null;
492
+
493
+ header.appendChild(right);
494
+
495
+ const countItems = () =>
496
+ itemsWrap.querySelectorAll(":scope > .groupItem").length;
497
+
498
+ const addItem = (prefillObj) => {
499
+ const item = document.createElement("div");
500
+ item.className =
501
+ "groupItem border border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-3 mb-3 bg-blue-50/30 dark:bg-blue-900/10";
502
+ const subCtx = {
503
+ path: pathJoin(ctx.path, element.key + `[${countItems()}]`),
504
+ prefill: prefillObj || {},
505
+ };
506
+ element.elements.forEach((child) =>
507
+ item.appendChild(renderElement(child, subCtx)),
508
+ );
509
+
510
+ const rem = document.createElement("button");
511
+ rem.type = "button";
512
+ rem.className =
513
+ "bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded text-xs font-medium transition-colors";
514
+ rem.textContent = "Удалить";
515
+ rem.addEventListener("click", () => {
516
+ if (countItems() <= (element.repeat.min ?? 0)) return;
517
+ itemsWrap.removeChild(item);
518
+ refreshControls();
519
+ });
520
+ item.appendChild(rem);
521
+ itemsWrap.appendChild(item);
522
+ refreshControls();
523
+ };
524
+
525
+ groupWrap.appendChild(itemsWrap);
526
+
527
+ // Add button after items
528
+ const addBtn = document.createElement("button");
529
+ addBtn.type = "button";
530
+ addBtn.className =
531
+ "w-full py-2 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 flex items-center justify-center mt-3";
532
+ addBtn.innerHTML =
533
+ '<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>Добавить элемент';
534
+ groupWrap.appendChild(addBtn);
535
+
536
+ const refreshControls = () => {
537
+ const n = countItems();
538
+ addBtn.disabled = n >= max;
539
+ left.innerHTML = `<span>${element.label || element.key}</span> <span class="text-slate-500 dark:text-slate-400 text-xs">[${n} / ${max === Infinity ? "∞" : max}, min=${min}]</span>`;
540
+ };
541
+
542
+ if (pre && pre.length) {
543
+ const n = Math.min(max, Math.max(min, pre.length));
544
+ for (let i = 0; i < n; i++) addItem(pre[i]);
545
+ } else {
546
+ const n = Math.max(min, 0);
547
+ for (let i = 0; i < n; i++) addItem(null);
548
+ }
549
+
550
+ addBtn.addEventListener("click", () => addItem(null));
551
+ } else {
552
+ // Single object group
553
+ const subCtx = {
554
+ path: pathJoin(ctx.path, element.key),
555
+ prefill: ctx.prefill?.[element.key] || {},
556
+ };
557
+ element.elements.forEach((child) =>
558
+ itemsWrap.appendChild(renderElement(child, subCtx)),
559
+ );
560
+ groupWrap.appendChild(itemsWrap);
561
+ left.innerHTML = `<span>${element.label || element.key}</span>`;
562
+ }
563
+
564
+ wrapper.appendChild(groupWrap);
565
+ break;
566
+
567
+ default:
568
+ const unsupported = document.createElement("div");
569
+ unsupported.className = "text-red-500 text-sm";
570
+ unsupported.textContent = `Unsupported field type: ${element.type}`;
571
+ wrapper.appendChild(unsupported);
572
+ }
573
+
574
+ return wrapper;
535
575
  }
536
576
 
537
577
  function makeFieldHint(element) {
538
- const parts = [];
539
-
540
- if (element.required) {
541
- parts.push('required');
542
- } else {
543
- parts.push('optional');
544
- }
545
-
546
- if (element.minLength != null || element.maxLength != null) {
547
- if (element.minLength != null && element.maxLength != null) {
548
- parts.push(`length=${element.minLength}-${element.maxLength} characters`);
549
- } else if (element.maxLength != null) {
550
- parts.push(`max=${element.maxLength} characters`);
551
- } else if (element.minLength != null) {
552
- parts.push(`min=${element.minLength} characters`);
553
- }
554
- }
555
-
556
- if (element.min != null || element.max != null) {
557
- if (element.min != null && element.max != null) {
558
- parts.push(`range=${element.min}-${element.max}`);
559
- } else if (element.max != null) {
560
- parts.push(`max=${element.max}`);
561
- } else if (element.min != null) {
562
- parts.push(`min=${element.min}`);
563
- }
564
- }
565
-
566
- if (element.maxSizeMB) {
567
- parts.push(`max_size=${element.maxSizeMB}MB`);
568
- }
569
-
570
- if (element.accept && element.accept.extensions) {
571
- parts.push(`formats=${element.accept.extensions.map(ext => ext.toUpperCase()).join(',')}`);
578
+ const parts = [];
579
+
580
+ if (element.required) {
581
+ parts.push("required");
582
+ } else {
583
+ parts.push("optional");
584
+ }
585
+
586
+ if (element.minLength != null || element.maxLength != null) {
587
+ if (element.minLength != null && element.maxLength != null) {
588
+ parts.push(`length=${element.minLength}-${element.maxLength} characters`);
589
+ } else if (element.maxLength != null) {
590
+ parts.push(`max=${element.maxLength} characters`);
591
+ } else if (element.minLength != null) {
592
+ parts.push(`min=${element.minLength} characters`);
572
593
  }
573
-
574
- if (element.pattern && !element.pattern.includes('А-Я')) {
575
- parts.push('plain text only');
576
- } else if (element.pattern && element.pattern.includes('А-Я')) {
577
- parts.push('text with punctuation');
594
+ }
595
+
596
+ if (element.min != null || element.max != null) {
597
+ if (element.min != null && element.max != null) {
598
+ parts.push(`range=${element.min}-${element.max}`);
599
+ } else if (element.max != null) {
600
+ parts.push(`max=${element.max}`);
601
+ } else if (element.min != null) {
602
+ parts.push(`min=${element.min}`);
578
603
  }
579
-
580
- return parts.join(' • ');
604
+ }
605
+
606
+ if (element.maxSizeMB) {
607
+ parts.push(`max_size=${element.maxSizeMB}MB`);
608
+ }
609
+
610
+ if (element.accept && element.accept.extensions) {
611
+ parts.push(
612
+ `formats=${element.accept.extensions.map((ext) => ext.toUpperCase()).join(",")}`,
613
+ );
614
+ }
615
+
616
+ if (element.pattern && !element.pattern.includes("А-Я")) {
617
+ parts.push("plain text only");
618
+ } else if (element.pattern && element.pattern.includes("А-Я")) {
619
+ parts.push("text with punctuation");
620
+ }
621
+
622
+ return parts.join(" • ");
581
623
  }
582
624
 
583
- async function renderFilePreview(container, resourceId, fileName, fileType, isReadonly = false) {
584
- // Don't change container className - preserve max-w-xs and other styling
585
-
586
- // Clear container content first
587
- clear(container);
588
-
589
- if (isReadonly) {
590
- container.classList.add('cursor-pointer');
625
+ async function renderFilePreview(
626
+ container,
627
+ resourceId,
628
+ fileName,
629
+ fileType,
630
+ isReadonly = false,
631
+ ) {
632
+ // Don't change container className - preserve max-w-xs and other styling
633
+
634
+ // Clear container content first
635
+ clear(container);
636
+
637
+ if (isReadonly) {
638
+ container.classList.add("cursor-pointer");
639
+ }
640
+
641
+ const img = document.createElement("img");
642
+ img.className = "w-full h-full object-contain";
643
+ img.alt = fileName || "Preview";
644
+
645
+ // Use stored file from resourceIndex if available, or try getThumbnail
646
+ const meta = state.resourceIndex.get(resourceId);
647
+
648
+ if (meta && meta.file && meta.file instanceof File) {
649
+ // For local files, use FileReader to display preview
650
+ if (meta.type && meta.type.startsWith("image/")) {
651
+ const reader = new FileReader();
652
+ reader.onload = (e) => {
653
+ img.src = e.target.result;
654
+ };
655
+ reader.readAsDataURL(meta.file);
656
+ container.appendChild(img);
657
+ } else {
658
+ // Non-image file
659
+ container.innerHTML =
660
+ '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">📁</div><div class="text-sm">' +
661
+ fileName +
662
+ "</div></div>";
591
663
  }
592
-
593
- const img = document.createElement('img');
594
- img.className = 'w-full h-full object-contain';
595
- img.alt = fileName || 'Preview';
596
-
597
- // Use stored file from resourceIndex if available, or try getThumbnail
598
- const meta = state.resourceIndex.get(resourceId);
599
- console.log(`🔧 renderFilePreview debug:`, { resourceId, meta, hasGetThumbnail: !!state.config.getThumbnail });
600
- if (meta && meta.file && meta.file instanceof File) {
601
- console.log(`🔧 Using local file branch`);
602
- // For local files, use FileReader to display preview
603
- if (meta.type && meta.type.startsWith('image/')) {
604
- const reader = new FileReader();
605
- reader.onload = (e) => {
606
- img.src = e.target.result;
607
- };
608
- reader.readAsDataURL(meta.file);
609
- container.appendChild(img);
610
- } else {
611
- // Non-image file
612
- container.innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">📁</div><div class="text-sm">' + fileName + '</div></div>';
664
+
665
+ // Add delete button for edit mode
666
+ if (!isReadonly) {
667
+ addDeleteButton(container, () => {
668
+ // Clear the file
669
+ state.resourceIndex.delete(resourceId);
670
+ // Update hidden input
671
+ const hiddenInput = container.parentElement.querySelector(
672
+ 'input[type="hidden"]',
673
+ );
674
+ if (hiddenInput) {
675
+ hiddenInput.value = "";
613
676
  }
614
-
615
- // Add delete button for edit mode
616
- if (!isReadonly) {
617
- addDeleteButton(container, () => {
618
- // Clear the file
619
- state.resourceIndex.delete(resourceId);
620
- // Update hidden input
621
- const hiddenInput = container.parentElement.querySelector('input[type="hidden"]');
622
- if (hiddenInput) {
623
- hiddenInput.value = '';
624
- }
625
- // Clear preview and show placeholder
626
- container.innerHTML = `
677
+ // Clear preview and show placeholder
678
+ container.innerHTML = `
627
679
  <div class="flex flex-col items-center justify-center h-full text-gray-400">
628
680
  <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
629
681
  <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
@@ -631,38 +683,45 @@ async function renderFilePreview(container, resourceId, fileName, fileType, isRe
631
683
  <div class="text-sm text-center">Нажмите или перетащите файл</div>
632
684
  </div>
633
685
  `;
634
- });
635
- }
636
- } else if (state.config.getThumbnail) {
637
- console.log(`🔧 Using getThumbnail branch for resourceId:`, resourceId);
638
- // Try to get thumbnail from config for uploaded files
639
- try {
640
- const thumbnailUrl = await state.config.getThumbnail(resourceId);
641
- console.log(`📸 getThumbnail called with result:`, { resourceId, thumbnailUrl });
642
- if (thumbnailUrl) {
643
- img.src = thumbnailUrl;
644
- container.appendChild(img);
645
- } else {
646
- // Fallback to file icon
647
- container.innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">🖼️</div><div class="text-sm">' + fileName + '</div></div>';
648
- }
649
- } catch (error) {
650
- console.warn('Thumbnail loading failed:', error);
651
- container.innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">📁</div><div class="text-sm">' + fileName + '</div></div>';
686
+ });
687
+ }
688
+ } else if (state.config.getThumbnail) {
689
+ // Try to get thumbnail from config for uploaded files
690
+ try {
691
+ const thumbnailUrl = await state.config.getThumbnail(resourceId);
692
+
693
+ if (thumbnailUrl) {
694
+ img.src = thumbnailUrl;
695
+ container.appendChild(img);
696
+ } else {
697
+ // Fallback to file icon
698
+ container.innerHTML =
699
+ '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">🖼️</div><div class="text-sm">' +
700
+ fileName +
701
+ "</div></div>";
702
+ }
703
+ } catch (error) {
704
+ console.warn("Thumbnail loading failed:", error);
705
+ container.innerHTML =
706
+ '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">📁</div><div class="text-sm">' +
707
+ fileName +
708
+ "</div></div>";
709
+ }
710
+
711
+ // Add delete button for edit mode
712
+ if (!isReadonly) {
713
+ addDeleteButton(container, () => {
714
+ // Clear the file
715
+ state.resourceIndex.delete(resourceId);
716
+ // Update hidden input
717
+ const hiddenInput = container.parentElement.querySelector(
718
+ 'input[type="hidden"]',
719
+ );
720
+ if (hiddenInput) {
721
+ hiddenInput.value = "";
652
722
  }
653
-
654
- // Add delete button for edit mode
655
- if (!isReadonly) {
656
- addDeleteButton(container, () => {
657
- // Clear the file
658
- state.resourceIndex.delete(resourceId);
659
- // Update hidden input
660
- const hiddenInput = container.parentElement.querySelector('input[type="hidden"]');
661
- if (hiddenInput) {
662
- hiddenInput.value = '';
663
- }
664
- // Clear preview and show placeholder
665
- container.innerHTML = `
723
+ // Clear preview and show placeholder
724
+ container.innerHTML = `
666
725
  <div class="flex flex-col items-center justify-center h-full text-gray-400">
667
726
  <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
668
727
  <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
@@ -670,630 +729,682 @@ async function renderFilePreview(container, resourceId, fileName, fileType, isRe
670
729
  <div class="text-sm text-center">Нажмите или перетащите файл</div>
671
730
  </div>
672
731
  `;
673
- });
674
- }
675
- } else {
676
- // No file and no getThumbnail config - fallback
677
- container.innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">🖼️</div><div class="text-sm">' + fileName + '</div></div>';
678
- }
679
-
680
- // Add click handler for download in readonly mode
681
- if (isReadonly && state.config.downloadFile) {
682
- container.style.cursor = 'pointer';
683
- container.onclick = () => {
684
- if (state.config.downloadFile) {
685
- state.config.downloadFile(resourceId, fileName);
686
- }
687
- };
732
+ });
688
733
  }
734
+ } else {
735
+ // No file and no getThumbnail config - fallback
736
+ container.innerHTML =
737
+ '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">🖼️</div><div class="text-sm">' +
738
+ fileName +
739
+ "</div></div>";
740
+ }
741
+
742
+ // Add click handler for download in readonly mode
743
+ if (isReadonly && state.config.downloadFile) {
744
+ container.style.cursor = "pointer";
745
+ container.onclick = () => {
746
+ if (state.config.downloadFile) {
747
+ state.config.downloadFile(resourceId, fileName);
748
+ }
749
+ };
750
+ }
689
751
  }
690
-
752
+
691
753
  function renderResourcePills(container, rids, onRemove) {
692
- clear(container);
693
-
694
- // Show initial placeholder only if this is the first render (no previous grid)
695
- // Check if container already has grid class to determine if this is initial render
696
- const isInitialRender = !container.classList.contains('grid');
697
-
698
- if ((!rids || rids.length === 0) && isInitialRender) {
699
- // Create grid container
700
- const gridContainer = document.createElement('div');
701
- gridContainer.className = 'grid grid-cols-4 gap-3 mb-3';
702
-
703
- // Create 4 placeholder slots
704
- for (let i = 0; i < 4; i++) {
705
- const slot = document.createElement('div');
706
- slot.className = 'aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors';
707
-
708
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
709
- svg.setAttribute('class', 'w-12 h-12 text-gray-400');
710
- svg.setAttribute('fill', 'currentColor');
711
- svg.setAttribute('viewBox', '0 0 24 24');
712
-
713
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
714
- path.setAttribute('d', 'M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z');
715
-
716
- svg.appendChild(path);
717
- slot.appendChild(svg);
718
-
719
- // Add click handler to each slot
720
- slot.onclick = () => {
721
- // Look for file input in the files wrapper (go up from list -> filesContainer -> filesWrapper)
722
- const filesWrapper = container.closest('.space-y-2');
723
- const fileInput = filesWrapper?.querySelector('input[type="file"]');
724
- if (fileInput) fileInput.click();
725
- };
726
-
727
- gridContainer.appendChild(slot);
728
- }
729
-
730
- // Create text container
731
- const textContainer = document.createElement('div');
732
- textContainer.className = 'text-center text-xs text-gray-600';
733
-
734
- const uploadLink = document.createElement('span');
735
- uploadLink.className = 'underline cursor-pointer';
736
- uploadLink.textContent = 'Загрузите';
737
- uploadLink.onclick = (e) => {
738
- e.stopPropagation();
739
- // Look for file input in the files wrapper (go up from list -> filesContainer -> filesWrapper)
740
- const filesWrapper = container.closest('.space-y-2');
741
- const fileInput = filesWrapper?.querySelector('input[type="file"]');
742
- if (fileInput) fileInput.click();
743
- };
744
-
745
- textContainer.appendChild(uploadLink);
746
- textContainer.appendChild(document.createTextNode(' или перетащите файлы'));
747
-
748
- // Clear and append
749
- container.appendChild(gridContainer);
750
- container.appendChild(textContainer);
751
- return;
754
+ clear(container);
755
+
756
+ // Show initial placeholder only if this is the first render (no previous grid)
757
+ // Check if container already has grid class to determine if this is initial render
758
+ const isInitialRender = !container.classList.contains("grid");
759
+
760
+ if ((!rids || rids.length === 0) && isInitialRender) {
761
+ // Create grid container
762
+ const gridContainer = document.createElement("div");
763
+ gridContainer.className = "grid grid-cols-4 gap-3 mb-3";
764
+
765
+ // Create 4 placeholder slots
766
+ for (let i = 0; i < 4; i++) {
767
+ const slot = document.createElement("div");
768
+ slot.className =
769
+ "aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors";
770
+
771
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
772
+ svg.setAttribute("class", "w-12 h-12 text-gray-400");
773
+ svg.setAttribute("fill", "currentColor");
774
+ svg.setAttribute("viewBox", "0 0 24 24");
775
+
776
+ const path = document.createElementNS(
777
+ "http://www.w3.org/2000/svg",
778
+ "path",
779
+ );
780
+ path.setAttribute(
781
+ "d",
782
+ "M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z",
783
+ );
784
+
785
+ svg.appendChild(path);
786
+ slot.appendChild(svg);
787
+
788
+ // Add click handler to each slot
789
+ slot.onclick = () => {
790
+ // Look for file input in the files wrapper (go up from list -> filesContainer -> filesWrapper)
791
+ const filesWrapper = container.closest(".space-y-2");
792
+ const fileInput = filesWrapper?.querySelector('input[type="file"]');
793
+ if (fileInput) fileInput.click();
794
+ };
795
+
796
+ gridContainer.appendChild(slot);
752
797
  }
753
-
754
- // Always show files grid if we have files OR if this was already a grid
755
- // This prevents shrinking when deleting the last file
756
- container.className = 'grid grid-cols-4 gap-3 mt-2';
757
-
758
- // Calculate how many slots we need (at least 4, then expand by rows of 4)
759
- const currentImagesCount = rids ? rids.length : 0;
760
- // Calculate rows needed: always show an extra slot for adding next file
761
- // 0-3 files → 1 row (4 slots), 4-7 files → 2 rows (8 slots), 8-11 files → 3 rows (12 slots)
762
- const rowsNeeded = Math.floor(currentImagesCount / 4) + 1;
763
- const slotsNeeded = rowsNeeded * 4;
764
-
765
-
766
- // Add all slots (filled and empty)
767
- for (let i = 0; i < slotsNeeded; i++) {
768
- const slot = document.createElement('div');
769
-
770
- if (rids && i < rids.length) {
771
- // Filled slot with image preview
772
- const rid = rids[i];
773
- const meta = state.resourceIndex.get(rid);
774
- slot.className = 'aspect-square bg-gray-100 rounded-lg overflow-hidden relative group border border-gray-300';
775
-
776
- // Add image or file content
777
- if (meta && meta.type?.startsWith('image/')) {
778
- if (meta.file && meta.file instanceof File) {
779
- // Use FileReader for local files
780
- const img = document.createElement('img');
781
- img.className = 'w-full h-full object-contain';
782
- img.alt = meta.name;
783
-
784
- const reader = new FileReader();
785
- reader.onload = (e) => {
786
- img.src = e.target.result;
787
- };
788
- reader.readAsDataURL(meta.file);
789
- slot.appendChild(img);
790
- } else if (state.config.getThumbnail) {
791
- // Use getThumbnail for uploaded files
792
- const img = document.createElement('img');
793
- img.className = 'w-full h-full object-contain';
794
- img.alt = meta.name;
795
-
796
- const url = state.config.getThumbnail(rid);
797
- if (url) {
798
- img.src = url;
799
- slot.appendChild(img);
800
- } else {
801
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
798
+
799
+ // Create text container
800
+ const textContainer = document.createElement("div");
801
+ textContainer.className = "text-center text-xs text-gray-600";
802
+
803
+ const uploadLink = document.createElement("span");
804
+ uploadLink.className = "underline cursor-pointer";
805
+ uploadLink.textContent = "Загрузите";
806
+ uploadLink.onclick = (e) => {
807
+ e.stopPropagation();
808
+ // Look for file input in the files wrapper (go up from list -> filesContainer -> filesWrapper)
809
+ const filesWrapper = container.closest(".space-y-2");
810
+ const fileInput = filesWrapper?.querySelector('input[type="file"]');
811
+ if (fileInput) fileInput.click();
812
+ };
813
+
814
+ textContainer.appendChild(uploadLink);
815
+ textContainer.appendChild(document.createTextNode(" или перетащите файлы"));
816
+
817
+ // Clear and append
818
+ container.appendChild(gridContainer);
819
+ container.appendChild(textContainer);
820
+ return;
821
+ }
822
+
823
+ // Always show files grid if we have files OR if this was already a grid
824
+ // This prevents shrinking when deleting the last file
825
+ container.className = "grid grid-cols-4 gap-3 mt-2";
826
+
827
+ // Calculate how many slots we need (at least 4, then expand by rows of 4)
828
+ const currentImagesCount = rids ? rids.length : 0;
829
+ // Calculate rows needed: always show an extra slot for adding next file
830
+ // 0-3 files → 1 row (4 slots), 4-7 files → 2 rows (8 slots), 8-11 files → 3 rows (12 slots)
831
+ const rowsNeeded = Math.floor(currentImagesCount / 4) + 1;
832
+ const slotsNeeded = rowsNeeded * 4;
833
+
834
+ // Add all slots (filled and empty)
835
+ for (let i = 0; i < slotsNeeded; i++) {
836
+ const slot = document.createElement("div");
837
+
838
+ if (rids && i < rids.length) {
839
+ // Filled slot with image preview
840
+ const rid = rids[i];
841
+ const meta = state.resourceIndex.get(rid);
842
+ slot.className =
843
+ "aspect-square bg-gray-100 rounded-lg overflow-hidden relative group border border-gray-300";
844
+
845
+ // Add image or file content
846
+ if (meta && meta.type?.startsWith("image/")) {
847
+ if (meta.file && meta.file instanceof File) {
848
+ // Use FileReader for local files
849
+ const img = document.createElement("img");
850
+ img.className = "w-full h-full object-contain";
851
+ img.alt = meta.name;
852
+
853
+ const reader = new FileReader();
854
+ reader.onload = (e) => {
855
+ img.src = e.target.result;
856
+ };
857
+ reader.readAsDataURL(meta.file);
858
+ slot.appendChild(img);
859
+ } else if (state.config.getThumbnail) {
860
+ // Use getThumbnail for uploaded files
861
+ const img = document.createElement("img");
862
+ img.className = "w-full h-full object-contain";
863
+ img.alt = meta.name;
864
+
865
+ const url = state.config.getThumbnail(rid);
866
+ if (url) {
867
+ img.src = url;
868
+ slot.appendChild(img);
869
+ } else {
870
+ slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
802
871
  <svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
803
872
  <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
804
873
  </svg>
805
874
  </div>`;
806
- }
807
- } else {
808
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
875
+ }
876
+ } else {
877
+ slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
809
878
  <svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
810
879
  <path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
811
880
  </svg>
812
881
  </div>`;
813
- }
814
- } else {
815
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
882
+ }
883
+ } else {
884
+ slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
816
885
  <div class="text-2xl mb-1">📁</div>
817
- <div class="text-xs">${meta?.name || 'File'}</div>
886
+ <div class="text-xs">${meta?.name || "File"}</div>
818
887
  </div>`;
819
- }
820
-
821
- // Add remove button overlay (similar to file field)
822
- if (onRemove) {
823
- const overlay = document.createElement('div');
824
- overlay.className = 'absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center';
825
-
826
- const removeBtn = document.createElement('button');
827
- removeBtn.className = 'bg-red-600 text-white px-2 py-1 rounded text-xs';
828
- removeBtn.textContent = 'Удалить';
829
- removeBtn.onclick = (e) => {
830
- e.stopPropagation();
831
- onRemove(rid);
832
- };
833
-
834
- overlay.appendChild(removeBtn);
835
- slot.appendChild(overlay);
836
- }
837
- } else {
838
- // Empty slot placeholder
839
- slot.className = 'aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors';
840
- slot.innerHTML = '<svg class="w-12 h-12 text-gray-400" fill="currentColor" viewBox="0 0 24 24"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>';
841
- slot.onclick = () => {
842
- // Look for file input in the files wrapper (go up from list -> filesContainer -> filesWrapper)
843
- const filesWrapper = container.closest('.space-y-2');
844
- const fileInput = filesWrapper?.querySelector('input[type="file"]');
845
- if (fileInput) fileInput.click();
846
- };
847
- }
848
-
849
- container.appendChild(slot);
888
+ }
889
+
890
+ // Add remove button overlay (similar to file field)
891
+ if (onRemove) {
892
+ const overlay = document.createElement("div");
893
+ overlay.className =
894
+ "absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center";
895
+
896
+ const removeBtn = document.createElement("button");
897
+ removeBtn.className = "bg-red-600 text-white px-2 py-1 rounded text-xs";
898
+ removeBtn.textContent = "Удалить";
899
+ removeBtn.onclick = (e) => {
900
+ e.stopPropagation();
901
+ onRemove(rid);
902
+ };
903
+
904
+ overlay.appendChild(removeBtn);
905
+ slot.appendChild(overlay);
906
+ }
907
+ } else {
908
+ // Empty slot placeholder
909
+ slot.className =
910
+ "aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors";
911
+ slot.innerHTML =
912
+ '<svg class="w-12 h-12 text-gray-400" fill="currentColor" viewBox="0 0 24 24"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>';
913
+ slot.onclick = () => {
914
+ // Look for file input in the files wrapper (go up from list -> filesContainer -> filesWrapper)
915
+ const filesWrapper = container.closest(".space-y-2");
916
+ const fileInput = filesWrapper?.querySelector('input[type="file"]');
917
+ if (fileInput) fileInput.click();
918
+ };
850
919
  }
920
+
921
+ container.appendChild(slot);
922
+ }
851
923
  }
852
924
 
853
925
  function formatFileSize(bytes) {
854
- if (bytes === 0) return '0 B';
855
- const k = 1024;
856
- const sizes = ['B', 'KB', 'MB', 'GB'];
857
- const i = Math.floor(Math.log(bytes) / Math.log(k));
858
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
926
+ if (bytes === 0) return "0 B";
927
+ const k = 1024;
928
+ const sizes = ["B", "KB", "MB", "GB"];
929
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
930
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
859
931
  }
860
932
 
861
933
  function generateResourceId() {
862
- return 'res_' + Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
934
+ return (
935
+ "res_" + Math.random().toString(36).substr(2, 9) + Date.now().toString(36)
936
+ );
863
937
  }
864
938
 
865
939
  async function handleFileSelect(file, container, fieldName) {
866
- let rid;
867
-
868
- // If uploadHandler is configured, use it to upload the file
869
- if (state.config.uploadFile) {
870
- try {
871
- rid = await state.config.uploadFile(file);
872
- if (typeof rid !== 'string') {
873
- throw new Error('Upload handler must return a string resource ID');
874
- }
875
- } catch (error) {
876
- throw new Error(`File upload failed: ${error.message}`);
877
- }
878
- } else {
879
- throw new Error('No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()');
880
- }
881
-
882
- state.resourceIndex.set(rid, {
883
- name: file.name,
884
- type: file.type,
885
- size: file.size,
886
- file: null // Files are always uploaded, never stored locally
887
- });
888
-
889
- // Create hidden input to store the resource ID
890
- let hiddenInput = container.parentElement.querySelector('input[type="hidden"]');
891
- if (!hiddenInput) {
892
- hiddenInput = document.createElement('input');
893
- hiddenInput.type = 'hidden';
894
- hiddenInput.name = fieldName;
895
- container.parentElement.appendChild(hiddenInput);
940
+ let rid;
941
+
942
+ // If uploadHandler is configured, use it to upload the file
943
+ if (state.config.uploadFile) {
944
+ try {
945
+ rid = await state.config.uploadFile(file);
946
+ if (typeof rid !== "string") {
947
+ throw new Error("Upload handler must return a string resource ID");
948
+ }
949
+ } catch (error) {
950
+ throw new Error(`File upload failed: ${error.message}`);
896
951
  }
897
- hiddenInput.value = rid;
898
-
899
- renderFilePreview(container, rid, file.name, file.type, false).catch(console.error);
952
+ } else {
953
+ throw new Error(
954
+ "No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()",
955
+ );
956
+ }
957
+
958
+ state.resourceIndex.set(rid, {
959
+ name: file.name,
960
+ type: file.type,
961
+ size: file.size,
962
+ file: null, // Files are always uploaded, never stored locally
963
+ });
964
+
965
+ // Create hidden input to store the resource ID
966
+ let hiddenInput = container.parentElement.querySelector(
967
+ 'input[type="hidden"]',
968
+ );
969
+ if (!hiddenInput) {
970
+ hiddenInput = document.createElement("input");
971
+ hiddenInput.type = "hidden";
972
+ hiddenInput.name = fieldName;
973
+ container.parentElement.appendChild(hiddenInput);
974
+ }
975
+ hiddenInput.value = rid;
976
+
977
+ renderFilePreview(container, rid, file.name, file.type, false).catch(
978
+ console.error,
979
+ );
900
980
  }
901
981
 
902
982
  function setupDragAndDrop(element, dropHandler) {
903
- element.addEventListener('dragover', (e) => {
904
- e.preventDefault();
905
- element.classList.add('border-blue-500', 'bg-blue-50');
906
- });
983
+ element.addEventListener("dragover", (e) => {
984
+ e.preventDefault();
985
+ element.classList.add("border-blue-500", "bg-blue-50");
986
+ });
907
987
 
908
- element.addEventListener('dragleave', (e) => {
909
- e.preventDefault();
910
- element.classList.remove('border-blue-500', 'bg-blue-50');
911
- });
988
+ element.addEventListener("dragleave", (e) => {
989
+ e.preventDefault();
990
+ element.classList.remove("border-blue-500", "bg-blue-50");
991
+ });
912
992
 
913
- element.addEventListener('drop', (e) => {
914
- e.preventDefault();
915
- element.classList.remove('border-blue-500', 'bg-blue-50');
916
- dropHandler(e.dataTransfer.files);
917
- });
993
+ element.addEventListener("drop", (e) => {
994
+ e.preventDefault();
995
+ element.classList.remove("border-blue-500", "bg-blue-50");
996
+ dropHandler(e.dataTransfer.files);
997
+ });
918
998
  }
919
999
 
920
1000
  function addDeleteButton(container, onDelete) {
921
- // Remove existing overlay if any
922
- const existingOverlay = container.querySelector('.delete-overlay');
923
- if (existingOverlay) {
924
- existingOverlay.remove();
925
- }
926
-
927
- // Create overlay with center delete button (like in files)
928
- const overlay = document.createElement('div');
929
- overlay.className = 'delete-overlay absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center';
930
-
931
- const deleteBtn = document.createElement('button');
932
- deleteBtn.className = 'bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors';
933
- deleteBtn.textContent = 'Удалить';
934
- deleteBtn.onclick = (e) => {
935
- e.stopPropagation();
936
- onDelete();
937
- };
938
-
939
- overlay.appendChild(deleteBtn);
940
- container.appendChild(overlay);
1001
+ // Remove existing overlay if any
1002
+ const existingOverlay = container.querySelector(".delete-overlay");
1003
+ if (existingOverlay) {
1004
+ existingOverlay.remove();
1005
+ }
1006
+
1007
+ // Create overlay with center delete button (like in files)
1008
+ const overlay = document.createElement("div");
1009
+ overlay.className =
1010
+ "delete-overlay absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center";
1011
+
1012
+ const deleteBtn = document.createElement("button");
1013
+ deleteBtn.className =
1014
+ "bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors";
1015
+ deleteBtn.textContent = "Удалить";
1016
+ deleteBtn.onclick = (e) => {
1017
+ e.stopPropagation();
1018
+ onDelete();
1019
+ };
1020
+
1021
+ overlay.appendChild(deleteBtn);
1022
+ container.appendChild(overlay);
941
1023
  }
942
1024
 
943
1025
  function showTooltip(tooltipId, button) {
944
- const tooltip = document.getElementById(tooltipId);
945
- const isCurrentlyVisible = !tooltip.classList.contains('hidden');
946
-
947
- // Hide all tooltips first
948
- document.querySelectorAll('[id^="tooltip-"]').forEach(t => {
949
- t.classList.add('hidden');
950
- });
951
-
952
- // If the tooltip was already visible, keep it hidden (toggle behavior)
953
- if (isCurrentlyVisible) {
954
- return;
955
- }
956
-
957
- // Position the tooltip intelligently
958
- const rect = button.getBoundingClientRect();
959
- const viewportWidth = window.innerWidth;
960
- const viewportHeight = window.innerHeight;
961
-
962
- // Ensure tooltip is appended to body for proper positioning
963
- if (tooltip && tooltip.parentElement !== document.body) {
964
- document.body.appendChild(tooltip);
965
- }
966
-
967
- // Show tooltip temporarily to measure its size
968
- tooltip.style.visibility = 'hidden';
969
- tooltip.style.position = 'fixed';
970
- tooltip.classList.remove('hidden');
971
- const tooltipRect = tooltip.getBoundingClientRect();
972
- tooltip.classList.add('hidden');
973
- tooltip.style.visibility = 'visible';
974
-
975
- let left = rect.left;
976
- let top = rect.bottom + 5;
977
-
978
- // Adjust horizontal position if tooltip would go off-screen
979
- if (left + tooltipRect.width > viewportWidth) {
980
- left = rect.right - tooltipRect.width;
981
- }
982
-
983
- // Adjust vertical position if tooltip would go off-screen
984
- if (top + tooltipRect.height > viewportHeight) {
985
- top = rect.top - tooltipRect.height - 5;
986
- }
987
-
988
- // Ensure tooltip doesn't go off the left edge
989
- if (left < 10) {
990
- left = 10;
991
- }
992
-
993
- // Ensure tooltip doesn't go off the top edge
994
- if (top < 10) {
995
- top = rect.bottom + 5;
996
- }
997
-
998
- tooltip.style.left = left + 'px';
999
- tooltip.style.top = top + 'px';
1000
-
1001
- // Show the tooltip
1002
- tooltip.classList.remove('hidden');
1003
-
1004
- // Hide after 25 seconds
1005
- setTimeout(() => {
1006
- tooltip.classList.add('hidden');
1007
- }, 25000);
1026
+ const tooltip = document.getElementById(tooltipId);
1027
+ const isCurrentlyVisible = !tooltip.classList.contains("hidden");
1028
+
1029
+ // Hide all tooltips first
1030
+ document.querySelectorAll('[id^="tooltip-"]').forEach((t) => {
1031
+ t.classList.add("hidden");
1032
+ });
1033
+
1034
+ // If the tooltip was already visible, keep it hidden (toggle behavior)
1035
+ if (isCurrentlyVisible) {
1036
+ return;
1037
+ }
1038
+
1039
+ // Position the tooltip intelligently
1040
+ const rect = button.getBoundingClientRect();
1041
+ const viewportWidth = window.innerWidth;
1042
+ const viewportHeight = window.innerHeight;
1043
+
1044
+ // Ensure tooltip is appended to body for proper positioning
1045
+ if (tooltip && tooltip.parentElement !== document.body) {
1046
+ document.body.appendChild(tooltip);
1047
+ }
1048
+
1049
+ // Show tooltip temporarily to measure its size
1050
+ tooltip.style.visibility = "hidden";
1051
+ tooltip.style.position = "fixed";
1052
+ tooltip.classList.remove("hidden");
1053
+ const tooltipRect = tooltip.getBoundingClientRect();
1054
+ tooltip.classList.add("hidden");
1055
+ tooltip.style.visibility = "visible";
1056
+
1057
+ let left = rect.left;
1058
+ let top = rect.bottom + 5;
1059
+
1060
+ // Adjust horizontal position if tooltip would go off-screen
1061
+ if (left + tooltipRect.width > viewportWidth) {
1062
+ left = rect.right - tooltipRect.width;
1063
+ }
1064
+
1065
+ // Adjust vertical position if tooltip would go off-screen
1066
+ if (top + tooltipRect.height > viewportHeight) {
1067
+ top = rect.top - tooltipRect.height - 5;
1068
+ }
1069
+
1070
+ // Ensure tooltip doesn't go off the left edge
1071
+ if (left < 10) {
1072
+ left = 10;
1073
+ }
1074
+
1075
+ // Ensure tooltip doesn't go off the top edge
1076
+ if (top < 10) {
1077
+ top = rect.bottom + 5;
1078
+ }
1079
+
1080
+ tooltip.style.left = left + "px";
1081
+ tooltip.style.top = top + "px";
1082
+
1083
+ // Show the tooltip
1084
+ tooltip.classList.remove("hidden");
1085
+
1086
+ // Hide after 25 seconds
1087
+ setTimeout(() => {
1088
+ tooltip.classList.add("hidden");
1089
+ }, 25000);
1008
1090
  }
1009
1091
 
1010
- // Close tooltips when clicking outside
1011
- document.addEventListener('click', function(e) {
1012
- const isInfoButton = e.target.closest('button') && e.target.closest('button').onclick;
1092
+ // Close tooltips when clicking outside (only in browser)
1093
+ if (typeof document !== "undefined") {
1094
+ document.addEventListener("click", function (e) {
1095
+ const isInfoButton =
1096
+ e.target.closest("button") && e.target.closest("button").onclick;
1013
1097
  const isTooltip = e.target.closest('[id^="tooltip-"]');
1014
-
1098
+
1015
1099
  if (!isInfoButton && !isTooltip) {
1016
- document.querySelectorAll('[id^="tooltip-"]').forEach(tooltip => {
1017
- tooltip.classList.add('hidden');
1018
- });
1100
+ document.querySelectorAll('[id^="tooltip-"]').forEach((tooltip) => {
1101
+ tooltip.classList.add("hidden");
1102
+ });
1019
1103
  }
1020
- });
1104
+ });
1105
+ }
1021
1106
 
1022
1107
  // Form validation and data extraction
1023
1108
  function validateForm(skipValidation = false) {
1024
- if (!state.schema || !state.formRoot) return { valid: true, data: {} };
1025
-
1026
- const errors = [];
1027
- const data = {};
1028
-
1029
- function markValidity(input, errorMessage) {
1030
- if (!input) return;
1031
- if (errorMessage) {
1032
- input.classList.add('invalid');
1033
- input.title = errorMessage;
1034
- } else {
1035
- input.classList.remove('invalid');
1036
- input.title = '';
1037
- }
1109
+ if (!state.schema || !state.formRoot) return { valid: true, data: {} };
1110
+
1111
+ const errors = [];
1112
+ const data = {};
1113
+
1114
+ function markValidity(input, errorMessage) {
1115
+ if (!input) return;
1116
+ if (errorMessage) {
1117
+ input.classList.add("invalid");
1118
+ input.title = errorMessage;
1119
+ } else {
1120
+ input.classList.remove("invalid");
1121
+ input.title = "";
1038
1122
  }
1039
-
1040
- function validateElement(element, ctx) {
1041
- const key = element.key;
1042
- const scopeRoot = state.formRoot;
1043
-
1044
- switch (element.type) {
1045
- case 'text':
1046
- case 'textarea': {
1047
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
1048
- const val = input?.value ?? '';
1049
- if (!skipValidation && element.required && val === '') {
1050
- errors.push(`${key}: required`);
1051
- markValidity(input, 'required');
1052
- return '';
1053
- }
1054
- if (!skipValidation && val) {
1055
- if (element.minLength != null && val.length < element.minLength) {
1056
- errors.push(`${key}: minLength=${element.minLength}`);
1057
- markValidity(input, `minLength=${element.minLength}`);
1058
- }
1059
- if (element.maxLength != null && val.length > element.maxLength) {
1060
- errors.push(`${key}: maxLength=${element.maxLength}`);
1061
- markValidity(input, `maxLength=${element.maxLength}`);
1062
- }
1063
- if (element.pattern) {
1064
- try {
1065
- const re = new RegExp(element.pattern);
1066
- if (!re.test(val)) {
1067
- errors.push(`${key}: pattern mismatch`);
1068
- markValidity(input, 'pattern mismatch');
1069
- }
1070
- } catch {
1071
- errors.push(`${key}: invalid pattern`);
1072
- markValidity(input, 'invalid pattern');
1073
- }
1074
- }
1075
- } else if (skipValidation) {
1076
- markValidity(input, null);
1077
- } else {
1078
- markValidity(input, null);
1079
- }
1080
- return val;
1081
- }
1082
- case 'number': {
1083
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
1084
- const raw = input?.value ?? '';
1085
- if (!skipValidation && element.required && raw === '') {
1086
- errors.push(`${key}: required`);
1087
- markValidity(input, 'required');
1088
- return null;
1089
- }
1090
- if (raw === '') {
1091
- markValidity(input, null);
1092
- return null;
1093
- }
1094
- const v = parseFloat(raw);
1095
- if (!skipValidation && !Number.isFinite(v)) {
1096
- errors.push(`${key}: not a number`);
1097
- markValidity(input, 'not a number');
1098
- return null;
1099
- }
1100
- if (!skipValidation && element.min != null && v < element.min) {
1101
- errors.push(`${key}: < min=${element.min}`);
1102
- markValidity(input, `< min=${element.min}`);
1103
- }
1104
- if (!skipValidation && element.max != null && v > element.max) {
1105
- errors.push(`${key}: > max=${element.max}`);
1106
- markValidity(input, `> max=${element.max}`);
1107
- }
1108
- const d = Number.isInteger(element.decimals ?? 0) ? element.decimals : 0;
1109
- markValidity(input, null);
1110
- return Number(v.toFixed(d));
1111
- }
1112
- case 'select': {
1113
- const input = scopeRoot.querySelector(`[name$="${key}"]`);
1114
- const val = input?.value ?? '';
1115
- if (!skipValidation && element.required && val === '') {
1116
- errors.push(`${key}: required`);
1117
- markValidity(input, 'required');
1118
- return '';
1119
- }
1120
- markValidity(input, null);
1121
- return val;
1122
- }
1123
- case 'file': {
1124
- const input = scopeRoot.querySelector(`input[name$="${key}"][type="hidden"]`);
1125
- const rid = input?.value ?? '';
1126
- if (!skipValidation && element.required && rid === '') {
1127
- errors.push(`${key}: required`);
1128
- return null;
1129
- }
1130
- return rid || null;
1131
- }
1132
- case 'files': {
1133
- // For files, we need to collect all resource IDs
1134
- const container = scopeRoot.querySelector(`[name$="${key}"]`)?.parentElement?.querySelector('.files-list');
1135
- const rids = [];
1136
- if (container) {
1137
- // Extract resource IDs from the current state
1138
- // This is a simplified approach - in practice you'd track this better
1139
- }
1140
- return rids;
1141
- }
1142
- case 'group': {
1143
- if (element.repeat && isPlainObject(element.repeat)) {
1144
- const items = [];
1145
- const itemElements = scopeRoot.querySelectorAll(`[name^="${key}["]`);
1146
- const itemCount = Math.max(0, Math.floor(itemElements.length / element.elements.length));
1147
-
1148
- for (let i = 0; i < itemCount; i++) {
1149
- const itemData = {};
1150
- element.elements.forEach(child => {
1151
- const childKey = `${key}[${i}].${child.key}`;
1152
- itemData[child.key] = validateElement({...child, key: childKey}, ctx);
1153
- });
1154
- items.push(itemData);
1155
- }
1156
- return items;
1157
- } else {
1158
- const groupData = {};
1159
- element.elements.forEach(child => {
1160
- const childKey = `${key}.${child.key}`;
1161
- groupData[child.key] = validateElement({...child, key: childKey}, ctx);
1162
- });
1163
- return groupData;
1164
- }
1123
+ }
1124
+
1125
+ function validateElement(element, ctx) {
1126
+ const key = element.key;
1127
+ const scopeRoot = state.formRoot;
1128
+
1129
+ switch (element.type) {
1130
+ case "text":
1131
+ case "textarea": {
1132
+ const input = scopeRoot.querySelector(`[name$="${key}"]`);
1133
+ const val = input?.value ?? "";
1134
+ if (!skipValidation && element.required && val === "") {
1135
+ errors.push(`${key}: required`);
1136
+ markValidity(input, "required");
1137
+ return "";
1138
+ }
1139
+ if (!skipValidation && val) {
1140
+ if (element.minLength != null && val.length < element.minLength) {
1141
+ errors.push(`${key}: minLength=${element.minLength}`);
1142
+ markValidity(input, `minLength=${element.minLength}`);
1143
+ }
1144
+ if (element.maxLength != null && val.length > element.maxLength) {
1145
+ errors.push(`${key}: maxLength=${element.maxLength}`);
1146
+ markValidity(input, `maxLength=${element.maxLength}`);
1147
+ }
1148
+ if (element.pattern) {
1149
+ try {
1150
+ const re = new RegExp(element.pattern);
1151
+ if (!re.test(val)) {
1152
+ errors.push(`${key}: pattern mismatch`);
1153
+ markValidity(input, "pattern mismatch");
1154
+ }
1155
+ } catch {
1156
+ errors.push(`${key}: invalid pattern`);
1157
+ markValidity(input, "invalid pattern");
1165
1158
  }
1166
- default:
1167
- return null;
1159
+ }
1160
+ } else if (skipValidation) {
1161
+ markValidity(input, null);
1162
+ } else {
1163
+ markValidity(input, null);
1164
+ }
1165
+ return val;
1166
+ }
1167
+ case "number": {
1168
+ const input = scopeRoot.querySelector(`[name$="${key}"]`);
1169
+ const raw = input?.value ?? "";
1170
+ if (!skipValidation && element.required && raw === "") {
1171
+ errors.push(`${key}: required`);
1172
+ markValidity(input, "required");
1173
+ return null;
1174
+ }
1175
+ if (raw === "") {
1176
+ markValidity(input, null);
1177
+ return null;
1178
+ }
1179
+ const v = parseFloat(raw);
1180
+ if (!skipValidation && !Number.isFinite(v)) {
1181
+ errors.push(`${key}: not a number`);
1182
+ markValidity(input, "not a number");
1183
+ return null;
1184
+ }
1185
+ if (!skipValidation && element.min != null && v < element.min) {
1186
+ errors.push(`${key}: < min=${element.min}`);
1187
+ markValidity(input, `< min=${element.min}`);
1188
+ }
1189
+ if (!skipValidation && element.max != null && v > element.max) {
1190
+ errors.push(`${key}: > max=${element.max}`);
1191
+ markValidity(input, `> max=${element.max}`);
1192
+ }
1193
+ const d = Number.isInteger(element.decimals ?? 0)
1194
+ ? element.decimals
1195
+ : 0;
1196
+ markValidity(input, null);
1197
+ return Number(v.toFixed(d));
1198
+ }
1199
+ case "select": {
1200
+ const input = scopeRoot.querySelector(`[name$="${key}"]`);
1201
+ const val = input?.value ?? "";
1202
+ if (!skipValidation && element.required && val === "") {
1203
+ errors.push(`${key}: required`);
1204
+ markValidity(input, "required");
1205
+ return "";
1206
+ }
1207
+ markValidity(input, null);
1208
+ return val;
1209
+ }
1210
+ case "file": {
1211
+ const input = scopeRoot.querySelector(
1212
+ `input[name$="${key}"][type="hidden"]`,
1213
+ );
1214
+ const rid = input?.value ?? "";
1215
+ if (!skipValidation && element.required && rid === "") {
1216
+ errors.push(`${key}: required`);
1217
+ return null;
1218
+ }
1219
+ return rid || null;
1220
+ }
1221
+ case "files": {
1222
+ // For files, we need to collect all resource IDs
1223
+ const container = scopeRoot
1224
+ .querySelector(`[name$="${key}"]`)
1225
+ ?.parentElement?.querySelector(".files-list");
1226
+ const rids = [];
1227
+ if (container) {
1228
+ // Extract resource IDs from the current state
1229
+ // This is a simplified approach - in practice you'd track this better
1230
+ }
1231
+ return rids;
1232
+ }
1233
+ case "group": {
1234
+ if (element.repeat && isPlainObject(element.repeat)) {
1235
+ const items = [];
1236
+ const itemElements = scopeRoot.querySelectorAll(`[name^="${key}["]`);
1237
+ const itemCount = Math.max(
1238
+ 0,
1239
+ Math.floor(itemElements.length / element.elements.length),
1240
+ );
1241
+
1242
+ for (let i = 0; i < itemCount; i++) {
1243
+ const itemData = {};
1244
+ element.elements.forEach((child) => {
1245
+ const childKey = `${key}[${i}].${child.key}`;
1246
+ itemData[child.key] = validateElement(
1247
+ { ...child, key: childKey },
1248
+ ctx,
1249
+ );
1250
+ });
1251
+ items.push(itemData);
1252
+ }
1253
+ return items;
1254
+ } else {
1255
+ const groupData = {};
1256
+ element.elements.forEach((child) => {
1257
+ const childKey = `${key}.${child.key}`;
1258
+ groupData[child.key] = validateElement(
1259
+ { ...child, key: childKey },
1260
+ ctx,
1261
+ );
1262
+ });
1263
+ return groupData;
1168
1264
  }
1265
+ }
1266
+ default:
1267
+ return null;
1169
1268
  }
1170
-
1171
- state.schema.elements.forEach(element => {
1172
- data[element.key] = validateElement(element, { path: '' });
1173
- });
1174
-
1175
- return {
1176
- valid: errors.length === 0,
1177
- errors,
1178
- data
1179
- };
1269
+ }
1270
+
1271
+ state.schema.elements.forEach((element) => {
1272
+ data[element.key] = validateElement(element, { path: "" });
1273
+ });
1274
+
1275
+ return {
1276
+ valid: errors.length === 0,
1277
+ errors,
1278
+ data,
1279
+ };
1180
1280
  }
1181
1281
 
1182
1282
  // Element rendering functions
1183
1283
  function renderTextElement(element, ctx, wrapper, pathKey) {
1184
- const textInput = document.createElement('input');
1185
- textInput.type = 'text';
1186
- textInput.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
1187
- textInput.name = pathKey;
1188
- textInput.placeholder = element.placeholder || 'Введите текст';
1189
- textInput.value = ctx.prefill[element.key] || element.default || '';
1190
- textInput.readOnly = state.config.readonly;
1191
- wrapper.appendChild(textInput);
1192
-
1193
- // Add hint
1194
- const textHint = document.createElement('p');
1195
- textHint.className = 'text-xs text-gray-500 mt-1';
1196
- textHint.textContent = makeFieldHint(element);
1197
- wrapper.appendChild(textHint);
1284
+ const textInput = document.createElement("input");
1285
+ textInput.type = "text";
1286
+ textInput.className =
1287
+ "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
1288
+ textInput.name = pathKey;
1289
+ textInput.placeholder = element.placeholder || "Введите текст";
1290
+ textInput.value = ctx.prefill[element.key] || element.default || "";
1291
+ textInput.readOnly = state.config.readonly;
1292
+ wrapper.appendChild(textInput);
1293
+
1294
+ // Add hint
1295
+ const textHint = document.createElement("p");
1296
+ textHint.className = "text-xs text-gray-500 mt-1";
1297
+ textHint.textContent = makeFieldHint(element);
1298
+ wrapper.appendChild(textHint);
1198
1299
  }
1199
1300
 
1200
1301
  function renderTextareaElement(element, ctx, wrapper, pathKey) {
1201
- const textareaInput = document.createElement('textarea');
1202
- textareaInput.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none';
1203
- textareaInput.name = pathKey;
1204
- textareaInput.placeholder = element.placeholder || 'Введите текст';
1205
- textareaInput.rows = element.rows || 4;
1206
- textareaInput.value = ctx.prefill[element.key] || element.default || '';
1207
- textareaInput.readOnly = state.config.readonly;
1208
- wrapper.appendChild(textareaInput);
1209
-
1210
- // Add hint
1211
- const textareaHint = document.createElement('p');
1212
- textareaHint.className = 'text-xs text-gray-500 mt-1';
1213
- textareaHint.textContent = makeFieldHint(element);
1214
- wrapper.appendChild(textareaHint);
1302
+ const textareaInput = document.createElement("textarea");
1303
+ textareaInput.className =
1304
+ "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none";
1305
+ textareaInput.name = pathKey;
1306
+ textareaInput.placeholder = element.placeholder || "Введите текст";
1307
+ textareaInput.rows = element.rows || 4;
1308
+ textareaInput.value = ctx.prefill[element.key] || element.default || "";
1309
+ textareaInput.readOnly = state.config.readonly;
1310
+ wrapper.appendChild(textareaInput);
1311
+
1312
+ // Add hint
1313
+ const textareaHint = document.createElement("p");
1314
+ textareaHint.className = "text-xs text-gray-500 mt-1";
1315
+ textareaHint.textContent = makeFieldHint(element);
1316
+ wrapper.appendChild(textareaHint);
1215
1317
  }
1216
1318
 
1217
1319
  function renderNumberElement(element, ctx, wrapper, pathKey) {
1218
- const numberInput = document.createElement('input');
1219
- numberInput.type = 'number';
1220
- numberInput.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
1221
- numberInput.name = pathKey;
1222
- numberInput.placeholder = element.placeholder || '0';
1223
- if (element.min !== undefined) numberInput.min = element.min;
1224
- if (element.max !== undefined) numberInput.max = element.max;
1225
- if (element.step !== undefined) numberInput.step = element.step;
1226
- numberInput.value = ctx.prefill[element.key] || element.default || '';
1227
- numberInput.readOnly = state.config.readonly;
1228
- wrapper.appendChild(numberInput);
1229
-
1230
- // Add hint
1231
- const numberHint = document.createElement('p');
1232
- numberHint.className = 'text-xs text-gray-500 mt-1';
1233
- numberHint.textContent = makeFieldHint(element);
1234
- wrapper.appendChild(numberHint);
1320
+ const numberInput = document.createElement("input");
1321
+ numberInput.type = "number";
1322
+ numberInput.className =
1323
+ "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
1324
+ numberInput.name = pathKey;
1325
+ numberInput.placeholder = element.placeholder || "0";
1326
+ if (element.min !== undefined) numberInput.min = element.min;
1327
+ if (element.max !== undefined) numberInput.max = element.max;
1328
+ if (element.step !== undefined) numberInput.step = element.step;
1329
+ numberInput.value = ctx.prefill[element.key] || element.default || "";
1330
+ numberInput.readOnly = state.config.readonly;
1331
+ wrapper.appendChild(numberInput);
1332
+
1333
+ // Add hint
1334
+ const numberHint = document.createElement("p");
1335
+ numberHint.className = "text-xs text-gray-500 mt-1";
1336
+ numberHint.textContent = makeFieldHint(element);
1337
+ wrapper.appendChild(numberHint);
1235
1338
  }
1236
1339
 
1237
1340
  function renderSelectElement(element, ctx, wrapper, pathKey) {
1238
- const selectInput = document.createElement('select');
1239
- selectInput.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
1240
- selectInput.name = pathKey;
1241
- selectInput.disabled = state.config.readonly;
1242
-
1243
- (element.options || []).forEach(option => {
1244
- const optionEl = document.createElement('option');
1245
- optionEl.value = option.value;
1246
- optionEl.textContent = option.label;
1247
- if ((ctx.prefill[element.key] || element.default) === option.value) {
1248
- optionEl.selected = true;
1249
- }
1250
- selectInput.appendChild(optionEl);
1251
- });
1252
-
1253
- wrapper.appendChild(selectInput);
1254
-
1255
- // Add hint
1256
- const selectHint = document.createElement('p');
1257
- selectHint.className = 'text-xs text-gray-500 mt-1';
1258
- selectHint.textContent = makeFieldHint(element);
1259
- wrapper.appendChild(selectHint);
1341
+ const selectInput = document.createElement("select");
1342
+ selectInput.className =
1343
+ "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
1344
+ selectInput.name = pathKey;
1345
+ selectInput.disabled = state.config.readonly;
1346
+
1347
+ (element.options || []).forEach((option) => {
1348
+ const optionEl = document.createElement("option");
1349
+ optionEl.value = option.value;
1350
+ optionEl.textContent = option.label;
1351
+ if ((ctx.prefill[element.key] || element.default) === option.value) {
1352
+ optionEl.selected = true;
1353
+ }
1354
+ selectInput.appendChild(optionEl);
1355
+ });
1356
+
1357
+ wrapper.appendChild(selectInput);
1358
+
1359
+ // Add hint
1360
+ const selectHint = document.createElement("p");
1361
+ selectHint.className = "text-xs text-gray-500 mt-1";
1362
+ selectHint.textContent = makeFieldHint(element);
1363
+ wrapper.appendChild(selectHint);
1260
1364
  }
1261
1365
 
1262
1366
  // Common file preview rendering function for readonly mode
1263
1367
  function renderFilePreviewReadonly(resourceId, fileName) {
1264
- const meta = state.resourceIndex.get(resourceId);
1265
- const actualFileName = fileName || meta?.name || 'file';
1266
-
1267
- // Individual file result container
1268
- const fileResult = document.createElement('div');
1269
- fileResult.className = 'space-y-3';
1270
-
1271
- // Large preview container
1272
- const previewContainer = document.createElement('div');
1273
- previewContainer.className = 'bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity';
1274
-
1275
- // Check file type and render appropriate preview
1276
- if (meta?.type?.startsWith('image/') || actualFileName.toLowerCase().match(/\.(jpg|jpeg|png|gif|webp)$/)) {
1277
- // Image preview
1278
- if (state.config.getThumbnail) {
1279
- const thumbnailUrl = state.config.getThumbnail(resourceId);
1280
- if (thumbnailUrl) {
1281
- previewContainer.innerHTML = `<img src="${thumbnailUrl}" alt="${actualFileName}" class="w-full h-auto">`;
1282
- } else {
1283
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🖼️</div><div class="text-sm">${actualFileName}</div></div></div>`;
1284
- }
1285
- } else {
1286
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🖼️</div><div class="text-sm">${actualFileName}</div></div></div>`;
1287
- }
1288
- } else if (meta?.type?.startsWith('video/') || actualFileName.toLowerCase().match(/\.(mp4|webm|avi|mov)$/)) {
1289
- // Video preview
1290
- if (state.config.getThumbnail) {
1291
- const thumbnailUrl = state.config.getThumbnail(resourceId);
1292
- if (thumbnailUrl) {
1293
- previewContainer.innerHTML = `
1368
+ const meta = state.resourceIndex.get(resourceId);
1369
+ const actualFileName = fileName || meta?.name || "file";
1370
+
1371
+ // Individual file result container
1372
+ const fileResult = document.createElement("div");
1373
+ fileResult.className = "space-y-3";
1374
+
1375
+ // Large preview container
1376
+ const previewContainer = document.createElement("div");
1377
+ previewContainer.className =
1378
+ "bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity";
1379
+
1380
+ // Check file type and render appropriate preview
1381
+ if (
1382
+ meta?.type?.startsWith("image/") ||
1383
+ actualFileName.toLowerCase().match(/\.(jpg|jpeg|png|gif|webp)$/)
1384
+ ) {
1385
+ // Image preview
1386
+ if (state.config.getThumbnail) {
1387
+ const thumbnailUrl = state.config.getThumbnail(resourceId);
1388
+ if (thumbnailUrl) {
1389
+ previewContainer.innerHTML = `<img src="${thumbnailUrl}" alt="${actualFileName}" class="w-full h-auto">`;
1390
+ } else {
1391
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🖼️</div><div class="text-sm">${actualFileName}</div></div></div>`;
1392
+ }
1393
+ } else {
1394
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🖼️</div><div class="text-sm">${actualFileName}</div></div></div>`;
1395
+ }
1396
+ } else if (
1397
+ meta?.type?.startsWith("video/") ||
1398
+ actualFileName.toLowerCase().match(/\.(mp4|webm|avi|mov)$/)
1399
+ ) {
1400
+ // Video preview
1401
+ if (state.config.getThumbnail) {
1402
+ const thumbnailUrl = state.config.getThumbnail(resourceId);
1403
+ if (thumbnailUrl) {
1404
+ previewContainer.innerHTML = `
1294
1405
  <div class="relative group">
1295
1406
  <video class="w-full h-auto" controls preload="auto" muted>
1296
- <source src="${thumbnailUrl}" type="${meta?.type || 'video/mp4'}">
1407
+ <source src="${thumbnailUrl}" type="${meta?.type || "video/mp4"}">
1297
1408
  Ваш браузер не поддерживает видео.
1298
1409
  </video>
1299
1410
  <div class="absolute inset-0 bg-black bg-opacity-20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
@@ -1305,186 +1416,218 @@ function renderFilePreviewReadonly(resourceId, fileName) {
1305
1416
  </div>
1306
1417
  </div>
1307
1418
  `;
1308
- } else {
1309
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🎥</div><div class="text-sm">${actualFileName}</div></div></div>`;
1310
- }
1311
- } else {
1312
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🎥</div><div class="text-sm">${actualFileName}</div></div></div>`;
1313
- }
1419
+ } else {
1420
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🎥</div><div class="text-sm">${actualFileName}</div></div></div>`;
1421
+ }
1314
1422
  } else {
1315
- // Other file types
1316
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">📁</div><div class="text-sm">${actualFileName}</div></div></div>`;
1423
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">🎥</div><div class="text-sm">${actualFileName}</div></div></div>`;
1317
1424
  }
1318
-
1319
- // File name
1320
- const fileNameElement = document.createElement('p');
1321
- fileNameElement.className = 'text-sm font-medium text-gray-900 text-center';
1322
- fileNameElement.textContent = actualFileName;
1323
-
1324
- // Download button
1325
- const downloadButton = document.createElement('button');
1326
- downloadButton.className = 'w-full px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors';
1327
- downloadButton.textContent = 'Скачать';
1328
- downloadButton.onclick = (e) => {
1329
- e.preventDefault();
1330
- e.stopPropagation();
1331
- if (state.config.downloadFile) {
1332
- state.config.downloadFile(resourceId, actualFileName);
1333
- } else {
1334
- forceDownload(resourceId, actualFileName);
1335
- }
1336
- };
1337
-
1338
- fileResult.appendChild(previewContainer);
1339
- fileResult.appendChild(fileNameElement);
1340
- fileResult.appendChild(downloadButton);
1341
-
1342
- return fileResult;
1425
+ } else {
1426
+ // Other file types
1427
+ previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">📁</div><div class="text-sm">${actualFileName}</div></div></div>`;
1428
+ }
1429
+
1430
+ // File name
1431
+ const fileNameElement = document.createElement("p");
1432
+ fileNameElement.className = "text-sm font-medium text-gray-900 text-center";
1433
+ fileNameElement.textContent = actualFileName;
1434
+
1435
+ // Download button
1436
+ const downloadButton = document.createElement("button");
1437
+ downloadButton.className =
1438
+ "w-full px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors";
1439
+ downloadButton.textContent = "Скачать";
1440
+ downloadButton.onclick = (e) => {
1441
+ e.preventDefault();
1442
+ e.stopPropagation();
1443
+ if (state.config.downloadFile) {
1444
+ state.config.downloadFile(resourceId, actualFileName);
1445
+ } else {
1446
+ forceDownload(resourceId, actualFileName);
1447
+ }
1448
+ };
1449
+
1450
+ fileResult.appendChild(previewContainer);
1451
+ fileResult.appendChild(fileNameElement);
1452
+ fileResult.appendChild(downloadButton);
1453
+
1454
+ return fileResult;
1343
1455
  }
1344
1456
 
1345
1457
  // TODO: Extract large file, files, and group rendering logic to separate functions:
1346
- // - renderFileElement(element, ctx, wrapper, pathKey)
1458
+ // - renderFileElement(element, ctx, wrapper, pathKey)
1347
1459
  // - renderFilesElement(element, ctx, wrapper, pathKey)
1348
1460
  // - renderGroupElement(element, ctx, wrapper, pathKey)
1349
1461
 
1350
1462
  // Force download helper function
1351
1463
  function forceDownload(resourceId, fileName) {
1352
- // Try to get URL from thumbnail handler first
1353
- let fileUrl = null;
1354
- if (state.config.getThumbnail) {
1355
- fileUrl = state.config.getThumbnail(resourceId);
1356
- }
1357
-
1358
- if (fileUrl) {
1359
- // Always try to fetch and create blob for true download behavior
1360
- // This prevents files from opening in browser
1361
- const finalUrl = fileUrl.startsWith('http') ? fileUrl : new URL(fileUrl, window.location.href).href;
1362
-
1363
- fetch(finalUrl)
1364
- .then(response => {
1365
- if (!response.ok) {
1366
- throw new Error(`HTTP error! status: ${response.status}`);
1367
- }
1368
- return response.blob();
1369
- })
1370
- .then(blob => {
1371
- downloadBlob(blob, fileName);
1372
- })
1373
- .catch(error => {
1374
- throw new Error(`File download failed: ${error.message}`);
1375
- });
1376
- } else {
1377
- console.warn('No download URL available for resource:', resourceId);
1378
- }
1464
+ // Try to get URL from thumbnail handler first
1465
+ let fileUrl = null;
1466
+ if (state.config.getThumbnail) {
1467
+ fileUrl = state.config.getThumbnail(resourceId);
1468
+ }
1469
+
1470
+ if (fileUrl) {
1471
+ // Always try to fetch and create blob for true download behavior
1472
+ // This prevents files from opening in browser
1473
+ const finalUrl = fileUrl.startsWith("http")
1474
+ ? fileUrl
1475
+ : new URL(fileUrl, window.location.href).href;
1476
+
1477
+ fetch(finalUrl)
1478
+ .then((response) => {
1479
+ if (!response.ok) {
1480
+ throw new Error(`HTTP error! status: ${response.status}`);
1481
+ }
1482
+ return response.blob();
1483
+ })
1484
+ .then((blob) => {
1485
+ downloadBlob(blob, fileName);
1486
+ })
1487
+ .catch((error) => {
1488
+ throw new Error(`File download failed: ${error.message}`);
1489
+ });
1490
+ } else {
1491
+ console.warn("No download URL available for resource:", resourceId);
1492
+ }
1379
1493
  }
1380
1494
 
1381
1495
  // Helper to download blob with proper cleanup
1382
1496
  function downloadBlob(blob, fileName) {
1383
- try {
1384
- const blobUrl = URL.createObjectURL(blob);
1385
- const link = document.createElement('a');
1386
- link.href = blobUrl;
1387
- link.download = fileName;
1388
- link.style.display = 'none';
1389
-
1390
- // Important: add to DOM, click, then remove
1391
- document.body.appendChild(link);
1392
- link.click();
1393
- document.body.removeChild(link);
1394
-
1395
- // Clean up blob URL after download
1396
- setTimeout(() => {
1397
- URL.revokeObjectURL(blobUrl);
1398
- }, 100);
1399
-
1400
- } catch (error) {
1401
- throw new Error(`Blob download failed: ${error.message}`);
1402
- }
1497
+ try {
1498
+ const blobUrl = URL.createObjectURL(blob);
1499
+ const link = document.createElement("a");
1500
+ link.href = blobUrl;
1501
+ link.download = fileName;
1502
+ link.style.display = "none";
1503
+
1504
+ // Important: add to DOM, click, then remove
1505
+ document.body.appendChild(link);
1506
+ link.click();
1507
+ document.body.removeChild(link);
1508
+
1509
+ // Clean up blob URL after download
1510
+ setTimeout(() => {
1511
+ URL.revokeObjectURL(blobUrl);
1512
+ }, 100);
1513
+ } catch (error) {
1514
+ throw new Error(`Blob download failed: ${error.message}`);
1515
+ }
1403
1516
  }
1404
1517
 
1405
1518
  // Public API
1406
1519
  function setFormRoot(element) {
1407
- state.formRoot = element;
1520
+ state.formRoot = element;
1408
1521
  }
1409
1522
 
1410
1523
  function configure(config) {
1411
- Object.assign(state.config, config);
1524
+ Object.assign(state.config, config);
1412
1525
  }
1413
1526
 
1414
1527
  function setUploadHandler(uploadFn) {
1415
- state.config.uploadFile = uploadFn;
1528
+ state.config.uploadFile = uploadFn;
1416
1529
  }
1417
1530
 
1418
1531
  function setDownloadHandler(downloadFn) {
1419
- state.config.downloadFile = downloadFn;
1532
+ state.config.downloadFile = downloadFn;
1420
1533
  }
1421
1534
 
1422
1535
  function setThumbnailHandler(thumbnailFn) {
1423
- state.config.getThumbnail = thumbnailFn;
1536
+ state.config.getThumbnail = thumbnailFn;
1424
1537
  }
1425
1538
 
1426
1539
  function setMode(mode) {
1427
- if (mode === 'readonly') {
1428
- state.config.readonly = true;
1429
- } else {
1430
- state.config.readonly = false;
1431
- }
1540
+ if (mode === "readonly") {
1541
+ state.config.readonly = true;
1542
+ } else {
1543
+ state.config.readonly = false;
1544
+ }
1432
1545
  }
1433
1546
 
1434
1547
  function getFormData() {
1435
- return validateForm(false);
1548
+ return validateForm(false);
1436
1549
  }
1437
1550
 
1438
1551
  function submitForm() {
1439
- const result = validateForm(false);
1440
- if (result.valid) {
1441
- // Emit event for successful submission
1442
- if (typeof window !== 'undefined' && window.parent) {
1443
- window.parent.postMessage({
1444
- type: 'formSubmit',
1445
- data: result.data,
1446
- schema: state.schema
1447
- }, '*');
1448
- }
1552
+ const result = validateForm(false);
1553
+ if (result.valid) {
1554
+ // Emit event for successful submission
1555
+ if (typeof window !== "undefined" && window.parent) {
1556
+ window.parent.postMessage(
1557
+ {
1558
+ type: "formSubmit",
1559
+ data: result.data,
1560
+ schema: state.schema,
1561
+ },
1562
+ "*",
1563
+ );
1449
1564
  }
1450
- return result;
1565
+ }
1566
+ return result;
1451
1567
  }
1452
1568
 
1453
1569
  function saveDraft() {
1454
- const result = validateForm(true); // Skip validation for drafts
1455
- // Emit event for draft save
1456
- if (typeof window !== 'undefined' && window.parent) {
1457
- window.parent.postMessage({
1458
- type: 'formDraft',
1459
- data: result.data,
1460
- schema: state.schema
1461
- }, '*');
1462
- }
1463
- return result;
1570
+ const result = validateForm(true); // Skip validation for drafts
1571
+ // Emit event for draft save
1572
+ if (typeof window !== "undefined" && window.parent) {
1573
+ window.parent.postMessage(
1574
+ {
1575
+ type: "formDraft",
1576
+ data: result.data,
1577
+ schema: state.schema,
1578
+ },
1579
+ "*",
1580
+ );
1581
+ }
1582
+ return result;
1464
1583
  }
1465
1584
 
1466
1585
  function clearForm() {
1467
- if (state.formRoot) {
1468
- clear(state.formRoot);
1469
- }
1586
+ if (state.formRoot) {
1587
+ clear(state.formRoot);
1588
+ }
1470
1589
  }
1471
1590
 
1472
- // Export the API
1473
- if (typeof window !== 'undefined') {
1474
- window.FormBuilder = {
1475
- setFormRoot,
1476
- renderForm,
1477
- configure,
1478
- setUploadHandler,
1479
- setDownloadHandler,
1480
- setThumbnailHandler,
1481
- setMode,
1482
- getFormData,
1483
- submitForm,
1484
- saveDraft,
1485
- clearForm,
1486
- validateSchema,
1487
- pretty,
1488
- state
1489
- };
1591
+ // Create the API object
1592
+ const formBuilderAPI = {
1593
+ setFormRoot,
1594
+ renderForm,
1595
+ configure,
1596
+ setUploadHandler,
1597
+ setDownloadHandler,
1598
+ setThumbnailHandler,
1599
+ setMode,
1600
+ getFormData,
1601
+ submitForm,
1602
+ saveDraft,
1603
+ clearForm,
1604
+ validateSchema,
1605
+ pretty,
1606
+ state,
1607
+ };
1608
+
1609
+ // Browser global export (existing behavior)
1610
+ if (typeof window !== "undefined") {
1611
+ window.FormBuilder = formBuilderAPI;
1490
1612
  }
1613
+
1614
+ // ES6 Module exports for modern bundlers
1615
+ export {
1616
+ setFormRoot,
1617
+ renderForm,
1618
+ configure,
1619
+ setUploadHandler,
1620
+ setDownloadHandler,
1621
+ setThumbnailHandler,
1622
+ setMode,
1623
+ getFormData,
1624
+ submitForm,
1625
+ saveDraft,
1626
+ clearForm,
1627
+ validateSchema,
1628
+ pretty,
1629
+ state,
1630
+ };
1631
+
1632
+ // Default export
1633
+ export default formBuilderAPI;