@dmitryvim/form-builder 0.2.27 → 0.2.28

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
@@ -2104,30 +2104,58 @@ function isFileExtensionAllowed(fileName, allowedExtensions) {
2104
2104
  const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
2105
2105
  return allowedExtensions.includes(ext);
2106
2106
  }
2107
- function isFileSizeAllowed(file, maxSizeMB) {
2107
+ function isSizeWithinLimit(bytes, maxSizeMB) {
2108
2108
  if (maxSizeMB === Infinity) return true;
2109
- return file.size <= maxSizeMB * 1024 * 1024;
2109
+ return bytes <= maxSizeMB * 1024 * 1024;
2110
+ }
2111
+ function isFileSizeAllowed(file, maxSizeMB) {
2112
+ return isSizeWithinLimit(file.size, maxSizeMB);
2113
+ }
2114
+ function getAllowedMimes(accept) {
2115
+ if (!accept) return [];
2116
+ if (typeof accept === "string") return [];
2117
+ if (!Array.isArray(accept.mime)) return [];
2118
+ return accept.mime.map((m) => m.toLowerCase());
2119
+ }
2120
+ function isMimeAllowed(mimeType, allowedMimes) {
2121
+ if (allowedMimes.length === 0) return true;
2122
+ const normalizedType = mimeType.toLowerCase();
2123
+ return allowedMimes.some((allowed) => {
2124
+ if (allowed.endsWith("/*")) {
2125
+ const prefix = allowed.slice(0, -1);
2126
+ return normalizedType.startsWith(prefix);
2127
+ }
2128
+ return normalizedType === allowed;
2129
+ });
2130
+ }
2131
+ function inferMimeFromResourceId(resourceId) {
2132
+ const filename = resourceId.split("/").pop() || "file";
2133
+ const extension = filename.split(".").pop()?.toLowerCase();
2134
+ if (extension) {
2135
+ if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
2136
+ return `image/${extension === "jpg" ? "jpeg" : extension}`;
2137
+ }
2138
+ if (["mp4", "webm", "mov", "avi"].includes(extension)) {
2139
+ return `video/${extension === "mov" ? "quicktime" : extension}`;
2140
+ }
2141
+ }
2142
+ return "application/octet-stream";
2143
+ }
2144
+ function seedInferredResource(resourceId, resourceIndex) {
2145
+ if (resourceIndex.has(resourceId)) return;
2146
+ const filename = resourceId.split("/").pop() || "file";
2147
+ resourceIndex.set(resourceId, {
2148
+ name: filename,
2149
+ type: inferMimeFromResourceId(resourceId),
2150
+ size: 0,
2151
+ uploadedAt: /* @__PURE__ */ new Date(),
2152
+ file: void 0,
2153
+ inferredFromExtension: true
2154
+ });
2110
2155
  }
2111
2156
  function addPrefillFilesToIndex(initialFiles, resourceIndex) {
2112
2157
  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
- });
2158
+ seedInferredResource(resourceId, resourceIndex);
2131
2159
  }
2132
2160
  }
2133
2161
 
@@ -2404,6 +2432,84 @@ function ensureFileStyles() {
2404
2432
  z-index: 10000;
2405
2433
  }
2406
2434
 
2435
+ /* Two-card empty-state layout (upload card + library card) */
2436
+ .fb-file-card-row {
2437
+ display: flex;
2438
+ gap: 8px;
2439
+ align-items: stretch;
2440
+ }
2441
+ .fb-file-card-row .fb-file-dropzone,
2442
+ .fb-file-card-row .fb-file-library-card {
2443
+ flex: 1;
2444
+ min-width: 0;
2445
+ }
2446
+
2447
+ /* Library picker card \u2014 mirrors .fb-file-dropzone styling */
2448
+ .fb-file-library-card {
2449
+ height: 128px;
2450
+ border: 2px dashed var(--fb-file-upload-border-color, #d1d5db);
2451
+ border-radius: var(--fb-border-radius, 0.5rem);
2452
+ display: flex;
2453
+ flex-direction: column;
2454
+ align-items: center;
2455
+ justify-content: center;
2456
+ gap: 4px;
2457
+ cursor: pointer;
2458
+ background: none;
2459
+ padding: 0;
2460
+ transition:
2461
+ border-color var(--fb-transition-duration, 200ms),
2462
+ background var(--fb-transition-duration, 200ms);
2463
+ width: 100%;
2464
+ }
2465
+ .fb-file-library-card:hover,
2466
+ .fb-file-library-card:focus-visible {
2467
+ border-color: var(--fb-file-upload-hover-border-color, #3b82f6);
2468
+ background: var(--fb-background-hover-color, #f9fafb);
2469
+ outline: none;
2470
+ }
2471
+ .fb-file-library-card-icon {
2472
+ font-size: 24px;
2473
+ line-height: 1;
2474
+ flex-shrink: 0;
2475
+ }
2476
+ .fb-file-library-card-label {
2477
+ font-size: 13px;
2478
+ color: var(--fb-text-secondary-color, #6b7280);
2479
+ }
2480
+ .fb-file-library-card-hint {
2481
+ font-size: 11px;
2482
+ color: var(--fb-file-upload-text-color, #9ca3af);
2483
+ }
2484
+
2485
+ /* Library "\u{1F4DA}" add-tile \u2014 same size/style as the "+" add tile */
2486
+ .fb-tile-add-library {
2487
+ border: 2px dashed var(--fb-file-upload-border-color, #d1d5db);
2488
+ display: flex;
2489
+ align-items: center;
2490
+ justify-content: center;
2491
+ cursor: pointer;
2492
+ font-size: 24px;
2493
+ color: var(--fb-file-upload-text-color, #9ca3af);
2494
+ transition:
2495
+ border-color var(--fb-transition-duration, 200ms),
2496
+ color var(--fb-transition-duration, 200ms);
2497
+ background: none;
2498
+ padding: 0;
2499
+ width: var(--fb-tile-size, 160px);
2500
+ height: var(--fb-tile-size, 160px);
2501
+ flex-shrink: 0;
2502
+ position: relative;
2503
+ overflow: hidden;
2504
+ border-radius: var(--fb-border-radius, 0.5rem);
2505
+ }
2506
+ .fb-tile-add-library:hover,
2507
+ .fb-tile-add-library:focus-visible {
2508
+ border-color: var(--fb-file-upload-hover-border-color, #3b82f6);
2509
+ color: var(--fb-text-color, #1f2937);
2510
+ outline: none;
2511
+ }
2512
+
2407
2513
  /* Hover zoom preview popup for image tiles \u2014 appended to document.body (fixed) */
2408
2514
  .fb-tile-zoom-preview {
2409
2515
  position: fixed;
@@ -3222,7 +3328,18 @@ async function uploadSingleFile(file, state) {
3222
3328
  throw new Error(`File upload failed: ${err.message}`);
3223
3329
  }
3224
3330
  }
3225
- async function handleFileSelect(file, container, fieldName, state, deps = null, instance, allowedExtensions = [], maxSizeMB = Infinity) {
3331
+ async function handleFileSelect(opts) {
3332
+ const {
3333
+ file,
3334
+ container,
3335
+ fieldName,
3336
+ state,
3337
+ deps = null,
3338
+ instance = null,
3339
+ allowedExtensions = [],
3340
+ allowedMimes = [],
3341
+ maxSizeMB = Infinity
3342
+ } = opts;
3226
3343
  if (!isFileExtensionAllowed(file.name, allowedExtensions)) {
3227
3344
  const formats = allowedExtensions.join(", ");
3228
3345
  showFileError(
@@ -3231,6 +3348,14 @@ async function handleFileSelect(file, container, fieldName, state, deps = null,
3231
3348
  );
3232
3349
  return;
3233
3350
  }
3351
+ if (!isMimeAllowed(file.type, allowedMimes)) {
3352
+ const mimes = allowedMimes.join(", ");
3353
+ showFileError(
3354
+ container,
3355
+ t("invalidFileMime", state, { name: file.name, type: file.type, mimes })
3356
+ );
3357
+ return;
3358
+ }
3234
3359
  if (!isFileSizeAllowed(file, maxSizeMB)) {
3235
3360
  showFileError(
3236
3361
  container,
@@ -3290,10 +3415,16 @@ function filterAndSlice(allFiles, currentCount, constraints, state) {
3290
3415
  const afterExt = allFiles.filter(
3291
3416
  (f) => isFileExtensionAllowed(f.name, constraints.allowedExtensions)
3292
3417
  );
3293
- const rejectedBySize = afterExt.filter(
3418
+ const rejectedByMime = afterExt.filter(
3419
+ (f) => !isMimeAllowed(f.type, constraints.allowedMimes)
3420
+ );
3421
+ const afterMime = afterExt.filter(
3422
+ (f) => isMimeAllowed(f.type, constraints.allowedMimes)
3423
+ );
3424
+ const rejectedBySize = afterMime.filter(
3294
3425
  (f) => !isFileSizeAllowed(f, constraints.maxSize)
3295
3426
  );
3296
- const valid = afterExt.filter((f) => isFileSizeAllowed(f, constraints.maxSize));
3427
+ const valid = afterMime.filter((f) => isFileSizeAllowed(f, constraints.maxSize));
3297
3428
  const remaining = constraints.maxCount === Infinity ? valid.length : Math.max(0, constraints.maxCount - currentCount);
3298
3429
  const accepted = valid.slice(0, remaining);
3299
3430
  const skippedByCount = valid.length - accepted.length;
@@ -3303,6 +3434,11 @@ function filterAndSlice(allFiles, currentCount, constraints, state) {
3303
3434
  const names = rejectedByExt.map((f) => f.name).join(", ");
3304
3435
  errorParts.push(t("invalidFileExtension", state, { name: names, formats }));
3305
3436
  }
3437
+ if (rejectedByMime.length > 0) {
3438
+ const mimes = constraints.allowedMimes.join(", ");
3439
+ const names = rejectedByMime.map((f) => f.name).join(", ");
3440
+ errorParts.push(t("invalidFileMime", state, { name: names, type: rejectedByMime.map((f) => f.type).join(", "), mimes }));
3441
+ }
3306
3442
  if (rejectedBySize.length > 0) {
3307
3443
  const names = rejectedBySize.map((f) => f.name).join(", ");
3308
3444
  errorParts.push(
@@ -3394,27 +3530,159 @@ function setupFilesPickerHandler(filesPicker, resourceIds, state, updateCallback
3394
3530
  };
3395
3531
  }
3396
3532
 
3397
- // src/components/file/render-edit.ts
3398
- function handleInitialFileData(initial, fileContainer, pathKey, fileWrapper, state, deps) {
3399
- if (!state.resourceIndex.has(initial)) {
3400
- const filename = initial.split("/").pop() || "file";
3401
- const extension = filename.split(".").pop()?.toLowerCase();
3402
- let fileType = "application/octet-stream";
3403
- if (extension) {
3404
- if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
3405
- fileType = `image/${extension === "jpg" ? "jpeg" : extension}`;
3406
- } else if (["mp4", "webm", "mov", "avi"].includes(extension)) {
3407
- fileType = `video/${extension === "mov" ? "quicktime" : extension}`;
3408
- }
3409
- }
3410
- state.resourceIndex.set(initial, {
3411
- name: filename,
3412
- type: fileType,
3413
- size: 0,
3414
- uploadedAt: /* @__PURE__ */ new Date(),
3415
- file: void 0
3533
+ // src/components/file/library.ts
3534
+ function buildAcceptContext(element) {
3535
+ if (!element.accept) return void 0;
3536
+ if (typeof element.accept === "string") {
3537
+ const exts2 = getAllowedExtensions(element.accept);
3538
+ return exts2.length > 0 ? { extensions: exts2 } : void 0;
3539
+ }
3540
+ const exts = element.accept.extensions ?? [];
3541
+ const mime = element.accept.mime ?? [];
3542
+ const normalizedExts = exts.map((e) => e.toLowerCase());
3543
+ if (normalizedExts.length === 0 && mime.length === 0) return void 0;
3544
+ const result = {};
3545
+ if (normalizedExts.length > 0) result.extensions = normalizedExts;
3546
+ if (mime.length > 0) result.mime = mime;
3547
+ return result;
3548
+ }
3549
+ function validatePickedResource(resource, allowedExtensions, allowedMimes, maxSizeMB, state) {
3550
+ if (!isFileExtensionAllowed(resource.name, allowedExtensions)) {
3551
+ const formats = allowedExtensions.join(", ");
3552
+ return t("invalidFileExtension", state, { name: resource.name, formats });
3553
+ }
3554
+ if (!isMimeAllowed(resource.type, allowedMimes)) {
3555
+ const mimes = allowedMimes.join(", ");
3556
+ return t("invalidFileMime", state, { name: resource.name, type: resource.type, mimes });
3557
+ }
3558
+ if (!isSizeWithinLimit(resource.size, maxSizeMB)) {
3559
+ return t("fileTooLarge", state, { name: resource.name, maxSize: maxSizeMB });
3560
+ }
3561
+ return null;
3562
+ }
3563
+ function readCurrentResourceIds(wrapper) {
3564
+ const raw = wrapper.dataset.resourceIds;
3565
+ if (!raw) return [];
3566
+ try {
3567
+ const parsed = JSON.parse(raw);
3568
+ return Array.isArray(parsed) ? parsed : [];
3569
+ } catch {
3570
+ return [];
3571
+ }
3572
+ }
3573
+ function registerPickedResource(resource, state) {
3574
+ const existing = state.resourceIndex.get(resource.resourceId);
3575
+ state.resourceIndex.set(resource.resourceId, {
3576
+ name: resource.name,
3577
+ type: resource.type,
3578
+ size: resource.size,
3579
+ uploadedAt: existing?.uploadedAt ?? /* @__PURE__ */ new Date(),
3580
+ file: existing?.file
3581
+ });
3582
+ }
3583
+ function extractPickerError(error, state) {
3584
+ if (error instanceof Error && error.message) return error.message;
3585
+ return t("pickerError", state);
3586
+ }
3587
+ async function handleLibraryPickMulti(state, element, wrapper, fieldPath, resourceIds, maxCount, updateCallback, instance) {
3588
+ if (!state.config.pickExistingFiles) return;
3589
+ const allowedExtensions = getAllowedExtensions(element.accept);
3590
+ const allowedMimes = getAllowedMimes(element.accept);
3591
+ const maxSizeMB = element.maxSize ?? Infinity;
3592
+ const currentIds = readCurrentResourceIds(wrapper);
3593
+ const remaining = maxCount === Infinity ? Infinity : Math.max(0, maxCount - currentIds.length);
3594
+ let picked;
3595
+ try {
3596
+ picked = await state.config.pickExistingFiles({
3597
+ fieldPath,
3598
+ mode: "multiple",
3599
+ accept: buildAcceptContext(element),
3600
+ maxSizeMB: maxSizeMB === Infinity ? void 0 : maxSizeMB,
3601
+ remainingSlots: remaining === Infinity ? void 0 : remaining,
3602
+ selectedResourceIds: [...currentIds]
3603
+ });
3604
+ } catch (error) {
3605
+ showFileError(wrapper, extractPickerError(error, state));
3606
+ return;
3607
+ }
3608
+ if (picked.length === 0) return;
3609
+ const existingSet = new Set(currentIds);
3610
+ const seen = /* @__PURE__ */ new Set();
3611
+ const deduped = picked.filter((r) => {
3612
+ if (existingSet.has(r.resourceId)) return false;
3613
+ if (seen.has(r.resourceId)) return false;
3614
+ seen.add(r.resourceId);
3615
+ return true;
3616
+ });
3617
+ const validItems = deduped.filter((r) => {
3618
+ const err = validatePickedResource(r, allowedExtensions, allowedMimes, maxSizeMB, state);
3619
+ return err === null;
3620
+ });
3621
+ const freshRemaining = maxCount === Infinity ? validItems.length : Math.max(0, maxCount - resourceIds.length);
3622
+ const accepted = validItems.slice(0, freshRemaining);
3623
+ const skipped = validItems.length - accepted.length;
3624
+ if (accepted.length === 0) return;
3625
+ clearFileError(wrapper);
3626
+ if (skipped > 0) {
3627
+ showFileError(
3628
+ wrapper,
3629
+ t("filesLimitExceeded", state, { skipped, max: maxCount })
3630
+ );
3631
+ }
3632
+ for (const resource of accepted) {
3633
+ registerPickedResource(resource, state);
3634
+ resourceIds.push(resource.resourceId);
3635
+ }
3636
+ wrapper.dataset.resourceIds = JSON.stringify(resourceIds);
3637
+ updateCallback();
3638
+ if (!state.config.readonly) {
3639
+ instance.triggerOnChange(fieldPath, resourceIds);
3640
+ }
3641
+ }
3642
+ async function handleLibraryPickSingle(state, element, container, fileWrapper, pathKey, fieldPath, renderCallback, instance) {
3643
+ if (!state.config.pickExistingFiles) return;
3644
+ const allowedExtensions = getAllowedExtensions(element.accept);
3645
+ const allowedMimes = getAllowedMimes(element.accept);
3646
+ const maxSizeMB = element.maxSize ?? Infinity;
3647
+ let picked;
3648
+ try {
3649
+ picked = await state.config.pickExistingFiles({
3650
+ fieldPath,
3651
+ mode: "single",
3652
+ accept: buildAcceptContext(element),
3653
+ maxSizeMB: maxSizeMB === Infinity ? void 0 : maxSizeMB,
3654
+ selectedResourceIds: []
3416
3655
  });
3656
+ } catch (error) {
3657
+ showFileError(container, extractPickerError(error, state));
3658
+ return;
3659
+ }
3660
+ if (picked.length === 0) return;
3661
+ const first = picked[0];
3662
+ const validationError = validatePickedResource(first, allowedExtensions, allowedMimes, maxSizeMB, state);
3663
+ if (validationError !== null) {
3664
+ showFileError(container, validationError);
3665
+ return;
3666
+ }
3667
+ clearFileError(container);
3668
+ registerPickedResource(first, state);
3669
+ let hiddenInput = fileWrapper.querySelector('input[type="hidden"]');
3670
+ if (!hiddenInput) {
3671
+ hiddenInput = document.createElement("input");
3672
+ hiddenInput.type = "hidden";
3673
+ hiddenInput.name = pathKey;
3674
+ fileWrapper.appendChild(hiddenInput);
3675
+ }
3676
+ hiddenInput.value = first.resourceId;
3677
+ await renderCallback(first.resourceId);
3678
+ if (!state.config.readonly) {
3679
+ instance.triggerOnChange(fieldPath, first.resourceId);
3417
3680
  }
3681
+ }
3682
+
3683
+ // src/components/file/render-edit.ts
3684
+ function handleInitialFileData(initial, fileContainer, pathKey, fileWrapper, state, deps) {
3685
+ seedInferredResource(initial, state.resourceIndex);
3418
3686
  const meta = state.resourceIndex.get(initial);
3419
3687
  const isVideo = meta?.type?.startsWith("video/");
3420
3688
  if (isVideo) {
@@ -3432,7 +3700,50 @@ function handleInitialFileData(initial, fileContainer, pathKey, fileWrapper, sta
3432
3700
  hiddenInput.value = initial;
3433
3701
  fileWrapper.appendChild(hiddenInput);
3434
3702
  }
3435
- function renderResourcePills(container, rids, state, onRemove, hint, countInfo, maxCount, isReadonly = false) {
3703
+ var UPLOAD_SVG = `<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0;color:var(--fb-file-upload-text-color,#9ca3af);">
3704
+ <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"/>
3705
+ </svg>`;
3706
+ function buildEmptyDropzone(state, primaryText, subHint, openPicker) {
3707
+ const dropzone = document.createElement("div");
3708
+ dropzone.className = "fb-file-dropzone";
3709
+ dropzone.innerHTML = `
3710
+ ${UPLOAD_SVG}
3711
+ <div class="fb-dropzone-primary-text">${escapeHtml(primaryText)}</div>
3712
+ ${subHint ? `<div class="fb-dropzone-hint-text">${escapeHtml(subHint)}</div>` : ""}
3713
+ `;
3714
+ dropzone.onclick = openPicker;
3715
+ return dropzone;
3716
+ }
3717
+ function buildLibraryButton(variant, state, onClick) {
3718
+ const btn = document.createElement("button");
3719
+ btn.type = "button";
3720
+ btn.className = variant === "card" ? "fb-file-library-card" : "fb-tile fb-tile-add-library";
3721
+ if (variant === "card") {
3722
+ btn.innerHTML = `
3723
+ <span class="fb-file-library-card-icon" aria-hidden="true">\u{1F4DA}</span>
3724
+ <span class="fb-file-library-card-label">${escapeHtml(t("fromLibrary", state))}</span>
3725
+ <span class="fb-file-library-card-hint">${escapeHtml(t("libraryHint", state))}</span>
3726
+ `;
3727
+ } else {
3728
+ btn.innerHTML = `<span aria-hidden="true">\u{1F4DA}</span>`;
3729
+ btn.title = t("fromLibrary", state);
3730
+ btn.setAttribute("aria-label", t("fromLibrary", state));
3731
+ }
3732
+ btn.addEventListener("click", onClick);
3733
+ return btn;
3734
+ }
3735
+ function renderResourcePills(opts) {
3736
+ const {
3737
+ container,
3738
+ rids,
3739
+ state,
3740
+ onRemove,
3741
+ hint,
3742
+ countInfo,
3743
+ maxCount,
3744
+ isReadonly = false,
3745
+ onLibraryPick
3746
+ } = opts;
3436
3747
  ensureFileStyles();
3437
3748
  const wrapper = container.closest("[data-files-wrapper]");
3438
3749
  if (wrapper) {
@@ -3441,6 +3752,7 @@ function renderResourcePills(container, rids, state, onRemove, hint, countInfo,
3441
3752
  while (container.firstChild) container.removeChild(container.firstChild);
3442
3753
  const ridList = rids ?? [];
3443
3754
  const atMax = maxCount !== void 0 && ridList.length >= maxCount;
3755
+ const hasLibrary = !isReadonly && typeof onLibraryPick === "function";
3444
3756
  const buildSubHint = () => {
3445
3757
  const parts = [];
3446
3758
  if (hint) parts.push(hint);
@@ -3457,18 +3769,26 @@ function renderResourcePills(container, rids, state, onRemove, hint, countInfo,
3457
3769
  emptyEl.className = "fb-tile-empty-text";
3458
3770
  emptyEl.textContent = t("noFilesSelected", state);
3459
3771
  container.appendChild(emptyEl);
3772
+ } else if (hasLibrary) {
3773
+ const row = document.createElement("div");
3774
+ row.className = "fb-file-card-row";
3775
+ const dropzone = buildEmptyDropzone(
3776
+ state,
3777
+ t("clickDragTextMultiple", state),
3778
+ buildSubHint(),
3779
+ openPicker
3780
+ );
3781
+ const libraryBtn = buildLibraryButton("card", state, onLibraryPick);
3782
+ row.appendChild(dropzone);
3783
+ row.appendChild(libraryBtn);
3784
+ container.appendChild(row);
3460
3785
  } else {
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;
3786
+ const dropzone = buildEmptyDropzone(
3787
+ state,
3788
+ t("clickDragTextMultiple", state),
3789
+ buildSubHint(),
3790
+ openPicker
3791
+ );
3472
3792
  container.appendChild(dropzone);
3473
3793
  }
3474
3794
  return;
@@ -3499,6 +3819,10 @@ function renderResourcePills(container, rids, state, onRemove, hint, countInfo,
3499
3819
  addTile.innerHTML = "+";
3500
3820
  addTile.onclick = openPicker;
3501
3821
  tilesWrap.appendChild(addTile);
3822
+ if (hasLibrary) {
3823
+ const libraryTile = buildLibraryButton("tile", state, onLibraryPick);
3824
+ tilesWrap.appendChild(libraryTile);
3825
+ }
3502
3826
  } else if (!isReadonly && atMax) {
3503
3827
  const chip = document.createElement("div");
3504
3828
  chip.className = "fb-tile-counter";
@@ -3526,12 +3850,16 @@ function renderFileElementEdit(element, ctx, wrapper, pathKey) {
3526
3850
  picker.name = pathKey;
3527
3851
  picker.style.display = "none";
3528
3852
  if (element.accept) {
3529
- picker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
3853
+ picker.accept = typeof element.accept === "string" ? element.accept : [
3854
+ ...element.accept.extensions?.map((ext) => `.${ext}`) ?? [],
3855
+ ...element.accept.mime ?? []
3856
+ ].join(",") || "";
3530
3857
  }
3531
3858
  const fileContainer = document.createElement("div");
3532
3859
  fileContainer.className = "file-preview-container";
3533
3860
  const initial = ctx.prefill[element.key];
3534
3861
  const allowedExts = getAllowedExtensions(element.accept);
3862
+ const allowedMimes = getAllowedMimes(element.accept);
3535
3863
  const maxSizeMB = element.maxSize ?? Infinity;
3536
3864
  const handlers = {
3537
3865
  fileUploadHandler() {
@@ -3539,16 +3867,17 @@ function renderFileElementEdit(element, ctx, wrapper, pathKey) {
3539
3867
  },
3540
3868
  dragHandler(files) {
3541
3869
  if (files.length > 0) {
3542
- handleFileSelect(
3543
- files[0],
3544
- fileContainer,
3545
- pathKey,
3870
+ handleFileSelect({
3871
+ file: files[0],
3872
+ container: fileContainer,
3873
+ fieldName: pathKey,
3546
3874
  state,
3547
- buildDeps(),
3548
- ctx.instance,
3549
- allowedExts,
3875
+ deps: buildDeps(),
3876
+ instance: ctx.instance,
3877
+ allowedExtensions: allowedExts,
3878
+ allowedMimes,
3550
3879
  maxSizeMB
3551
- );
3880
+ });
3552
3881
  }
3553
3882
  },
3554
3883
  setupDrop(container) {
@@ -3579,6 +3908,47 @@ function renderFileElementEdit(element, ctx, wrapper, pathKey) {
3579
3908
  setupDrop: handlers.setupDrop,
3580
3909
  onRemove: handlers.onRemove
3581
3910
  });
3911
+ const renderEmptySingleState = () => {
3912
+ if (state.config.pickExistingFiles && !element.disableLibrary) {
3913
+ fileContainer.className = "file-preview-container";
3914
+ fileContainer.removeAttribute("style");
3915
+ fileContainer.onclick = null;
3916
+ const row = document.createElement("div");
3917
+ row.className = "fb-file-card-row";
3918
+ row.style.cssText = "display:flex;gap:8px;align-items:stretch;";
3919
+ const hint = makeFieldHint(element, state);
3920
+ const uploadCard = buildEmptyDropzone(
3921
+ state,
3922
+ t("clickDragText", state),
3923
+ hint,
3924
+ handlers.fileUploadHandler
3925
+ );
3926
+ uploadCard.style.cssText = "flex:1;min-width:0;height:128px;";
3927
+ setupDragAndDrop(uploadCard, handlers.dragHandler);
3928
+ const libraryBtn = buildLibraryButton("card", state, () => {
3929
+ handleLibraryPickSingle(
3930
+ state,
3931
+ element,
3932
+ fileContainer,
3933
+ fileWrapper,
3934
+ pathKey,
3935
+ pathKey,
3936
+ async (rid) => {
3937
+ await renderSingleFileEditTile(fileContainer, rid, state, buildDeps());
3938
+ },
3939
+ ctx.instance
3940
+ ).catch((err) => {
3941
+ console.error("Library pick failed:", err);
3942
+ });
3943
+ });
3944
+ libraryBtn.style.cssText = "flex:1;min-width:0;";
3945
+ row.appendChild(uploadCard);
3946
+ row.appendChild(libraryBtn);
3947
+ fileContainer.appendChild(row);
3948
+ } else {
3949
+ handlers.restoreDropzone();
3950
+ }
3951
+ };
3582
3952
  if (initial) {
3583
3953
  handleInitialFileData(
3584
3954
  initial,
@@ -3594,20 +3964,21 @@ function renderFileElementEdit(element, ctx, wrapper, pathKey) {
3594
3964
  setupDragAndDrop(fileContainer, handlers.dragHandler);
3595
3965
  }
3596
3966
  } else {
3597
- handlers.restoreDropzone();
3967
+ renderEmptySingleState();
3598
3968
  }
3599
3969
  picker.onchange = () => {
3600
3970
  if (picker.files && picker.files.length > 0) {
3601
- handleFileSelect(
3602
- picker.files[0],
3603
- fileContainer,
3604
- pathKey,
3971
+ handleFileSelect({
3972
+ file: picker.files[0],
3973
+ container: fileContainer,
3974
+ fieldName: pathKey,
3605
3975
  state,
3606
- buildDeps(),
3607
- ctx.instance,
3608
- allowedExts,
3976
+ deps: buildDeps(),
3977
+ instance: ctx.instance,
3978
+ allowedExtensions: allowedExts,
3979
+ allowedMimes,
3609
3980
  maxSizeMB
3610
- );
3981
+ });
3611
3982
  }
3612
3983
  };
3613
3984
  fileWrapper.appendChild(fileContainer);
@@ -3625,7 +3996,10 @@ function renderFilesElementEdit(element, ctx, wrapper, pathKey) {
3625
3996
  filesPicker.multiple = true;
3626
3997
  filesPicker.style.display = "none";
3627
3998
  if (element.accept) {
3628
- filesPicker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
3999
+ filesPicker.accept = typeof element.accept === "string" ? element.accept : [
4000
+ ...element.accept.extensions?.map((ext) => `.${ext}`) ?? [],
4001
+ ...element.accept.mime ?? []
4002
+ ].join(",") || "";
3629
4003
  }
3630
4004
  const filesContainer = document.createElement("div");
3631
4005
  filesContainer.className = "files-list-wrapper";
@@ -3639,29 +4013,43 @@ function renderFilesElementEdit(element, ctx, wrapper, pathKey) {
3639
4013
  const filesConstraints = {
3640
4014
  maxCount: Infinity,
3641
4015
  allowedExtensions: getAllowedExtensions(element.accept),
4016
+ allowedMimes: getAllowedMimes(element.accept),
3642
4017
  maxSize: element.maxSize ?? Infinity
3643
4018
  };
3644
4019
  filesContainer.appendChild(list);
3645
4020
  filesWrapper.appendChild(filesPicker);
3646
4021
  filesWrapper.appendChild(filesContainer);
3647
4022
  wrapper.appendChild(filesWrapper);
4023
+ const onLibraryPickFiles = state.config.pickExistingFiles && !element.disableLibrary ? () => {
4024
+ handleLibraryPickMulti(
4025
+ state,
4026
+ element,
4027
+ filesWrapper,
4028
+ pathKey,
4029
+ initialFiles,
4030
+ Infinity,
4031
+ updateFilesList,
4032
+ ctx.instance
4033
+ ).catch((err) => {
4034
+ console.error("Library pick failed:", err);
4035
+ });
4036
+ } : null;
3648
4037
  function updateFilesList() {
3649
4038
  const currentlyReadonly = isElementReadonly(element, state);
3650
- renderResourcePills(
3651
- list,
3652
- initialFiles,
4039
+ renderResourcePills({
4040
+ container: list,
4041
+ rids: initialFiles,
3653
4042
  state,
3654
- currentlyReadonly ? null : (ridToRemove) => {
4043
+ onRemove: currentlyReadonly ? null : (ridToRemove) => {
3655
4044
  releaseLocalFileUrl(state.resourceIndex.get(ridToRemove)?.file);
3656
4045
  const index = initialFiles.indexOf(ridToRemove);
3657
4046
  if (index > -1) initialFiles.splice(index, 1);
3658
4047
  updateFilesList();
3659
4048
  },
3660
- filesFieldHint,
3661
- void 0,
3662
- void 0,
3663
- currentlyReadonly
3664
- );
4049
+ hint: filesFieldHint,
4050
+ isReadonly: currentlyReadonly,
4051
+ onLibraryPick: currentlyReadonly ? null : onLibraryPickFiles
4052
+ });
3665
4053
  }
3666
4054
  updateFilesList();
3667
4055
  setupFilesDropHandler(
@@ -3696,7 +4084,10 @@ function renderMultipleFileElementEdit(element, ctx, wrapper, pathKey) {
3696
4084
  filesPicker.multiple = true;
3697
4085
  filesPicker.style.display = "none";
3698
4086
  if (element.accept) {
3699
- filesPicker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
4087
+ filesPicker.accept = typeof element.accept === "string" ? element.accept : [
4088
+ ...element.accept.extensions?.map((ext) => `.${ext}`) ?? [],
4089
+ ...element.accept.mime ?? []
4090
+ ].join(",") || "";
3700
4091
  }
3701
4092
  const filesContainer = document.createElement("div");
3702
4093
  filesContainer.className = "files-list-wrapper";
@@ -3713,6 +4104,7 @@ function renderMultipleFileElementEdit(element, ctx, wrapper, pathKey) {
3713
4104
  const multipleConstraints = {
3714
4105
  maxCount: maxFiles,
3715
4106
  allowedExtensions: getAllowedExtensions(element.accept),
4107
+ allowedMimes: getAllowedMimes(element.accept),
3716
4108
  maxSize: element.maxSize ?? Infinity
3717
4109
  };
3718
4110
  const buildCountInfo = () => {
@@ -3720,22 +4112,37 @@ function renderMultipleFileElementEdit(element, ctx, wrapper, pathKey) {
3720
4112
  const minMaxText = minFiles > 0 || maxFiles < Infinity ? ` ${t("fileCountRange", state, { min: minFiles, max: maxFiles })}` : "";
3721
4113
  return countText + minMaxText;
3722
4114
  };
4115
+ const onLibraryPickMultiple = state.config.pickExistingFiles && !element.disableLibrary ? () => {
4116
+ handleLibraryPickMulti(
4117
+ state,
4118
+ element,
4119
+ filesWrapper,
4120
+ pathKey,
4121
+ initialFiles,
4122
+ maxFiles,
4123
+ updateFilesDisplay,
4124
+ ctx.instance
4125
+ ).catch((err) => {
4126
+ console.error("Library pick failed:", err);
4127
+ });
4128
+ } : null;
3723
4129
  const updateFilesDisplay = () => {
3724
4130
  const currentlyReadonly = isElementReadonly(element, state);
3725
- renderResourcePills(
3726
- list,
3727
- initialFiles,
4131
+ renderResourcePills({
4132
+ container: list,
4133
+ rids: initialFiles,
3728
4134
  state,
3729
- currentlyReadonly ? null : (index) => {
4135
+ onRemove: currentlyReadonly ? null : (index) => {
3730
4136
  releaseLocalFileUrl(state.resourceIndex.get(index)?.file);
3731
4137
  initialFiles.splice(initialFiles.indexOf(index), 1);
3732
4138
  updateFilesDisplay();
3733
4139
  },
3734
- multipleFilesHint,
3735
- buildCountInfo(),
3736
- maxFiles < Infinity ? maxFiles : void 0,
3737
- currentlyReadonly
3738
- );
4140
+ hint: multipleFilesHint,
4141
+ countInfo: buildCountInfo(),
4142
+ maxCount: maxFiles < Infinity ? maxFiles : void 0,
4143
+ isReadonly: currentlyReadonly,
4144
+ onLibraryPick: currentlyReadonly ? null : onLibraryPickMultiple
4145
+ });
3739
4146
  };
3740
4147
  setupFilesDropHandler(
3741
4148
  filesContainer,
@@ -3792,18 +4199,29 @@ function validateFileCount(key, resourceIds, element, state, errors) {
3792
4199
  errors.push(`${key}: ${t("maxFiles", state, { max: maxFiles })}`);
3793
4200
  }
3794
4201
  }
3795
- function validateFileExtensions(key, resourceIds, element, state, errors) {
4202
+ function validateFileTypes(key, resourceIds, element, state, errors) {
3796
4203
  const acceptField = "accept" in element ? element.accept : void 0;
3797
4204
  const allowedExtensions = getAllowedExtensions(acceptField);
3798
- if (allowedExtensions.length === 0) return;
4205
+ const allowedMimes = getAllowedMimes(acceptField);
4206
+ if (allowedExtensions.length === 0 && allowedMimes.length === 0) return;
3799
4207
  const formats = allowedExtensions.join(", ");
4208
+ const mimes = allowedMimes.join(", ");
3800
4209
  for (const rid of resourceIds) {
3801
4210
  const meta = state.resourceIndex.get(rid);
3802
4211
  const fileName = meta?.name ?? rid;
3803
- if (!isFileExtensionAllowed(fileName, allowedExtensions)) {
4212
+ if (allowedExtensions.length > 0 && !isFileExtensionAllowed(fileName, allowedExtensions)) {
3804
4213
  errors.push(
3805
4214
  `${key}: ${t("invalidFileExtension", state, { name: fileName, formats })}`
3806
4215
  );
4216
+ continue;
4217
+ }
4218
+ if (allowedMimes.length > 0 && !meta?.inferredFromExtension) {
4219
+ const mimeType = meta?.type ?? "";
4220
+ if (!isMimeAllowed(mimeType, allowedMimes)) {
4221
+ errors.push(
4222
+ `${key}: ${t("invalidFileMime", state, { name: fileName, type: mimeType, mimes })}`
4223
+ );
4224
+ }
3807
4225
  }
3808
4226
  }
3809
4227
  }
@@ -3827,7 +4245,7 @@ function validateMultiFile(element, key, context) {
3827
4245
  const resourceIds = readMultiFileResourceIds(scopeRoot, fullKey);
3828
4246
  if (!skipValidation) {
3829
4247
  validateFileCount(key, resourceIds, element, state, errors);
3830
- validateFileExtensions(key, resourceIds, element, state, errors);
4248
+ validateFileTypes(key, resourceIds, element, state, errors);
3831
4249
  validateFileSizes(key, resourceIds, element, state, errors);
3832
4250
  }
3833
4251
  return { value: resourceIds, errors };
@@ -3844,7 +4262,7 @@ function validateSingleFile(element, key, context) {
3844
4262
  return { value: null, errors };
3845
4263
  }
3846
4264
  if (!skipValidation && rid !== "") {
3847
- validateFileExtensions(key, [rid], element, state, errors);
4265
+ validateFileTypes(key, [rid], element, state, errors);
3848
4266
  validateFileSizes(key, [rid], element, state, errors);
3849
4267
  }
3850
4268
  return { value: rid || null, errors };
@@ -3970,9 +4388,7 @@ function updateFileField(element, fieldPath, value, context) {
3970
4388
  }
3971
4389
  value.forEach((resourceId) => {
3972
4390
  if (resourceId && typeof resourceId === "string") {
3973
- if (!state.resourceIndex.has(resourceId)) {
3974
- addResourceToIndex(resourceId, state);
3975
- }
4391
+ seedInferredResource(resourceId, state.resourceIndex);
3976
4392
  }
3977
4393
  });
3978
4394
  const filesWrapper = scopeRoot.querySelector(
@@ -3997,34 +4413,13 @@ function updateFileField(element, fieldPath, value, context) {
3997
4413
  }
3998
4414
  hiddenInput.value = value != null ? String(value) : "";
3999
4415
  if (value && typeof value === "string") {
4000
- if (!state.resourceIndex.has(value)) {
4001
- addResourceToIndex(value, state);
4002
- }
4416
+ seedInferredResource(value, state.resourceIndex);
4003
4417
  console.info(
4004
4418
  `updateFileField: File field "${fieldPath}" updated. Preview update requires re-render.`
4005
4419
  );
4006
4420
  }
4007
4421
  }
4008
4422
  }
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
- }
4028
4423
 
4029
4424
  // src/components/colour.ts
4030
4425
  function normalizeColourValue(value) {
@@ -8668,6 +9063,7 @@ var defaultConfig = {
8668
9063
  enableFilePreview: true,
8669
9064
  maxPreviewSize: "200px",
8670
9065
  readonly: false,
9066
+ pickExistingFiles: null,
8671
9067
  parseTableFile: null,
8672
9068
  locale: "en",
8673
9069
  translations: {
@@ -8704,6 +9100,10 @@ var defaultConfig = {
8704
9100
  fileCountRange: "({min}-{max})",
8705
9101
  uploadingFile: "Uploading\u2026",
8706
9102
  filesCounter: "{count}/{max}",
9103
+ fromLibrary: "From library",
9104
+ libraryEmpty: "Library is empty",
9105
+ libraryHint: "Choose from previously uploaded files",
9106
+ pickerError: "Failed to load files from library",
8707
9107
  // Validation errors
8708
9108
  required: "Required",
8709
9109
  minItems: "Minimum {min} items required",
@@ -8719,6 +9119,7 @@ var defaultConfig = {
8719
9119
  minFiles: "Minimum {min} files required",
8720
9120
  maxFiles: "Maximum {max} files allowed",
8721
9121
  invalidFileExtension: 'File "{name}" has unsupported format. Allowed: {formats}',
9122
+ invalidFileMime: 'File "{name}": file type {type} not allowed (allowed: {mimes})',
8722
9123
  fileTooLarge: 'File "{name}" exceeds maximum size of {maxSize}MB',
8723
9124
  filesLimitExceeded: "{skipped} file(s) skipped: maximum {max} files allowed",
8724
9125
  unsupportedFieldType: "Unsupported field type: {type}",
@@ -8770,6 +9171,10 @@ var defaultConfig = {
8770
9171
  fileCountRange: "({min}-{max})",
8771
9172
  uploadingFile: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430\u2026",
8772
9173
  filesCounter: "{count}/{max}",
9174
+ fromLibrary: "\u0418\u0437 \u0431\u0438\u0431\u043B\u0438\u043E\u0442\u0435\u043A\u0438",
9175
+ libraryEmpty: "\u0411\u0438\u0431\u043B\u0438\u043E\u0442\u0435\u043A\u0430 \u043F\u0443\u0441\u0442\u0430",
9176
+ libraryHint: "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0438\u0437 \u0440\u0430\u043D\u0435\u0435 \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043D\u044B\u0445 \u0444\u0430\u0439\u043B\u043E\u0432",
9177
+ pickerError: "\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0444\u0430\u0439\u043B\u044B \u0438\u0437 \u0431\u0438\u0431\u043B\u0438\u043E\u0442\u0435\u043A\u0438",
8773
9178
  // Validation errors
8774
9179
  required: "\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u043E\u0435 \u043F\u043E\u043B\u0435",
8775
9180
  minItems: "\u041C\u0438\u043D\u0438\u043C\u0443\u043C {min} \u044D\u043B\u0435\u043C\u0435\u043D\u0442\u043E\u0432",
@@ -8785,6 +9190,7 @@ var defaultConfig = {
8785
9190
  minFiles: "\u041C\u0438\u043D\u0438\u043C\u0443\u043C {min} \u0444\u0430\u0439\u043B\u043E\u0432",
8786
9191
  maxFiles: "\u041C\u0430\u043A\u0441\u0438\u043C\u0443\u043C {max} \u0444\u0430\u0439\u043B\u043E\u0432",
8787
9192
  invalidFileExtension: '\u0424\u0430\u0439\u043B "{name}" \u0438\u043C\u0435\u0435\u0442 \u043D\u0435\u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043C\u044B\u0439 \u0444\u043E\u0440\u043C\u0430\u0442. \u0414\u043E\u043F\u0443\u0441\u0442\u0438\u043C\u044B\u0435: {formats}',
9193
+ invalidFileMime: '\u0424\u0430\u0439\u043B "{name}": \u0442\u0438\u043F \u0444\u0430\u0439\u043B\u0430 {type} \u043D\u0435 \u0440\u0430\u0437\u0440\u0435\u0448\u0451\u043D (\u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043D\u044B: {mimes})',
8788
9194
  fileTooLarge: '\u0424\u0430\u0439\u043B "{name}" \u043F\u0440\u0435\u0432\u044B\u0448\u0430\u0435\u0442 \u043C\u0430\u043A\u0441\u0438\u043C\u0430\u043B\u044C\u043D\u044B\u0439 \u0440\u0430\u0437\u043C\u0435\u0440 {maxSize}\u041C\u0411',
8789
9195
  filesLimitExceeded: "{skipped} \u0444\u0430\u0439\u043B(\u043E\u0432) \u043F\u0440\u043E\u043F\u0443\u0449\u0435\u043D\u043E: \u043C\u0430\u043A\u0441\u0438\u043C\u0443\u043C {max} \u0444\u0430\u0439\u043B\u043E\u0432",
8790
9196
  unsupportedFieldType: "\u041D\u0435\u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043C\u044B\u0439 \u0442\u0438\u043F \u043F\u043E\u043B\u044F: {type}",