@dmitryvim/form-builder 0.2.25 → 0.2.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/esm/index.js CHANGED
@@ -6,7 +6,10 @@ function t(key, state, params) {
6
6
  let text = localeTranslations?.[key] || fallbackTranslations?.[key] || key;
7
7
  if (params) {
8
8
  for (const [paramKey, paramValue] of Object.entries(params)) {
9
- text = text.replace(new RegExp(`\\{${paramKey}\\}`, "g"), String(paramValue));
9
+ text = text.replace(
10
+ new RegExp(`\\{${paramKey}\\}`, "g"),
11
+ String(paramValue)
12
+ );
10
13
  }
11
14
  }
12
15
  return text;
@@ -249,6 +252,9 @@ function validateSchema(schema) {
249
252
  }
250
253
 
251
254
  // src/utils/helpers.ts
255
+ function isElementReadonly(element, state, ctx) {
256
+ return element.readonly === true || state.config.readonly === true || ctx?.inheritedReadonly === true;
257
+ }
252
258
  function isPlainObject(obj) {
253
259
  return obj && typeof obj === "object" && obj.constructor === Object;
254
260
  }
@@ -407,6 +413,7 @@ function createCharCounter(element, input, isTextarea = false) {
407
413
  }
408
414
  function renderTextElement(element, ctx, wrapper, pathKey) {
409
415
  const state = ctx.state;
416
+ const readonly = isElementReadonly(element, state, ctx);
410
417
  const inputWrapper = document.createElement("div");
411
418
  inputWrapper.style.cssText = "position: relative;";
412
419
  const textInput = document.createElement("input");
@@ -417,7 +424,7 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
417
424
  padding-right: 60px;
418
425
  border: var(--fb-border-width) solid var(--fb-border-color);
419
426
  border-radius: var(--fb-border-radius);
420
- background-color: ${state.config.readonly ? "var(--fb-background-readonly-color)" : "var(--fb-background-color)"};
427
+ background-color: ${readonly ? "var(--fb-background-readonly-color)" : "var(--fb-background-color)"};
421
428
  color: var(--fb-text-color);
422
429
  font-size: var(--fb-font-size);
423
430
  font-family: var(--fb-font-family);
@@ -428,8 +435,8 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
428
435
  textInput.name = pathKey;
429
436
  textInput.placeholder = element.placeholder || "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442";
430
437
  textInput.value = ctx.prefill[element.key] || element.default || "";
431
- textInput.readOnly = state.config.readonly;
432
- if (!state.config.readonly) {
438
+ textInput.readOnly = readonly;
439
+ if (!readonly) {
433
440
  textInput.addEventListener("focus", () => {
434
441
  textInput.style.borderColor = "var(--fb-border-focus-color)";
435
442
  textInput.style.outline = `var(--fb-focus-ring-width) solid var(--fb-focus-ring-color)`;
@@ -450,7 +457,7 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
450
457
  }
451
458
  });
452
459
  }
453
- if (!state.config.readonly && ctx.instance) {
460
+ if (!readonly && ctx.instance) {
454
461
  const handleChange = () => {
455
462
  const value = textInput.value === "" ? null : textInput.value;
456
463
  ctx.instance.triggerOnChange(pathKey, value);
@@ -459,7 +466,7 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
459
466
  textInput.addEventListener("input", handleChange);
460
467
  }
461
468
  inputWrapper.appendChild(textInput);
462
- if (!state.config.readonly && (element.minLength != null || element.maxLength != null)) {
469
+ if (!readonly && (element.minLength != null || element.maxLength != null)) {
463
470
  const counter = createCharCounter(element, textInput, false);
464
471
  inputWrapper.appendChild(counter);
465
472
  }
@@ -467,6 +474,7 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
467
474
  }
468
475
  function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
469
476
  const state = ctx.state;
477
+ const readonly = isElementReadonly(element, state, ctx);
470
478
  const prefillValues = ctx.prefill[element.key] || [];
471
479
  const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
472
480
  const minCount = element.minCount ?? 1;
@@ -498,7 +506,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
498
506
  padding-right: 60px;
499
507
  border: var(--fb-border-width) solid var(--fb-border-color);
500
508
  border-radius: var(--fb-border-radius);
501
- background-color: ${state.config.readonly ? "var(--fb-background-readonly-color)" : "var(--fb-background-color)"};
509
+ background-color: ${readonly ? "var(--fb-background-readonly-color)" : "var(--fb-background-color)"};
502
510
  color: var(--fb-text-color);
503
511
  font-size: var(--fb-font-size);
504
512
  font-family: var(--fb-font-family);
@@ -508,8 +516,8 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
508
516
  `;
509
517
  textInput.placeholder = element.placeholder || t("placeholderText", state);
510
518
  textInput.value = value;
511
- textInput.readOnly = state.config.readonly;
512
- if (!state.config.readonly) {
519
+ textInput.readOnly = readonly;
520
+ if (!readonly) {
513
521
  textInput.addEventListener("focus", () => {
514
522
  textInput.style.borderColor = "var(--fb-border-focus-color)";
515
523
  textInput.style.outline = `var(--fb-focus-ring-width) solid var(--fb-focus-ring-color)`;
@@ -530,7 +538,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
530
538
  }
531
539
  });
532
540
  }
533
- if (!state.config.readonly && ctx.instance) {
541
+ if (!readonly && ctx.instance) {
534
542
  const handleChange = () => {
535
543
  const value2 = textInput.value === "" ? null : textInput.value;
536
544
  ctx.instance.triggerOnChange(textInput.name, value2);
@@ -539,7 +547,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
539
547
  textInput.addEventListener("input", handleChange);
540
548
  }
541
549
  inputContainer.appendChild(textInput);
542
- if (!state.config.readonly && (element.minLength != null || element.maxLength != null)) {
550
+ if (!readonly && (element.minLength != null || element.maxLength != null)) {
543
551
  const counter = createCharCounter(element, textInput, false);
544
552
  inputContainer.appendChild(counter);
545
553
  }
@@ -553,7 +561,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
553
561
  return itemWrapper;
554
562
  }
555
563
  function updateRemoveButtons() {
556
- if (state.config.readonly) return;
564
+ if (readonly) return;
557
565
  const items = container.querySelectorAll(".multiple-text-item");
558
566
  const currentCount = items.length;
559
567
  items.forEach((item) => {
@@ -598,7 +606,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
598
606
  }
599
607
  let addRow = null;
600
608
  let countDisplay = null;
601
- if (!state.config.readonly) {
609
+ if (!readonly) {
602
610
  addRow = document.createElement("div");
603
611
  addRow.className = "flex items-center gap-3 mt-2";
604
612
  const addBtn = document.createElement("button");
@@ -806,6 +814,7 @@ function applyAutoExpand(textarea) {
806
814
  }
807
815
  function renderTextareaElement(element, ctx, wrapper, pathKey) {
808
816
  const state = ctx.state;
817
+ const readonly = isElementReadonly(element, state, ctx);
809
818
  const textareaWrapper = document.createElement("div");
810
819
  textareaWrapper.style.cssText = "position: relative;";
811
820
  const textareaInput = document.createElement("textarea");
@@ -815,8 +824,8 @@ function renderTextareaElement(element, ctx, wrapper, pathKey) {
815
824
  textareaInput.placeholder = element.placeholder || "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442";
816
825
  textareaInput.rows = element.rows || 4;
817
826
  textareaInput.value = ctx.prefill[element.key] || element.default || "";
818
- textareaInput.readOnly = state.config.readonly;
819
- if (!state.config.readonly && ctx.instance) {
827
+ textareaInput.readOnly = readonly;
828
+ if (!readonly && ctx.instance) {
820
829
  const handleChange = () => {
821
830
  const value = textareaInput.value === "" ? null : textareaInput.value;
822
831
  ctx.instance.triggerOnChange(pathKey, value);
@@ -824,11 +833,11 @@ function renderTextareaElement(element, ctx, wrapper, pathKey) {
824
833
  textareaInput.addEventListener("blur", handleChange);
825
834
  textareaInput.addEventListener("input", handleChange);
826
835
  }
827
- if (element.autoExpand || state.config.readonly) {
836
+ if (element.autoExpand || readonly) {
828
837
  applyAutoExpand(textareaInput);
829
838
  }
830
839
  textareaWrapper.appendChild(textareaInput);
831
- if (!state.config.readonly && (element.minLength != null || element.maxLength != null)) {
840
+ if (!readonly && (element.minLength != null || element.maxLength != null)) {
832
841
  const counter = createCharCounter(element, textareaInput, true);
833
842
  textareaWrapper.appendChild(counter);
834
843
  }
@@ -836,6 +845,7 @@ function renderTextareaElement(element, ctx, wrapper, pathKey) {
836
845
  }
837
846
  function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
838
847
  const state = ctx.state;
848
+ const readonly = isElementReadonly(element, state, ctx);
839
849
  const prefillValues = ctx.prefill[element.key] || [];
840
850
  const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
841
851
  const minCount = element.minCount ?? 1;
@@ -866,8 +876,8 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
866
876
  textareaInput.placeholder = element.placeholder || t("placeholderText", state);
867
877
  textareaInput.rows = element.rows || 4;
868
878
  textareaInput.value = value;
869
- textareaInput.readOnly = state.config.readonly;
870
- if (!state.config.readonly && ctx.instance) {
879
+ textareaInput.readOnly = readonly;
880
+ if (!readonly && ctx.instance) {
871
881
  const handleChange = () => {
872
882
  const value2 = textareaInput.value === "" ? null : textareaInput.value;
873
883
  ctx.instance.triggerOnChange(textareaInput.name, value2);
@@ -875,11 +885,11 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
875
885
  textareaInput.addEventListener("blur", handleChange);
876
886
  textareaInput.addEventListener("input", handleChange);
877
887
  }
878
- if (element.autoExpand || state.config.readonly) {
888
+ if (element.autoExpand || readonly) {
879
889
  applyAutoExpand(textareaInput);
880
890
  }
881
891
  textareaContainer.appendChild(textareaInput);
882
- if (!state.config.readonly && (element.minLength != null || element.maxLength != null)) {
892
+ if (!readonly && (element.minLength != null || element.maxLength != null)) {
883
893
  const counter = createCharCounter(element, textareaInput, true);
884
894
  textareaContainer.appendChild(counter);
885
895
  }
@@ -893,7 +903,7 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
893
903
  return itemWrapper;
894
904
  }
895
905
  function updateRemoveButtons() {
896
- if (state.config.readonly) return;
906
+ if (readonly) return;
897
907
  const items = container.querySelectorAll(".multiple-textarea-item");
898
908
  const currentCount = items.length;
899
909
  items.forEach((item) => {
@@ -927,7 +937,7 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
927
937
  }
928
938
  let addRow = null;
929
939
  let countDisplay = null;
930
- if (!state.config.readonly) {
940
+ if (!readonly) {
931
941
  addRow = document.createElement("div");
932
942
  addRow.className = "flex items-center gap-3 mt-2";
933
943
  const addBtn = document.createElement("button");
@@ -961,7 +971,9 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
961
971
  }
962
972
  function updateAddButton() {
963
973
  if (!addRow || !countDisplay) return;
964
- const addBtn = addRow.querySelector(".add-textarea-btn");
974
+ const addBtn = addRow.querySelector(
975
+ ".add-textarea-btn"
976
+ );
965
977
  if (addBtn) {
966
978
  const disabled = values.length >= maxCount;
967
979
  addBtn.disabled = disabled;
@@ -980,7 +992,7 @@ function validateTextareaElement(element, key, context) {
980
992
  function updateTextareaField(element, fieldPath, value, context) {
981
993
  updateTextField(element, fieldPath, value, context);
982
994
  const { scopeRoot, state } = context;
983
- const shouldAutoExpand = element.autoExpand || state.config.readonly;
995
+ const shouldAutoExpand = element.autoExpand || isElementReadonly(element, state);
984
996
  if (!shouldAutoExpand) return;
985
997
  if (element.multiple) {
986
998
  const textareas = scopeRoot.querySelectorAll(
@@ -1041,6 +1053,7 @@ function createNumberRangeHint(element, input) {
1041
1053
  }
1042
1054
  function renderNumberElement(element, ctx, wrapper, pathKey) {
1043
1055
  const state = ctx.state;
1056
+ const readonly = isElementReadonly(element, state, ctx);
1044
1057
  const inputWrapper = document.createElement("div");
1045
1058
  inputWrapper.style.cssText = "position: relative;";
1046
1059
  const numberInput = document.createElement("input");
@@ -1053,8 +1066,8 @@ function renderNumberElement(element, ctx, wrapper, pathKey) {
1053
1066
  if (element.max !== void 0) numberInput.max = element.max.toString();
1054
1067
  if (element.step !== void 0) numberInput.step = element.step.toString();
1055
1068
  numberInput.value = ctx.prefill[element.key] || element.default || "";
1056
- numberInput.readOnly = state.config.readonly;
1057
- if (!state.config.readonly && ctx.instance) {
1069
+ numberInput.readOnly = readonly;
1070
+ if (!readonly && ctx.instance) {
1058
1071
  const handleChange = () => {
1059
1072
  const value = numberInput.value ? parseFloat(numberInput.value) : null;
1060
1073
  ctx.instance.triggerOnChange(pathKey, value);
@@ -1063,7 +1076,7 @@ function renderNumberElement(element, ctx, wrapper, pathKey) {
1063
1076
  numberInput.addEventListener("input", handleChange);
1064
1077
  }
1065
1078
  inputWrapper.appendChild(numberInput);
1066
- if (!state.config.readonly && (element.min != null || element.max != null)) {
1079
+ if (!readonly && (element.min != null || element.max != null)) {
1067
1080
  const counter = createNumberRangeHint(element, numberInput);
1068
1081
  inputWrapper.appendChild(counter);
1069
1082
  }
@@ -1071,6 +1084,7 @@ function renderNumberElement(element, ctx, wrapper, pathKey) {
1071
1084
  }
1072
1085
  function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1073
1086
  const state = ctx.state;
1087
+ const readonly = isElementReadonly(element, state, ctx);
1074
1088
  const prefillValues = ctx.prefill[element.key] || [];
1075
1089
  const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
1076
1090
  const minCount = element.minCount ?? 1;
@@ -1104,8 +1118,8 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1104
1118
  if (element.max !== void 0) numberInput.max = element.max.toString();
1105
1119
  if (element.step !== void 0) numberInput.step = element.step.toString();
1106
1120
  numberInput.value = value.toString();
1107
- numberInput.readOnly = state.config.readonly;
1108
- if (!state.config.readonly && ctx.instance) {
1121
+ numberInput.readOnly = readonly;
1122
+ if (!readonly && ctx.instance) {
1109
1123
  const handleChange = () => {
1110
1124
  const val = numberInput.value ? parseFloat(numberInput.value) : null;
1111
1125
  ctx.instance.triggerOnChange(numberInput.name, val);
@@ -1114,7 +1128,7 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1114
1128
  numberInput.addEventListener("input", handleChange);
1115
1129
  }
1116
1130
  inputContainer.appendChild(numberInput);
1117
- if (!state.config.readonly && (element.min != null || element.max != null)) {
1131
+ if (!readonly && (element.min != null || element.max != null)) {
1118
1132
  const counter = createNumberRangeHint(element, numberInput);
1119
1133
  inputContainer.appendChild(counter);
1120
1134
  }
@@ -1128,7 +1142,7 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1128
1142
  return itemWrapper;
1129
1143
  }
1130
1144
  function updateRemoveButtons() {
1131
- if (state.config.readonly) return;
1145
+ if (readonly) return;
1132
1146
  const items = container.querySelectorAll(".multiple-number-item");
1133
1147
  const currentCount = items.length;
1134
1148
  items.forEach((item) => {
@@ -1162,7 +1176,7 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1162
1176
  }
1163
1177
  let addRow = null;
1164
1178
  let countDisplay = null;
1165
- if (!state.config.readonly) {
1179
+ if (!readonly) {
1166
1180
  addRow = document.createElement("div");
1167
1181
  addRow.className = "flex items-center gap-3 mt-2";
1168
1182
  const addBtn = document.createElement("button");
@@ -1367,10 +1381,11 @@ function updateNumberField(element, fieldPath, value, context) {
1367
1381
  // src/components/select.ts
1368
1382
  function renderSelectElement(element, ctx, wrapper, pathKey) {
1369
1383
  const state = ctx.state;
1384
+ const readonly = isElementReadonly(element, state, ctx);
1370
1385
  const selectInput = document.createElement("select");
1371
1386
  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";
1372
1387
  selectInput.name = pathKey;
1373
- selectInput.disabled = state.config.readonly;
1388
+ selectInput.disabled = readonly;
1374
1389
  (element.options || []).forEach((option) => {
1375
1390
  const optionEl = document.createElement("option");
1376
1391
  optionEl.value = option.value;
@@ -1380,14 +1395,14 @@ function renderSelectElement(element, ctx, wrapper, pathKey) {
1380
1395
  }
1381
1396
  selectInput.appendChild(optionEl);
1382
1397
  });
1383
- if (!state.config.readonly && ctx.instance) {
1398
+ if (!readonly && ctx.instance) {
1384
1399
  const handleChange = () => {
1385
1400
  ctx.instance.triggerOnChange(pathKey, selectInput.value);
1386
1401
  };
1387
1402
  selectInput.addEventListener("change", handleChange);
1388
1403
  }
1389
1404
  wrapper.appendChild(selectInput);
1390
- if (!state.config.readonly) {
1405
+ if (!readonly) {
1391
1406
  const selectHint = document.createElement("p");
1392
1407
  selectHint.className = "text-xs text-gray-500 mt-1";
1393
1408
  selectHint.textContent = makeFieldHint(element, state);
@@ -1396,6 +1411,7 @@ function renderSelectElement(element, ctx, wrapper, pathKey) {
1396
1411
  }
1397
1412
  function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1398
1413
  const state = ctx.state;
1414
+ const readonly = isElementReadonly(element, state, ctx);
1399
1415
  const prefillValues = ctx.prefill[element.key] || [];
1400
1416
  const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
1401
1417
  const minCount = element.minCount ?? 1;
@@ -1420,7 +1436,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1420
1436
  itemWrapper.className = "multiple-select-item flex items-center gap-2";
1421
1437
  const selectInput = document.createElement("select");
1422
1438
  selectInput.className = "flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
1423
- selectInput.disabled = state.config.readonly;
1439
+ selectInput.disabled = readonly;
1424
1440
  (element.options || []).forEach((option) => {
1425
1441
  const optionElement = document.createElement("option");
1426
1442
  optionElement.value = option.value;
@@ -1430,7 +1446,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1430
1446
  }
1431
1447
  selectInput.appendChild(optionElement);
1432
1448
  });
1433
- if (!state.config.readonly && ctx.instance) {
1449
+ if (!readonly && ctx.instance) {
1434
1450
  const handleChange = () => {
1435
1451
  ctx.instance.triggerOnChange(selectInput.name, selectInput.value);
1436
1452
  };
@@ -1446,7 +1462,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1446
1462
  return itemWrapper;
1447
1463
  }
1448
1464
  function updateRemoveButtons() {
1449
- if (state.config.readonly) return;
1465
+ if (readonly) return;
1450
1466
  const items = container.querySelectorAll(".multiple-select-item");
1451
1467
  const currentCount = items.length;
1452
1468
  items.forEach((item) => {
@@ -1478,7 +1494,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1478
1494
  }
1479
1495
  let addRow = null;
1480
1496
  let countDisplay = null;
1481
- if (!state.config.readonly) {
1497
+ if (!readonly) {
1482
1498
  addRow = document.createElement("div");
1483
1499
  addRow.className = "flex items-center gap-3 mt-2";
1484
1500
  const addBtn = document.createElement("button");
@@ -1525,7 +1541,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1525
1541
  values.forEach((value) => addSelectItem(value));
1526
1542
  updateAddButton();
1527
1543
  updateRemoveButtons();
1528
- if (!state.config.readonly) {
1544
+ if (!readonly) {
1529
1545
  const hint = document.createElement("p");
1530
1546
  hint.className = "text-xs text-gray-500 mt-1";
1531
1547
  hint.textContent = makeFieldHint(element, state);
@@ -1741,12 +1757,14 @@ function buildSegmentedGroup(element, currentValue, hiddenInput, readonly, onCha
1741
1757
  }
1742
1758
  function renderSwitcherElement(element, ctx, wrapper, pathKey) {
1743
1759
  const state = ctx.state;
1744
- const initialValue = String(ctx.prefill[element.key] ?? element.default ?? "");
1760
+ const initialValue = String(
1761
+ ctx.prefill[element.key] ?? element.default ?? ""
1762
+ );
1745
1763
  const hiddenInput = document.createElement("input");
1746
1764
  hiddenInput.type = "hidden";
1747
1765
  hiddenInput.name = pathKey;
1748
1766
  hiddenInput.value = initialValue;
1749
- const readonly = state.config.readonly;
1767
+ const readonly = isElementReadonly(element, state, ctx);
1750
1768
  const onChange = !readonly && ctx.instance ? (value) => {
1751
1769
  ctx.instance.triggerOnChange(pathKey, value);
1752
1770
  } : null;
@@ -1775,7 +1793,7 @@ function renderMultipleSwitcherElement(element, ctx, wrapper, pathKey) {
1775
1793
  while (values.length < minCount) {
1776
1794
  values.push(element.default || element.options?.[0]?.value || "");
1777
1795
  }
1778
- const readonly = state.config.readonly;
1796
+ const readonly = isElementReadonly(element, state, ctx);
1779
1797
  const container = document.createElement("div");
1780
1798
  container.className = "space-y-2";
1781
1799
  wrapper.appendChild(container);
@@ -2070,7 +2088,7 @@ function updateSwitcherField(element, fieldPath, value, context) {
2070
2088
  }
2071
2089
  }
2072
2090
 
2073
- // src/components/file.ts
2091
+ // src/components/file/constraints.ts
2074
2092
  function getAllowedExtensions(accept) {
2075
2093
  if (!accept) return [];
2076
2094
  if (typeof accept === "object" && Array.isArray(accept.extensions)) {
@@ -2083,16 +2101,662 @@ function getAllowedExtensions(accept) {
2083
2101
  }
2084
2102
  function isFileExtensionAllowed(fileName, allowedExtensions) {
2085
2103
  if (allowedExtensions.length === 0) return true;
2086
- const ext = fileName.split(".").pop()?.toLowerCase() || "";
2104
+ const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
2087
2105
  return allowedExtensions.includes(ext);
2088
2106
  }
2089
2107
  function isFileSizeAllowed(file, maxSizeMB) {
2090
2108
  if (maxSizeMB === Infinity) return true;
2091
2109
  return file.size <= maxSizeMB * 1024 * 1024;
2092
2110
  }
2111
+ function addPrefillFilesToIndex(initialFiles, resourceIndex) {
2112
+ for (const resourceId of initialFiles) {
2113
+ if (resourceIndex.has(resourceId)) continue;
2114
+ const filename = resourceId.split("/").pop() || "file";
2115
+ const extension = filename.split(".").pop()?.toLowerCase();
2116
+ let fileType = "application/octet-stream";
2117
+ if (extension) {
2118
+ if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
2119
+ fileType = `image/${extension === "jpg" ? "jpeg" : extension}`;
2120
+ } else if (["mp4", "webm", "mov", "avi"].includes(extension)) {
2121
+ fileType = `video/${extension === "mov" ? "quicktime" : extension}`;
2122
+ }
2123
+ }
2124
+ resourceIndex.set(resourceId, {
2125
+ name: filename,
2126
+ type: fileType,
2127
+ size: 0,
2128
+ uploadedAt: /* @__PURE__ */ new Date(),
2129
+ file: void 0
2130
+ });
2131
+ }
2132
+ }
2133
+
2134
+ // src/components/file/styles.ts
2135
+ var STYLE_ID = "fb-file-styles";
2136
+ function ensureFileStyles() {
2137
+ if (typeof document === "undefined") return;
2138
+ if (document.getElementById(STYLE_ID)) return;
2139
+ const style = document.createElement("style");
2140
+ style.id = STYLE_ID;
2141
+ style.setAttribute("data-fb-file-styles", "true");
2142
+ style.textContent = `
2143
+ @keyframes fb-spin { to { transform: rotate(360deg); } }
2144
+
2145
+ /* Spinner used during single-file and multi-file upload */
2146
+ .fb-spinner {
2147
+ width: 36px;
2148
+ height: 36px;
2149
+ border: 3px solid rgba(0,0,0,0.12);
2150
+ border-top-color: var(--fb-text-secondary-color, #6b7280);
2151
+ border-radius: 50%;
2152
+ animation: fb-spin 0.7s linear infinite;
2153
+ flex-shrink: 0;
2154
+ }
2155
+
2156
+ /* Base tile: fixed 160\xD7160 square, theme-aware background */
2157
+ .fb-tile {
2158
+ width: var(--fb-tile-size, 160px);
2159
+ height: var(--fb-tile-size, 160px);
2160
+ flex-shrink: 0;
2161
+ position: relative;
2162
+ overflow: hidden;
2163
+ border-radius: var(--fb-border-radius, 0.5rem);
2164
+ background: var(--fb-file-upload-bg-color, #f3f4f6);
2165
+ }
2166
+
2167
+ /* Uploaded resource tile \u2014 adds a visible border */
2168
+ .fb-tile-resource {
2169
+ border: 1px solid var(--fb-file-upload-border-color, #d1d5db);
2170
+ }
2171
+
2172
+ /* Uploading placeholder tile \u2014 dashed border, uploading indicator */
2173
+ .fb-tile-uploading {
2174
+ border: 2px dashed var(--fb-file-upload-border-color, #d1d5db);
2175
+ }
2176
+
2177
+ /* "+" add-more tile */
2178
+ .fb-tile-add {
2179
+ border: 2px dashed var(--fb-file-upload-border-color, #d1d5db);
2180
+ display: flex;
2181
+ align-items: center;
2182
+ justify-content: center;
2183
+ cursor: pointer;
2184
+ font-size: 32px;
2185
+ color: var(--fb-file-upload-text-color, #9ca3af);
2186
+ transition:
2187
+ border-color var(--fb-transition-duration, 200ms),
2188
+ color var(--fb-transition-duration, 200ms);
2189
+ }
2190
+ .fb-tile-add:hover {
2191
+ border-color: var(--fb-file-upload-hover-border-color, #3b82f6);
2192
+ color: var(--fb-text-color, #1f2937);
2193
+ }
2194
+
2195
+ /* Count chip shown when at maxCount */
2196
+ .fb-tile-counter {
2197
+ font-size: 11px;
2198
+ color: var(--fb-text-secondary-color, #6b7280);
2199
+ background: var(--fb-file-upload-bg-color, #f3f4f6);
2200
+ border: 1px solid var(--fb-file-upload-border-color, #d1d5db);
2201
+ border-radius: 4px;
2202
+ padding: 2px 6px;
2203
+ align-self: flex-end;
2204
+ margin-bottom: 4px;
2205
+ }
2206
+
2207
+ /* Empty-state dropzone */
2208
+ .fb-file-dropzone {
2209
+ width: 100%;
2210
+ height: 128px;
2211
+ border: 2px dashed var(--fb-file-upload-border-color, #d1d5db);
2212
+ border-radius: var(--fb-border-radius, 0.5rem);
2213
+ display: flex;
2214
+ flex-direction: column;
2215
+ align-items: center;
2216
+ justify-content: center;
2217
+ gap: 4px;
2218
+ cursor: pointer;
2219
+ transition:
2220
+ border-color var(--fb-transition-duration, 200ms),
2221
+ background var(--fb-transition-duration, 200ms);
2222
+ }
2223
+ .fb-file-dropzone:hover {
2224
+ border-color: var(--fb-file-upload-hover-border-color, #3b82f6);
2225
+ background: var(--fb-background-hover-color, #f9fafb);
2226
+ }
2227
+
2228
+ /* Inline text inside tiles */
2229
+ .fb-tile-label {
2230
+ font-size: 9px;
2231
+ color: var(--fb-text-secondary-color, #6b7280);
2232
+ text-align: center;
2233
+ overflow: hidden;
2234
+ word-break: break-all;
2235
+ max-height: 28px;
2236
+ }
2237
+ .fb-tile-uploading-text {
2238
+ font-size: 8px;
2239
+ color: var(--fb-file-upload-text-color, #9ca3af);
2240
+ }
2241
+ .fb-tile-hint {
2242
+ font-size: 11px;
2243
+ color: var(--fb-file-upload-text-color, #9ca3af);
2244
+ margin-top: 4px;
2245
+ }
2246
+ .fb-tile-empty-text {
2247
+ font-size: 12px;
2248
+ color: var(--fb-text-secondary-color, #6b7280);
2249
+ padding: 4px 0;
2250
+ }
2251
+ .fb-dropzone-primary-text {
2252
+ font-size: 13px;
2253
+ color: var(--fb-text-secondary-color, #6b7280);
2254
+ }
2255
+ .fb-dropzone-hint-text {
2256
+ font-size: 11px;
2257
+ color: var(--fb-file-upload-text-color, #9ca3af);
2258
+ }
2259
+
2260
+ /* Hover overlay + X-button on resource tiles */
2261
+ .fb-tile-overlay {
2262
+ position: absolute;
2263
+ inset: 0;
2264
+ background: transparent;
2265
+ transition: background var(--fb-transition-duration, 200ms);
2266
+ display: flex;
2267
+ align-items: flex-start;
2268
+ justify-content: flex-end;
2269
+ }
2270
+ .fb-tile-resource:hover .fb-tile-overlay {
2271
+ background: var(--fb-tile-hover-overlay-color, rgba(0,0,0,0.4));
2272
+ }
2273
+ .fb-tile-x-btn {
2274
+ margin: 3px;
2275
+ width: 18px;
2276
+ height: 18px;
2277
+ background: var(--fb-error-color, #ef4444);
2278
+ color: var(--fb-file-bg-color, #fff);
2279
+ border: none;
2280
+ border-radius: 50%;
2281
+ font-size: 11px;
2282
+ line-height: 1;
2283
+ cursor: pointer;
2284
+ display: flex;
2285
+ align-items: center;
2286
+ justify-content: center;
2287
+ opacity: 0;
2288
+ transition: opacity var(--fb-transition-duration, 200ms);
2289
+ }
2290
+ .fb-tile-resource:hover .fb-tile-x-btn {
2291
+ opacity: 1;
2292
+ }
2293
+
2294
+ /* Video play button overlay (readonly tiles with video thumbnails) */
2295
+ .fb-video-overlay {
2296
+ position: absolute;
2297
+ inset: 0;
2298
+ display: flex;
2299
+ align-items: center;
2300
+ justify-content: center;
2301
+ background: var(--fb-tile-hover-overlay-color, rgba(0,0,0,0.25));
2302
+ }
2303
+ .fb-play-btn {
2304
+ background: var(--fb-file-bg-color, rgba(255,255,255,0.9));
2305
+ border-radius: 50%;
2306
+ display: flex;
2307
+ align-items: center;
2308
+ justify-content: center;
2309
+ }
2310
+
2311
+ /* Edit-mode local video preview wrapper */
2312
+ .fb-video-preview-wrap {
2313
+ position: relative;
2314
+ width: 100%;
2315
+ height: 100%;
2316
+ }
2317
+
2318
+ /* Hover overlay for edit-mode local video (Remove / Change buttons) */
2319
+ .fb-video-btn-overlay {
2320
+ position: absolute;
2321
+ top: 8px;
2322
+ right: 8px;
2323
+ z-index: 10;
2324
+ display: flex;
2325
+ gap: 4px;
2326
+ opacity: 0;
2327
+ transition: opacity var(--fb-transition-duration, 200ms);
2328
+ pointer-events: none;
2329
+ }
2330
+ .fb-video-preview-wrap:hover .fb-video-btn-overlay {
2331
+ opacity: 1;
2332
+ pointer-events: auto;
2333
+ }
2334
+ .fb-video-btn {
2335
+ border: none;
2336
+ border-radius: var(--fb-border-radius, 4px);
2337
+ font-size: 11px;
2338
+ padding: 4px 8px;
2339
+ cursor: pointer;
2340
+ color: #fff;
2341
+ line-height: 1.2;
2342
+ }
2343
+ .fb-video-btn-delete {
2344
+ background: rgba(220, 38, 38, 0.85);
2345
+ }
2346
+ .fb-video-btn-delete:hover {
2347
+ background: rgba(185, 28, 28, 0.95);
2348
+ }
2349
+ .fb-video-btn-change {
2350
+ background: rgba(31, 41, 55, 0.85);
2351
+ }
2352
+ .fb-video-btn-change:hover {
2353
+ background: rgba(17, 24, 39, 0.95);
2354
+ }
2355
+
2356
+ /* Tile action icon buttons (download / open / remove) \u2014 shown on tile hover */
2357
+ .fb-tile-actions {
2358
+ position: absolute;
2359
+ top: 3px;
2360
+ right: 3px;
2361
+ display: flex;
2362
+ flex-direction: row;
2363
+ gap: 3px;
2364
+ opacity: 0;
2365
+ transition: opacity var(--fb-transition-duration, 200ms);
2366
+ z-index: 10;
2367
+ }
2368
+ .fb-tile-resource:hover .fb-tile-actions {
2369
+ opacity: 1;
2370
+ }
2371
+ .fb-tile-action-btn {
2372
+ width: 28px;
2373
+ height: 28px;
2374
+ display: flex;
2375
+ align-items: center;
2376
+ justify-content: center;
2377
+ border: none;
2378
+ border-radius: 50%;
2379
+ cursor: pointer;
2380
+ background: rgba(31, 41, 55, 0.75);
2381
+ color: #fff;
2382
+ padding: 0;
2383
+ flex-shrink: 0;
2384
+ transition:
2385
+ background var(--fb-transition-duration, 200ms),
2386
+ opacity var(--fb-transition-duration, 200ms);
2387
+ }
2388
+ .fb-tile-action-btn:hover {
2389
+ background: rgba(17, 24, 39, 0.95);
2390
+ }
2391
+ .fb-tile-action-remove {
2392
+ background: rgba(220, 38, 38, 0.8);
2393
+ }
2394
+ .fb-tile-action-remove:hover {
2395
+ background: rgba(185, 28, 28, 0.95);
2396
+ }
2397
+
2398
+ /* Actions row inside zoom popup \u2014 always visible while popup is shown */
2399
+ .fb-tile-zoom-preview .fb-tile-actions {
2400
+ position: absolute;
2401
+ top: 6px;
2402
+ right: 6px;
2403
+ opacity: 1;
2404
+ z-index: 10000;
2405
+ }
2406
+
2407
+ /* Hover zoom preview popup for image tiles \u2014 appended to document.body (fixed) */
2408
+ .fb-tile-zoom-preview {
2409
+ position: fixed;
2410
+ z-index: 9999;
2411
+ background: var(--fb-background-color, #fff);
2412
+ border: 1px solid var(--fb-file-upload-border-color, #d1d5db);
2413
+ border-radius: var(--fb-border-radius, 0.5rem);
2414
+ box-shadow: 0 4px 16px rgba(0,0,0,0.18);
2415
+ padding: 4px;
2416
+ width: 350px;
2417
+ height: 350px;
2418
+ pointer-events: none;
2419
+ opacity: 0;
2420
+ transition: opacity 150ms ease;
2421
+ }
2422
+ .fb-tile-zoom-preview.fb-tile-zoom-preview--visible {
2423
+ opacity: 1;
2424
+ }
2425
+ .fb-tile-zoom-preview-img {
2426
+ width: 100%;
2427
+ height: 100%;
2428
+ object-fit: contain;
2429
+ display: block;
2430
+ background: var(--fb-file-upload-bg-color, #f3f4f6);
2431
+ border-radius: calc(var(--fb-border-radius, 0.5rem) - 2px);
2432
+ }
2433
+ `;
2434
+ document.head.appendChild(style);
2435
+ }
2436
+
2437
+ // src/components/file/dom.ts
2438
+ var TILE_SIZE = "160px";
2439
+ function createFileTile() {
2440
+ ensureFileStyles();
2441
+ const tile = document.createElement("div");
2442
+ tile.className = "fb-tile";
2443
+ return tile;
2444
+ }
2445
+ function showFileError(container, message) {
2446
+ const existing = container.closest(".space-y-2")?.querySelector(".file-error-message");
2447
+ if (existing) existing.remove();
2448
+ const errorEl = document.createElement("div");
2449
+ errorEl.className = "file-error-message error-message";
2450
+ errorEl.style.cssText = `
2451
+ color: var(--fb-error-color);
2452
+ font-size: var(--fb-font-size-small);
2453
+ margin-top: 0.25rem;
2454
+ `;
2455
+ errorEl.textContent = message;
2456
+ container.closest(".space-y-2")?.appendChild(errorEl);
2457
+ }
2458
+ function clearFileError(container) {
2459
+ const existing = container.closest(".space-y-2")?.querySelector(".file-error-message");
2460
+ if (existing) existing.remove();
2461
+ }
2462
+ function addDeleteButton(container, state, onDelete) {
2463
+ const existingOverlay = container.querySelector(".delete-overlay");
2464
+ if (existingOverlay) existingOverlay.remove();
2465
+ const overlay = document.createElement("div");
2466
+ 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";
2467
+ const deleteBtn = document.createElement("button");
2468
+ deleteBtn.className = "bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors";
2469
+ deleteBtn.textContent = t("removeElement", state);
2470
+ deleteBtn.onclick = (e) => {
2471
+ e.stopPropagation();
2472
+ onDelete();
2473
+ };
2474
+ overlay.appendChild(deleteBtn);
2475
+ container.appendChild(overlay);
2476
+ }
2477
+ function findFilePicker(container) {
2478
+ let el = container.parentElement;
2479
+ while (el && !el.dataset.filesWrapper) {
2480
+ el = el.parentElement;
2481
+ }
2482
+ return el?.querySelector('input[type="file"]') ?? null;
2483
+ }
2484
+ function createUploadingTile(fileName, state) {
2485
+ ensureFileStyles();
2486
+ const tile = createFileTile();
2487
+ tile.classList.add("fb-tile-uploading");
2488
+ tile.className += " fb-uploading-tile";
2489
+ const label = fileName.length > 10 ? fileName.substring(0, 8) + "\u2026" : fileName;
2490
+ tile.innerHTML = `
2491
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:6px;padding:4px;">
2492
+ <div class="fb-spinner"></div>
2493
+ <div class="fb-tile-label">${escapeHtml(label)}</div>
2494
+ <div class="fb-tile-uploading-text">${escapeHtml(t("uploadingFile", state))}</div>
2495
+ </div>`;
2496
+ return tile;
2497
+ }
2498
+ function ensureTilesWrap(list) {
2499
+ const existing = list.querySelector(".fb-tiles-wrap");
2500
+ if (existing) return existing;
2501
+ const dropzone = list.querySelector(".fb-file-dropzone");
2502
+ if (dropzone) dropzone.remove();
2503
+ const tilesWrap = document.createElement("div");
2504
+ tilesWrap.className = "fb-tiles-wrap";
2505
+ tilesWrap.style.cssText = "display:flex;flex-wrap:wrap;gap:6px;align-items:flex-start;";
2506
+ const addTile = document.createElement("div");
2507
+ addTile.className = "fb-tile fb-tile-add";
2508
+ addTile.innerHTML = "+";
2509
+ tilesWrap.appendChild(addTile);
2510
+ list.appendChild(tilesWrap);
2511
+ return tilesWrap;
2512
+ }
2513
+ function setEmptyFileContainer(fileContainer, state, hint) {
2514
+ const hintHtml = hint ? `<div class="text-xs text-gray-500 mt-1">${escapeHtml(hint)}</div>` : "";
2515
+ fileContainer.innerHTML = `
2516
+ <div class="flex flex-col items-center justify-center h-full text-gray-400">
2517
+ <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
2518
+ <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"/>
2519
+ </svg>
2520
+ <div class="text-sm text-center">${escapeHtml(t("clickDragText", state))}</div>
2521
+ ${hintHtml}
2522
+ </div>
2523
+ `;
2524
+ }
2525
+ function setupDragAndDrop(element, dropHandler) {
2526
+ element.addEventListener("dragover", (e) => {
2527
+ e.preventDefault();
2528
+ element.classList.add("border-blue-500", "bg-blue-50");
2529
+ });
2530
+ element.addEventListener("dragleave", (e) => {
2531
+ e.preventDefault();
2532
+ element.classList.remove("border-blue-500", "bg-blue-50");
2533
+ });
2534
+ element.addEventListener("drop", (e) => {
2535
+ e.preventDefault();
2536
+ element.classList.remove("border-blue-500", "bg-blue-50");
2537
+ if (e.dataTransfer?.files) {
2538
+ dropHandler(e.dataTransfer.files);
2539
+ }
2540
+ });
2541
+ }
2542
+
2543
+ // src/components/file/preview.ts
2544
+ var ICON_DOWNLOAD = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
2545
+ var ICON_OPEN = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`;
2546
+ var ICON_REMOVE = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/></svg>`;
2547
+ function canDownload(state, meta) {
2548
+ return Boolean(
2549
+ state.config.downloadFile || state.config.getDownloadUrl || state.config.getThumbnail || meta?.file
2550
+ );
2551
+ }
2552
+ function canOpenInTab(state, meta) {
2553
+ return Boolean(
2554
+ state.config.getDownloadUrl || state.config.getThumbnail || meta?.file
2555
+ );
2556
+ }
2557
+ function createTileActions(options) {
2558
+ const { canRemove, removeHandler, state, resourceId, fileName, meta } = options;
2559
+ const group = document.createElement("div");
2560
+ group.className = "fb-tile-actions";
2561
+ const makeBtn = (icon, label, cls) => {
2562
+ const btn = document.createElement("button");
2563
+ btn.type = "button";
2564
+ btn.className = `fb-tile-action-btn ${cls}`;
2565
+ btn.innerHTML = icon;
2566
+ btn.title = label;
2567
+ btn.setAttribute("aria-label", label);
2568
+ btn.addEventListener("click", (e) => {
2569
+ e.stopPropagation();
2570
+ });
2571
+ return btn;
2572
+ };
2573
+ if (canDownload(state, meta)) {
2574
+ const dlBtn = makeBtn(ICON_DOWNLOAD, t("downloadFile", state), "fb-tile-action-download");
2575
+ dlBtn.addEventListener("click", () => {
2576
+ triggerTileDownload(resourceId, fileName, state, meta);
2577
+ });
2578
+ group.appendChild(dlBtn);
2579
+ }
2580
+ if (canOpenInTab(state, meta)) {
2581
+ const openBtn = makeBtn(ICON_OPEN, t("openInNewTab", state), "fb-tile-action-open");
2582
+ openBtn.addEventListener("click", () => {
2583
+ triggerTileOpen(resourceId, state, meta).catch((err) => {
2584
+ console.error("Open failed:", err);
2585
+ });
2586
+ });
2587
+ group.appendChild(openBtn);
2588
+ }
2589
+ if (canRemove && removeHandler) {
2590
+ const rmBtn = makeBtn(ICON_REMOVE, t("removeElement", state), "fb-tile-action-remove");
2591
+ rmBtn.addEventListener("click", () => {
2592
+ removeHandler();
2593
+ });
2594
+ group.appendChild(rmBtn);
2595
+ }
2596
+ return group;
2597
+ }
2598
+ var localFileUrlCache = /* @__PURE__ */ new WeakMap();
2599
+ function getLocalFileUrl(file) {
2600
+ let url = localFileUrlCache.get(file);
2601
+ if (!url) {
2602
+ url = URL.createObjectURL(file);
2603
+ localFileUrlCache.set(file, url);
2604
+ }
2605
+ return url;
2606
+ }
2607
+ function releaseLocalFileUrl(file) {
2608
+ if (!file) return;
2609
+ const url = localFileUrlCache.get(file);
2610
+ if (url) {
2611
+ URL.revokeObjectURL(url);
2612
+ localFileUrlCache.delete(file);
2613
+ }
2614
+ }
2615
+ function triggerTileDownload(resourceId, fileName, state, meta) {
2616
+ if (state.config.downloadFile) {
2617
+ state.config.downloadFile(resourceId, fileName);
2618
+ return;
2619
+ }
2620
+ if (meta?.file instanceof File) {
2621
+ downloadBlob(meta.file, fileName || meta.file.name);
2622
+ return;
2623
+ }
2624
+ forceDownload(resourceId, fileName, state).catch((err) => {
2625
+ console.error("Download failed:", err);
2626
+ });
2627
+ }
2628
+ async function triggerTileOpen(resourceId, state, meta) {
2629
+ let url = null;
2630
+ if (state.config.getDownloadUrl) {
2631
+ url = state.config.getDownloadUrl(resourceId);
2632
+ } else if (state.config.getThumbnail) {
2633
+ url = await state.config.getThumbnail(resourceId);
2634
+ } else if (meta?.file instanceof File) {
2635
+ url = getLocalFileUrl(meta.file);
2636
+ }
2637
+ if (url) {
2638
+ window.open(url, "_blank");
2639
+ }
2640
+ }
2641
+ var sharedZoomPopup = null;
2642
+ var zoomTimer = null;
2643
+ var zoomHideTimer = null;
2644
+ var zoomOwner = null;
2645
+ function getOrCreateZoomPopup() {
2646
+ if (!sharedZoomPopup) {
2647
+ sharedZoomPopup = document.createElement("div");
2648
+ sharedZoomPopup.className = "fb-tile-zoom-preview";
2649
+ const img = document.createElement("img");
2650
+ img.className = "fb-tile-zoom-preview-img";
2651
+ sharedZoomPopup.appendChild(img);
2652
+ sharedZoomPopup.addEventListener("mouseenter", cancelHideZoomPopup);
2653
+ sharedZoomPopup.addEventListener("mouseleave", scheduleHideZoomPopup);
2654
+ }
2655
+ return sharedZoomPopup;
2656
+ }
2657
+ function positionZoomPopup(popup, tile) {
2658
+ const tileRect = tile.getBoundingClientRect();
2659
+ const popupSize = 350;
2660
+ const margin = 6;
2661
+ const padding = 8;
2662
+ let top;
2663
+ if (tileRect.top - popupSize - margin >= padding) {
2664
+ top = tileRect.top - popupSize - margin;
2665
+ } else if (tileRect.bottom + margin + popupSize + padding <= window.innerHeight) {
2666
+ top = tileRect.bottom + margin;
2667
+ } else {
2668
+ top = Math.max(padding, Math.min(window.innerHeight - popupSize - padding, tileRect.top));
2669
+ }
2670
+ const tileCenterX = tileRect.left + tileRect.width / 2;
2671
+ let left = tileCenterX - popupSize / 2;
2672
+ left = Math.max(padding, Math.min(window.innerWidth - popupSize - padding, left));
2673
+ popup.style.top = `${top}px`;
2674
+ popup.style.left = `${left}px`;
2675
+ }
2676
+ function scheduleHideZoomPopup() {
2677
+ if (zoomHideTimer !== null) {
2678
+ clearTimeout(zoomHideTimer);
2679
+ }
2680
+ zoomHideTimer = setTimeout(() => {
2681
+ zoomHideTimer = null;
2682
+ removeZoomPopupNow();
2683
+ }, 100);
2684
+ }
2685
+ function cancelHideZoomPopup() {
2686
+ if (zoomHideTimer !== null) {
2687
+ clearTimeout(zoomHideTimer);
2688
+ zoomHideTimer = null;
2689
+ }
2690
+ }
2691
+ function removeZoomPopupNow() {
2692
+ if (zoomTimer !== null) {
2693
+ clearTimeout(zoomTimer);
2694
+ zoomTimer = null;
2695
+ }
2696
+ if (sharedZoomPopup && sharedZoomPopup.parentNode) {
2697
+ sharedZoomPopup.classList.remove("fb-tile-zoom-preview--visible");
2698
+ sharedZoomPopup.parentNode.removeChild(sharedZoomPopup);
2699
+ }
2700
+ zoomOwner = null;
2701
+ }
2702
+ function attachZoomHover(tile, src, alt, actionsEl) {
2703
+ tile.dataset.zoomSrc = src;
2704
+ tile.dataset.zoomAlt = alt;
2705
+ tile.addEventListener("mouseenter", () => {
2706
+ cancelHideZoomPopup();
2707
+ if (zoomOwner !== tile) {
2708
+ removeZoomPopupNow();
2709
+ }
2710
+ zoomOwner = tile;
2711
+ zoomTimer = setTimeout(() => {
2712
+ zoomTimer = null;
2713
+ const popup = getOrCreateZoomPopup();
2714
+ const existingActions = popup.querySelector(".fb-tile-actions");
2715
+ if (existingActions) existingActions.remove();
2716
+ const img = popup.querySelector(".fb-tile-zoom-preview-img");
2717
+ img.src = src;
2718
+ img.alt = alt;
2719
+ if (actionsEl) {
2720
+ popup.appendChild(actionsEl.cloneNode(true));
2721
+ attachClonedActionListeners(
2722
+ popup.querySelector(".fb-tile-actions"),
2723
+ actionsEl
2724
+ );
2725
+ }
2726
+ popup.style.pointerEvents = "auto";
2727
+ positionZoomPopup(popup, tile);
2728
+ document.body.appendChild(popup);
2729
+ popup.getBoundingClientRect();
2730
+ popup.classList.add("fb-tile-zoom-preview--visible");
2731
+ }, 200);
2732
+ });
2733
+ tile.addEventListener("mouseleave", () => {
2734
+ if (zoomTimer !== null) {
2735
+ clearTimeout(zoomTimer);
2736
+ zoomTimer = null;
2737
+ zoomOwner = null;
2738
+ } else {
2739
+ scheduleHideZoomPopup();
2740
+ }
2741
+ });
2742
+ }
2743
+ function attachClonedActionListeners(cloned, original) {
2744
+ const originalBtns = Array.from(original.querySelectorAll(".fb-tile-action-btn"));
2745
+ const clonedBtns = Array.from(cloned.querySelectorAll(".fb-tile-action-btn"));
2746
+ clonedBtns.forEach((clonedBtn, i) => {
2747
+ const origBtn = originalBtns[i];
2748
+ if (origBtn) {
2749
+ clonedBtn.addEventListener("click", (e) => {
2750
+ e.stopPropagation();
2751
+ origBtn.click();
2752
+ });
2753
+ }
2754
+ });
2755
+ }
2093
2756
  function renderLocalImagePreview(container, file, fileName, state) {
2094
2757
  const img = document.createElement("img");
2095
2758
  img.className = "w-full h-full object-contain";
2759
+ img.style.background = "var(--fb-file-upload-bg-color,#f3f4f6)";
2096
2760
  img.alt = fileName || t("previewAlt", state);
2097
2761
  const reader = new FileReader();
2098
2762
  reader.onload = (e) => {
@@ -2101,23 +2765,27 @@ function renderLocalImagePreview(container, file, fileName, state) {
2101
2765
  reader.readAsDataURL(file);
2102
2766
  container.appendChild(img);
2103
2767
  }
2104
- function renderLocalVideoPreview(container, file, videoType, resourceId, state, deps) {
2105
- const videoUrl = URL.createObjectURL(file);
2768
+ function setupDragDropless(container, _deps) {
2106
2769
  container.onclick = null;
2107
2770
  const newContainer = container.cloneNode(false);
2108
2771
  if (container.parentNode) {
2109
2772
  container.parentNode.replaceChild(newContainer, container);
2110
2773
  }
2774
+ return newContainer;
2775
+ }
2776
+ function renderLocalVideoPreview(container, file, videoType, resourceId, state, deps) {
2777
+ const videoUrl = URL.createObjectURL(file);
2778
+ const newContainer = setupDragDropless(container);
2111
2779
  newContainer.innerHTML = `
2112
- <div class="relative group h-full">
2780
+ <div class="fb-video-preview-wrap">
2113
2781
  <video class="w-full h-full object-contain" controls preload="auto" muted src="${videoUrl}">
2114
2782
  ${escapeHtml(t("videoNotSupported", state))}
2115
2783
  </video>
2116
- <div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10 flex gap-1">
2117
- <button class="bg-red-600 bg-opacity-75 hover:bg-opacity-90 text-white p-1 rounded text-xs delete-file-btn">
2784
+ <div class="fb-video-btn-overlay">
2785
+ <button class="fb-video-btn fb-video-btn-delete delete-file-btn">
2118
2786
  ${escapeHtml(t("removeElement", state))}
2119
2787
  </button>
2120
- <button class="bg-gray-800 bg-opacity-75 hover:bg-opacity-90 text-white p-1 rounded text-xs change-file-btn">
2788
+ <button class="fb-video-btn fb-video-btn-change change-file-btn">
2121
2789
  ${escapeHtml(t("changeButton", state))}
2122
2790
  </button>
2123
2791
  </div>
@@ -2127,20 +2795,14 @@ function renderLocalVideoPreview(container, file, videoType, resourceId, state,
2127
2795
  return newContainer;
2128
2796
  }
2129
2797
  function attachVideoButtonHandlers(container, resourceId, state, deps) {
2130
- const changeBtn = container.querySelector(
2131
- ".change-file-btn"
2132
- );
2798
+ const changeBtn = container.querySelector(".change-file-btn");
2133
2799
  if (changeBtn) {
2134
2800
  changeBtn.onclick = (e) => {
2135
2801
  e.stopPropagation();
2136
- if (deps?.picker) {
2137
- deps.picker.click();
2138
- }
2802
+ deps?.picker?.click();
2139
2803
  };
2140
2804
  }
2141
- const deleteBtn = container.querySelector(
2142
- ".delete-file-btn"
2143
- );
2805
+ const deleteBtn = container.querySelector(".delete-file-btn");
2144
2806
  if (deleteBtn) {
2145
2807
  deleteBtn.onclick = (e) => {
2146
2808
  e.stopPropagation();
@@ -2159,9 +2821,6 @@ function handleVideoDelete(container, resourceId, state, deps) {
2159
2821
  if (deps?.fileUploadHandler) {
2160
2822
  container.onclick = deps.fileUploadHandler;
2161
2823
  }
2162
- if (deps?.dragHandler) {
2163
- setupDragAndDrop(container, deps.dragHandler);
2164
- }
2165
2824
  container.innerHTML = `
2166
2825
  <div class="flex flex-col items-center justify-center h-full text-gray-400">
2167
2826
  <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
@@ -2170,18 +2829,9 @@ function handleVideoDelete(container, resourceId, state, deps) {
2170
2829
  <div class="text-sm text-center">${escapeHtml(t("clickDragText", state))}</div>
2171
2830
  </div>
2172
2831
  `;
2173
- }
2174
- function renderUploadedVideoPreview(container, thumbnailUrl, _videoType, state) {
2175
- const video = document.createElement("video");
2176
- video.className = "w-full h-full object-contain";
2177
- video.controls = true;
2178
- video.preload = "metadata";
2179
- video.muted = true;
2180
- video.src = thumbnailUrl;
2181
- video.appendChild(
2182
- document.createTextNode(t("videoNotSupported", state))
2183
- );
2184
- container.appendChild(video);
2832
+ if (deps?.setupDrop) {
2833
+ deps.setupDrop(container);
2834
+ }
2185
2835
  }
2186
2836
  function renderDeleteButton(container, resourceId, state) {
2187
2837
  addDeleteButton(container, state, () => {
@@ -2203,13 +2853,11 @@ function renderDeleteButton(container, resourceId, state) {
2203
2853
  });
2204
2854
  }
2205
2855
  async function renderLocalFilePreview(container, meta, fileName, resourceId, isReadonly, state, deps) {
2206
- if (!meta.file || !(meta.file instanceof File)) {
2207
- return;
2208
- }
2209
- if (meta.type && meta.type.startsWith("image/")) {
2856
+ if (!meta.file || !(meta.file instanceof File)) return;
2857
+ if (meta.type?.startsWith("image/")) {
2210
2858
  renderLocalImagePreview(container, meta.file, fileName, state);
2211
- } else if (meta.type && meta.type.startsWith("video/")) {
2212
- const newContainer = renderLocalVideoPreview(
2859
+ } else if (meta.type?.startsWith("video/")) {
2860
+ container = renderLocalVideoPreview(
2213
2861
  container,
2214
2862
  meta.file,
2215
2863
  meta.type,
@@ -2217,14 +2865,23 @@ async function renderLocalFilePreview(container, meta, fileName, resourceId, isR
2217
2865
  state,
2218
2866
  deps
2219
2867
  );
2220
- container = newContainer;
2221
2868
  } else {
2222
- container.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">\u{1F4C1}</div><div class="text-sm">${escapeHtml(fileName)}</div></div>`;
2869
+ container.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400"><div style="font-size:36px;" class="mb-2">\u{1F4C1}</div><div class="text-sm">${escapeHtml(fileName)}</div></div>`;
2223
2870
  }
2224
- if (!isReadonly && !(meta.type && meta.type.startsWith("video/"))) {
2871
+ if (!isReadonly && !meta.type?.startsWith("video/")) {
2225
2872
  renderDeleteButton(container, resourceId, state);
2226
2873
  }
2227
2874
  }
2875
+ function renderUploadedVideoPreview(container, thumbnailUrl, state) {
2876
+ const video = document.createElement("video");
2877
+ video.className = "w-full h-full object-contain";
2878
+ video.controls = true;
2879
+ video.preload = "metadata";
2880
+ video.muted = true;
2881
+ video.src = thumbnailUrl;
2882
+ video.appendChild(document.createTextNode(t("videoNotSupported", state)));
2883
+ container.appendChild(video);
2884
+ }
2228
2885
  async function renderUploadedFilePreview(container, resourceId, fileName, meta, state) {
2229
2886
  if (!state.config.getThumbnail) {
2230
2887
  setEmptyFileContainer(container, state);
@@ -2234,11 +2891,12 @@ async function renderUploadedFilePreview(container, resourceId, fileName, meta,
2234
2891
  const thumbnailUrl = await state.config.getThumbnail(resourceId);
2235
2892
  if (thumbnailUrl) {
2236
2893
  clear(container);
2237
- if (meta && meta.type && meta.type.startsWith("video/")) {
2238
- renderUploadedVideoPreview(container, thumbnailUrl, meta.type, state);
2894
+ if (meta?.type?.startsWith("video/")) {
2895
+ renderUploadedVideoPreview(container, thumbnailUrl, state);
2239
2896
  } else {
2240
2897
  const img = document.createElement("img");
2241
2898
  img.className = "w-full h-full object-contain";
2899
+ img.style.background = "var(--fb-file-upload-bg-color,#f3f4f6)";
2242
2900
  img.alt = fileName || t("previewAlt", state);
2243
2901
  img.src = thumbnailUrl;
2244
2902
  container.appendChild(img);
@@ -2266,9 +2924,6 @@ async function renderFilePreview(container, resourceId, state, options = {}) {
2266
2924
  );
2267
2925
  }
2268
2926
  clear(container);
2269
- if (isReadonly) {
2270
- container.classList.add("cursor-pointer");
2271
- }
2272
2927
  const meta = state.resourceIndex.get(resourceId);
2273
2928
  if (meta && meta.file && meta.file instanceof File) {
2274
2929
  await renderLocalFilePreview(
@@ -2281,364 +2936,291 @@ async function renderFilePreview(container, resourceId, state, options = {}) {
2281
2936
  deps
2282
2937
  );
2283
2938
  } else {
2284
- await renderUploadedFilePreview(
2285
- container,
2286
- resourceId,
2287
- fileName,
2288
- meta,
2289
- state
2290
- );
2939
+ await renderUploadedFilePreview(container, resourceId, fileName, meta, state);
2291
2940
  const isVideo = meta?.type?.startsWith("video/");
2292
2941
  if (!isReadonly && !isVideo) {
2293
2942
  renderDeleteButton(container, resourceId, state);
2294
2943
  }
2295
2944
  }
2296
2945
  }
2297
- async function renderFilePreviewReadonly(resourceId, state, fileName) {
2946
+ function resolveFileName(resourceId, meta, fileName) {
2947
+ if (fileName) return fileName;
2948
+ if (meta?.name?.includes(".")) return meta.name;
2949
+ const basename = resourceId.includes("/") ? resourceId.split("/").pop() : resourceId;
2950
+ return basename?.includes(".") ? basename : "";
2951
+ }
2952
+ async function renderFilePreviewReadonly(resourceId, state, fileName, options = {}) {
2298
2953
  const meta = state.resourceIndex.get(resourceId);
2299
- const actualFileName = meta?.name || resourceId.split("/").pop() || "file";
2300
- const isPSD = actualFileName.toLowerCase().match(/\.psd$/);
2301
- const fileResult = document.createElement("div");
2302
- fileResult.className = isPSD ? "space-y-2" : "space-y-3";
2303
- const previewContainer = document.createElement("div");
2304
- if (isPSD) {
2305
- previewContainer.className = "bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity flex items-center p-3 max-w-sm";
2306
- } else {
2307
- previewContainer.className = "bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity";
2308
- }
2309
- const isImage = !isPSD && (meta?.type?.startsWith("image/") || actualFileName.toLowerCase().match(/\.(jpg|jpeg|png|gif|webp)$/));
2310
- const isVideo = meta?.type?.startsWith("video/") || actualFileName.toLowerCase().match(/\.(mp4|webm|avi|mov)$/);
2311
- if (isImage) {
2954
+ const actualFileName = resolveFileName(resourceId, meta, fileName);
2955
+ const { canRemove = false, removeHandler = null } = options;
2956
+ const isImage = meta?.type?.startsWith("image/") || Boolean(actualFileName.toLowerCase().match(/\.(jpg|jpeg|png|gif|webp)$/));
2957
+ const isVideo = meta?.type?.startsWith("video/") || Boolean(actualFileName.toLowerCase().match(/\.(mp4|webm|avi|mov)$/));
2958
+ const tile = createFileTile();
2959
+ tile.classList.add("fb-tile-resource");
2960
+ tile.style.cursor = "pointer";
2961
+ if (actualFileName) {
2962
+ tile.title = actualFileName;
2963
+ }
2964
+ const localFileUrl = meta?.file instanceof File ? getLocalFileUrl(meta.file) : null;
2965
+ const resolveOpenUrl = async () => {
2966
+ if (state.config.getDownloadUrl) return state.config.getDownloadUrl(resourceId);
2967
+ if (state.config.getThumbnail) return state.config.getThumbnail(resourceId);
2968
+ return localFileUrl;
2969
+ };
2970
+ tile.onclick = async () => {
2971
+ const url = await resolveOpenUrl();
2972
+ if (url) {
2973
+ window.open(url, "_blank");
2974
+ } else if (state.config.downloadFile) {
2975
+ state.config.downloadFile(resourceId, actualFileName);
2976
+ } else {
2977
+ forceDownload(resourceId, actualFileName, state).catch((err) => {
2978
+ console.error("Download failed:", err);
2979
+ });
2980
+ }
2981
+ };
2982
+ const actionsEl = createTileActions({
2983
+ canRemove,
2984
+ removeHandler,
2985
+ state,
2986
+ resourceId,
2987
+ fileName: actualFileName,
2988
+ meta
2989
+ });
2990
+ const resolveImageDisplayUrl = async () => {
2312
2991
  if (state.config.getThumbnail) {
2313
2992
  try {
2314
- const thumbnailUrl = await state.config.getThumbnail(resourceId);
2315
- if (thumbnailUrl) {
2316
- previewContainer.innerHTML = `<img src="${thumbnailUrl}" alt="${escapeHtml(actualFileName)}" class="w-full h-auto">`;
2317
- } else {
2318
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F5BC}\uFE0F</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
2319
- }
2320
- } catch (error) {
2321
- console.warn("getThumbnail failed for", resourceId, error);
2322
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F5BC}\uFE0F</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
2993
+ const url = await state.config.getThumbnail(resourceId);
2994
+ if (url) return url;
2995
+ } catch {
2323
2996
  }
2997
+ }
2998
+ return localFileUrl;
2999
+ };
3000
+ const renderImageFallback = () => {
3001
+ tile.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F5BC}\uFE0F</div>`;
3002
+ tile.appendChild(actionsEl);
3003
+ };
3004
+ if (isImage) {
3005
+ const displayUrl = await resolveImageDisplayUrl();
3006
+ if (displayUrl) {
3007
+ const img = document.createElement("img");
3008
+ img.style.cssText = "width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);";
3009
+ img.alt = actualFileName;
3010
+ img.src = displayUrl;
3011
+ tile.appendChild(img);
3012
+ tile.appendChild(actionsEl);
3013
+ attachZoomHover(tile, displayUrl, actualFileName, actionsEl);
2324
3014
  } else {
2325
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F5BC}\uFE0F</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
3015
+ renderImageFallback();
2326
3016
  }
2327
3017
  } else if (isVideo) {
2328
3018
  if (state.config.getThumbnail) {
2329
3019
  try {
2330
3020
  const videoUrl = await state.config.getThumbnail(resourceId);
2331
3021
  if (videoUrl) {
2332
- previewContainer.innerHTML = `
2333
- <div class="relative group">
2334
- <video class="w-full h-auto" controls preload="auto" muted src="${videoUrl}">
2335
- ${escapeHtml(t("videoNotSupported", state))}
2336
- </video>
2337
- <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">
2338
- <div class="bg-white bg-opacity-90 rounded-full p-3">
2339
- <svg class="w-8 h-8 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
2340
- <path d="M8 5v14l11-7z"/>
2341
- </svg>
2342
- </div>
3022
+ tile.innerHTML = `
3023
+ <img style="width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);" alt="${escapeHtml(actualFileName)}" src="${videoUrl}">
3024
+ <div class="fb-video-overlay">
3025
+ <div class="fb-play-btn" style="width:22px;height:22px;">
3026
+ <svg width="10" height="12" viewBox="0 0 10 12" fill="currentColor"><path d="M0 0l10 6-10 6z"/></svg>
2343
3027
  </div>
2344
- </div>
2345
- `;
3028
+ </div>`;
3029
+ tile.appendChild(actionsEl);
2346
3030
  } else {
2347
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F3A5}</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
3031
+ tile.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F3A5}</div>`;
3032
+ tile.appendChild(actionsEl);
2348
3033
  }
2349
- } catch (error) {
2350
- console.warn("getThumbnail failed for video", resourceId, error);
2351
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F3A5}</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
3034
+ } catch {
3035
+ tile.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F3A5}</div>`;
3036
+ tile.appendChild(actionsEl);
2352
3037
  }
2353
3038
  } else {
2354
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F3A5}</div><div class="text-sm">${escapeHtml(actualFileName)}</div></div></div>`;
3039
+ tile.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F3A5}</div>`;
3040
+ tile.appendChild(actionsEl);
2355
3041
  }
2356
3042
  } else {
2357
- const fileIcon = isPSD ? "\u{1F3A8}" : "\u{1F4C1}";
2358
- const fileDescription = isPSD ? "PSD File" : "Document";
2359
- if (isPSD) {
2360
- previewContainer.innerHTML = `
2361
- <div class="flex items-center space-x-3">
2362
- <div class="text-3xl text-gray-400">${fileIcon}</div>
2363
- <div class="flex-1 min-w-0">
2364
- <div class="text-sm font-medium text-gray-900 truncate">${escapeHtml(actualFileName)}</div>
2365
- <div class="text-xs text-gray-500">${fileDescription}</div>
2366
- </div>
2367
- </div>
2368
- `;
2369
- } else {
2370
- previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">${fileIcon}</div><div class="text-sm">${escapeHtml(actualFileName)}</div><div class="text-xs text-gray-500 mt-1">${fileDescription}</div></div></div>`;
3043
+ if (state.config.getThumbnail) {
3044
+ try {
3045
+ const thumbUrl = await state.config.getThumbnail(resourceId);
3046
+ if (thumbUrl) {
3047
+ const img = document.createElement("img");
3048
+ img.style.cssText = "width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);";
3049
+ img.alt = actualFileName || resourceId;
3050
+ img.src = thumbUrl;
3051
+ tile.appendChild(img);
3052
+ tile.appendChild(actionsEl);
3053
+ return tile;
3054
+ }
3055
+ } catch {
3056
+ }
2371
3057
  }
3058
+ const captionHtml = actualFileName ? `<div class="fb-tile-label">${escapeHtml(actualFileName.length > 10 ? actualFileName.substring(0, 8) + "\u2026" : actualFileName)}</div>
3059
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zm-7 9H5v2h14v-2h-7z"/></svg>` : "";
3060
+ tile.innerHTML = `
3061
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:6px;gap:4px;">
3062
+ <div style="font-size:36px;">\u{1F4C1}</div>
3063
+ ${captionHtml}
3064
+ </div>`;
3065
+ tile.appendChild(actionsEl);
2372
3066
  }
2373
- const fileNameElement = document.createElement("p");
2374
- fileNameElement.className = isPSD ? "hidden" : "text-sm font-medium text-gray-900 text-center";
2375
- fileNameElement.textContent = actualFileName;
2376
- const downloadButton = document.createElement("button");
2377
- downloadButton.className = "w-full px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors";
2378
- downloadButton.textContent = t("downloadButton", state);
2379
- downloadButton.onclick = (e) => {
2380
- e.preventDefault();
2381
- e.stopPropagation();
2382
- if (state.config.downloadFile) {
2383
- state.config.downloadFile(resourceId, actualFileName);
2384
- } else {
2385
- forceDownload(resourceId, actualFileName, state);
2386
- }
2387
- };
2388
- fileResult.appendChild(previewContainer);
2389
- fileResult.appendChild(fileNameElement);
2390
- fileResult.appendChild(downloadButton);
2391
- return fileResult;
3067
+ return tile;
2392
3068
  }
2393
- function renderResourcePills(container, rids, state, onRemove, hint, countInfo) {
2394
- clear(container);
2395
- const buildHintLine = () => {
2396
- const parts = [t("clickDragTextMultiple", state)];
2397
- if (hint) parts.push(hint);
2398
- if (countInfo) parts.push(countInfo);
2399
- return parts.join(" \u2022 ");
2400
- };
2401
- const isInitialRender = !container.classList.contains("grid");
2402
- if ((!rids || rids.length === 0) && isInitialRender) {
2403
- const gridContainer2 = document.createElement("div");
2404
- gridContainer2.className = "grid grid-cols-4 gap-3 mb-3";
2405
- for (let i = 0; i < 4; i++) {
2406
- const slot = document.createElement("div");
2407
- 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";
2408
- const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
2409
- svg.setAttribute("class", "w-12 h-12 text-gray-400");
2410
- svg.setAttribute("fill", "currentColor");
2411
- svg.setAttribute("viewBox", "0 0 24 24");
2412
- const path = document.createElementNS(
2413
- "http://www.w3.org/2000/svg",
2414
- "path"
2415
- );
2416
- path.setAttribute(
2417
- "d",
2418
- "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"
2419
- );
2420
- svg.appendChild(path);
2421
- slot.appendChild(svg);
2422
- slot.onclick = () => {
2423
- let filesWrapper = container.parentElement;
2424
- while (filesWrapper && !filesWrapper.classList.contains("space-y-2")) {
2425
- filesWrapper = filesWrapper.parentElement;
2426
- }
2427
- if (!filesWrapper && container.classList.contains("space-y-2")) {
2428
- filesWrapper = container;
2429
- }
2430
- const fileInput = filesWrapper?.querySelector(
2431
- 'input[type="file"]'
2432
- );
2433
- if (fileInput) fileInput.click();
2434
- };
2435
- gridContainer2.appendChild(slot);
2436
- }
2437
- const hintText2 = document.createElement("div");
2438
- hintText2.className = "text-center text-xs text-gray-500 mt-2";
2439
- hintText2.textContent = buildHintLine();
2440
- container.appendChild(gridContainer2);
2441
- container.appendChild(hintText2);
2442
- return;
2443
- }
2444
- const gridContainer = document.createElement("div");
2445
- gridContainer.className = "files-list grid grid-cols-4 gap-3";
2446
- const currentImagesCount = rids ? rids.length : 0;
2447
- const rowsNeeded = Math.floor(currentImagesCount / 4) + 1;
2448
- const slotsNeeded = rowsNeeded * 4;
2449
- for (let i = 0; i < slotsNeeded; i++) {
2450
- const slot = document.createElement("div");
2451
- if (rids && i < rids.length) {
2452
- const rid = rids[i];
2453
- const meta = state.resourceIndex.get(rid);
2454
- slot.className = "resource-pill aspect-square bg-gray-100 rounded-lg overflow-hidden relative group border border-gray-300";
2455
- slot.dataset.resourceId = rid;
2456
- renderThumbnailForResource(slot, rid, meta, state).catch((err) => {
2457
- console.error("Failed to render thumbnail:", err);
2458
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
2459
- <div class="text-2xl mb-1">\u{1F4C1}</div>
2460
- <div class="text-xs">${escapeHtml(t("previewError", state))}</div>
2461
- </div>`;
2462
- });
2463
- if (onRemove) {
2464
- const overlay = document.createElement("div");
2465
- overlay.className = "absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center";
2466
- const removeBtn = document.createElement("button");
2467
- removeBtn.className = "bg-red-600 text-white px-2 py-1 rounded text-xs";
2468
- removeBtn.textContent = t("removeElement", state);
2469
- removeBtn.onclick = (e) => {
2470
- e.stopPropagation();
2471
- onRemove(rid);
2472
- };
2473
- overlay.appendChild(removeBtn);
2474
- slot.appendChild(overlay);
2475
- }
2476
- } else {
2477
- 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";
2478
- 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>';
2479
- slot.onclick = () => {
2480
- let filesWrapper = container.parentElement;
2481
- while (filesWrapper && !filesWrapper.classList.contains("space-y-2")) {
2482
- filesWrapper = filesWrapper.parentElement;
2483
- }
2484
- if (!filesWrapper && container.classList.contains("space-y-2")) {
2485
- filesWrapper = container;
2486
- }
2487
- const fileInput = filesWrapper?.querySelector(
2488
- 'input[type="file"]'
2489
- );
2490
- if (fileInput) fileInput.click();
2491
- };
2492
- }
2493
- gridContainer.appendChild(slot);
2494
- }
2495
- container.appendChild(gridContainer);
2496
- const hintText = document.createElement("div");
2497
- hintText.className = "text-center text-xs text-gray-500 mt-2";
2498
- hintText.textContent = buildHintLine();
2499
- container.appendChild(hintText);
2500
- }
2501
- function renderThumbnailError(slot, state, iconSize = "w-12 h-12") {
2502
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
2503
- <svg class="${escapeHtml(iconSize)} text-red-400" fill="currentColor" viewBox="0 0 24 24">
2504
- <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-2h2v2zm0-4h-2V7h2v6z"/>
2505
- </svg>
2506
- <div class="text-xs mt-1 text-red-600">${escapeHtml(t("previewError", state))}</div>
2507
- </div>`;
3069
+ async function renderSingleFileEditTile(fileContainer, resourceId, state, deps) {
3070
+ const meta = state.resourceIndex.get(resourceId);
3071
+ const fileName = meta?.name ?? resourceId.split("/").pop() ?? "";
3072
+ const removeHandler = deps.onRemove ?? null;
3073
+ const tile = await renderFilePreviewReadonly(resourceId, state, fileName, {
3074
+ canRemove: true,
3075
+ removeHandler
3076
+ });
3077
+ fileContainer.className = "file-preview-container";
3078
+ fileContainer.removeAttribute("style");
3079
+ clear(fileContainer);
3080
+ fileContainer.appendChild(tile);
2508
3081
  }
2509
- async function renderThumbnailForResource(slot, rid, meta, state) {
2510
- if (meta && meta.type?.startsWith("image/")) {
3082
+ async function fillTileContent(tile, rid, meta, state, actionsEl) {
3083
+ if (meta?.type?.startsWith("image/")) {
2511
3084
  if (meta.file && meta.file instanceof File) {
2512
3085
  const img = document.createElement("img");
2513
- img.className = "w-full h-full object-contain";
3086
+ img.style.cssText = "width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);";
2514
3087
  img.alt = meta.name;
2515
3088
  const reader = new FileReader();
2516
3089
  reader.onload = (e) => {
2517
3090
  img.src = e.target?.result || "";
3091
+ attachZoomHover(tile, img.src, meta.name, actionsEl ?? null);
2518
3092
  };
2519
3093
  reader.readAsDataURL(meta.file);
2520
- slot.appendChild(img);
3094
+ tile.appendChild(img);
2521
3095
  } else if (state.config.getThumbnail) {
2522
3096
  try {
2523
3097
  const url = await state.config.getThumbnail(rid);
2524
3098
  if (url) {
2525
3099
  const img = document.createElement("img");
2526
- img.className = "w-full h-full object-contain";
3100
+ img.style.cssText = "width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);";
2527
3101
  img.alt = meta.name;
2528
3102
  img.src = url;
2529
- slot.appendChild(img);
3103
+ tile.appendChild(img);
3104
+ attachZoomHover(tile, url, meta.name, actionsEl ?? null);
2530
3105
  } else {
2531
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
2532
- <svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
2533
- <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"/>
2534
- </svg>
2535
- </div>`;
3106
+ tile.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F5BC}\uFE0F</div>`;
2536
3107
  }
2537
3108
  } catch (error) {
2538
3109
  const err = error instanceof Error ? error : new Error(String(error));
2539
- if (state.config.onThumbnailError) {
2540
- state.config.onThumbnailError(err, rid);
2541
- }
2542
- renderThumbnailError(slot, state);
3110
+ if (state.config.onThumbnailError) state.config.onThumbnailError(err, rid);
3111
+ tile.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:16px;color:var(--fb-error-color,#ef4444);">\u2715</div>`;
2543
3112
  }
2544
3113
  } else {
2545
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
2546
- <svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
2547
- <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"/>
2548
- </svg>
2549
- </div>`;
3114
+ tile.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F5BC}\uFE0F</div>`;
2550
3115
  }
2551
- } else if (meta && meta.type?.startsWith("video/")) {
3116
+ if (actionsEl) tile.appendChild(actionsEl);
3117
+ } else if (meta?.type?.startsWith("video/")) {
2552
3118
  if (meta.file && meta.file instanceof File) {
2553
3119
  const videoUrl = URL.createObjectURL(meta.file);
2554
- slot.innerHTML = `
2555
- <div class="relative group h-full w-full">
2556
- <video class="w-full h-full object-contain" preload="metadata" muted src="${videoUrl}">
2557
- </video>
2558
- <div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
2559
- <div class="bg-white bg-opacity-90 rounded-full p-1">
2560
- <svg class="w-4 h-4 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
2561
- <path d="M8 5v14l11-7z"/>
2562
- </svg>
2563
- </div>
3120
+ tile.innerHTML = `
3121
+ <video style="width:100%;height:100%;" preload="metadata" muted src="${videoUrl}"></video>
3122
+ <div class="fb-video-overlay">
3123
+ <div class="fb-play-btn" style="width:20px;height:20px;">
3124
+ <svg width="8" height="10" viewBox="0 0 8 10" fill="currentColor"><path d="M0 0l8 5-8 5z"/></svg>
2564
3125
  </div>
2565
- </div>
2566
- `;
3126
+ </div>`;
2567
3127
  } else if (state.config.getThumbnail) {
2568
3128
  try {
2569
3129
  const videoUrl = await state.config.getThumbnail(rid);
2570
3130
  if (videoUrl) {
2571
- slot.innerHTML = `
2572
- <div class="relative group h-full w-full">
2573
- <video class="w-full h-full object-contain" preload="metadata" muted src="${videoUrl}">
2574
- </video>
2575
- <div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
2576
- <div class="bg-white bg-opacity-90 rounded-full p-1">
2577
- <svg class="w-4 h-4 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
2578
- <path d="M8 5v14l11-7z"/>
2579
- </svg>
2580
- </div>
3131
+ tile.innerHTML = `
3132
+ <img style="width:100%;height:100%;object-fit:contain;background:var(--fb-file-upload-bg-color,#f3f4f6);" alt="${escapeHtml(meta.name)}" src="${videoUrl}">
3133
+ <div class="fb-video-overlay">
3134
+ <div class="fb-play-btn" style="width:20px;height:20px;">
3135
+ <svg width="8" height="10" viewBox="0 0 8 10" fill="currentColor"><path d="M0 0l8 5-8 5z"/></svg>
2581
3136
  </div>
2582
- </div>
2583
- `;
3137
+ </div>`;
2584
3138
  } else {
2585
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
2586
- <svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
2587
- <path d="M8 5v14l11-7z"/>
2588
- </svg>
2589
- <div class="text-xs mt-1">${escapeHtml(meta?.name || "Video")}</div>
2590
- </div>`;
3139
+ tile.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F3A5}</div>`;
2591
3140
  }
2592
3141
  } catch (error) {
2593
3142
  const err = error instanceof Error ? error : new Error(String(error));
2594
- if (state.config.onThumbnailError) {
2595
- state.config.onThumbnailError(err, rid);
2596
- }
2597
- renderThumbnailError(slot, state, "w-8 h-8");
3143
+ if (state.config.onThumbnailError) state.config.onThumbnailError(err, rid);
3144
+ tile.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:16px;color:var(--fb-error-color,#ef4444);">\u2715</div>`;
2598
3145
  }
2599
3146
  } else {
2600
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
2601
- <svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
2602
- <path d="M8 5v14l11-7z"/>
2603
- </svg>
2604
- <div class="text-xs mt-1">${escapeHtml(meta?.name || "Video")}</div>
2605
- </div>`;
3147
+ tile.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:36px;">\u{1F3A5}</div>`;
2606
3148
  }
3149
+ if (actionsEl) tile.appendChild(actionsEl);
2607
3150
  } else {
2608
- slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
2609
- <div class="text-2xl mb-1">\u{1F4C1}</div>
2610
- <div class="text-xs">${escapeHtml(meta?.name || "File")}</div>
2611
- </div>`;
3151
+ const name = meta?.name ?? "";
3152
+ const hasExtension = name.includes(".");
3153
+ const captionHtml = hasExtension ? `<div class="fb-tile-label">${escapeHtml(name.length > 10 ? name.substring(0, 8) + "\u2026" : name)}</div>` : "";
3154
+ tile.innerHTML = `
3155
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:6px;gap:4px;">
3156
+ <div style="font-size:36px;">\u{1F4C1}</div>
3157
+ ${captionHtml}
3158
+ </div>`;
3159
+ if (actionsEl) tile.appendChild(actionsEl);
2612
3160
  }
2613
3161
  }
2614
- function setEmptyFileContainer(fileContainer, state, hint) {
2615
- const hintHtml = hint ? `<div class="text-xs text-gray-500 mt-1">${escapeHtml(hint)}</div>` : "";
2616
- fileContainer.innerHTML = `
2617
- <div class="flex flex-col items-center justify-center h-full text-gray-400">
2618
- <svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
2619
- <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"/>
2620
- </svg>
2621
- <div class="text-sm text-center">${escapeHtml(t("clickDragText", state))}</div>
2622
- ${hintHtml}
2623
- </div>
2624
- `;
3162
+ async function forceDownload(resourceId, fileName, state) {
3163
+ try {
3164
+ let fileUrl = null;
3165
+ if (state.config.getDownloadUrl) {
3166
+ fileUrl = state.config.getDownloadUrl(resourceId);
3167
+ } else if (state.config.getThumbnail) {
3168
+ fileUrl = await state.config.getThumbnail(resourceId);
3169
+ }
3170
+ if (fileUrl) {
3171
+ const finalUrl = fileUrl.startsWith("http") ? fileUrl : new URL(fileUrl, window.location.href).href;
3172
+ const response = await fetch(finalUrl);
3173
+ if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
3174
+ const blob = await response.blob();
3175
+ downloadBlob(blob, fileName);
3176
+ } else {
3177
+ throw new Error("No download URL available for resource");
3178
+ }
3179
+ } catch (error) {
3180
+ const err = error instanceof Error ? error : new Error(String(error));
3181
+ if (state.config.onDownloadError) {
3182
+ state.config.onDownloadError(err, resourceId, fileName);
3183
+ }
3184
+ console.error(`File download failed for ${fileName}:`, err);
3185
+ throw err;
3186
+ }
2625
3187
  }
2626
- function showFileError(container, message) {
2627
- const existing = container.closest(".space-y-2")?.querySelector(".file-error-message");
2628
- if (existing) existing.remove();
2629
- const errorEl = document.createElement("div");
2630
- errorEl.className = "file-error-message error-message";
2631
- errorEl.style.cssText = `
2632
- color: var(--fb-error-color);
2633
- font-size: var(--fb-font-size-small);
2634
- margin-top: 0.25rem;
2635
- `;
2636
- errorEl.textContent = message;
2637
- container.closest(".space-y-2")?.appendChild(errorEl);
3188
+ function downloadBlob(blob, fileName) {
3189
+ try {
3190
+ const blobUrl = URL.createObjectURL(blob);
3191
+ const link = document.createElement("a");
3192
+ link.href = blobUrl;
3193
+ link.download = fileName;
3194
+ link.style.display = "none";
3195
+ document.body.appendChild(link);
3196
+ link.click();
3197
+ document.body.removeChild(link);
3198
+ setTimeout(() => {
3199
+ URL.revokeObjectURL(blobUrl);
3200
+ }, 100);
3201
+ } catch (error) {
3202
+ throw new Error(`Blob download failed: ${error.message}`);
3203
+ }
2638
3204
  }
2639
- function clearFileError(container) {
2640
- const existing = container.closest(".space-y-2")?.querySelector(".file-error-message");
2641
- if (existing) existing.remove();
3205
+
3206
+ // src/components/file/upload.ts
3207
+ async function uploadSingleFile(file, state) {
3208
+ if (!state.config.uploadFile) {
3209
+ throw new Error(
3210
+ "No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()"
3211
+ );
3212
+ }
3213
+ try {
3214
+ const rid = await state.config.uploadFile(file);
3215
+ if (typeof rid !== "string") {
3216
+ throw new Error("Upload handler must return a string resource ID");
3217
+ }
3218
+ return rid;
3219
+ } catch (error) {
3220
+ const err = error instanceof Error ? error : new Error(String(error));
3221
+ if (state.config.onUploadError) state.config.onUploadError(err, file);
3222
+ throw new Error(`File upload failed: ${err.message}`);
3223
+ }
2642
3224
  }
2643
3225
  async function handleFileSelect(file, container, fieldName, state, deps = null, instance, allowedExtensions = [], maxSizeMB = Infinity) {
2644
3226
  if (!isFileExtensionAllowed(file.name, allowedExtensions)) {
@@ -2657,24 +3239,18 @@ async function handleFileSelect(file, container, fieldName, state, deps = null,
2657
3239
  return;
2658
3240
  }
2659
3241
  clearFileError(container);
3242
+ ensureFileStyles();
3243
+ container.innerHTML = `
3244
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:6px;padding:6px;">
3245
+ <div class="fb-spinner"></div>
3246
+ <div style="font-size:11px;color:var(--fb-text-secondary-color,#6b7280);text-align:center;">${escapeHtml(t("uploadingFile", state))}</div>
3247
+ </div>`;
2660
3248
  let rid;
2661
- if (state.config.uploadFile) {
2662
- try {
2663
- rid = await state.config.uploadFile(file);
2664
- if (typeof rid !== "string") {
2665
- throw new Error("Upload handler must return a string resource ID");
2666
- }
2667
- } catch (error) {
2668
- const err = error instanceof Error ? error : new Error(String(error));
2669
- if (state.config.onUploadError) {
2670
- state.config.onUploadError(err, file);
2671
- }
2672
- throw new Error(`File upload failed: ${err.message}`);
2673
- }
2674
- } else {
2675
- throw new Error(
2676
- "No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()"
2677
- );
3249
+ try {
3250
+ rid = await uploadSingleFile(file, state);
3251
+ } catch (error) {
3252
+ setEmptyFileContainer(container, state);
3253
+ throw error;
2678
3254
  }
2679
3255
  state.resourceIndex.set(rid, {
2680
3256
  name: file.name,
@@ -2682,7 +3258,6 @@ async function handleFileSelect(file, container, fieldName, state, deps = null,
2682
3258
  size: file.size,
2683
3259
  uploadedAt: /* @__PURE__ */ new Date(),
2684
3260
  file
2685
- // Store the file object for local preview
2686
3261
  });
2687
3262
  let hiddenInput = container.parentElement?.querySelector(
2688
3263
  'input[type="hidden"]'
@@ -2694,140 +3269,132 @@ async function handleFileSelect(file, container, fieldName, state, deps = null,
2694
3269
  container.parentElement?.appendChild(hiddenInput);
2695
3270
  }
2696
3271
  hiddenInput.value = rid;
2697
- renderFilePreview(container, rid, state, {
2698
- fileName: file.name,
2699
- isReadonly: false,
2700
- deps
2701
- }).catch(console.error);
3272
+ const isVideo = file.type.startsWith("video/");
3273
+ if (!isVideo && deps) {
3274
+ renderSingleFileEditTile(container, rid, state, deps).catch(console.error);
3275
+ } else {
3276
+ renderFilePreview(container, rid, state, {
3277
+ fileName: file.name,
3278
+ isReadonly: false,
3279
+ deps
3280
+ }).catch(console.error);
3281
+ }
2702
3282
  if (instance && !state.config.readonly) {
2703
3283
  instance.triggerOnChange(fieldName, rid);
2704
3284
  }
2705
3285
  }
2706
- function setupDragAndDrop(element, dropHandler) {
2707
- element.addEventListener("dragover", (e) => {
2708
- e.preventDefault();
2709
- element.classList.add("border-blue-500", "bg-blue-50");
2710
- });
2711
- element.addEventListener("dragleave", (e) => {
2712
- e.preventDefault();
2713
- element.classList.remove("border-blue-500", "bg-blue-50");
2714
- });
2715
- element.addEventListener("drop", (e) => {
2716
- e.preventDefault();
2717
- element.classList.remove("border-blue-500", "bg-blue-50");
2718
- if (e.dataTransfer?.files) {
2719
- dropHandler(e.dataTransfer.files);
2720
- }
2721
- });
2722
- }
2723
- function addDeleteButton(container, state, onDelete) {
2724
- const existingOverlay = container.querySelector(".delete-overlay");
2725
- if (existingOverlay) {
2726
- existingOverlay.remove();
2727
- }
2728
- const overlay = document.createElement("div");
2729
- 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";
2730
- const deleteBtn = document.createElement("button");
2731
- deleteBtn.className = "bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors";
2732
- deleteBtn.textContent = t("removeElement", state);
2733
- deleteBtn.onclick = (e) => {
2734
- e.stopPropagation();
2735
- onDelete();
2736
- };
2737
- overlay.appendChild(deleteBtn);
2738
- container.appendChild(overlay);
2739
- }
2740
- async function uploadSingleFile(file, state) {
2741
- if (state.config.uploadFile) {
2742
- try {
2743
- const rid = await state.config.uploadFile(file);
2744
- if (typeof rid !== "string") {
2745
- throw new Error("Upload handler must return a string resource ID");
2746
- }
2747
- return rid;
2748
- } catch (error) {
2749
- const err = error instanceof Error ? error : new Error(String(error));
2750
- if (state.config.onUploadError) {
2751
- state.config.onUploadError(err, file);
2752
- }
2753
- throw new Error(`File upload failed: ${err.message}`);
2754
- }
2755
- } else {
2756
- throw new Error(
2757
- "No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()"
3286
+ function filterAndSlice(allFiles, currentCount, constraints, state) {
3287
+ const rejectedByExt = allFiles.filter(
3288
+ (f) => !isFileExtensionAllowed(f.name, constraints.allowedExtensions)
3289
+ );
3290
+ const afterExt = allFiles.filter(
3291
+ (f) => isFileExtensionAllowed(f.name, constraints.allowedExtensions)
3292
+ );
3293
+ const rejectedBySize = afterExt.filter(
3294
+ (f) => !isFileSizeAllowed(f, constraints.maxSize)
3295
+ );
3296
+ const valid = afterExt.filter((f) => isFileSizeAllowed(f, constraints.maxSize));
3297
+ const remaining = constraints.maxCount === Infinity ? valid.length : Math.max(0, constraints.maxCount - currentCount);
3298
+ const accepted = valid.slice(0, remaining);
3299
+ const skippedByCount = valid.length - accepted.length;
3300
+ const errorParts = [];
3301
+ if (rejectedByExt.length > 0) {
3302
+ const formats = constraints.allowedExtensions.join(", ");
3303
+ const names = rejectedByExt.map((f) => f.name).join(", ");
3304
+ errorParts.push(t("invalidFileExtension", state, { name: names, formats }));
3305
+ }
3306
+ if (rejectedBySize.length > 0) {
3307
+ const names = rejectedBySize.map((f) => f.name).join(", ");
3308
+ errorParts.push(
3309
+ t("fileTooLarge", state, { name: names, maxSize: constraints.maxSize })
2758
3310
  );
2759
3311
  }
2760
- }
2761
- async function forceDownload(resourceId, fileName, state) {
2762
- try {
2763
- let fileUrl = null;
2764
- if (state.config.getDownloadUrl) {
2765
- fileUrl = state.config.getDownloadUrl(resourceId);
2766
- } else if (state.config.getThumbnail) {
2767
- fileUrl = await state.config.getThumbnail(resourceId);
2768
- }
2769
- if (fileUrl) {
2770
- const finalUrl = fileUrl.startsWith("http") ? fileUrl : new URL(fileUrl, window.location.href).href;
2771
- const response = await fetch(finalUrl);
2772
- if (!response.ok) {
2773
- throw new Error(`HTTP error! status: ${response.status}`);
2774
- }
2775
- const blob = await response.blob();
2776
- downloadBlob(blob, fileName);
2777
- } else {
2778
- throw new Error("No download URL available for resource");
2779
- }
2780
- } catch (error) {
2781
- const err = error instanceof Error ? error : new Error(String(error));
2782
- if (state.config.onDownloadError) {
2783
- state.config.onDownloadError(err, resourceId, fileName);
2784
- }
2785
- console.error(`File download failed for ${fileName}:`, err);
2786
- throw err;
2787
- }
2788
- }
2789
- function downloadBlob(blob, fileName) {
2790
- try {
2791
- const blobUrl = URL.createObjectURL(blob);
2792
- const link = document.createElement("a");
2793
- link.href = blobUrl;
2794
- link.download = fileName;
2795
- link.style.display = "none";
2796
- document.body.appendChild(link);
2797
- link.click();
2798
- document.body.removeChild(link);
2799
- setTimeout(() => {
2800
- URL.revokeObjectURL(blobUrl);
2801
- }, 100);
2802
- } catch (error) {
2803
- throw new Error(`Blob download failed: ${error.message}`);
3312
+ if (skippedByCount > 0) {
3313
+ errorParts.push(
3314
+ t("filesLimitExceeded", state, {
3315
+ skipped: skippedByCount,
3316
+ max: constraints.maxCount
3317
+ })
3318
+ );
2804
3319
  }
2805
- }
2806
- function addPrefillFilesToIndex(initialFiles, state) {
2807
- if (initialFiles.length > 0) {
2808
- initialFiles.forEach((resourceId) => {
2809
- if (!state.resourceIndex.has(resourceId)) {
2810
- const filename = resourceId.split("/").pop() || "file";
2811
- const extension = filename.split(".").pop()?.toLowerCase();
2812
- let fileType = "application/octet-stream";
2813
- if (extension) {
2814
- if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
2815
- fileType = `image/${extension === "jpg" ? "jpeg" : extension}`;
2816
- } else if (["mp4", "webm", "mov", "avi"].includes(extension)) {
2817
- fileType = `video/${extension === "mov" ? "quicktime" : extension}`;
2818
- }
3320
+ return { accepted, errorMessage: errorParts.join(" \u2022 ") };
3321
+ }
3322
+ async function uploadBatch(accepted, resourceIds, listEl, state) {
3323
+ await Promise.all(
3324
+ accepted.map(async (file) => {
3325
+ const placeholder = createUploadingTile(file.name, state);
3326
+ if (listEl) {
3327
+ const tilesWrap = ensureTilesWrap(listEl);
3328
+ const addTile = tilesWrap.querySelector(".fb-tile-add");
3329
+ if (addTile) {
3330
+ tilesWrap.insertBefore(placeholder, addTile);
3331
+ } else {
3332
+ tilesWrap.appendChild(placeholder);
2819
3333
  }
2820
- state.resourceIndex.set(resourceId, {
2821
- name: filename,
2822
- type: fileType,
2823
- size: 0,
3334
+ }
3335
+ try {
3336
+ const rid = await uploadSingleFile(file, state);
3337
+ state.resourceIndex.set(rid, {
3338
+ name: file.name,
3339
+ type: file.type,
3340
+ size: file.size,
2824
3341
  uploadedAt: /* @__PURE__ */ new Date(),
2825
3342
  file: void 0
2826
3343
  });
3344
+ resourceIds.push(rid);
3345
+ } finally {
3346
+ placeholder.remove();
2827
3347
  }
2828
- });
2829
- }
3348
+ })
3349
+ );
3350
+ }
3351
+ function setupFilesDropHandler(filesContainer, resourceIds, state, updateCallback, constraints, pathKey, instance) {
3352
+ setupDragAndDrop(filesContainer, async (files) => {
3353
+ const { accepted, errorMessage } = filterAndSlice(
3354
+ Array.from(files),
3355
+ resourceIds.length,
3356
+ constraints,
3357
+ state
3358
+ );
3359
+ if (errorMessage) {
3360
+ showFileError(filesContainer, errorMessage);
3361
+ } else {
3362
+ clearFileError(filesContainer);
3363
+ }
3364
+ const list = filesContainer.querySelector(".files-list") ?? filesContainer;
3365
+ await uploadBatch(accepted, resourceIds, list, state);
3366
+ updateCallback();
3367
+ if (instance && pathKey && !state.config.readonly) {
3368
+ instance.triggerOnChange(pathKey, resourceIds);
3369
+ }
3370
+ });
3371
+ }
3372
+ function setupFilesPickerHandler(filesPicker, resourceIds, state, updateCallback, constraints, pathKey, instance) {
3373
+ filesPicker.onchange = async () => {
3374
+ if (!filesPicker.files) return;
3375
+ const wrapperEl = filesPicker.closest(".space-y-2") || filesPicker.parentElement;
3376
+ const { accepted, errorMessage } = filterAndSlice(
3377
+ Array.from(filesPicker.files),
3378
+ resourceIds.length,
3379
+ constraints,
3380
+ state
3381
+ );
3382
+ if (errorMessage && wrapperEl) {
3383
+ showFileError(wrapperEl, errorMessage);
3384
+ } else if (wrapperEl) {
3385
+ clearFileError(wrapperEl);
3386
+ }
3387
+ const listEl = wrapperEl?.querySelector(".files-list");
3388
+ await uploadBatch(accepted, resourceIds, listEl ?? null, state);
3389
+ updateCallback();
3390
+ filesPicker.value = "";
3391
+ if (instance && pathKey && !state.config.readonly) {
3392
+ instance.triggerOnChange(pathKey, resourceIds);
3393
+ }
3394
+ };
2830
3395
  }
3396
+
3397
+ // src/components/file/render-edit.ts
2831
3398
  function handleInitialFileData(initial, fileContainer, pathKey, fileWrapper, state, deps) {
2832
3399
  if (!state.resourceIndex.has(initial)) {
2833
3400
  const filename = initial.split("/").pop() || "file";
@@ -2848,493 +3415,553 @@ function handleInitialFileData(initial, fileContainer, pathKey, fileWrapper, sta
2848
3415
  file: void 0
2849
3416
  });
2850
3417
  }
2851
- renderFilePreview(fileContainer, initial, state, {
2852
- fileName: initial,
2853
- isReadonly: false,
2854
- deps
2855
- }).catch(console.error);
3418
+ const meta = state.resourceIndex.get(initial);
3419
+ const isVideo = meta?.type?.startsWith("video/");
3420
+ if (isVideo) {
3421
+ renderFilePreview(fileContainer, initial, state, {
3422
+ fileName: initial,
3423
+ isReadonly: false,
3424
+ deps
3425
+ }).catch(console.error);
3426
+ } else {
3427
+ renderSingleFileEditTile(fileContainer, initial, state, deps).catch(console.error);
3428
+ }
2856
3429
  const hiddenInput = document.createElement("input");
2857
3430
  hiddenInput.type = "hidden";
2858
3431
  hiddenInput.name = pathKey;
2859
3432
  hiddenInput.value = initial;
2860
3433
  fileWrapper.appendChild(hiddenInput);
2861
3434
  }
2862
- function setupFilesDropHandler(filesContainer, initialFiles, state, updateCallback, constraints, pathKey, instance) {
2863
- setupDragAndDrop(filesContainer, async (files) => {
2864
- const allFiles = Array.from(files);
2865
- const rejectedByExtension = allFiles.filter(
2866
- (f) => !isFileExtensionAllowed(f.name, constraints.allowedExtensions)
2867
- );
2868
- const afterExtension = allFiles.filter(
2869
- (f) => isFileExtensionAllowed(f.name, constraints.allowedExtensions)
2870
- );
2871
- const rejectedBySize = afterExtension.filter(
2872
- (f) => !isFileSizeAllowed(f, constraints.maxSize)
2873
- );
2874
- const validFiles = afterExtension.filter(
2875
- (f) => isFileSizeAllowed(f, constraints.maxSize)
2876
- );
2877
- const remaining = constraints.maxCount === Infinity ? validFiles.length : Math.max(0, constraints.maxCount - initialFiles.length);
2878
- const arr = validFiles.slice(0, remaining);
2879
- const skippedByCount = validFiles.length - arr.length;
2880
- const errorParts = [];
2881
- if (rejectedByExtension.length > 0) {
2882
- const formats = constraints.allowedExtensions.join(", ");
2883
- const names = rejectedByExtension.map((f) => f.name).join(", ");
2884
- errorParts.push(
2885
- t("invalidFileExtension", state, { name: names, formats })
2886
- );
2887
- }
2888
- if (rejectedBySize.length > 0) {
2889
- const names = rejectedBySize.map((f) => f.name).join(", ");
2890
- errorParts.push(
2891
- t("fileTooLarge", state, { name: names, maxSize: constraints.maxSize })
2892
- );
2893
- }
2894
- if (skippedByCount > 0) {
2895
- errorParts.push(
2896
- t("filesLimitExceeded", state, {
2897
- skipped: skippedByCount,
2898
- max: constraints.maxCount
2899
- })
2900
- );
2901
- }
2902
- if (errorParts.length > 0) {
2903
- showFileError(filesContainer, errorParts.join(" \u2022 "));
2904
- } else {
2905
- clearFileError(filesContainer);
2906
- }
2907
- for (const file of arr) {
2908
- const rid = await uploadSingleFile(file, state);
2909
- state.resourceIndex.set(rid, {
2910
- name: file.name,
2911
- type: file.type,
2912
- size: file.size,
2913
- uploadedAt: /* @__PURE__ */ new Date(),
2914
- file: void 0
2915
- });
2916
- initialFiles.push(rid);
2917
- }
2918
- updateCallback();
2919
- if (instance && pathKey && !state.config.readonly) {
2920
- instance.triggerOnChange(pathKey, initialFiles);
2921
- }
2922
- });
2923
- }
2924
- function setupFilesPickerHandler(filesPicker, initialFiles, state, updateCallback, constraints, pathKey, instance) {
2925
- filesPicker.onchange = async () => {
2926
- if (filesPicker.files) {
2927
- const allFiles = Array.from(filesPicker.files);
2928
- const rejectedByExtension = allFiles.filter(
2929
- (f) => !isFileExtensionAllowed(f.name, constraints.allowedExtensions)
2930
- );
2931
- const afterExtension = allFiles.filter(
2932
- (f) => isFileExtensionAllowed(f.name, constraints.allowedExtensions)
2933
- );
2934
- const rejectedBySize = afterExtension.filter(
2935
- (f) => !isFileSizeAllowed(f, constraints.maxSize)
2936
- );
2937
- const validFiles = afterExtension.filter(
2938
- (f) => isFileSizeAllowed(f, constraints.maxSize)
2939
- );
2940
- const remaining = constraints.maxCount === Infinity ? validFiles.length : Math.max(0, constraints.maxCount - initialFiles.length);
2941
- const arr = validFiles.slice(0, remaining);
2942
- const skippedByCount = validFiles.length - arr.length;
2943
- const errorParts = [];
2944
- if (rejectedByExtension.length > 0) {
2945
- const formats = constraints.allowedExtensions.join(", ");
2946
- const names = rejectedByExtension.map((f) => f.name).join(", ");
2947
- errorParts.push(
2948
- t("invalidFileExtension", state, { name: names, formats })
2949
- );
2950
- }
2951
- if (rejectedBySize.length > 0) {
2952
- const names = rejectedBySize.map((f) => f.name).join(", ");
2953
- errorParts.push(
2954
- t("fileTooLarge", state, {
2955
- name: names,
2956
- maxSize: constraints.maxSize
2957
- })
2958
- );
2959
- }
2960
- if (skippedByCount > 0) {
2961
- errorParts.push(
2962
- t("filesLimitExceeded", state, {
2963
- skipped: skippedByCount,
2964
- max: constraints.maxCount
2965
- })
2966
- );
2967
- }
2968
- const wrapper = filesPicker.closest(".space-y-2") || filesPicker.parentElement;
2969
- if (errorParts.length > 0 && wrapper) {
2970
- showFileError(wrapper, errorParts.join(" \u2022 "));
2971
- } else if (wrapper) {
2972
- clearFileError(wrapper);
2973
- }
2974
- for (const file of arr) {
2975
- const rid = await uploadSingleFile(file, state);
2976
- state.resourceIndex.set(rid, {
2977
- name: file.name,
2978
- type: file.type,
2979
- size: file.size,
2980
- uploadedAt: /* @__PURE__ */ new Date(),
2981
- file: void 0
2982
- });
2983
- initialFiles.push(rid);
2984
- }
2985
- }
2986
- updateCallback();
2987
- filesPicker.value = "";
2988
- if (instance && pathKey && !state.config.readonly) {
2989
- instance.triggerOnChange(pathKey, initialFiles);
2990
- }
3435
+ function renderResourcePills(container, rids, state, onRemove, hint, countInfo, maxCount, isReadonly = false) {
3436
+ ensureFileStyles();
3437
+ const wrapper = container.closest("[data-files-wrapper]");
3438
+ if (wrapper) {
3439
+ wrapper.dataset.resourceIds = JSON.stringify(rids ?? []);
3440
+ }
3441
+ while (container.firstChild) container.removeChild(container.firstChild);
3442
+ const ridList = rids ?? [];
3443
+ const atMax = maxCount !== void 0 && ridList.length >= maxCount;
3444
+ const buildSubHint = () => {
3445
+ const parts = [];
3446
+ if (hint) parts.push(hint);
3447
+ if (countInfo) parts.push(countInfo);
3448
+ return parts.join(" \u2022 ");
2991
3449
  };
2992
- }
2993
- function renderFileElement(element, ctx, wrapper, pathKey) {
2994
- const state = ctx.state;
2995
- if (state.config.readonly) {
2996
- const initial = ctx.prefill[element.key];
2997
- if (initial) {
2998
- renderFilePreviewReadonly(initial, state).then((filePreview) => {
2999
- wrapper.appendChild(filePreview);
3000
- }).catch((err) => {
3001
- console.error("Failed to render file preview:", err);
3002
- const emptyState = document.createElement("div");
3003
- emptyState.className = "aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500";
3004
- emptyState.innerHTML = `<div class="text-center">${escapeHtml(t("previewUnavailable", state))}</div>`;
3005
- wrapper.appendChild(emptyState);
3006
- });
3007
- } else {
3008
- const emptyState = document.createElement("div");
3009
- emptyState.className = "aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500";
3010
- emptyState.innerHTML = `<div class="text-center">${escapeHtml(t("noFileSelected", state))}</div>`;
3011
- wrapper.appendChild(emptyState);
3012
- }
3013
- } else {
3014
- const fileWrapper = document.createElement("div");
3015
- fileWrapper.className = "space-y-2";
3016
- const picker = document.createElement("input");
3017
- picker.type = "file";
3018
- picker.name = pathKey;
3019
- picker.style.display = "none";
3020
- if (element.accept) {
3021
- picker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
3022
- }
3023
- const fileContainer = document.createElement("div");
3024
- fileContainer.className = "file-preview-container w-full aspect-square max-w-xs bg-gray-100 rounded-lg overflow-hidden relative group cursor-pointer";
3025
- const initial = ctx.prefill[element.key];
3026
- const allowedExts = getAllowedExtensions(element.accept);
3027
- const maxSizeMB = element.maxSize ?? Infinity;
3028
- const fileUploadHandler = () => picker.click();
3029
- const dragHandler = (files) => {
3030
- if (files.length > 0) {
3031
- const deps = { picker, fileUploadHandler, dragHandler };
3032
- handleFileSelect(
3033
- files[0],
3034
- fileContainer,
3035
- pathKey,
3036
- state,
3037
- deps,
3038
- ctx.instance,
3039
- allowedExts,
3040
- maxSizeMB
3041
- );
3042
- }
3043
- };
3044
- if (initial) {
3045
- handleInitialFileData(
3046
- initial,
3047
- fileContainer,
3048
- pathKey,
3049
- fileWrapper,
3050
- state,
3051
- {
3052
- picker,
3053
- fileUploadHandler,
3054
- dragHandler
3055
- }
3056
- );
3450
+ const openPicker = () => {
3451
+ const picker = findFilePicker(container);
3452
+ if (picker) picker.click();
3453
+ };
3454
+ if (ridList.length === 0) {
3455
+ if (isReadonly) {
3456
+ const emptyEl = document.createElement("div");
3457
+ emptyEl.className = "fb-tile-empty-text";
3458
+ emptyEl.textContent = t("noFilesSelected", state);
3459
+ container.appendChild(emptyEl);
3057
3460
  } else {
3058
- const hint = makeFieldHint(element, state);
3059
- setEmptyFileContainer(fileContainer, state, hint);
3461
+ const dropzone = document.createElement("div");
3462
+ dropzone.className = "fb-file-dropzone";
3463
+ const subHint2 = buildSubHint();
3464
+ dropzone.innerHTML = `
3465
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0;color:var(--fb-file-upload-text-color,#9ca3af);">
3466
+ <path d="M19.35 10.04A7.49 7.49 0 0012 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 000 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/>
3467
+ </svg>
3468
+ <div class="fb-dropzone-primary-text">${escapeHtml(t("clickDragTextMultiple", state))}</div>
3469
+ ${subHint2 ? `<div class="fb-dropzone-hint-text">${escapeHtml(subHint2)}</div>` : ""}
3470
+ `;
3471
+ dropzone.onclick = openPicker;
3472
+ container.appendChild(dropzone);
3060
3473
  }
3061
- fileContainer.onclick = fileUploadHandler;
3062
- setupDragAndDrop(fileContainer, dragHandler);
3063
- picker.onchange = () => {
3064
- if (picker.files && picker.files.length > 0) {
3065
- const deps = { picker, fileUploadHandler, dragHandler };
3474
+ return;
3475
+ }
3476
+ const tilesWrap = document.createElement("div");
3477
+ tilesWrap.className = "fb-tiles-wrap";
3478
+ tilesWrap.style.cssText = "display:flex;flex-wrap:wrap;gap:6px;align-items:flex-start;";
3479
+ for (const rid of ridList) {
3480
+ const meta = state.resourceIndex.get(rid);
3481
+ const tile = createFileTile();
3482
+ tile.classList.add("fb-tile-resource", "resource-pill");
3483
+ tile.dataset.resourceId = rid;
3484
+ const actionsEl = createTileActions({
3485
+ canRemove: !isReadonly && onRemove !== null,
3486
+ removeHandler: onRemove ? () => onRemove(rid) : null,
3487
+ state,
3488
+ resourceId: rid,
3489
+ fileName: meta?.name ?? ""
3490
+ });
3491
+ fillTileContent(tile, rid, meta, state, actionsEl).catch((err) => {
3492
+ console.error("Failed to render tile:", err);
3493
+ });
3494
+ tilesWrap.appendChild(tile);
3495
+ }
3496
+ if (!isReadonly && !atMax) {
3497
+ const addTile = document.createElement("div");
3498
+ addTile.className = "fb-tile fb-tile-add";
3499
+ addTile.innerHTML = "+";
3500
+ addTile.onclick = openPicker;
3501
+ tilesWrap.appendChild(addTile);
3502
+ } else if (!isReadonly && atMax) {
3503
+ const chip = document.createElement("div");
3504
+ chip.className = "fb-tile-counter";
3505
+ chip.textContent = t("filesCounter", state, {
3506
+ count: ridList.length,
3507
+ max: maxCount
3508
+ });
3509
+ tilesWrap.appendChild(chip);
3510
+ }
3511
+ container.appendChild(tilesWrap);
3512
+ const subHint = buildSubHint();
3513
+ if (subHint) {
3514
+ const hintEl = document.createElement("div");
3515
+ hintEl.className = "fb-tile-hint";
3516
+ hintEl.textContent = subHint;
3517
+ container.appendChild(hintEl);
3518
+ }
3519
+ }
3520
+ function renderFileElementEdit(element, ctx, wrapper, pathKey) {
3521
+ const state = ctx.state;
3522
+ const fileWrapper = document.createElement("div");
3523
+ fileWrapper.className = "space-y-2";
3524
+ const picker = document.createElement("input");
3525
+ picker.type = "file";
3526
+ picker.name = pathKey;
3527
+ picker.style.display = "none";
3528
+ if (element.accept) {
3529
+ picker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
3530
+ }
3531
+ const fileContainer = document.createElement("div");
3532
+ fileContainer.className = "file-preview-container";
3533
+ const initial = ctx.prefill[element.key];
3534
+ const allowedExts = getAllowedExtensions(element.accept);
3535
+ const maxSizeMB = element.maxSize ?? Infinity;
3536
+ const handlers = {
3537
+ fileUploadHandler() {
3538
+ picker.click();
3539
+ },
3540
+ dragHandler(files) {
3541
+ if (files.length > 0) {
3066
3542
  handleFileSelect(
3067
- picker.files[0],
3543
+ files[0],
3068
3544
  fileContainer,
3069
3545
  pathKey,
3070
3546
  state,
3071
- deps,
3547
+ buildDeps(),
3072
3548
  ctx.instance,
3073
3549
  allowedExts,
3074
3550
  maxSizeMB
3075
3551
  );
3076
3552
  }
3077
- };
3078
- fileWrapper.appendChild(fileContainer);
3079
- fileWrapper.appendChild(picker);
3080
- wrapper.appendChild(fileWrapper);
3081
- }
3082
- }
3083
- function renderFilesElement(element, ctx, wrapper, pathKey) {
3084
- const state = ctx.state;
3085
- if (state.config.readonly) {
3086
- const resultsWrapper = document.createElement("div");
3087
- resultsWrapper.className = "space-y-4";
3088
- const initialFiles = ctx.prefill[element.key] || [];
3089
- if (initialFiles.length > 0) {
3090
- initialFiles.forEach((resourceId) => {
3091
- renderFilePreviewReadonly(resourceId, state).then((filePreview) => {
3092
- resultsWrapper.appendChild(filePreview);
3093
- }).catch((err) => {
3094
- console.error("Failed to render file preview:", err);
3095
- });
3096
- });
3097
- } else {
3098
- resultsWrapper.innerHTML = `<div class="aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500"><div class="text-center">${escapeHtml(t("noFilesSelected", state))}</div></div>`;
3553
+ },
3554
+ setupDrop(container) {
3555
+ setupDragAndDrop(container, handlers.dragHandler);
3556
+ },
3557
+ restoreDropzone() {
3558
+ const hint = makeFieldHint(element, state);
3559
+ fileContainer.className = "file-preview-container w-full max-w-md bg-gray-100 rounded-lg overflow-hidden relative group cursor-pointer";
3560
+ fileContainer.style.height = "128px";
3561
+ setEmptyFileContainer(fileContainer, state, hint);
3562
+ fileContainer.onclick = handlers.fileUploadHandler;
3563
+ setupDragAndDrop(fileContainer, handlers.dragHandler);
3564
+ },
3565
+ onRemove() {
3566
+ const hiddenInput = fileWrapper.querySelector('input[type="hidden"]');
3567
+ const currentRid = hiddenInput?.value;
3568
+ if (currentRid) {
3569
+ releaseLocalFileUrl(state.resourceIndex.get(currentRid)?.file);
3570
+ }
3571
+ if (hiddenInput) hiddenInput.value = "";
3572
+ handlers.restoreDropzone();
3573
+ }
3574
+ };
3575
+ const buildDeps = () => ({
3576
+ picker,
3577
+ fileUploadHandler: handlers.fileUploadHandler,
3578
+ dragHandler: handlers.dragHandler,
3579
+ setupDrop: handlers.setupDrop,
3580
+ onRemove: handlers.onRemove
3581
+ });
3582
+ if (initial) {
3583
+ handleInitialFileData(
3584
+ initial,
3585
+ fileContainer,
3586
+ pathKey,
3587
+ fileWrapper,
3588
+ state,
3589
+ buildDeps()
3590
+ );
3591
+ const prefillMeta = state.resourceIndex.get(initial);
3592
+ if (prefillMeta?.type?.startsWith("video/")) {
3593
+ fileContainer.onclick = handlers.fileUploadHandler;
3594
+ setupDragAndDrop(fileContainer, handlers.dragHandler);
3099
3595
  }
3100
- wrapper.appendChild(resultsWrapper);
3101
3596
  } else {
3102
- let updateFilesList2 = function() {
3103
- renderResourcePills(
3104
- list,
3105
- initialFiles,
3597
+ handlers.restoreDropzone();
3598
+ }
3599
+ picker.onchange = () => {
3600
+ if (picker.files && picker.files.length > 0) {
3601
+ handleFileSelect(
3602
+ picker.files[0],
3603
+ fileContainer,
3604
+ pathKey,
3106
3605
  state,
3107
- (ridToRemove) => {
3108
- const index = initialFiles.indexOf(ridToRemove);
3109
- if (index > -1) {
3110
- initialFiles.splice(index, 1);
3111
- }
3112
- updateFilesList2();
3113
- },
3114
- filesFieldHint
3606
+ buildDeps(),
3607
+ ctx.instance,
3608
+ allowedExts,
3609
+ maxSizeMB
3115
3610
  );
3116
- };
3117
- const filesWrapper = document.createElement("div");
3118
- filesWrapper.className = "space-y-2";
3119
- const filesPicker = document.createElement("input");
3120
- filesPicker.type = "file";
3121
- filesPicker.name = pathKey;
3122
- filesPicker.multiple = true;
3123
- filesPicker.style.display = "none";
3124
- if (element.accept) {
3125
- filesPicker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
3126
- }
3127
- const filesContainer = document.createElement("div");
3128
- filesContainer.className = "border-2 border-dashed border-gray-300 rounded-lg p-3 hover:border-gray-400 transition-colors";
3129
- const list = document.createElement("div");
3130
- list.className = "files-list";
3131
- const initialFiles = ctx.prefill[element.key] || [];
3132
- addPrefillFilesToIndex(initialFiles, state);
3133
- const filesFieldHint = makeFieldHint(element, state);
3134
- const filesConstraints = {
3135
- maxCount: Infinity,
3136
- allowedExtensions: getAllowedExtensions(element.accept),
3137
- maxSize: element.maxSize ?? Infinity
3138
- };
3139
- updateFilesList2();
3140
- setupFilesDropHandler(
3141
- filesContainer,
3142
- initialFiles,
3143
- state,
3144
- updateFilesList2,
3145
- filesConstraints,
3146
- pathKey,
3147
- ctx.instance
3148
- );
3149
- setupFilesPickerHandler(
3150
- filesPicker,
3611
+ }
3612
+ };
3613
+ fileWrapper.appendChild(fileContainer);
3614
+ fileWrapper.appendChild(picker);
3615
+ wrapper.appendChild(fileWrapper);
3616
+ }
3617
+ function renderFilesElementEdit(element, ctx, wrapper, pathKey) {
3618
+ const state = ctx.state;
3619
+ const filesWrapper = document.createElement("div");
3620
+ filesWrapper.className = "space-y-2";
3621
+ filesWrapper.dataset.filesWrapper = pathKey;
3622
+ const filesPicker = document.createElement("input");
3623
+ filesPicker.type = "file";
3624
+ filesPicker.name = pathKey;
3625
+ filesPicker.multiple = true;
3626
+ filesPicker.style.display = "none";
3627
+ if (element.accept) {
3628
+ filesPicker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
3629
+ }
3630
+ const filesContainer = document.createElement("div");
3631
+ filesContainer.className = "files-list-wrapper";
3632
+ filesContainer.style.cssText = "border:2px dashed var(--fb-file-upload-border-color,#d1d5db);border-radius:var(--fb-border-radius,0.5rem);padding:8px;transition:border-color var(--fb-transition-duration,200ms),background var(--fb-transition-duration,200ms);";
3633
+ const list = document.createElement("div");
3634
+ list.className = "files-list";
3635
+ const initialFiles = ctx.prefill[element.key] || [];
3636
+ addPrefillFilesToIndex(initialFiles, state.resourceIndex);
3637
+ filesWrapper.dataset.resourceIds = JSON.stringify(initialFiles);
3638
+ const filesFieldHint = makeFieldHint(element, state);
3639
+ const filesConstraints = {
3640
+ maxCount: Infinity,
3641
+ allowedExtensions: getAllowedExtensions(element.accept),
3642
+ maxSize: element.maxSize ?? Infinity
3643
+ };
3644
+ filesContainer.appendChild(list);
3645
+ filesWrapper.appendChild(filesPicker);
3646
+ filesWrapper.appendChild(filesContainer);
3647
+ wrapper.appendChild(filesWrapper);
3648
+ function updateFilesList() {
3649
+ const currentlyReadonly = isElementReadonly(element, state);
3650
+ renderResourcePills(
3651
+ list,
3151
3652
  initialFiles,
3152
3653
  state,
3153
- updateFilesList2,
3154
- filesConstraints,
3155
- pathKey,
3156
- ctx.instance
3654
+ currentlyReadonly ? null : (ridToRemove) => {
3655
+ releaseLocalFileUrl(state.resourceIndex.get(ridToRemove)?.file);
3656
+ const index = initialFiles.indexOf(ridToRemove);
3657
+ if (index > -1) initialFiles.splice(index, 1);
3658
+ updateFilesList();
3659
+ },
3660
+ filesFieldHint,
3661
+ void 0,
3662
+ void 0,
3663
+ currentlyReadonly
3157
3664
  );
3158
- filesContainer.appendChild(list);
3159
- filesWrapper.appendChild(filesContainer);
3160
- filesWrapper.appendChild(filesPicker);
3161
- wrapper.appendChild(filesWrapper);
3162
3665
  }
3666
+ updateFilesList();
3667
+ setupFilesDropHandler(
3668
+ filesContainer,
3669
+ initialFiles,
3670
+ state,
3671
+ updateFilesList,
3672
+ filesConstraints,
3673
+ pathKey,
3674
+ ctx.instance
3675
+ );
3676
+ setupFilesPickerHandler(
3677
+ filesPicker,
3678
+ initialFiles,
3679
+ state,
3680
+ updateFilesList,
3681
+ filesConstraints,
3682
+ pathKey,
3683
+ ctx.instance
3684
+ );
3163
3685
  }
3164
- function renderMultipleFileElement(element, ctx, wrapper, pathKey) {
3686
+ function renderMultipleFileElementEdit(element, ctx, wrapper, pathKey) {
3165
3687
  const state = ctx.state;
3166
3688
  const minFiles = element.minCount ?? 0;
3167
3689
  const maxFiles = element.maxCount ?? Infinity;
3168
- if (state.config.readonly) {
3169
- const resultsWrapper = document.createElement("div");
3170
- resultsWrapper.className = "space-y-4";
3171
- const initialFiles = ctx.prefill[element.key] || [];
3172
- if (initialFiles.length > 0) {
3173
- initialFiles.forEach((resourceId) => {
3174
- renderFilePreviewReadonly(resourceId, state).then((filePreview) => {
3175
- resultsWrapper.appendChild(filePreview);
3176
- }).catch((err) => {
3177
- console.error("Failed to render file preview:", err);
3178
- });
3179
- });
3180
- } else {
3181
- resultsWrapper.innerHTML = `<div class="aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500"><div class="text-center">${escapeHtml(t("noFilesSelected", state))}</div></div>`;
3182
- }
3183
- wrapper.appendChild(resultsWrapper);
3184
- } else {
3185
- const filesWrapper = document.createElement("div");
3186
- filesWrapper.className = "space-y-2";
3187
- const filesPicker = document.createElement("input");
3188
- filesPicker.type = "file";
3189
- filesPicker.name = pathKey;
3190
- filesPicker.multiple = true;
3191
- filesPicker.style.display = "none";
3192
- if (element.accept) {
3193
- filesPicker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
3194
- }
3195
- const filesContainer = document.createElement("div");
3196
- filesContainer.className = "files-list space-y-2";
3197
- filesWrapper.appendChild(filesPicker);
3198
- filesWrapper.appendChild(filesContainer);
3199
- const initialFiles = Array.isArray(ctx.prefill[element.key]) ? [...ctx.prefill[element.key]] : [];
3200
- addPrefillFilesToIndex(initialFiles, state);
3201
- const multipleFilesHint = makeFieldHint(element, state);
3202
- const multipleConstraints = {
3203
- maxCount: maxFiles,
3204
- allowedExtensions: getAllowedExtensions(element.accept),
3205
- maxSize: element.maxSize ?? Infinity
3206
- };
3207
- const buildCountInfo = () => {
3208
- const countText = initialFiles.length === 1 ? t("fileCountSingle", state, { count: initialFiles.length }) : t("fileCountPlural", state, { count: initialFiles.length });
3209
- const minMaxText = minFiles > 0 || maxFiles < Infinity ? ` ${t("fileCountRange", state, { min: minFiles, max: maxFiles })}` : "";
3210
- return countText + minMaxText;
3211
- };
3212
- const updateFilesDisplay = () => {
3213
- renderResourcePills(
3214
- filesContainer,
3215
- initialFiles,
3216
- state,
3217
- (index) => {
3218
- initialFiles.splice(initialFiles.indexOf(index), 1);
3219
- updateFilesDisplay();
3220
- },
3221
- multipleFilesHint,
3222
- buildCountInfo()
3223
- );
3224
- };
3225
- setupFilesDropHandler(
3226
- filesContainer,
3690
+ const filesWrapper = document.createElement("div");
3691
+ filesWrapper.className = "space-y-2";
3692
+ filesWrapper.dataset.filesWrapper = pathKey;
3693
+ const filesPicker = document.createElement("input");
3694
+ filesPicker.type = "file";
3695
+ filesPicker.name = pathKey;
3696
+ filesPicker.multiple = true;
3697
+ filesPicker.style.display = "none";
3698
+ if (element.accept) {
3699
+ filesPicker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
3700
+ }
3701
+ const filesContainer = document.createElement("div");
3702
+ filesContainer.className = "files-list-wrapper";
3703
+ filesContainer.style.cssText = "border:2px dashed var(--fb-file-upload-border-color,#d1d5db);border-radius:var(--fb-border-radius,0.5rem);padding:8px;transition:border-color var(--fb-transition-duration,200ms),background var(--fb-transition-duration,200ms);";
3704
+ const list = document.createElement("div");
3705
+ list.className = "files-list";
3706
+ filesWrapper.appendChild(filesPicker);
3707
+ filesWrapper.appendChild(filesContainer);
3708
+ filesContainer.appendChild(list);
3709
+ const initialFiles = Array.isArray(ctx.prefill[element.key]) ? [...ctx.prefill[element.key]] : [];
3710
+ addPrefillFilesToIndex(initialFiles, state.resourceIndex);
3711
+ filesWrapper.dataset.resourceIds = JSON.stringify(initialFiles);
3712
+ const multipleFilesHint = makeFieldHint(element, state);
3713
+ const multipleConstraints = {
3714
+ maxCount: maxFiles,
3715
+ allowedExtensions: getAllowedExtensions(element.accept),
3716
+ maxSize: element.maxSize ?? Infinity
3717
+ };
3718
+ const buildCountInfo = () => {
3719
+ const countText = initialFiles.length === 1 ? t("fileCountSingle", state, { count: initialFiles.length }) : t("fileCountPlural", state, { count: initialFiles.length });
3720
+ const minMaxText = minFiles > 0 || maxFiles < Infinity ? ` ${t("fileCountRange", state, { min: minFiles, max: maxFiles })}` : "";
3721
+ return countText + minMaxText;
3722
+ };
3723
+ const updateFilesDisplay = () => {
3724
+ const currentlyReadonly = isElementReadonly(element, state);
3725
+ renderResourcePills(
3726
+ list,
3227
3727
  initialFiles,
3228
3728
  state,
3229
- updateFilesDisplay,
3230
- multipleConstraints,
3231
- pathKey,
3232
- ctx.instance
3729
+ currentlyReadonly ? null : (index) => {
3730
+ releaseLocalFileUrl(state.resourceIndex.get(index)?.file);
3731
+ initialFiles.splice(initialFiles.indexOf(index), 1);
3732
+ updateFilesDisplay();
3733
+ },
3734
+ multipleFilesHint,
3735
+ buildCountInfo(),
3736
+ maxFiles < Infinity ? maxFiles : void 0,
3737
+ currentlyReadonly
3233
3738
  );
3234
- setupFilesPickerHandler(
3235
- filesPicker,
3236
- initialFiles,
3237
- state,
3238
- updateFilesDisplay,
3239
- multipleConstraints,
3240
- pathKey,
3241
- ctx.instance
3739
+ };
3740
+ setupFilesDropHandler(
3741
+ filesContainer,
3742
+ initialFiles,
3743
+ state,
3744
+ updateFilesDisplay,
3745
+ multipleConstraints,
3746
+ pathKey,
3747
+ ctx.instance
3748
+ );
3749
+ setupFilesPickerHandler(
3750
+ filesPicker,
3751
+ initialFiles,
3752
+ state,
3753
+ updateFilesDisplay,
3754
+ multipleConstraints,
3755
+ pathKey,
3756
+ ctx.instance
3757
+ );
3758
+ updateFilesDisplay();
3759
+ wrapper.appendChild(filesWrapper);
3760
+ }
3761
+
3762
+ // src/components/file/validate.ts
3763
+ function readMultiFileResourceIds(scopeRoot, fullKey) {
3764
+ const wrapper = scopeRoot.querySelector(
3765
+ `[data-files-wrapper="${fullKey}"]`
3766
+ );
3767
+ if (!wrapper) return [];
3768
+ const encoded = wrapper.dataset.resourceIds;
3769
+ if (encoded === void 0) {
3770
+ throw new Error(
3771
+ `readMultiFileResourceIds: [data-files-wrapper="${fullKey}"] is missing data-resource-ids attribute. This is a render bug.`
3242
3772
  );
3243
- updateFilesDisplay();
3244
- wrapper.appendChild(filesWrapper);
3245
3773
  }
3774
+ const parsed = JSON.parse(encoded);
3775
+ if (!Array.isArray(parsed)) {
3776
+ throw new Error(
3777
+ `readMultiFileResourceIds: data-resource-ids on [data-files-wrapper="${fullKey}"] is not a JSON array. Got: ${encoded}`
3778
+ );
3779
+ }
3780
+ return parsed;
3246
3781
  }
3247
- function validateFileElement(element, key, context) {
3248
- const errors = [];
3249
- const { scopeRoot, skipValidation, path } = context;
3250
- const isMultipleField = element.type === "files" || "multiple" in element && Boolean(element.multiple);
3251
- const validateFileCount = (key2, resourceIds, element2) => {
3252
- if (skipValidation) return;
3253
- const { state } = context;
3254
- const minFiles = "minCount" in element2 ? element2.minCount ?? 0 : 0;
3255
- const maxFiles = "maxCount" in element2 ? element2.maxCount ?? Infinity : Infinity;
3256
- if (element2.required && resourceIds.length === 0) {
3257
- errors.push(`${key2}: ${t("required", state)}`);
3258
- }
3259
- if (resourceIds.length < minFiles) {
3260
- errors.push(`${key2}: ${t("minFiles", state, { min: minFiles })}`);
3261
- }
3262
- if (resourceIds.length > maxFiles) {
3263
- errors.push(`${key2}: ${t("maxFiles", state, { max: maxFiles })}`);
3264
- }
3265
- };
3266
- const validateFileExtensions = (key2, resourceIds, element2) => {
3267
- if (skipValidation) return;
3268
- const { state } = context;
3269
- const acceptField = "accept" in element2 ? element2.accept : void 0;
3270
- const allowedExtensions = getAllowedExtensions(acceptField);
3271
- if (allowedExtensions.length === 0) return;
3272
- const formats = allowedExtensions.join(", ");
3273
- for (const rid of resourceIds) {
3274
- const meta = state.resourceIndex.get(rid);
3275
- const fileName = meta?.name ?? rid;
3276
- if (!isFileExtensionAllowed(fileName, allowedExtensions)) {
3277
- errors.push(
3278
- `${key2}: ${t("invalidFileExtension", state, { name: fileName, formats })}`
3279
- );
3280
- }
3782
+ function validateFileCount(key, resourceIds, element, state, errors) {
3783
+ const minFiles = "minCount" in element ? element.minCount ?? 0 : 0;
3784
+ const maxFiles = "maxCount" in element ? element.maxCount ?? Infinity : Infinity;
3785
+ if (element.required && resourceIds.length === 0) {
3786
+ errors.push(`${key}: ${t("required", state)}`);
3787
+ }
3788
+ if (resourceIds.length < minFiles) {
3789
+ errors.push(`${key}: ${t("minFiles", state, { min: minFiles })}`);
3790
+ }
3791
+ if (resourceIds.length > maxFiles) {
3792
+ errors.push(`${key}: ${t("maxFiles", state, { max: maxFiles })}`);
3793
+ }
3794
+ }
3795
+ function validateFileExtensions(key, resourceIds, element, state, errors) {
3796
+ const acceptField = "accept" in element ? element.accept : void 0;
3797
+ const allowedExtensions = getAllowedExtensions(acceptField);
3798
+ if (allowedExtensions.length === 0) return;
3799
+ const formats = allowedExtensions.join(", ");
3800
+ for (const rid of resourceIds) {
3801
+ const meta = state.resourceIndex.get(rid);
3802
+ const fileName = meta?.name ?? rid;
3803
+ if (!isFileExtensionAllowed(fileName, allowedExtensions)) {
3804
+ errors.push(
3805
+ `${key}: ${t("invalidFileExtension", state, { name: fileName, formats })}`
3806
+ );
3281
3807
  }
3282
- };
3283
- const validateFileSizes = (key2, resourceIds, element2) => {
3284
- if (skipValidation) return;
3285
- const { state } = context;
3286
- const maxSizeMB = "maxSize" in element2 ? element2.maxSize ?? Infinity : Infinity;
3287
- if (maxSizeMB === Infinity) return;
3288
- for (const rid of resourceIds) {
3289
- const meta = state.resourceIndex.get(rid);
3290
- if (!meta) continue;
3291
- if (meta.size > maxSizeMB * 1024 * 1024) {
3292
- errors.push(
3293
- `${key2}: ${t("fileTooLarge", state, { name: meta.name, maxSize: maxSizeMB })}`
3294
- );
3295
- }
3808
+ }
3809
+ }
3810
+ function validateFileSizes(key, resourceIds, element, state, errors) {
3811
+ const maxSizeMB = "maxSize" in element ? element.maxSize ?? Infinity : Infinity;
3812
+ if (maxSizeMB === Infinity) return;
3813
+ for (const rid of resourceIds) {
3814
+ const meta = state.resourceIndex.get(rid);
3815
+ if (!meta) continue;
3816
+ if (meta.size > maxSizeMB * 1024 * 1024) {
3817
+ errors.push(
3818
+ `${key}: ${t("fileTooLarge", state, { name: meta.name, maxSize: maxSizeMB })}`
3819
+ );
3296
3820
  }
3297
- };
3821
+ }
3822
+ }
3823
+ function validateMultiFile(element, key, context) {
3824
+ const { scopeRoot, skipValidation, path, state } = context;
3825
+ const errors = [];
3826
+ const fullKey = pathJoin(path, key);
3827
+ const resourceIds = readMultiFileResourceIds(scopeRoot, fullKey);
3828
+ if (!skipValidation) {
3829
+ validateFileCount(key, resourceIds, element, state, errors);
3830
+ validateFileExtensions(key, resourceIds, element, state, errors);
3831
+ validateFileSizes(key, resourceIds, element, state, errors);
3832
+ }
3833
+ return { value: resourceIds, errors };
3834
+ }
3835
+ function validateSingleFile(element, key, context) {
3836
+ const { scopeRoot, skipValidation, state } = context;
3837
+ const errors = [];
3838
+ const input = scopeRoot.querySelector(
3839
+ `input[name$="${key}"][type="hidden"]`
3840
+ );
3841
+ const rid = input?.value ?? "";
3842
+ if (!skipValidation && element.required && rid === "") {
3843
+ errors.push(`${key}: ${t("required", state)}`);
3844
+ return { value: null, errors };
3845
+ }
3846
+ if (!skipValidation && rid !== "") {
3847
+ validateFileExtensions(key, [rid], element, state, errors);
3848
+ validateFileSizes(key, [rid], element, state, errors);
3849
+ }
3850
+ return { value: rid || null, errors };
3851
+ }
3852
+ function validateFileElement(element, key, context) {
3853
+ const isMultipleField = element.type === "files" || "multiple" in element && Boolean(element.multiple);
3298
3854
  if (isMultipleField) {
3299
- const fullKey = pathJoin(path, key);
3300
- const pickerInput = scopeRoot.querySelector(
3301
- `input[type="file"][name="${fullKey}"]`
3302
- );
3303
- const filesWrapper = pickerInput?.closest(".space-y-2");
3304
- const container = filesWrapper?.querySelector(".files-list") || null;
3305
- const resourceIds = [];
3306
- if (container) {
3307
- const pills = container.querySelectorAll(".resource-pill");
3308
- pills.forEach((pill) => {
3309
- const resourceId = pill.dataset.resourceId;
3310
- if (resourceId) {
3311
- resourceIds.push(resourceId);
3312
- }
3313
- });
3314
- }
3315
- validateFileCount(key, resourceIds, element);
3316
- validateFileExtensions(key, resourceIds, element);
3317
- validateFileSizes(key, resourceIds, element);
3318
- return { value: resourceIds, errors };
3855
+ return validateMultiFile(element, key, context);
3856
+ }
3857
+ return validateSingleFile(element, key, context);
3858
+ }
3859
+
3860
+ // src/components/file/render-readonly.ts
3861
+ function renderFileElementReadonly(element, ctx, wrapper, pathKey) {
3862
+ const state = ctx.state;
3863
+ const rawInitial = ctx.prefill[element.key];
3864
+ const initial = typeof rawInitial === "string" ? rawInitial : "";
3865
+ if (initial) {
3866
+ addPrefillFilesToIndex([initial], state.resourceIndex);
3867
+ const hiddenInput = document.createElement("input");
3868
+ hiddenInput.type = "hidden";
3869
+ hiddenInput.name = pathKey;
3870
+ hiddenInput.value = initial;
3871
+ wrapper.appendChild(hiddenInput);
3872
+ renderFilePreviewReadonly(initial, state).then((filePreview) => {
3873
+ wrapper.appendChild(filePreview);
3874
+ }).catch((err) => {
3875
+ console.error("Failed to render file preview:", err);
3876
+ wrapper.appendChild(buildEmptyReadonlyTile(state));
3877
+ });
3319
3878
  } else {
3320
- const input = scopeRoot.querySelector(
3321
- `input[name$="${key}"][type="hidden"]`
3322
- );
3323
- const rid = input?.value ?? "";
3324
- if (!skipValidation && element.required && rid === "") {
3325
- errors.push(`${key}: ${t("required", context.state)}`);
3326
- return { value: null, errors };
3327
- }
3328
- if (!skipValidation && rid !== "") {
3329
- validateFileExtensions(key, [rid], element);
3330
- validateFileSizes(key, [rid], element);
3331
- }
3332
- return { value: rid || null, errors };
3879
+ wrapper.appendChild(buildEmptyReadonlyTile(state));
3880
+ }
3881
+ }
3882
+ function buildEmptyReadonlyTile(state) {
3883
+ const emptyState = document.createElement("div");
3884
+ emptyState.style.cssText = `
3885
+ width:${TILE_SIZE};
3886
+ height:${TILE_SIZE};
3887
+ display:flex;
3888
+ align-items:center;
3889
+ justify-content:center;
3890
+ background:var(--fb-file-upload-bg-color,#f3f4f6);
3891
+ border-radius:var(--fb-border-radius,0.5rem);
3892
+ border:1px solid var(--fb-file-upload-border-color,#d1d5db);
3893
+ `;
3894
+ emptyState.innerHTML = `<div style="font-size:11px;text-align:center;color:var(--fb-text-secondary-color,#6b7280);">${escapeHtml(t("noFileSelected", state))}</div>`;
3895
+ return emptyState;
3896
+ }
3897
+ function renderMultiFileReadonly(rids, state, wrapper, pathKey, marginTop) {
3898
+ addPrefillFilesToIndex(rids, state.resourceIndex);
3899
+ const filesWrapper = document.createElement("div");
3900
+ filesWrapper.dataset.filesWrapper = pathKey;
3901
+ filesWrapper.dataset.resourceIds = JSON.stringify(rids);
3902
+ wrapper.appendChild(filesWrapper);
3903
+ if (rids.length === 0) {
3904
+ const emptyEl = document.createElement("div");
3905
+ emptyEl.className = "fb-tile-empty-text";
3906
+ emptyEl.textContent = t("noFilesSelected", state);
3907
+ filesWrapper.appendChild(emptyEl);
3908
+ return;
3909
+ }
3910
+ const tilesWrap = document.createElement("div");
3911
+ tilesWrap.style.cssText = `display:flex;flex-wrap:wrap;gap:6px;${marginTop ? `margin-top:${marginTop};` : ""}`;
3912
+ filesWrapper.appendChild(tilesWrap);
3913
+ const placeholders = rids.map(() => {
3914
+ const placeholder = document.createElement("div");
3915
+ placeholder.style.cssText = `width:${TILE_SIZE};height:${TILE_SIZE};`;
3916
+ tilesWrap.appendChild(placeholder);
3917
+ return placeholder;
3918
+ });
3919
+ for (let i = 0; i < rids.length; i++) {
3920
+ const resourceId = rids[i];
3921
+ const placeholder = placeholders[i];
3922
+ renderFilePreviewReadonly(resourceId, state).then((tileEl) => {
3923
+ placeholder.replaceWith(tileEl);
3924
+ }).catch((err) => {
3925
+ console.error("Failed to render readonly tile:", err);
3926
+ });
3927
+ }
3928
+ }
3929
+ function renderFilesElementReadonly(element, ctx, wrapper, pathKey) {
3930
+ const rawPrefill = ctx.prefill[element.key];
3931
+ const initialFiles = Array.isArray(rawPrefill) ? rawPrefill : [];
3932
+ renderMultiFileReadonly(initialFiles, ctx.state, wrapper, pathKey);
3933
+ }
3934
+ function renderMultipleFileElementReadonly(element, ctx, wrapper, pathKey) {
3935
+ const rawPrefill = ctx.prefill[element.key];
3936
+ const initialFiles = Array.isArray(rawPrefill) ? rawPrefill : [];
3937
+ renderMultiFileReadonly(initialFiles, ctx.state, wrapper, pathKey, "4px");
3938
+ }
3939
+
3940
+ // src/components/file.ts
3941
+ function renderFileElement(element, ctx, wrapper, pathKey) {
3942
+ if (isElementReadonly(element, ctx.state, ctx)) {
3943
+ renderFileElementReadonly(element, ctx, wrapper, pathKey);
3944
+ } else {
3945
+ renderFileElementEdit(element, ctx, wrapper, pathKey);
3946
+ }
3947
+ }
3948
+ function renderFilesElement(element, ctx, wrapper, pathKey) {
3949
+ if (isElementReadonly(element, ctx.state, ctx)) {
3950
+ renderFilesElementReadonly(element, ctx, wrapper, pathKey);
3951
+ } else {
3952
+ renderFilesElementEdit(element, ctx, wrapper, pathKey);
3953
+ }
3954
+ }
3955
+ function renderMultipleFileElement(element, ctx, wrapper, pathKey) {
3956
+ if (isElementReadonly(element, ctx.state, ctx)) {
3957
+ renderMultipleFileElementReadonly(element, ctx, wrapper, pathKey);
3958
+ } else {
3959
+ renderMultipleFileElementEdit(element, ctx, wrapper, pathKey);
3333
3960
  }
3334
3961
  }
3335
3962
  function updateFileField(element, fieldPath, value, context) {
3336
3963
  const { scopeRoot, state } = context;
3337
- if ("multiple" in element && element.multiple) {
3964
+ if (element.type === "files" || "multiple" in element && element.multiple) {
3338
3965
  if (!Array.isArray(value)) {
3339
3966
  console.warn(
3340
3967
  `updateFileField: Expected array for multiple file field "${fieldPath}", got ${typeof value}`
@@ -3344,29 +3971,20 @@ function updateFileField(element, fieldPath, value, context) {
3344
3971
  value.forEach((resourceId) => {
3345
3972
  if (resourceId && typeof resourceId === "string") {
3346
3973
  if (!state.resourceIndex.has(resourceId)) {
3347
- const filename = resourceId.split("/").pop() || "file";
3348
- const extension = filename.split(".").pop()?.toLowerCase();
3349
- let fileType = "application/octet-stream";
3350
- if (extension) {
3351
- if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
3352
- fileType = `image/${extension === "jpg" ? "jpeg" : extension}`;
3353
- } else if (["mp4", "webm", "mov", "avi"].includes(extension)) {
3354
- fileType = `video/${extension === "mov" ? "quicktime" : extension}`;
3355
- }
3356
- }
3357
- state.resourceIndex.set(resourceId, {
3358
- name: filename,
3359
- type: fileType,
3360
- size: 0,
3361
- uploadedAt: /* @__PURE__ */ new Date(),
3362
- file: void 0
3363
- });
3974
+ addResourceToIndex(resourceId, state);
3364
3975
  }
3365
3976
  }
3366
3977
  });
3367
- console.info(
3368
- `updateFileField: Multiple file field "${fieldPath}" updated. Preview update requires re-render.`
3978
+ const filesWrapper = scopeRoot.querySelector(
3979
+ `[data-files-wrapper="${fieldPath}"]`
3369
3980
  );
3981
+ if (filesWrapper) {
3982
+ filesWrapper.dataset.resourceIds = JSON.stringify(value);
3983
+ } else {
3984
+ console.warn(
3985
+ `updateFileField: [data-files-wrapper="${fieldPath}"] not found in DOM; data-resource-ids not updated`
3986
+ );
3987
+ }
3370
3988
  } else {
3371
3989
  const hiddenInput = scopeRoot.querySelector(
3372
3990
  `input[name="${fieldPath}"][type="hidden"]`
@@ -3380,23 +3998,7 @@ function updateFileField(element, fieldPath, value, context) {
3380
3998
  hiddenInput.value = value != null ? String(value) : "";
3381
3999
  if (value && typeof value === "string") {
3382
4000
  if (!state.resourceIndex.has(value)) {
3383
- const filename = value.split("/").pop() || "file";
3384
- const extension = filename.split(".").pop()?.toLowerCase();
3385
- let fileType = "application/octet-stream";
3386
- if (extension) {
3387
- if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
3388
- fileType = `image/${extension === "jpg" ? "jpeg" : extension}`;
3389
- } else if (["mp4", "webm", "mov", "avi"].includes(extension)) {
3390
- fileType = `video/${extension === "mov" ? "quicktime" : extension}`;
3391
- }
3392
- }
3393
- state.resourceIndex.set(value, {
3394
- name: filename,
3395
- type: fileType,
3396
- size: 0,
3397
- uploadedAt: /* @__PURE__ */ new Date(),
3398
- file: void 0
3399
- });
4001
+ addResourceToIndex(value, state);
3400
4002
  }
3401
4003
  console.info(
3402
4004
  `updateFileField: File field "${fieldPath}" updated. Preview update requires re-render.`
@@ -3404,6 +4006,25 @@ function updateFileField(element, fieldPath, value, context) {
3404
4006
  }
3405
4007
  }
3406
4008
  }
4009
+ function addResourceToIndex(resourceId, state) {
4010
+ const filename = resourceId.split("/").pop() || "file";
4011
+ const extension = filename.split(".").pop()?.toLowerCase();
4012
+ let fileType = "application/octet-stream";
4013
+ if (extension) {
4014
+ if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
4015
+ fileType = `image/${extension === "jpg" ? "jpeg" : extension}`;
4016
+ } else if (["mp4", "webm", "mov", "avi"].includes(extension)) {
4017
+ fileType = `video/${extension === "mov" ? "quicktime" : extension}`;
4018
+ }
4019
+ }
4020
+ state.resourceIndex.set(resourceId, {
4021
+ name: filename,
4022
+ type: fileType,
4023
+ size: 0,
4024
+ uploadedAt: /* @__PURE__ */ new Date(),
4025
+ file: void 0
4026
+ });
4027
+ }
3407
4028
 
3408
4029
  // src/components/colour.ts
3409
4030
  function normalizeColourValue(value) {
@@ -3559,15 +4180,16 @@ function createEditColourUI(value, pathKey, ctx) {
3559
4180
  }
3560
4181
  function renderColourElement(element, ctx, wrapper, pathKey) {
3561
4182
  const state = ctx.state;
4183
+ const readonly = isElementReadonly(element, state, ctx);
3562
4184
  const initialValue = ctx.prefill[element.key] || element.default || "#000000";
3563
- if (state.config.readonly) {
4185
+ if (readonly) {
3564
4186
  const readonlyUI = createReadonlyColourUI(initialValue);
3565
4187
  wrapper.appendChild(readonlyUI);
3566
4188
  } else {
3567
4189
  const editUI = createEditColourUI(initialValue, pathKey, ctx);
3568
4190
  wrapper.appendChild(editUI);
3569
4191
  }
3570
- if (!state.config.readonly) {
4192
+ if (!readonly) {
3571
4193
  const colourHint = document.createElement("p");
3572
4194
  colourHint.className = "mt-1";
3573
4195
  colourHint.style.cssText = `
@@ -3580,6 +4202,7 @@ function renderColourElement(element, ctx, wrapper, pathKey) {
3580
4202
  }
3581
4203
  function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
3582
4204
  const state = ctx.state;
4205
+ const readonly = isElementReadonly(element, state, ctx);
3583
4206
  const prefillValues = ctx.prefill[element.key] || [];
3584
4207
  const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
3585
4208
  const minCount = element.minCount ?? 1;
@@ -3602,7 +4225,7 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
3602
4225
  function addColourItem(value = "#000000", index = -1) {
3603
4226
  const itemWrapper = document.createElement("div");
3604
4227
  itemWrapper.className = "multiple-colour-item flex items-center gap-2";
3605
- if (state.config.readonly) {
4228
+ if (readonly) {
3606
4229
  const readonlyUI = createReadonlyColourUI(value);
3607
4230
  while (readonlyUI.firstChild) {
3608
4231
  itemWrapper.appendChild(readonlyUI.firstChild);
@@ -3622,7 +4245,7 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
3622
4245
  return itemWrapper;
3623
4246
  }
3624
4247
  function updateRemoveButtons() {
3625
- if (state.config.readonly) return;
4248
+ if (readonly) return;
3626
4249
  const items = container.querySelectorAll(".multiple-colour-item");
3627
4250
  const currentCount = items.length;
3628
4251
  items.forEach((item) => {
@@ -3667,7 +4290,7 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
3667
4290
  }
3668
4291
  let addRow = null;
3669
4292
  let countDisplay = null;
3670
- if (!state.config.readonly) {
4293
+ if (!readonly) {
3671
4294
  addRow = document.createElement("div");
3672
4295
  addRow.className = "flex items-center gap-3 mt-2";
3673
4296
  const addBtn = document.createElement("button");
@@ -3714,7 +4337,7 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
3714
4337
  values.forEach((value) => addColourItem(value));
3715
4338
  updateAddButton();
3716
4339
  updateRemoveButtons();
3717
- if (!state.config.readonly) {
4340
+ if (!readonly) {
3718
4341
  const hint = document.createElement("p");
3719
4342
  hint.className = "mt-1";
3720
4343
  hint.style.cssText = `
@@ -3783,7 +4406,9 @@ function validateColourElement(element, key, context) {
3783
4406
  return normalized;
3784
4407
  };
3785
4408
  if (element.multiple) {
3786
- const hexInputs = scopeRoot.querySelectorAll(`[name^="${key}["].colour-hex-input`);
4409
+ const hexInputs = scopeRoot.querySelectorAll(
4410
+ `[name^="${key}["].colour-hex-input`
4411
+ );
3787
4412
  const values = [];
3788
4413
  hexInputs.forEach((input, index) => {
3789
4414
  const val = input?.value ?? "";
@@ -3830,7 +4455,9 @@ function updateColourField(element, fieldPath, value, context) {
3830
4455
  );
3831
4456
  return;
3832
4457
  }
3833
- const hexInputs = scopeRoot.querySelectorAll(`[name^="${fieldPath}["].colour-hex-input`);
4458
+ const hexInputs = scopeRoot.querySelectorAll(
4459
+ `[name^="${fieldPath}["].colour-hex-input`
4460
+ );
3834
4461
  hexInputs.forEach((hexInput, index) => {
3835
4462
  if (index < value.length) {
3836
4463
  const normalized = normalizeColourValue(value[index]);
@@ -4025,6 +4652,7 @@ function renderSliderElement(element, ctx, wrapper, pathKey) {
4025
4652
  );
4026
4653
  }
4027
4654
  const state = ctx.state;
4655
+ const readonly = isElementReadonly(element, state, ctx);
4028
4656
  const defaultValue = element.default !== void 0 ? element.default : (element.min + element.max) / 2;
4029
4657
  const initialValue = ctx.prefill[element.key] ?? defaultValue;
4030
4658
  const sliderUI = createSliderUI(
@@ -4032,10 +4660,10 @@ function renderSliderElement(element, ctx, wrapper, pathKey) {
4032
4660
  pathKey,
4033
4661
  element,
4034
4662
  ctx,
4035
- state.config.readonly
4663
+ readonly
4036
4664
  );
4037
4665
  wrapper.appendChild(sliderUI);
4038
- if (!state.config.readonly) {
4666
+ if (!readonly) {
4039
4667
  const hint = document.createElement("p");
4040
4668
  hint.className = "mt-1";
4041
4669
  hint.style.cssText = `
@@ -4059,6 +4687,7 @@ function renderMultipleSliderElement(element, ctx, wrapper, pathKey) {
4059
4687
  );
4060
4688
  }
4061
4689
  const state = ctx.state;
4690
+ const readonly = isElementReadonly(element, state, ctx);
4062
4691
  const prefillValues = ctx.prefill[element.key] || [];
4063
4692
  const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
4064
4693
  const minCount = element.minCount ?? 1;
@@ -4083,13 +4712,7 @@ function renderMultipleSliderElement(element, ctx, wrapper, pathKey) {
4083
4712
  const itemWrapper = document.createElement("div");
4084
4713
  itemWrapper.className = "multiple-slider-item flex items-start gap-2";
4085
4714
  const tempPathKey = `${pathKey}[${container.children.length}]`;
4086
- const sliderUI = createSliderUI(
4087
- value,
4088
- tempPathKey,
4089
- element,
4090
- ctx,
4091
- state.config.readonly
4092
- );
4715
+ const sliderUI = createSliderUI(value, tempPathKey, element, ctx, readonly);
4093
4716
  sliderUI.style.flex = "1";
4094
4717
  itemWrapper.appendChild(sliderUI);
4095
4718
  if (index === -1) {
@@ -4101,7 +4724,7 @@ function renderMultipleSliderElement(element, ctx, wrapper, pathKey) {
4101
4724
  return itemWrapper;
4102
4725
  }
4103
4726
  function updateRemoveButtons() {
4104
- if (state.config.readonly) return;
4727
+ if (readonly) return;
4105
4728
  const items = container.querySelectorAll(".multiple-slider-item");
4106
4729
  const currentCount = items.length;
4107
4730
  items.forEach((item) => {
@@ -4147,7 +4770,7 @@ function renderMultipleSliderElement(element, ctx, wrapper, pathKey) {
4147
4770
  }
4148
4771
  let addRow = null;
4149
4772
  let countDisplay = null;
4150
- if (!state.config.readonly) {
4773
+ if (!readonly) {
4151
4774
  addRow = document.createElement("div");
4152
4775
  addRow.className = "flex items-center gap-3 mt-2";
4153
4776
  const addBtn = document.createElement("button");
@@ -4193,7 +4816,7 @@ function renderMultipleSliderElement(element, ctx, wrapper, pathKey) {
4193
4816
  values.forEach((value) => addSliderItem(value));
4194
4817
  updateAddButton();
4195
4818
  updateRemoveButtons();
4196
- if (!state.config.readonly) {
4819
+ if (!readonly) {
4197
4820
  const hint = document.createElement("p");
4198
4821
  hint.className = "mt-1";
4199
4822
  hint.style.cssText = `
@@ -4432,9 +5055,7 @@ function mergeWithDefaults(prefill, defaults) {
4432
5055
  }
4433
5056
  function extractRootFormData(formRoot) {
4434
5057
  const data = {};
4435
- const inputs = formRoot.querySelectorAll(
4436
- "input, select, textarea"
4437
- );
5058
+ const inputs = formRoot.querySelectorAll("input, select, textarea");
4438
5059
  inputs.forEach((input) => {
4439
5060
  const fieldName = input.getAttribute("name");
4440
5061
  if (fieldName && !fieldName.includes("[") && !fieldName.includes(".")) {
@@ -4498,7 +5119,8 @@ function renderSingleContainerElement(element, ctx, wrapper, pathKey) {
4498
5119
  } else {
4499
5120
  itemsWrap.className = `grid grid-cols-${columns} gap-4`;
4500
5121
  }
4501
- if (!ctx.state.config.readonly) {
5122
+ const containerIsReadonly = isElementReadonly(element, ctx.state, ctx);
5123
+ if (!containerIsReadonly) {
4502
5124
  const hintsElement = createPrefillHints(element, pathKey);
4503
5125
  if (hintsElement) {
4504
5126
  containerWrap.appendChild(hintsElement);
@@ -4513,12 +5135,15 @@ function renderSingleContainerElement(element, ctx, wrapper, pathKey) {
4513
5135
  // Merged prefill with defaults for enableIf evaluation
4514
5136
  formData: ctx.formData ?? ctx.prefill,
4515
5137
  // Complete root data for enableIf evaluation
4516
- state: ctx.state
5138
+ state: ctx.state,
5139
+ inheritedReadonly: containerIsReadonly || ctx.inheritedReadonly
4517
5140
  };
4518
5141
  element.elements.forEach((child) => {
4519
5142
  if (child.hidden || child.type === "hidden") {
4520
5143
  const prefillVal = containerPrefill[child.key] ?? child.default ?? null;
4521
- itemsWrap.appendChild(createHiddenInput(pathJoin(subCtx.path, child.key), prefillVal));
5144
+ itemsWrap.appendChild(
5145
+ createHiddenInput(pathJoin(subCtx.path, child.key), prefillVal)
5146
+ );
4522
5147
  } else {
4523
5148
  itemsWrap.appendChild(renderElement(child, subCtx));
4524
5149
  }
@@ -4528,13 +5153,15 @@ function renderSingleContainerElement(element, ctx, wrapper, pathKey) {
4528
5153
  }
4529
5154
  function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
4530
5155
  const state = ctx.state;
5156
+ const containerIsReadonly = isElementReadonly(element, state, ctx);
5157
+ const childInheritedReadonly = containerIsReadonly || ctx.inheritedReadonly;
4531
5158
  const containerWrap = document.createElement("div");
4532
5159
  containerWrap.className = "border border-gray-200 rounded-lg p-4 bg-gray-50";
4533
5160
  const countDisplay = document.createElement("span");
4534
5161
  countDisplay.className = "text-sm text-gray-500";
4535
5162
  const itemsWrap = document.createElement("div");
4536
5163
  itemsWrap.className = "space-y-4";
4537
- if (!ctx.state.config.readonly) {
5164
+ if (!containerIsReadonly) {
4538
5165
  const hintsElement = createPrefillHints(element, element.key);
4539
5166
  if (hintsElement) {
4540
5167
  containerWrap.appendChild(hintsElement);
@@ -4572,8 +5199,9 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
4572
5199
  path: pathJoin(ctx.path, `${element.key}[${idx}]`),
4573
5200
  prefill: childDefaults,
4574
5201
  // Defaults for enableIf evaluation
4575
- formData: currentFormData
5202
+ formData: currentFormData,
4576
5203
  // Current root data from DOM for enableIf
5204
+ inheritedReadonly: childInheritedReadonly
4577
5205
  };
4578
5206
  const item = document.createElement("div");
4579
5207
  item.className = "containerItem border border-gray-300 rounded-lg p-4 bg-white";
@@ -4587,13 +5215,18 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
4587
5215
  }
4588
5216
  element.elements.forEach((child) => {
4589
5217
  if (child.hidden || child.type === "hidden") {
4590
- childWrapper.appendChild(createHiddenInput(pathJoin(subCtx.path, child.key), child.default ?? null));
5218
+ childWrapper.appendChild(
5219
+ createHiddenInput(
5220
+ pathJoin(subCtx.path, child.key),
5221
+ child.default ?? null
5222
+ )
5223
+ );
4591
5224
  } else {
4592
5225
  childWrapper.appendChild(renderElement(child, subCtx));
4593
5226
  }
4594
5227
  });
4595
5228
  item.appendChild(childWrapper);
4596
- if (!state.config.readonly) {
5229
+ if (!containerIsReadonly) {
4597
5230
  const rem = document.createElement("button");
4598
5231
  rem.type = "button";
4599
5232
  rem.className = "absolute top-2 right-2 px-2 py-1 rounded";
@@ -4643,8 +5276,9 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
4643
5276
  path: pathJoin(ctx.path, `${element.key}[${idx}]`),
4644
5277
  prefill: mergedPrefill,
4645
5278
  // Merged prefill with defaults for enableIf
4646
- formData: ctx.formData ?? ctx.prefill
5279
+ formData: ctx.formData ?? ctx.prefill,
4647
5280
  // Complete root data for enableIf
5281
+ inheritedReadonly: childInheritedReadonly
4648
5282
  };
4649
5283
  const item = document.createElement("div");
4650
5284
  item.className = "containerItem border border-gray-300 rounded-lg p-4 bg-white";
@@ -4659,13 +5293,15 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
4659
5293
  element.elements.forEach((child) => {
4660
5294
  if (child.hidden || child.type === "hidden") {
4661
5295
  const prefillVal = prefillObj?.[child.key] ?? child.default ?? null;
4662
- childWrapper.appendChild(createHiddenInput(pathJoin(subCtx.path, child.key), prefillVal));
5296
+ childWrapper.appendChild(
5297
+ createHiddenInput(pathJoin(subCtx.path, child.key), prefillVal)
5298
+ );
4663
5299
  } else {
4664
5300
  childWrapper.appendChild(renderElement(child, subCtx));
4665
5301
  }
4666
5302
  });
4667
5303
  item.appendChild(childWrapper);
4668
- if (!state.config.readonly) {
5304
+ if (!containerIsReadonly) {
4669
5305
  const rem = document.createElement("button");
4670
5306
  rem.type = "button";
4671
5307
  rem.className = "absolute top-2 right-2 px-2 py-1 rounded";
@@ -4688,7 +5324,7 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
4688
5324
  itemsWrap.appendChild(item);
4689
5325
  });
4690
5326
  }
4691
- if (!state.config.readonly) {
5327
+ if (!containerIsReadonly) {
4692
5328
  while (countItems() < min) {
4693
5329
  const idx = countItems();
4694
5330
  const subCtx = {
@@ -4696,8 +5332,9 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
4696
5332
  path: pathJoin(ctx.path, `${element.key}[${idx}]`),
4697
5333
  prefill: childDefaults,
4698
5334
  // Defaults for enableIf evaluation
4699
- formData: ctx.formData ?? ctx.prefill
5335
+ formData: ctx.formData ?? ctx.prefill,
4700
5336
  // Complete root data for enableIf
5337
+ inheritedReadonly: childInheritedReadonly
4701
5338
  };
4702
5339
  const item = document.createElement("div");
4703
5340
  item.className = "containerItem border border-gray-300 rounded-lg p-4 bg-white";
@@ -4711,7 +5348,12 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
4711
5348
  }
4712
5349
  element.elements.forEach((child) => {
4713
5350
  if (child.hidden || child.type === "hidden") {
4714
- childWrapper.appendChild(createHiddenInput(pathJoin(subCtx.path, child.key), child.default ?? null));
5351
+ childWrapper.appendChild(
5352
+ createHiddenInput(
5353
+ pathJoin(subCtx.path, child.key),
5354
+ child.default ?? null
5355
+ )
5356
+ );
4715
5357
  } else {
4716
5358
  childWrapper.appendChild(renderElement(child, subCtx));
4717
5359
  }
@@ -4743,7 +5385,7 @@ function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
4743
5385
  }
4744
5386
  }
4745
5387
  containerWrap.appendChild(itemsWrap);
4746
- if (!state.config.readonly) {
5388
+ if (!containerIsReadonly) {
4747
5389
  const addRow = document.createElement("div");
4748
5390
  addRow.className = "flex items-center gap-3 mt-2";
4749
5391
  addRow.appendChild(createAddButton());
@@ -4902,8 +5544,10 @@ function updateContainerField(element, fieldPath, value, context) {
4902
5544
  const filesKey = richChild.filesKey ?? "files";
4903
5545
  const containerValue = itemValue;
4904
5546
  const compositeValue = {};
4905
- if (textKey in containerValue) compositeValue[textKey] = containerValue[textKey];
4906
- if (filesKey in containerValue) compositeValue[filesKey] = containerValue[filesKey];
5547
+ if (textKey in containerValue)
5548
+ compositeValue[textKey] = containerValue[textKey];
5549
+ if (filesKey in containerValue)
5550
+ compositeValue[filesKey] = containerValue[filesKey];
4907
5551
  if (Object.keys(compositeValue).length > 0) {
4908
5552
  instance.updateField(childPath, compositeValue);
4909
5553
  }
@@ -4939,8 +5583,10 @@ function updateContainerField(element, fieldPath, value, context) {
4939
5583
  const filesKey = richChild.filesKey ?? "files";
4940
5584
  const containerValue = value;
4941
5585
  const compositeValue = {};
4942
- if (textKey in containerValue) compositeValue[textKey] = containerValue[textKey];
4943
- if (filesKey in containerValue) compositeValue[filesKey] = containerValue[filesKey];
5586
+ if (textKey in containerValue)
5587
+ compositeValue[textKey] = containerValue[textKey];
5588
+ if (filesKey in containerValue)
5589
+ compositeValue[filesKey] = containerValue[filesKey];
4944
5590
  if (Object.keys(compositeValue).length > 0) {
4945
5591
  instance.updateField(childPath, compositeValue);
4946
5592
  }
@@ -5278,12 +5924,21 @@ function renderEditTable(element, initialData, pathKey, ctx, wrapper) {
5278
5924
  const hiddenInput = document.createElement("input");
5279
5925
  hiddenInput.type = "hidden";
5280
5926
  hiddenInput.name = pathKey;
5281
- hiddenInput.value = JSON.stringify({ [cellsKey]: cells, [mergesKey]: merges });
5927
+ hiddenInput.value = JSON.stringify({
5928
+ [cellsKey]: cells,
5929
+ [mergesKey]: merges
5930
+ });
5282
5931
  wrapper.appendChild(hiddenInput);
5283
5932
  function persistValue() {
5284
- hiddenInput.value = JSON.stringify({ [cellsKey]: cells, [mergesKey]: merges });
5933
+ hiddenInput.value = JSON.stringify({
5934
+ [cellsKey]: cells,
5935
+ [mergesKey]: merges
5936
+ });
5285
5937
  if (instance) {
5286
- instance.triggerOnChange(pathKey, { [cellsKey]: cells, [mergesKey]: merges });
5938
+ instance.triggerOnChange(pathKey, {
5939
+ [cellsKey]: cells,
5940
+ [mergesKey]: merges
5941
+ });
5287
5942
  }
5288
5943
  }
5289
5944
  hiddenInput._applyExternalUpdate = (data) => {
@@ -5342,9 +5997,7 @@ function renderEditTable(element, initialData, pathKey, ctx, wrapper) {
5342
5997
  rebuild();
5343
5998
  } catch (e) {
5344
5999
  const errMsg = e instanceof Error ? e.message : String(e);
5345
- console.error(
5346
- t("tableImportError", state).replace("{error}", errMsg)
5347
- );
6000
+ console.error(t("tableImportError", state).replace("{error}", errMsg));
5348
6001
  } finally {
5349
6002
  overlay.remove();
5350
6003
  }
@@ -5492,15 +6145,19 @@ function renderEditTable(element, initialData, pathKey, ctx, wrapper) {
5492
6145
  contextMenu.style.display = "none";
5493
6146
  }
5494
6147
  const menuDismissCtrl = new AbortController();
5495
- document.addEventListener("mousedown", (e) => {
5496
- if (!wrapper.isConnected) {
5497
- menuDismissCtrl.abort();
5498
- return;
5499
- }
5500
- if (!contextMenu.contains(e.target)) {
5501
- hideContextMenu();
5502
- }
5503
- }, { signal: menuDismissCtrl.signal });
6148
+ document.addEventListener(
6149
+ "mousedown",
6150
+ (e) => {
6151
+ if (!wrapper.isConnected) {
6152
+ menuDismissCtrl.abort();
6153
+ return;
6154
+ }
6155
+ if (!contextMenu.contains(e.target)) {
6156
+ hideContextMenu();
6157
+ }
6158
+ },
6159
+ { signal: menuDismissCtrl.signal }
6160
+ );
5504
6161
  function applySelectionStyles() {
5505
6162
  const range = selectionRange(sel);
5506
6163
  const allTds = tableEl.querySelectorAll("td[data-row]");
@@ -5846,7 +6503,9 @@ function renderEditTable(element, initialData, pathKey, ctx, wrapper) {
5846
6503
  if (!anchor) return;
5847
6504
  const text = e.clipboardData?.getData("text/plain") ?? "";
5848
6505
  const isMultiCell = text.includes(" ") || text.split(/\r?\n/).filter((l) => l).length > 1;
5849
- const editing = tableEl.querySelector("[contenteditable='true']");
6506
+ const editing = tableEl.querySelector(
6507
+ "[contenteditable='true']"
6508
+ );
5850
6509
  if (editing && !isMultiCell) return;
5851
6510
  e.preventDefault();
5852
6511
  if (editing) {
@@ -6002,7 +6661,11 @@ function renderEditTable(element, initialData, pathKey, ctx, wrapper) {
6002
6661
  }
6003
6662
  }
6004
6663
  function updateTopZoneOverlays(mx, wr, tblR, scrollL, active) {
6005
- const headerCells = active ? Array.from(tableEl.querySelectorAll("thead td[data-col]")) : [];
6664
+ const headerCells = active ? Array.from(
6665
+ tableEl.querySelectorAll(
6666
+ "thead td[data-col]"
6667
+ )
6668
+ ) : [];
6006
6669
  let closestColIdx = -1;
6007
6670
  let closestColDist = Infinity;
6008
6671
  let closestBorderX = -1;
@@ -6020,7 +6683,10 @@ function renderEditTable(element, initialData, pathKey, ctx, wrapper) {
6020
6683
  if (borderDist < closestBorderDist && borderDist < 20) {
6021
6684
  closestBorderDist = borderDist;
6022
6685
  closestBorderX = cellRect.right - wr.left + scrollL;
6023
- closestAfterCol = parseInt(headerCells[i].getAttribute("data-col") ?? "0", 10);
6686
+ closestAfterCol = parseInt(
6687
+ headerCells[i].getAttribute("data-col") ?? "0",
6688
+ 10
6689
+ );
6024
6690
  }
6025
6691
  }
6026
6692
  colRemoveBtns.forEach((btn, idx) => {
@@ -6075,7 +6741,10 @@ function renderEditTable(element, initialData, pathKey, ctx, wrapper) {
6075
6741
  closestRowBorderDist = borderDist;
6076
6742
  closestBorderY = trRect.bottom - wr.top;
6077
6743
  const firstTd = allRowEls[i].querySelector("td[data-row]");
6078
- closestAfterRow = parseInt(firstTd?.getAttribute("data-row") ?? "0", 10);
6744
+ closestAfterRow = parseInt(
6745
+ firstTd?.getAttribute("data-row") ?? "0",
6746
+ 10
6747
+ );
6079
6748
  }
6080
6749
  }
6081
6750
  rowRemoveBtns.forEach((btn, idx) => {
@@ -6101,7 +6770,8 @@ function renderEditTable(element, initialData, pathKey, ctx, wrapper) {
6101
6770
  let rafPending = false;
6102
6771
  tableWrapper.onmousemove = (e) => {
6103
6772
  const target = e.target;
6104
- if (target.tagName === "BUTTON" && target.parentElement === tableWrapper) return;
6773
+ if (target.tagName === "BUTTON" && target.parentElement === tableWrapper)
6774
+ return;
6105
6775
  if (rafPending) return;
6106
6776
  rafPending = true;
6107
6777
  const mx = e.clientX;
@@ -6160,6 +6830,7 @@ function isTableDataWithFieldNames(v, cellsKey) {
6160
6830
  }
6161
6831
  function renderTableElement(element, ctx, wrapper, pathKey) {
6162
6832
  const state = ctx.state;
6833
+ const readonly = isElementReadonly(element, state, ctx);
6163
6834
  const rawPrefill = ctx.prefill[element.key];
6164
6835
  const cellsKey = element.fieldNames?.cells ?? "cells";
6165
6836
  const mergesKey = element.fieldNames?.merges ?? "merges";
@@ -6194,11 +6865,13 @@ function renderTableElement(element, ctx, wrapper, pathKey) {
6194
6865
  const cols = rows > 0 ? initialData.cells[0].length : 0;
6195
6866
  const err = validateMerges(initialData.merges, rows, cols);
6196
6867
  if (err) {
6197
- console.warn(`Table "${element.key}": invalid prefill merges stripped (${err})`);
6868
+ console.warn(
6869
+ `Table "${element.key}": invalid prefill merges stripped (${err})`
6870
+ );
6198
6871
  initialData = { ...initialData, merges: [] };
6199
6872
  }
6200
6873
  }
6201
- if (state.config.readonly) {
6874
+ if (readonly) {
6202
6875
  renderReadonlyTable(initialData, wrapper);
6203
6876
  } else {
6204
6877
  renderEditTable(element, initialData, pathKey, ctx, wrapper);
@@ -6875,20 +7548,26 @@ function renderEditMode(element, ctx, wrapper, pathKey, initialValue) {
6875
7548
  });
6876
7549
  let mentionTooltip = null;
6877
7550
  backdrop.addEventListener("mouseover", (e) => {
6878
- const mark = e.target.closest?.("mark");
7551
+ const mark = e.target.closest?.(
7552
+ "mark"
7553
+ );
6879
7554
  if (!mark?.dataset.rid) return;
6880
7555
  mentionTooltip = removePortalTooltip(mentionTooltip);
6881
7556
  mentionTooltip = showMentionTooltip(mark, mark.dataset.rid, state);
6882
7557
  });
6883
7558
  backdrop.addEventListener("mouseout", (e) => {
6884
- const mark = e.target.closest?.("mark");
7559
+ const mark = e.target.closest?.(
7560
+ "mark"
7561
+ );
6885
7562
  if (!mark) return;
6886
7563
  const related = e.relatedTarget;
6887
7564
  if (related?.closest?.("mark")) return;
6888
7565
  mentionTooltip = removePortalTooltip(mentionTooltip);
6889
7566
  });
6890
7567
  backdrop.addEventListener("mousedown", (e) => {
6891
- const mark = e.target.closest?.("mark");
7568
+ const mark = e.target.closest?.(
7569
+ "mark"
7570
+ );
6892
7571
  if (!mark) return;
6893
7572
  mentionTooltip = removePortalTooltip(mentionTooltip);
6894
7573
  const marks = backdrop.querySelectorAll("mark");
@@ -7132,11 +7811,7 @@ function renderEditMode(element, ctx, wrapper, pathKey, initialValue) {
7132
7811
  textarea.addEventListener("keydown", (e) => {
7133
7812
  if (!dropdownState.open) return;
7134
7813
  const labels = buildFileLabelsFromClosure();
7135
- const filtered = filterFilesForDropdown(
7136
- dropdownState.query,
7137
- files,
7138
- labels
7139
- );
7814
+ const filtered = filterFilesForDropdown(dropdownState.query, files, labels);
7140
7815
  if (e.key === "ArrowDown") {
7141
7816
  e.preventDefault();
7142
7817
  dropdownState.selectedIndex = Math.min(
@@ -7463,6 +8138,7 @@ function renderReadonlyMode(_element, ctx, wrapper, _pathKey, value) {
7463
8138
  }
7464
8139
  function renderRichInputElement(element, ctx, wrapper, pathKey) {
7465
8140
  const state = ctx.state;
8141
+ const readonly = isElementReadonly(element, state, ctx);
7466
8142
  const textKey = element.textKey ?? "text";
7467
8143
  const filesKey = element.filesKey ?? "files";
7468
8144
  let initialValue;
@@ -7500,7 +8176,7 @@ function renderRichInputElement(element, ctx, wrapper, pathKey) {
7500
8176
  });
7501
8177
  }
7502
8178
  }
7503
- if (state.config.readonly) {
8179
+ if (readonly) {
7504
8180
  renderReadonlyMode(element, ctx, wrapper, pathKey, initialValue);
7505
8181
  } else {
7506
8182
  if (!state.config.uploadFile) {
@@ -7562,9 +8238,7 @@ function validateRichInputElement(element, key, context) {
7562
8238
  }
7563
8239
  }
7564
8240
  if (element.maxFiles != null && files.length > element.maxFiles) {
7565
- errors.push(
7566
- `${key}: ${t("maxFiles", state, { max: element.maxFiles })}`
7567
- );
8241
+ errors.push(`${key}: ${t("maxFiles", state, { max: element.maxFiles })}`);
7568
8242
  }
7569
8243
  }
7570
8244
  return { value, errors, spread: !!element.flatOutput };
@@ -7680,9 +8354,7 @@ function shouldDisableElement(element, ctx) {
7680
8354
  return false;
7681
8355
  }
7682
8356
  function extractDOMValue(fieldPath, formRoot) {
7683
- const input = formRoot.querySelector(
7684
- `[name="${fieldPath}"]`
7685
- );
8357
+ const input = formRoot.querySelector(`[name="${fieldPath}"]`);
7686
8358
  if (!input) {
7687
8359
  return void 0;
7688
8360
  }
@@ -7727,9 +8399,7 @@ function reevaluateEnableIf(wrapper, element, ctx) {
7727
8399
  `[data-container-item="${containerKey}[${containerIndex}]"]`
7728
8400
  );
7729
8401
  if (containerItemElement) {
7730
- const inputs = containerItemElement.querySelectorAll(
7731
- "input, select, textarea"
7732
- );
8402
+ const inputs = containerItemElement.querySelectorAll("input, select, textarea");
7733
8403
  inputs.forEach((input) => {
7734
8404
  const fieldName = input.getAttribute("name");
7735
8405
  if (fieldName) {
@@ -7781,7 +8451,10 @@ function reevaluateEnableIf(wrapper, element, ctx) {
7781
8451
  wrapper.setAttribute("data-conditionally-disabled", "true");
7782
8452
  }
7783
8453
  } catch (error) {
7784
- console.error(`Error re-evaluating enableIf for field "${element.key}":`, error);
8454
+ console.error(
8455
+ `Error re-evaluating enableIf for field "${element.key}":`,
8456
+ error
8457
+ );
7785
8458
  }
7786
8459
  }
7787
8460
  function setupEnableIfListeners(wrapper, element, ctx) {
@@ -7802,14 +8475,10 @@ function setupEnableIfListeners(wrapper, element, ctx) {
7802
8475
  } else {
7803
8476
  dependencyFieldPath = dependencyKey;
7804
8477
  }
7805
- const dependencyInput = formRoot.querySelector(
7806
- `[name="${dependencyFieldPath}"]`
7807
- );
8478
+ const dependencyInput = formRoot.querySelector(`[name="${dependencyFieldPath}"]`);
7808
8479
  if (!dependencyInput) {
7809
8480
  const observer = new MutationObserver(() => {
7810
- const input = formRoot.querySelector(
7811
- `[name="${dependencyFieldPath}"]`
7812
- );
8481
+ const input = formRoot.querySelector(`[name="${dependencyFieldPath}"]`);
7813
8482
  if (input) {
7814
8483
  input.addEventListener("change", () => {
7815
8484
  reevaluateEnableIf(wrapper, element, ctx);
@@ -7956,7 +8625,9 @@ function dispatchToRenderer(element, ctx, wrapper, pathKey) {
7956
8625
  default: {
7957
8626
  const unsupported = document.createElement("div");
7958
8627
  unsupported.className = "text-red-500 text-sm";
7959
- unsupported.textContent = t("unsupportedFieldType", ctx.state, { type: element.type });
8628
+ unsupported.textContent = t("unsupportedFieldType", ctx.state, {
8629
+ type: element.type
8630
+ });
7960
8631
  wrapper.appendChild(unsupported);
7961
8632
  }
7962
8633
  }
@@ -8008,6 +8679,8 @@ var defaultConfig = {
8008
8679
  noFileSelected: "No file selected",
8009
8680
  noFilesSelected: "No files selected",
8010
8681
  downloadButton: "Download",
8682
+ downloadFile: "Download",
8683
+ openInNewTab: "Open in new tab",
8011
8684
  changeButton: "Change",
8012
8685
  placeholderText: "Enter text",
8013
8686
  previewAlt: "Preview",
@@ -8029,6 +8702,8 @@ var defaultConfig = {
8029
8702
  fileCountSingle: "{count} file",
8030
8703
  fileCountPlural: "{count} files",
8031
8704
  fileCountRange: "({min}-{max})",
8705
+ uploadingFile: "Uploading\u2026",
8706
+ filesCounter: "{count}/{max}",
8032
8707
  // Validation errors
8033
8708
  required: "Required",
8034
8709
  minItems: "Minimum {min} items required",
@@ -8070,6 +8745,8 @@ var defaultConfig = {
8070
8745
  noFileSelected: "\u0424\u0430\u0439\u043B \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D",
8071
8746
  noFilesSelected: "\u041D\u0435\u0442 \u0444\u0430\u0439\u043B\u043E\u0432",
8072
8747
  downloadButton: "\u0421\u043A\u0430\u0447\u0430\u0442\u044C",
8748
+ downloadFile: "\u0421\u043A\u0430\u0447\u0430\u0442\u044C",
8749
+ openInNewTab: "\u041E\u0442\u043A\u0440\u044B\u0442\u044C \u0432 \u043D\u043E\u0432\u043E\u0439 \u0432\u043A\u043B\u0430\u0434\u043A\u0435",
8073
8750
  changeButton: "\u0418\u0437\u043C\u0435\u043D\u0438\u0442\u044C",
8074
8751
  placeholderText: "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442",
8075
8752
  previewAlt: "\u041F\u0440\u0435\u0434\u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440",
@@ -8091,6 +8768,8 @@ var defaultConfig = {
8091
8768
  fileCountSingle: "{count} \u0444\u0430\u0439\u043B",
8092
8769
  fileCountPlural: "{count} \u0444\u0430\u0439\u043B\u043E\u0432",
8093
8770
  fileCountRange: "({min}-{max})",
8771
+ uploadingFile: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430\u2026",
8772
+ filesCounter: "{count}/{max}",
8094
8773
  // Validation errors
8095
8774
  required: "\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435 \u043F\u043E\u043B\u0435",
8096
8775
  minItems: "\u041C\u0438\u043D\u0438\u043C\u0443\u043C {min} \u044D\u043B\u0435\u043C\u0435\u043D\u0442\u043E\u0432",
@@ -8908,7 +9587,10 @@ var FormBuilderInstance = class {
8908
9587
  );
8909
9588
  if (componentResult !== null) {
8910
9589
  errors.push(...componentResult.errors);
8911
- return { value: componentResult.value, spread: !!componentResult.spread };
9590
+ return {
9591
+ value: componentResult.value,
9592
+ spread: !!componentResult.spread
9593
+ };
8912
9594
  }
8913
9595
  console.warn(`Unknown field type "${element.type}" for key "${key}"`);
8914
9596
  return { value: null, spread: false };
@@ -8917,10 +9599,7 @@ var FormBuilderInstance = class {
8917
9599
  this.state.schema.elements.forEach((element) => {
8918
9600
  if (element.enableIf) {
8919
9601
  try {
8920
- const shouldEnable = evaluateEnableCondition(
8921
- element.enableIf,
8922
- data
8923
- );
9602
+ const shouldEnable = evaluateEnableCondition(element.enableIf, data);
8924
9603
  if (!shouldEnable) {
8925
9604
  return;
8926
9605
  }
@@ -9215,7 +9894,10 @@ var FormBuilderInstance = class {
9215
9894
  disabledWrapper.className = "fb-field-wrapper-disabled";
9216
9895
  disabledWrapper.style.display = "none";
9217
9896
  disabledWrapper.setAttribute("data-field-key", element.key);
9218
- disabledWrapper.setAttribute("data-conditionally-disabled", "true");
9897
+ disabledWrapper.setAttribute(
9898
+ "data-conditionally-disabled",
9899
+ "true"
9900
+ );
9219
9901
  wrapper.parentNode?.replaceChild(disabledWrapper, wrapper);
9220
9902
  }
9221
9903
  } catch (error) {