form-page-builder 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -21,17 +21,25 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  // src/index.ts
22
22
  var index_exports = {};
23
23
  __export(index_exports, {
24
+ CLIPBOARD_KEY: () => CLIPBOARD_KEY,
24
25
  DARK_THEME: () => DARK_THEME,
25
26
  DEFAULT_THEME: () => DEFAULT_THEME,
27
+ DOCUMENT_VERSION: () => DOCUMENT_VERSION,
28
+ DRAFT_KEY: () => DRAFT_KEY,
26
29
  FormBuilder: () => FormBuilder_default,
30
+ INDEX_KEY: () => INDEX_KEY,
27
31
  default: () => FormBuilder_default,
28
- localStorageAdapter: () => localStorageAdapter
32
+ formKey: () => formKey,
33
+ localStorageAdapter: () => localStorageAdapter,
34
+ parseTemplate: () => parseTemplate,
35
+ savedFormId: () => savedFormId,
36
+ serializeTemplate: () => serializeTemplate
29
37
  });
30
38
  module.exports = __toCommonJS(index_exports);
31
39
 
32
40
  // src/FormBuilder.tsx
33
- var import_react8 = require("react");
34
- var import_lucide_react18 = require("lucide-react");
41
+ var import_react10 = require("react");
42
+ var import_lucide_react19 = require("lucide-react");
35
43
 
36
44
  // src/constants/fieldTypes.ts
37
45
  var import_lucide_react = require("lucide-react");
@@ -122,6 +130,7 @@ var DEFAULT_STRINGS = {
122
130
  tooLarge: (n) => `${n}\u4EE5\u4E0B\u306E\u5024\u3092\u5165\u529B\u3057\u3066\u304F\u3060\u3055\u3044\u3002`
123
131
  }
124
132
  };
133
+ DEFAULT_STRINGS.ja;
125
134
 
126
135
  // src/i18n/chrome.ts
127
136
  var CHROME = {
@@ -197,16 +206,33 @@ var CHROME = {
197
206
  formJson: "Form JSON",
198
207
  copy: "Copy",
199
208
  copied: "Copied",
209
+ copyTemplate: "Copy template",
210
+ pasteTemplate: "Paste template",
211
+ copyTemplateTitle: "Copy this form as a template you can paste into another builder",
212
+ pasteTemplateTitle: "Replace this form with the copied template",
213
+ pasteTemplateConfirm: "Replace the current form with the copied template? This can't be undone.",
200
214
  noTemplates: 'No templates saved yet. Click "Save" in the toolbar to store this form here.',
215
+ noTemplatesReadonly: "No templates available yet.",
201
216
  updated: "Updated",
202
217
  open: "Open",
218
+ applyTemplate: "Apply",
203
219
  saveForm: "Save as template",
204
220
  formName: "Template name",
205
221
  saveToLibrary: "Save template",
206
222
  loadingDraft: "Loading your draft...",
207
- templatesLimitReached: "You can save up to 5 templates. Delete one to save another.",
223
+ templatesLimitReached: (max) => `You can save up to ${max} template${max === 1 ? "" : "s"}. Delete one to save another.`,
224
+ templatesLimitTitle: "Template limit reached",
225
+ confirm: "Confirm",
226
+ cancel: "Cancel",
227
+ dismiss: "OK",
228
+ confirmReplace: "Replace",
208
229
  current: "current",
209
230
  customColor: "Custom color",
231
+ templateLabel: (name) => `Template: ${name}`,
232
+ templateEdited: "Edited",
233
+ templateLoading: "Loading template...",
234
+ templateLoadFailed: "Couldn't load that template.",
235
+ templateSaveFailed: "Couldn't save the template.",
210
236
  fieldsCount: (n) => `${n} field${n === 1 ? "" : "s"}`,
211
237
  fieldTypeInput: "Input",
212
238
  fieldTypeTextarea: "Textarea",
@@ -333,16 +359,33 @@ var CHROME = {
333
359
  formJson: "\u30D5\u30A9\u30FC\u30E0JSON",
334
360
  copy: "\u30B3\u30D4\u30FC",
335
361
  copied: "\u30B3\u30D4\u30FC\u3057\u307E\u3057\u305F",
362
+ copyTemplate: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3092\u30B3\u30D4\u30FC",
363
+ pasteTemplate: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3092\u8CBC\u308A\u4ED8\u3051",
364
+ copyTemplateTitle: "\u3053\u306E\u30D5\u30A9\u30FC\u30E0\u3092\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3068\u3057\u3066\u30B3\u30D4\u30FC\u3057\u3001\u4ED6\u306E\u30D3\u30EB\u30C0\u30FC\u306B\u8CBC\u308A\u4ED8\u3051\u307E\u3059",
365
+ pasteTemplateTitle: "\u30B3\u30D4\u30FC\u3057\u305F\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3067\u3053\u306E\u30D5\u30A9\u30FC\u30E0\u3092\u7F6E\u304D\u63DB\u3048\u307E\u3059",
366
+ pasteTemplateConfirm: "\u73FE\u5728\u306E\u30D5\u30A9\u30FC\u30E0\u3092\u30B3\u30D4\u30FC\u3057\u305F\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3067\u7F6E\u304D\u63DB\u3048\u307E\u3059\u304B\uFF1F\u3053\u306E\u64CD\u4F5C\u306F\u53D6\u308A\u6D88\u305B\u307E\u305B\u3093\u3002",
336
367
  noTemplates: "\u307E\u3060\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u304C\u3042\u308A\u307E\u305B\u3093\u3002\u30C4\u30FC\u30EB\u30D0\u30FC\u306E\u300C\u4FDD\u5B58\u300D\u3092\u30AF\u30EA\u30C3\u30AF\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
368
+ noTemplatesReadonly: "\u5229\u7528\u3067\u304D\u308B\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u304C\u307E\u3060\u3042\u308A\u307E\u305B\u3093\u3002",
337
369
  updated: "\u66F4\u65B0\u65E5\u6642",
338
370
  open: "\u958B\u304F",
371
+ applyTemplate: "\u9069\u7528",
339
372
  saveForm: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3068\u3057\u3066\u4FDD\u5B58",
340
373
  formName: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u540D",
341
374
  saveToLibrary: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3092\u4FDD\u5B58",
342
375
  loadingDraft: "\u4E0B\u66F8\u304D\u3092\u8AAD\u307F\u8FBC\u3093\u3067\u3044\u307E\u3059...",
343
- templatesLimitReached: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306F5\u4EF6\u307E\u3067\u4FDD\u5B58\u3067\u304D\u307E\u3059\u30021\u4EF6\u524A\u9664\u3057\u3066\u304B\u3089\u4FDD\u5B58\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
376
+ templatesLimitReached: (max) => `\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306F${max}\u4EF6\u307E\u3067\u4FDD\u5B58\u3067\u304D\u307E\u3059\u30021\u4EF6\u524A\u9664\u3057\u3066\u304B\u3089\u4FDD\u5B58\u3057\u3066\u304F\u3060\u3055\u3044\u3002`,
377
+ templatesLimitTitle: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306E\u4E0A\u9650\u306B\u9054\u3057\u307E\u3057\u305F",
378
+ confirm: "\u78BA\u5B9A",
379
+ cancel: "\u30AD\u30E3\u30F3\u30BB\u30EB",
380
+ dismiss: "OK",
381
+ confirmReplace: "\u7F6E\u304D\u63DB\u3048\u308B",
344
382
  current: "\u73FE\u5728\u7DE8\u96C6\u4E2D",
345
383
  customColor: "\u30AB\u30B9\u30BF\u30E0\u30AB\u30E9\u30FC",
384
+ templateLabel: (name) => `\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8: ${name}`,
385
+ templateEdited: "\u7DE8\u96C6\u6E08\u307F",
386
+ templateLoading: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3092\u8AAD\u307F\u8FBC\u307F\u4E2D...",
387
+ templateLoadFailed: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3092\u8AAD\u307F\u8FBC\u3081\u307E\u305B\u3093\u3067\u3057\u305F\u3002",
388
+ templateSaveFailed: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3092\u4FDD\u5B58\u3067\u304D\u307E\u305B\u3093\u3067\u3057\u305F\u3002",
346
389
  fieldsCount: (n) => `${n}\u4EF6\u306E\u30D5\u30A3\u30FC\u30EB\u30C9`,
347
390
  fieldTypeInput: "\u5165\u529B",
348
391
  fieldTypeTextarea: "\u30C6\u30AD\u30B9\u30C8\u30A8\u30EA\u30A2",
@@ -398,6 +441,7 @@ var CHROME = {
398
441
  spacingTicketPadding: "\u30D5\u30A3\u30FC\u30EB\u30C9\u5185\u4F59\u767D"
399
442
  }
400
443
  };
444
+ CHROME.ja;
401
445
 
402
446
  // src/lib/bilingual.ts
403
447
  function bi(en = "", ja = "") {
@@ -540,12 +584,8 @@ function defaultSection(titleEn = "") {
540
584
  return { id: genSectionId(), title: bi(titleEn, ""), background: "", collapsed: false, fields: [] };
541
585
  }
542
586
 
543
- // src/lib/submittable.ts
544
- function sectionHasFormFields(section) {
545
- return section.fields.some((f) => !getMeta(f.type).isContent);
546
- }
547
-
548
587
  // src/lib/migrate.ts
588
+ var DOCUMENT_VERSION = 5;
549
589
  function migrateField(field) {
550
590
  let f = { ...field };
551
591
  if (LEGACY_INPUT_TYPES.includes(f.type)) f = { ...f, type: "input", inputType: f.type };
@@ -576,25 +616,6 @@ function migrateField(field) {
576
616
  function migrateFields(fields) {
577
617
  return (fields || []).map(migrateField);
578
618
  }
579
- function synthesizeButtonField(label, style, scope) {
580
- const resolvedLabel = typeof label === "string" && label ? bi(label) : label && typeof label === "object" && (label.en || label.ja) ? label : bi("Submit", "");
581
- return {
582
- id: nextId(),
583
- type: "button",
584
- label: resolvedLabel,
585
- hideLabel: false,
586
- width: "1/1",
587
- verticalAlign: "top",
588
- labelPosition: "top",
589
- showIcon: false,
590
- displayIcon: "Type",
591
- action: "submit",
592
- submitScope: scope,
593
- buttonStyle: style || { color: "", size: "md" },
594
- href: "",
595
- target: "_self"
596
- };
597
- }
598
619
  function migrateDocument(raw) {
599
620
  if (!raw) return null;
600
621
  const title = typeof raw.title === "string" ? bi(raw.title) : raw.title || bi();
@@ -611,32 +632,36 @@ function migrateDocument(raw) {
611
632
  } else {
612
633
  sections = [{ ...defaultSection(), fields: migrateFields(raw.fields || []) }];
613
634
  }
614
- const legacy = raw.version === void 0 || raw.version < 5;
615
- if (legacy) {
616
- resyncIdCounter(sections.flatMap((s) => s.fields));
617
- const submitMode = raw.submitMode === "perSection" ? "perSection" : "combined";
618
- const submitLabel = raw.submitLabel;
619
- const submitStyle = raw.submitStyle || { color: "", size: "md" };
620
- if (submitMode === "perSection" && raw.sections) {
621
- sections = sections.map((s, i) => {
622
- if (!sectionHasFormFields(s)) return s;
623
- const rawSection = raw.sections[i] || {};
624
- const label = rawSection.submitLabel || submitLabel;
625
- const style = rawSection.submitStyle || submitStyle;
626
- return { ...s, fields: [...s.fields, synthesizeButtonField(label, style, "section")] };
627
- });
628
- } else if (sections.some(sectionHasFormFields)) {
629
- const lastIdx = sections.length - 1;
630
- sections = sections.map((s, i) => i === lastIdx ? { ...s, fields: [...s.fields, synthesizeButtonField(submitLabel, submitStyle, "form")] } : s);
631
- }
632
- }
633
635
  return { title, themeOverrides, sections };
634
636
  }
635
637
 
638
+ // src/lib/template.ts
639
+ var TEMPLATE_FORMAT = 1;
640
+ function serializeTemplate(document2) {
641
+ const envelope = { __fpb: "template", v: TEMPLATE_FORMAT, document: document2 };
642
+ return JSON.stringify(envelope);
643
+ }
644
+ function parseTemplate(raw) {
645
+ if (!raw) return null;
646
+ let parsed;
647
+ try {
648
+ parsed = JSON.parse(raw);
649
+ } catch {
650
+ return null;
651
+ }
652
+ if (!parsed || typeof parsed !== "object") return null;
653
+ const obj = parsed;
654
+ const candidate = obj.__fpb === "template" && obj.document ? obj.document : obj;
655
+ return migrateDocument(candidate);
656
+ }
657
+
636
658
  // src/lib/features.ts
659
+ var DEFAULT_MAX_TEMPLATES = 5;
637
660
  var DEFAULT_FEATURES = {
638
661
  naming: true,
639
- templates: true,
662
+ formTitle: false,
663
+ templates: { enabled: true, manage: true, max: DEFAULT_MAX_TEMPLATES },
664
+ templateClipboard: true,
640
665
  newForm: true,
641
666
  autosave: true,
642
667
  jsonView: true,
@@ -647,15 +672,24 @@ var DEFAULT_FEATURES = {
647
672
  contentBlocks: true,
648
673
  fieldTypes: true,
649
674
  sections: true,
675
+ sectionBackground: true,
650
676
  dragReorder: true,
651
677
  deviceToggle: true
652
678
  };
679
+ function resolveTemplates(t2) {
680
+ if (t2 === false) return { enabled: false, manage: false, max: DEFAULT_MAX_TEMPLATES };
681
+ if (t2 === void 0 || t2 === true) return { enabled: true, manage: true, max: DEFAULT_MAX_TEMPLATES };
682
+ return { enabled: true, manage: t2.manage ?? true, max: t2.max ?? DEFAULT_MAX_TEMPLATES };
683
+ }
653
684
  function resolveFeatures(features) {
654
685
  return {
655
686
  ...DEFAULT_FEATURES,
656
687
  ...features,
688
+ templates: resolveTemplates(features?.templates),
657
689
  contentBlocks: Array.isArray(features?.contentBlocks) ? new Set(features.contentBlocks) : features?.contentBlocks ?? DEFAULT_FEATURES.contentBlocks,
658
- fieldTypes: Array.isArray(features?.fieldTypes) ? new Set(features.fieldTypes) : features?.fieldTypes ?? DEFAULT_FEATURES.fieldTypes
690
+ fieldTypes: Array.isArray(features?.fieldTypes) ? new Set(features.fieldTypes) : features?.fieldTypes ?? DEFAULT_FEATURES.fieldTypes,
691
+ // Unset → follow `sections`, so an existing `sections: false` still hides the picker.
692
+ sectionBackground: features?.sectionBackground ?? features?.sections ?? DEFAULT_FEATURES.sectionBackground
659
693
  };
660
694
  }
661
695
  function isContentBlockEnabled(features, type) {
@@ -685,17 +719,17 @@ var DEFAULT_THEME = {
685
719
 
686
720
  // src/hooks/useTheme.ts
687
721
  function useTheme(themeOverrideProp) {
688
- const baseTheme = {
722
+ const [themeOverrides, setThemeOverrides] = (0, import_react.useState)({});
723
+ const baseTheme = (0, import_react.useMemo)(() => ({
689
724
  ...DEFAULT_THEME,
690
725
  ...themeOverrideProp || {},
691
726
  layout: { ...DEFAULT_THEME.layout, ...themeOverrideProp && themeOverrideProp.layout || {} }
692
- };
693
- const [themeOverrides, setThemeOverrides] = (0, import_react.useState)({});
694
- const theme = {
727
+ }), [themeOverrideProp]);
728
+ const theme = (0, import_react.useMemo)(() => ({
695
729
  ...baseTheme,
696
730
  ...themeOverrides,
697
731
  layout: { ...baseTheme.layout, ...themeOverrides.layout || {} }
698
- };
732
+ }), [baseTheme, themeOverrides]);
699
733
  function updateThemeColor(key, value) {
700
734
  setThemeOverrides((prev) => ({ ...prev, [key]: value }));
701
735
  }
@@ -894,35 +928,49 @@ var import_react3 = require("react");
894
928
  var DRAFT_KEY = "form-page-builder:draft";
895
929
  var INDEX_KEY = "form-page-builder:index";
896
930
  var formKey = (id) => `form-page-builder:saved:${id}`;
931
+ var savedFormId = (key) => key.startsWith("form-page-builder:saved:") ? key.slice("form-page-builder:saved:".length) : null;
932
+ var CLIPBOARD_KEY = "form-page-builder:clipboard";
897
933
 
898
934
  // src/hooks/usePersistence.ts
899
- var MAX_TEMPLATES = 5;
935
+ function snapshotOf(d) {
936
+ return JSON.stringify({ title: d.title, themeOverrides: d.themeOverrides, sections: d.sections });
937
+ }
900
938
  function usePersistence({
901
939
  storage,
902
940
  autosave,
941
+ templateMax,
942
+ templateManage,
903
943
  language,
904
944
  chrome,
905
- document,
945
+ document: document2,
906
946
  initialDocument,
907
947
  onLoadDocument,
908
948
  onLoadThemeOverrides,
909
949
  onTitleChange,
910
950
  onNewForm,
951
+ onTemplateChange,
911
952
  ensureActiveSection
912
953
  }) {
913
954
  const [currentFormId, setCurrentFormId] = (0, import_react3.useState)(null);
914
955
  const [loadingDraft, setLoadingDraft] = (0, import_react3.useState)(true);
915
956
  const [saveState, setSaveState] = (0, import_react3.useState)("idle");
957
+ const [templateState, setTemplateState] = (0, import_react3.useState)("idle");
916
958
  const [savedForms, setSavedForms] = (0, import_react3.useState)([]);
917
959
  const [saveAsPrompt, setSaveAsPrompt] = (0, import_react3.useState)(null);
960
+ const [notice, setNotice] = (0, import_react3.useState)(null);
918
961
  const autosaveTimer = (0, import_react3.useRef)(null);
919
962
  const hasLoadedOnce = (0, import_react3.useRef)(false);
963
+ const savedSnapshot = (0, import_react3.useRef)(null);
964
+ function flashSaved() {
965
+ setTemplateState("saved");
966
+ setTimeout(() => setTemplateState((s) => s === "saved" ? "idle" : s), 1500);
967
+ }
920
968
  async function refreshLibrary() {
921
969
  try {
922
970
  const raw = await storage.get(INDEX_KEY);
923
971
  const list = raw ? JSON.parse(raw) : [];
924
972
  setSavedForms(list.sort((a, b) => b.updatedAt - a.updatedAt));
925
- } catch (err) {
973
+ } catch {
926
974
  setSavedForms([]);
927
975
  }
928
976
  }
@@ -946,12 +994,21 @@ function usePersistence({
946
994
  onLoadDocument(doc);
947
995
  onLoadThemeOverrides(doc.themeOverrides);
948
996
  }
949
- if (parsed.currentFormId) setCurrentFormId(parsed.currentFormId);
997
+ if (parsed.currentFormId && templateManage) {
998
+ setCurrentFormId(parsed.currentFormId);
999
+ try {
1000
+ const tplRaw = await storage.get(formKey(parsed.currentFormId));
1001
+ const tplDoc = tplRaw ? migrateDocument(JSON.parse(tplRaw)) : null;
1002
+ savedSnapshot.current = tplDoc ? snapshotOf(tplDoc) : null;
1003
+ } catch {
1004
+ savedSnapshot.current = null;
1005
+ }
1006
+ }
950
1007
  } else {
951
1008
  ensureActiveSection();
952
1009
  }
953
1010
  }
954
- } catch (err) {
1011
+ } catch {
955
1012
  ensureActiveSection();
956
1013
  } finally {
957
1014
  setLoadingDraft(false);
@@ -966,86 +1023,125 @@ function usePersistence({
966
1023
  if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
967
1024
  autosaveTimer.current = setTimeout(async () => {
968
1025
  try {
969
- await storage.set(DRAFT_KEY, JSON.stringify({ ...document, currentFormId }));
1026
+ await storage.set(DRAFT_KEY, JSON.stringify({ version: DOCUMENT_VERSION, ...document2, currentFormId }));
970
1027
  setSaveState("saved");
971
- } catch (err) {
1028
+ } catch {
972
1029
  setSaveState("error");
973
1030
  }
974
1031
  }, 600);
975
1032
  return () => {
976
1033
  if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
977
1034
  };
978
- }, [document.title, document.themeOverrides, document.sections, currentFormId]);
1035
+ }, [document2.title, document2.themeOverrides, document2.sections, currentFormId]);
979
1036
  async function saveAs(name) {
980
- if (savedForms.length >= MAX_TEMPLATES) {
981
- alert(chrome.templatesLimitReached);
1037
+ if (!templateManage) return;
1038
+ if (savedForms.length >= templateMax) {
1039
+ setSaveAsPrompt(null);
1040
+ setNotice({ title: chrome.templatesLimitTitle, message: chrome.templatesLimitReached(templateMax) });
982
1041
  return;
983
1042
  }
984
1043
  const id = genFormId();
985
1044
  const now = Date.now();
1045
+ setTemplateState("saving");
986
1046
  try {
987
1047
  const newTitle = bi(name, "");
988
- await storage.set(formKey(id), JSON.stringify({ ...document, title: newTitle, id, updatedAt: now }));
1048
+ const docToSave = { ...document2, title: newTitle };
1049
+ await storage.set(formKey(id), JSON.stringify({ version: DOCUMENT_VERSION, ...docToSave, id, updatedAt: now }));
989
1050
  const next = [...savedForms, { id, title: name, updatedAt: now }];
990
1051
  await storage.set(INDEX_KEY, JSON.stringify(next));
991
1052
  setSavedForms(next);
992
1053
  setCurrentFormId(id);
993
1054
  onTitleChange(newTitle);
994
1055
  setSaveAsPrompt(null);
995
- } catch (err) {
996
- alert("Couldn't save the form. Please try again.");
1056
+ savedSnapshot.current = snapshotOf(docToSave);
1057
+ flashSaved();
1058
+ onTemplateChange?.({ id, title: name, source: "new" });
1059
+ } catch {
1060
+ setTemplateState("error");
997
1061
  }
998
1062
  }
999
1063
  async function saveExisting() {
1064
+ if (!templateManage) return;
1000
1065
  if (!currentFormId) {
1001
- setSaveAsPrompt({ open: true, suggestedName: t(document.title, language) });
1066
+ setSaveAsPrompt({ open: true, suggestedName: t(document2.title, language) });
1002
1067
  return;
1003
1068
  }
1004
1069
  const now = Date.now();
1070
+ setTemplateState("saving");
1005
1071
  try {
1006
- await storage.set(formKey(currentFormId), JSON.stringify({ ...document, id: currentFormId, updatedAt: now }));
1007
- const next = savedForms.map((f) => f.id === currentFormId ? { ...f, title: t(document.title, "en"), updatedAt: now } : f);
1072
+ await storage.set(formKey(currentFormId), JSON.stringify({ version: DOCUMENT_VERSION, ...document2, id: currentFormId, updatedAt: now }));
1073
+ const next = savedForms.map((f) => f.id === currentFormId ? { ...f, title: t(document2.title, "en"), updatedAt: now } : f);
1008
1074
  setSavedForms(next);
1009
1075
  await storage.set(INDEX_KEY, JSON.stringify(next));
1010
- } catch (err) {
1011
- alert("Couldn't save the form. Please try again.");
1076
+ savedSnapshot.current = snapshotOf(document2);
1077
+ flashSaved();
1078
+ onTemplateChange?.({ id: currentFormId, title: t(document2.title, language), source: "saved" });
1079
+ } catch {
1080
+ setTemplateState("error");
1012
1081
  }
1013
1082
  }
1014
1083
  async function loadForm(id) {
1084
+ setTemplateState("loading");
1015
1085
  try {
1016
1086
  const raw = await storage.get(formKey(id));
1017
- if (!raw) return;
1087
+ if (!raw) {
1088
+ setTemplateState("error");
1089
+ return false;
1090
+ }
1018
1091
  const doc = migrateDocument(JSON.parse(raw));
1019
- if (!doc) return;
1092
+ if (!doc) {
1093
+ setTemplateState("error");
1094
+ return false;
1095
+ }
1020
1096
  onLoadDocument(doc);
1021
1097
  onLoadThemeOverrides(doc.themeOverrides);
1022
- setCurrentFormId(id);
1023
- } catch (err) {
1024
- alert("Couldn't load that form.");
1098
+ const boundId = templateManage ? id : null;
1099
+ setCurrentFormId(boundId);
1100
+ savedSnapshot.current = boundId ? snapshotOf(doc) : null;
1101
+ setTemplateState("idle");
1102
+ onTemplateChange?.({ id, title: t(doc.title, language), source: "applied" });
1103
+ return true;
1104
+ } catch {
1105
+ setTemplateState("error");
1106
+ return false;
1025
1107
  }
1026
1108
  }
1027
1109
  async function deleteForm(id) {
1110
+ if (!templateManage) return;
1111
+ const removed = savedForms.find((f) => f.id === id);
1028
1112
  try {
1029
1113
  await storage.delete(formKey(id));
1030
1114
  const next = savedForms.filter((f) => f.id !== id);
1031
1115
  setSavedForms(next);
1032
1116
  await storage.set(INDEX_KEY, JSON.stringify(next));
1033
- if (currentFormId === id) setCurrentFormId(null);
1034
- } catch (err) {
1117
+ if (currentFormId === id) {
1118
+ setCurrentFormId(null);
1119
+ savedSnapshot.current = null;
1120
+ }
1121
+ onTemplateChange?.({ id: currentFormId === id ? null : id, title: removed?.title ?? "", source: "deleted" });
1122
+ } catch {
1123
+ setTemplateState("error");
1035
1124
  }
1036
1125
  }
1037
1126
  function newForm() {
1038
1127
  onNewForm();
1039
1128
  setCurrentFormId(null);
1129
+ savedSnapshot.current = null;
1130
+ setTemplateState("idle");
1040
1131
  }
1132
+ const activeTemplate = currentFormId ? savedForms.find((f) => f.id === currentFormId) ?? null : null;
1133
+ const isTemplateDirty = savedSnapshot.current !== null && snapshotOf(document2) !== savedSnapshot.current;
1041
1134
  function dismissSaveAsPrompt() {
1042
1135
  setSaveAsPrompt(null);
1043
1136
  }
1044
1137
  return {
1045
1138
  loadingDraft,
1046
1139
  saveState,
1140
+ templateState,
1047
1141
  savedForms,
1048
1142
  currentFormId,
1143
+ activeTemplateTitle: activeTemplate?.title ?? null,
1144
+ isTemplateDirty,
1049
1145
  saveAs,
1050
1146
  saveExisting,
1051
1147
  loadForm,
@@ -1053,15 +1149,64 @@ function usePersistence({
1053
1149
  refreshLibrary,
1054
1150
  newForm,
1055
1151
  saveAsPrompt,
1056
- dismissSaveAsPrompt
1152
+ dismissSaveAsPrompt,
1153
+ notice,
1154
+ dismissNotice: () => setNotice(null)
1057
1155
  };
1058
1156
  }
1059
1157
 
1060
- // src/hooks/useDragReorder.ts
1158
+ // src/hooks/useTemplateClipboard.ts
1061
1159
  var import_react4 = require("react");
1160
+ var sameTabListeners = /* @__PURE__ */ new Set();
1161
+ function notifySameTab() {
1162
+ sameTabListeners.forEach((fn) => fn());
1163
+ }
1164
+ function readKey(key) {
1165
+ try {
1166
+ return typeof window !== "undefined" && window.localStorage ? window.localStorage.getItem(key) : null;
1167
+ } catch {
1168
+ return null;
1169
+ }
1170
+ }
1171
+ function useTemplateClipboard(clipboardKey = CLIPBOARD_KEY) {
1172
+ const keyRef = (0, import_react4.useRef)(clipboardKey);
1173
+ keyRef.current = clipboardKey;
1174
+ const [hasClipboard, setHasClipboard] = (0, import_react4.useState)(false);
1175
+ const refresh = (0, import_react4.useCallback)(() => {
1176
+ setHasClipboard(parseTemplate(readKey(keyRef.current)) !== null);
1177
+ }, []);
1178
+ (0, import_react4.useEffect)(() => {
1179
+ refresh();
1180
+ const onStorage = (e) => {
1181
+ if (e.key === null || e.key === keyRef.current) refresh();
1182
+ };
1183
+ sameTabListeners.add(refresh);
1184
+ window.addEventListener("storage", onStorage);
1185
+ return () => {
1186
+ sameTabListeners.delete(refresh);
1187
+ window.removeEventListener("storage", onStorage);
1188
+ };
1189
+ }, [refresh]);
1190
+ const copyTemplate = (0, import_react4.useCallback)((document2) => {
1191
+ const str = serializeTemplate(document2);
1192
+ try {
1193
+ window.localStorage?.setItem(keyRef.current, str);
1194
+ } catch {
1195
+ }
1196
+ notifySameTab();
1197
+ refresh();
1198
+ }, [refresh]);
1199
+ const readTemplate = (0, import_react4.useCallback)(() => {
1200
+ return parseTemplate(readKey(keyRef.current));
1201
+ }, []);
1202
+ return { hasClipboard, copyTemplate, readTemplate };
1203
+ }
1204
+
1205
+ // src/hooks/useDragReorder.ts
1206
+ var import_react5 = require("react");
1062
1207
  function useDragReorder(reorderWithinSection) {
1063
- const dragRef = (0, import_react4.useRef)({ sectionId: null, index: null });
1064
- const [dragOverKey, setDragOverKey] = (0, import_react4.useState)(null);
1208
+ const dragRef = (0, import_react5.useRef)({ sectionId: null, index: null });
1209
+ const [dragOverKey, setDragOverKey] = (0, import_react5.useState)(null);
1065
1210
  function getDropZoneHandlers(sectionId, index) {
1066
1211
  const dragKey = `${sectionId}:${index}`;
1067
1212
  return {
@@ -1095,7 +1240,7 @@ var import_lucide_react2 = require("lucide-react");
1095
1240
  var styles = {
1096
1241
  app: { fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", background: "var(--fb-canvas)", borderRadius: 12, overflow: "hidden", border: "1px solid var(--fb-border)", color: "var(--fb-ink)", display: "flex", flexDirection: "column" },
1097
1242
  toolbar: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "var(--fb-space-toolbar) 16px", borderBottom: "1px solid var(--fb-border)", background: "var(--fb-surface)", flexWrap: "wrap", gap: 8, position: "relative" },
1098
- toolbarLeft: { display: "flex", alignItems: "center", gap: 10 },
1243
+ toolbarLeft: { display: "flex", alignItems: "center", gap: 10, minWidth: 0, flexShrink: 1 },
1099
1244
  logoMark: { width: 26, height: 26, borderRadius: 7, background: "var(--fb-primary)", color: "#fff", fontSize: 11, fontWeight: 700, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "'JetBrains Mono', monospace", letterSpacing: "-0.5px" },
1100
1245
  titleInput: { border: "none", outline: "none", fontSize: 14, fontWeight: 600, background: "transparent", color: "var(--fb-ink)", padding: "4px 6px", borderRadius: 6, minWidth: 160 },
1101
1246
  toolbarRight: { display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" },
@@ -1105,6 +1250,8 @@ var styles = {
1105
1250
  ghostBtn: { display: "flex", alignItems: "center", gap: 6, padding: "6px 12px", borderRadius: 7, border: "1px solid var(--fb-border)", background: "var(--fb-surface)", color: "var(--fb-ink)", fontSize: 13, fontWeight: 500 },
1106
1251
  primaryBtn: { display: "flex", alignItems: "center", gap: 6, padding: "6px 13px", borderRadius: 7, border: "1px solid var(--fb-primary)", background: "var(--fb-primary)", color: "#fff", fontSize: 13, fontWeight: 600 },
1107
1252
  saveStatus: { display: "flex", alignItems: "center", gap: 5, fontSize: 12, color: "var(--fb-muted)", minWidth: 60 },
1253
+ templateTag: { display: "flex", alignItems: "center", gap: 6, fontSize: 12, color: "var(--fb-muted)", minWidth: 0, flexShrink: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
1254
+ toolbarIconBtn: { display: "flex", alignItems: "center", justifyContent: "center", padding: "6px 9px", borderRadius: 7, border: "1px solid var(--fb-border)", background: "var(--fb-surface)", color: "var(--fb-ink)" },
1108
1255
  countBadge: { fontSize: 10.5, fontWeight: 700, background: "var(--fb-primary-soft)", color: "var(--fb-primary)", borderRadius: 9, padding: "1px 6px", marginLeft: 2 },
1109
1256
  loadingScreen: { flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", color: "var(--fb-muted)" },
1110
1257
  libraryBody: { padding: "var(--fb-space-panel)", overflowY: "auto", display: "flex", flexDirection: "column", gap: 4 },
@@ -1241,21 +1388,36 @@ function Toolbar({
1241
1388
  languages,
1242
1389
  mode,
1243
1390
  saveState,
1391
+ templateState,
1392
+ activeTemplateTitle,
1393
+ templateDirty,
1244
1394
  chrome,
1245
1395
  features,
1246
1396
  savedFormsCount,
1397
+ clipboardReady,
1398
+ templateCopied,
1247
1399
  onTitleChange,
1248
1400
  onLanguageChange,
1249
1401
  onModeChange,
1250
1402
  onNewForm,
1251
1403
  onOpenLibrary,
1252
1404
  onSaveExisting,
1253
- onOpenJson
1405
+ onOpenJson,
1406
+ onCopyTemplate,
1407
+ onPasteTemplate
1254
1408
  }) {
1255
1409
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: styles.toolbar, children: [
1256
1410
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: styles.toolbarLeft, children: [
1257
1411
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: styles.logoMark, children: "FB" }),
1258
- features.naming ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("input", { value: t(title, language), onChange: (e) => onTitleChange(e.target.value), style: styles.titleInput, "aria-label": "Form title" }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: styles.titleInput, children: t(title, language) })
1412
+ features.naming ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("input", { value: t(title, language), onChange: (e) => onTitleChange(e.target.value), style: styles.titleInput, "aria-label": "Form title" }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: styles.titleInput, children: t(title, language) }),
1413
+ templateState === "loading" ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { style: styles.saveStatus, children: [
1414
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.Loader2, { size: 12, className: "spin" }),
1415
+ " ",
1416
+ chrome.templateLoading
1417
+ ] }) : activeTemplateTitle && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "fb-template-tag", style: styles.templateTag, title: chrome.templateLabel(activeTemplateTitle), children: [
1418
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { overflow: "hidden", textOverflow: "ellipsis" }, children: chrome.templateLabel(activeTemplateTitle) }),
1419
+ templateDirty && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: styles.currentBadge, children: chrome.templateEdited })
1420
+ ] })
1259
1421
  ] }),
1260
1422
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: styles.toolbarRight, children: [
1261
1423
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { style: styles.saveStatus, children: [
@@ -1288,20 +1450,20 @@ function Toolbar({
1288
1450
  chrome.preview
1289
1451
  ] })
1290
1452
  ] }),
1291
- (features.newForm || features.templates) && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: styles.toolbarDivider }),
1453
+ (features.newForm || features.templates.enabled) && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: styles.toolbarDivider }),
1292
1454
  features.newForm && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("button", { style: styles.ghostBtn, onClick: onNewForm, title: chrome.startNewForm, children: [
1293
1455
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.FilePlus2, { size: 14 }),
1294
1456
  " ",
1295
1457
  chrome.newForm
1296
1458
  ] }),
1297
- features.templates && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
1459
+ features.templates.enabled && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
1298
1460
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("button", { style: styles.ghostBtn, onClick: onOpenLibrary, title: chrome.openTemplatesTitle, children: [
1299
1461
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.FolderOpen, { size: 14 }),
1300
1462
  " ",
1301
1463
  chrome.templates,
1302
1464
  savedFormsCount > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: styles.countBadge, children: savedFormsCount })
1303
1465
  ] }),
1304
- /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("button", { style: styles.primaryBtn, onClick: onSaveExisting, title: chrome.saveToLibraryTitle, children: [
1466
+ features.templates.manage && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("button", { style: styles.primaryBtn, onClick: onSaveExisting, title: chrome.saveToLibraryTitle, children: [
1305
1467
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.Save, { size: 14 }),
1306
1468
  " ",
1307
1469
  chrome.save
@@ -1311,13 +1473,17 @@ function Toolbar({
1311
1473
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.Code2, { size: 14 }),
1312
1474
  " ",
1313
1475
  chrome.viewJson
1476
+ ] }),
1477
+ features.templateClipboard && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
1478
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { style: styles.toolbarIconBtn, onClick: onCopyTemplate, title: chrome.copyTemplateTitle, "aria-label": chrome.copyTemplate, children: templateCopied ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.Check, { size: 14 }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.ClipboardCopy, { size: 14 }) }),
1479
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { style: styles.toolbarIconBtn, onClick: onPasteTemplate, disabled: !clipboardReady, title: chrome.pasteTemplateTitle, "aria-label": chrome.pasteTemplate, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.ClipboardPaste, { size: 14 }) })
1314
1480
  ] })
1315
1481
  ] })
1316
1482
  ] });
1317
1483
  }
1318
1484
 
1319
1485
  // src/components/Palette.tsx
1320
- var import_react5 = require("react");
1486
+ var import_react6 = require("react");
1321
1487
  var import_lucide_react5 = require("lucide-react");
1322
1488
 
1323
1489
  // src/components/Segmented.tsx
@@ -1415,7 +1581,7 @@ function Palette({
1415
1581
  resetTheme,
1416
1582
  fieldCount
1417
1583
  }) {
1418
- const [tab, setTab] = (0, import_react5.useState)("blocks");
1584
+ const [tab, setTab] = (0, import_react6.useState)("blocks");
1419
1585
  const contentTypes = CONTENT_TYPES.filter((f) => isContentBlockEnabled(features, f.type));
1420
1586
  const formTypes = FORM_TYPES.filter((f) => isFieldTypeEnabled(features, f.type));
1421
1587
  const atFieldCap = typeof features.maxFields === "number" && fieldCount >= features.maxFields;
@@ -1704,6 +1870,8 @@ function FieldTicket({
1704
1870
  {
1705
1871
  ...dropZoneHandlers,
1706
1872
  onClick: onSelect,
1873
+ className: `fb-field fb-field--${field.type} fb-field--${field.id}`,
1874
+ "data-field-id": field.id,
1707
1875
  style: {
1708
1876
  ...styles.ticket,
1709
1877
  flex: `1 1 calc(${widthPct} - 10px)`,
@@ -1800,7 +1968,7 @@ function SectionCard({
1800
1968
  ),
1801
1969
  section.collapsed && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("span", { style: styles.miniBadge, children: chrome.fieldsCount(section.fields.length) }),
1802
1970
  /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { style: styles.sectionHeaderActions, onClick: (e) => e.stopPropagation(), children: [
1803
- features.sections && !section.collapsed && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { style: styles.swatchRow, children: [
1971
+ features.sectionBackground && !section.collapsed && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { style: styles.swatchRow, children: [
1804
1972
  SECTION_BG_SWATCHES.map((c) => /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("button", { type: "button", title: c || chrome.none, style: { ...styles.swatchBtn, background: c || "var(--fb-canvas)", ...section.background === c ? styles.swatchBtnActive : {} }, onClick: () => onUpdateBackground(c) }, c || "none")),
1805
1973
  /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("input", { type: "color", title: chrome.customColor, value: /^#/.test(section.background) ? section.background : "#ffffff", onChange: (e) => onUpdateBackground(e.target.value), style: styles.colorPickerInput })
1806
1974
  ] }),
@@ -2121,7 +2289,7 @@ function InspectorBody({ selected, language, chrome, features, onUpdateField, on
2121
2289
  }
2122
2290
 
2123
2291
  // src/components/PreviewPane.tsx
2124
- var import_react6 = require("react");
2292
+ var import_react7 = require("react");
2125
2293
  var import_lucide_react14 = require("lucide-react");
2126
2294
 
2127
2295
  // src/lib/validate.ts
@@ -2170,10 +2338,10 @@ function collectFieldValues(fields) {
2170
2338
  // src/components/PreviewPane.tsx
2171
2339
  var import_jsx_runtime14 = require("react/jsx-runtime");
2172
2340
  function PreviewPane({ title, sections, onFieldChange, language, strings, chrome, baseMaxWidth, features, onSubmit }) {
2173
- const [device, setDevice] = (0, import_react6.useState)("laptop");
2174
- const [errors, setErrors] = (0, import_react6.useState)({});
2175
- const [submitted, setSubmitted] = (0, import_react6.useState)(null);
2176
- const [attemptedButtonId, setAttemptedButtonId] = (0, import_react6.useState)(null);
2341
+ const [device, setDevice] = (0, import_react7.useState)("laptop");
2342
+ const [errors, setErrors] = (0, import_react7.useState)({});
2343
+ const [submitted, setSubmitted] = (0, import_react7.useState)(null);
2344
+ const [attemptedButtonId, setAttemptedButtonId] = (0, import_react7.useState)(null);
2177
2345
  const deviceOptions = buildDeviceOptions(baseMaxWidth, chrome);
2178
2346
  const maxWidth = (deviceOptions.find((d) => d.value === device) || deviceOptions[0]).maxWidth;
2179
2347
  const allFields = sections.flatMap((s) => s.fields);
@@ -2233,16 +2401,16 @@ function PreviewPane({ title, sections, onFieldChange, language, strings, chrome
2233
2401
  return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { style: styles.previewWrap, children: [
2234
2402
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { style: { width: "100%", maxWidth, display: "flex", flexDirection: "column", alignItems: "center" }, children: [
2235
2403
  features.deviceToggle && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { style: styles.previewToolbar, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Segmented, { options: deviceOptions, value: device, onChange: setDevice }) }),
2236
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { style: { ...styles.previewCard, maxWidth, width: "100%" }, children: [
2237
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("h2", { style: styles.previewTitle, children: t(title, language) }),
2404
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "fb-form", style: { ...styles.previewCard, maxWidth, width: "100%" }, children: [
2405
+ features.formTitle && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("h2", { style: styles.previewTitle, children: t(title, language) }),
2238
2406
  allFields.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("p", { style: { color: "var(--fb-muted)", fontSize: 14 }, children: strings.addFieldsHint }),
2239
2407
  sections.map((section) => {
2240
- return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { style: { background: section.background || "transparent", padding: section.background ? 16 : 0, borderRadius: section.background ? 10 : 0, marginBottom: "var(--fb-space-section)" }, children: [
2408
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "fb-section", "data-section-id": section.id, style: { background: section.background || "transparent", padding: section.background ? 16 : 0, borderRadius: section.background ? 10 : 0, marginBottom: "var(--fb-space-section)" }, children: [
2241
2409
  t(section.title, language) && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("h3", { style: styles.sectionRuntimeTitle, children: t(section.title, language) }),
2242
2410
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { style: styles.previewGrid, children: section.fields.map((field) => {
2243
2411
  const width = effectiveWidth(field.width, device);
2244
2412
  const flexBasis = `1 1 calc(${WIDTH_PERCENT[width]} - 14px)`;
2245
- return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { style: { flex: flexBasis, minWidth: 0, alignSelf: ALIGN_MAP[field.verticalAlign || "top"] }, children: [
2413
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: `fb-field fb-field--${field.type} fb-field--${field.id}`, "data-field-id": field.id, style: { flex: flexBasis, minWidth: 0, alignSelf: ALIGN_MAP[field.verticalAlign || "top"] }, children: [
2246
2414
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(FieldBlock, { field, lang: language, onFieldChange, strings, chrome, error: errors[field.id], onButtonAction: handleButtonAction }),
2247
2415
  field.type === "button" && field.id === attemptedButtonId && Object.keys(errors).length > 0 && /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("p", { style: styles.formErrorNote, children: [
2248
2416
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_lucide_react14.CircleAlert, { size: 13 }),
@@ -2278,17 +2446,66 @@ function PreviewPane({ title, sections, onFieldChange, language, strings, chrome
2278
2446
 
2279
2447
  // src/components/modals/JsonModal.tsx
2280
2448
  var import_lucide_react15 = require("lucide-react");
2449
+
2450
+ // src/hooks/useModalA11y.ts
2451
+ var import_react8 = require("react");
2452
+ var FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
2453
+ function useModalA11y(onClose) {
2454
+ const panelRef = (0, import_react8.useRef)(null);
2455
+ const onCloseRef = (0, import_react8.useRef)(onClose);
2456
+ onCloseRef.current = onClose;
2457
+ (0, import_react8.useEffect)(() => {
2458
+ const opener = document.activeElement;
2459
+ const panel = panelRef.current;
2460
+ const focusables = () => Array.from(panel?.querySelectorAll(FOCUSABLE) ?? []);
2461
+ if (panel && !panel.contains(document.activeElement)) {
2462
+ (panel.querySelector("[autofocus]") ?? focusables()[0] ?? panel).focus();
2463
+ }
2464
+ const onKeyDown = (e) => {
2465
+ if (e.key === "Escape") {
2466
+ e.stopPropagation();
2467
+ onCloseRef.current();
2468
+ return;
2469
+ }
2470
+ if (e.key !== "Tab") return;
2471
+ const items = focusables();
2472
+ if (items.length === 0) {
2473
+ e.preventDefault();
2474
+ return;
2475
+ }
2476
+ const first = items[0];
2477
+ const last = items[items.length - 1];
2478
+ const active = document.activeElement;
2479
+ if (e.shiftKey && (active === first || !panel?.contains(active))) {
2480
+ e.preventDefault();
2481
+ last.focus();
2482
+ } else if (!e.shiftKey && active === last) {
2483
+ e.preventDefault();
2484
+ first.focus();
2485
+ }
2486
+ };
2487
+ document.addEventListener("keydown", onKeyDown, true);
2488
+ return () => {
2489
+ document.removeEventListener("keydown", onKeyDown, true);
2490
+ opener?.focus?.();
2491
+ };
2492
+ }, []);
2493
+ return panelRef;
2494
+ }
2495
+
2496
+ // src/components/modals/JsonModal.tsx
2281
2497
  var import_jsx_runtime15 = require("react/jsx-runtime");
2282
2498
  function JsonModal({ chrome, jsonString, copied, onCopy, onClose }) {
2283
- return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { style: styles.modalOverlay, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { style: styles.modal, onClick: (e) => e.stopPropagation(), children: [
2499
+ const panelRef = useModalA11y(onClose);
2500
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { style: styles.modalOverlay, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { ref: panelRef, style: styles.modal, role: "dialog", "aria-modal": "true", "aria-label": chrome.formJson, onClick: (e) => e.stopPropagation(), children: [
2284
2501
  /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { style: styles.modalHeader, children: [
2285
2502
  /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { style: { fontWeight: 600, fontSize: 14 }, children: chrome.formJson }),
2286
2503
  /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { style: { display: "flex", gap: 8 }, children: [
2287
- /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("button", { style: styles.ghostBtn, onClick: onCopy, children: [
2504
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("button", { type: "button", style: styles.ghostBtn, onClick: onCopy, children: [
2288
2505
  copied ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_lucide_react15.Check, { size: 14 }) : /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_lucide_react15.ClipboardCopy, { size: 14 }),
2289
2506
  copied ? chrome.copied : chrome.copy
2290
2507
  ] }),
2291
- /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("button", { style: styles.iconBtn, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_lucide_react15.X, { size: 16 }) })
2508
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("button", { type: "button", style: styles.iconBtn, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_lucide_react15.X, { size: 16 }) })
2292
2509
  ] })
2293
2510
  ] }),
2294
2511
  /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("pre", { style: styles.jsonPre, children: jsonString })
@@ -2298,55 +2515,97 @@ function JsonModal({ chrome, jsonString, copied, onCopy, onClose }) {
2298
2515
  // src/components/modals/TemplatesModal.tsx
2299
2516
  var import_lucide_react16 = require("lucide-react");
2300
2517
  var import_jsx_runtime16 = require("react/jsx-runtime");
2301
- function TemplatesModal({ chrome, savedForms, currentFormId, onOpen, onDelete, onClose }) {
2302
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: styles.modalOverlay, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.modal, onClick: (e) => e.stopPropagation(), children: [
2518
+ function TemplatesModal({ chrome, savedForms, currentFormId, manage, templateState, onOpen, onDelete, onClose }) {
2519
+ const busy = templateState === "loading";
2520
+ const panelRef = useModalA11y(onClose);
2521
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: styles.modalOverlay, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { ref: panelRef, style: styles.modal, role: "dialog", "aria-modal": "true", "aria-label": chrome.templates, onClick: (e) => e.stopPropagation(), children: [
2303
2522
  /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.modalHeader, children: [
2304
2523
  /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: { fontWeight: 600, fontSize: 14 }, children: chrome.templates }),
2305
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { style: styles.iconBtn, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react16.X, { size: 16 }) })
2524
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", style: styles.iconBtn, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react16.X, { size: 16 }) })
2306
2525
  ] }),
2307
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: styles.libraryBody, children: savedForms.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: styles.inspectorEmpty, children: chrome.noTemplates }) : savedForms.map((f) => /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.libraryRow, children: [
2308
- /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: { minWidth: 0 }, children: [
2309
- /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.libraryRowTitle, children: [
2310
- f.title,
2311
- f.id === currentFormId && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: styles.currentBadge, children: chrome.current })
2526
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.libraryBody, children: [
2527
+ savedForms.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: styles.inspectorEmpty, children: manage ? chrome.noTemplates : chrome.noTemplatesReadonly }) : savedForms.map((f) => /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.libraryRow, children: [
2528
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: { minWidth: 0 }, children: [
2529
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.libraryRowTitle, children: [
2530
+ f.title,
2531
+ f.id === currentFormId && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: styles.currentBadge, children: chrome.current })
2532
+ ] }),
2533
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.libraryRowMeta, children: [
2534
+ chrome.updated,
2535
+ " ",
2536
+ new Date(f.updatedAt).toLocaleString()
2537
+ ] })
2312
2538
  ] }),
2313
- /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.libraryRowMeta, children: [
2314
- chrome.updated,
2315
- " ",
2316
- new Date(f.updatedAt).toLocaleString()
2539
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: { display: "flex", gap: 6, flexShrink: 0 }, children: [
2540
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("button", { type: "button", style: styles.ghostBtn, disabled: busy, onClick: () => onOpen(f.id), children: [
2541
+ busy && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react16.Loader2, { size: 12, className: "spin" }),
2542
+ " ",
2543
+ manage ? chrome.open : chrome.applyTemplate
2544
+ ] }),
2545
+ manage && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", style: { ...styles.iconBtn, ...styles.iconBtnDanger }, disabled: busy, title: chrome.delete, onClick: () => onDelete(f.id), children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react16.Trash2, { size: 13 }) })
2317
2546
  ] })
2318
- ] }),
2319
- /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: { display: "flex", gap: 6, flexShrink: 0 }, children: [
2320
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { style: styles.ghostBtn, onClick: () => onOpen(f.id), children: chrome.open }),
2321
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { style: { ...styles.iconBtn, ...styles.iconBtnDanger }, title: chrome.delete, onClick: () => onDelete(f.id), children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react16.Trash2, { size: 13 }) })
2547
+ ] }, f.id)),
2548
+ templateState === "error" && /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: { ...styles.libraryRowMeta, color: "var(--fb-danger)", display: "flex", alignItems: "center", gap: 5, marginTop: 4 }, children: [
2549
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react16.AlertCircle, { size: 12 }),
2550
+ " ",
2551
+ chrome.templateLoadFailed
2322
2552
  ] })
2323
- ] }, f.id)) })
2553
+ ] })
2324
2554
  ] }) });
2325
2555
  }
2326
2556
 
2327
2557
  // src/components/modals/SaveAsModal.tsx
2328
- var import_react7 = require("react");
2558
+ var import_react9 = require("react");
2329
2559
  var import_lucide_react17 = require("lucide-react");
2330
2560
  var import_jsx_runtime17 = require("react/jsx-runtime");
2331
- function SaveAsModal({ chrome, suggestedName, onSave, onClose }) {
2332
- const [name, setName] = (0, import_react7.useState)(suggestedName);
2333
- return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { style: styles.modalOverlay, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { style: { ...styles.modal, maxWidth: 380 }, onClick: (e) => e.stopPropagation(), children: [
2561
+ function SaveAsModal({ chrome, suggestedName, templateState, onSave, onClose }) {
2562
+ const [name, setName] = (0, import_react9.useState)(suggestedName);
2563
+ const saving = templateState === "saving";
2564
+ const canSave = !!name.trim() && !saving;
2565
+ const panelRef = useModalA11y(onClose);
2566
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { style: styles.modalOverlay, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { ref: panelRef, style: { ...styles.modal, maxWidth: 380 }, role: "dialog", "aria-modal": "true", "aria-label": chrome.saveForm, onClick: (e) => e.stopPropagation(), children: [
2334
2567
  /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { style: styles.modalHeader, children: [
2335
2568
  /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { style: { fontWeight: 600, fontSize: 14 }, children: chrome.saveForm }),
2336
- /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("button", { style: styles.iconBtn, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_lucide_react17.X, { size: 16 }) })
2569
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("button", { type: "button", style: styles.iconBtn, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_lucide_react17.X, { size: 16 }) })
2337
2570
  ] }),
2338
2571
  /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { style: { padding: 16 }, children: [
2339
2572
  /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("label", { style: styles.propLabel, children: chrome.formName }),
2340
- /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("input", { autoFocus: true, style: styles.propInput, value: name, onChange: (e) => setName(e.target.value), onKeyDown: (e) => e.key === "Enter" && name.trim() && onSave(name.trim()) }),
2341
- /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("button", { style: { ...styles.primaryBtn, width: "100%", justifyContent: "center", marginTop: 14 }, disabled: !name.trim(), onClick: () => onSave(name.trim()), children: [
2342
- /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_lucide_react17.Save, { size: 14 }),
2573
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("input", { autoFocus: true, "aria-label": chrome.formName, style: styles.propInput, value: name, onChange: (e) => setName(e.target.value), onKeyDown: (e) => e.key === "Enter" && canSave && onSave(name.trim()) }),
2574
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("button", { type: "button", style: { ...styles.primaryBtn, width: "100%", justifyContent: "center", marginTop: 14 }, disabled: !canSave, onClick: () => onSave(name.trim()), children: [
2575
+ saving ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_lucide_react17.Loader2, { size: 14, className: "spin" }) : /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_lucide_react17.Save, { size: 14 }),
2343
2576
  " ",
2344
2577
  chrome.saveToLibrary
2578
+ ] }),
2579
+ templateState === "error" && /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { style: { fontSize: 12, color: "var(--fb-danger)", display: "flex", alignItems: "center", gap: 5, marginTop: 10 }, children: [
2580
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_lucide_react17.AlertCircle, { size: 12 }),
2581
+ " ",
2582
+ chrome.templateSaveFailed
2345
2583
  ] })
2346
2584
  ] })
2347
2585
  ] }) });
2348
2586
  }
2349
2587
 
2588
+ // src/components/modals/ConfirmModal.tsx
2589
+ var import_lucide_react18 = require("lucide-react");
2590
+ var import_jsx_runtime18 = require("react/jsx-runtime");
2591
+ function ConfirmModal({ chrome, title, message, onConfirm, confirmLabel, tone = "default", onClose }) {
2592
+ const panelRef = useModalA11y(onClose);
2593
+ const confirmStyle = tone === "danger" ? { ...styles.primaryBtn, background: "var(--fb-danger)", borderColor: "var(--fb-danger)" } : styles.primaryBtn;
2594
+ return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { style: styles.modalOverlay, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { ref: panelRef, style: { ...styles.modal, maxWidth: 380 }, role: "alertdialog", "aria-modal": "true", "aria-label": title, onClick: (e) => e.stopPropagation(), children: [
2595
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { style: styles.modalHeader, children: [
2596
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("span", { style: { fontWeight: 600, fontSize: 14 }, children: title }),
2597
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("button", { type: "button", style: styles.iconBtn, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(import_lucide_react18.X, { size: 16 }) })
2598
+ ] }),
2599
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { style: { padding: 16 }, children: [
2600
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("p", { style: { margin: 0, fontSize: 13, lineHeight: 1.5, color: "var(--fb-ink)" }, children: message }),
2601
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 16 }, children: onConfirm ? /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(import_jsx_runtime18.Fragment, { children: [
2602
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("button", { type: "button", autoFocus: true, style: styles.ghostBtn, onClick: onClose, children: chrome.cancel }),
2603
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("button", { type: "button", style: confirmStyle, onClick: onConfirm, children: confirmLabel ?? chrome.confirm })
2604
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("button", { type: "button", autoFocus: true, style: styles.primaryBtn, onClick: onClose, children: chrome.dismiss }) })
2605
+ ] })
2606
+ ] }) });
2607
+ }
2608
+
2350
2609
  // src/styles/globalCss.ts
2351
2610
  var css = `
2352
2611
  * { box-sizing: border-box; }
@@ -2354,20 +2613,14 @@ var css = `
2354
2613
  input, textarea, select { font-family: inherit; }
2355
2614
  @keyframes fb-spin { to { transform: rotate(360deg); } }
2356
2615
  .spin { animation: fb-spin 0.8s linear infinite; }
2357
- /* Caps the widget at the viewport height so it scrolls internally rather
2358
- than growing past whatever space its host container gives it; the
2359
- second declaration upgrades to the dynamic viewport unit on browsers
2360
- that support it (mobile browser chrome resizing the viewport), and is
2361
- simply ignored by ones that don't. */
2616
+ /* Cap at viewport height so the widget scrolls internally; dvh upgrade is ignored where unsupported. */
2362
2617
  .fb-root { max-height: 100vh; max-height: 100dvh; }
2363
2618
 
2364
- /* Below this width the fixed 200px Palette + 270px Inspector columns
2365
- don't leave room for a usable Canvas, so instead of stacking all
2366
- three in flow (which buries Canvas under a tall Palette), Palette
2367
- and Inspector become narrow drawers over Canvas (which stays
2368
- partially visible behind them), toggled by .fb-mobile-bar's two
2369
- buttons, tracked by .fb-work-area's data-mobile-panel attribute,
2370
- and dismissible via .fb-mobile-backdrop. */
2619
+ /* Toolbar's active-template label is nice-to-have \u2014 drop it first when width is tight. */
2620
+ @media (max-width: 600px) { .fb-template-tag { display: none; } }
2621
+
2622
+ /* Under 720px: Palette/Inspector become drawers over Canvas, toggled by .fb-mobile-bar,
2623
+ tracked by .fb-work-area[data-mobile-panel], dismissed via .fb-mobile-backdrop. */
2371
2624
  .fb-mobile-bar { display: none; align-items: center; gap: 6px; padding: 6px 10px; border-bottom: 1px solid var(--fb-border); background: var(--fb-surface); flex-shrink: 0; }
2372
2625
  .fb-mobile-btn { display: flex; align-items: center; gap: 6px; padding: 6px 10px; border-radius: 7px; border: 1px solid var(--fb-border); background: var(--fb-surface); color: var(--fb-muted); font-size: 12.5px; font-weight: 600; }
2373
2626
  .fb-mobile-btn[aria-pressed="true"] { border-color: var(--fb-primary); background: var(--fb-primary-soft); color: var(--fb-primary); }
@@ -2393,8 +2646,8 @@ var css = `
2393
2646
  `;
2394
2647
 
2395
2648
  // src/FormBuilder.tsx
2396
- var import_jsx_runtime18 = require("react/jsx-runtime");
2397
- var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2649
+ var import_jsx_runtime19 = require("react/jsx-runtime");
2650
+ var FormBuilder = (0, import_react10.forwardRef)(function FormBuilder2({
2398
2651
  theme: themeOverrideProp,
2399
2652
  language: languageOverride,
2400
2653
  languages = DEFAULT_LANGUAGES,
@@ -2405,31 +2658,38 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2405
2658
  onSubmit,
2406
2659
  initialDocument,
2407
2660
  initialMode,
2408
- onModeChange
2661
+ onModeChange,
2662
+ onTemplateChange,
2663
+ templateClipboardKey
2409
2664
  }, ref) {
2410
2665
  const features = resolveFeatures(featuresProp);
2411
2666
  const storage = storageProp ?? localStorageAdapter;
2412
2667
  const { theme, updateThemeColor, updateThemeLayout, resetTheme, replaceThemeOverrides, themeOverrides } = useTheme(themeOverrideProp);
2413
- const [language, setLanguage] = (0, import_react8.useState)(languageOverride || languages[0]?.code || "en");
2668
+ const [language, setLanguage] = (0, import_react10.useState)(languageOverride || languages[0]?.code || "en");
2414
2669
  const strings = { ...DEFAULT_STRINGS[language] || DEFAULT_STRINGS.en, ...stringsOverride && stringsOverride[language] || {} };
2415
2670
  const chrome = { ...CHROME[language] || CHROME.en, ...chromeOverride && chromeOverride[language] || {} };
2416
2671
  const doc = useFormDocument({ language, chrome });
2417
2672
  const drag = useDragReorder(doc.reorderWithinSection);
2418
- const [mode, setModeState] = (0, import_react8.useState)(initialMode ?? "build");
2673
+ const [mode, setModeState] = (0, import_react10.useState)(initialMode ?? "build");
2419
2674
  const setMode = (next) => {
2420
2675
  setModeState(next);
2421
2676
  onModeChange?.(next);
2422
2677
  };
2423
- (0, import_react8.useEffect)(() => {
2678
+ (0, import_react10.useEffect)(() => {
2424
2679
  onModeChange?.(mode);
2425
2680
  }, []);
2426
- const [showJson, setShowJson] = (0, import_react8.useState)(false);
2427
- const [showLibrary, setShowLibrary] = (0, import_react8.useState)(false);
2428
- const [copied, setCopied] = (0, import_react8.useState)(false);
2429
- const [mobilePanel, setMobilePanel] = (0, import_react8.useState)("none");
2681
+ const [showJson, setShowJson] = (0, import_react10.useState)(false);
2682
+ const [showLibrary, setShowLibrary] = (0, import_react10.useState)(false);
2683
+ const [copied, setCopied] = (0, import_react10.useState)(false);
2684
+ const [templateCopied, setTemplateCopied] = (0, import_react10.useState)(false);
2685
+ const [pastePrompt, setPastePrompt] = (0, import_react10.useState)(false);
2686
+ const clipboard = useTemplateClipboard(templateClipboardKey);
2687
+ const [mobilePanel, setMobilePanel] = (0, import_react10.useState)("none");
2430
2688
  const persistence = usePersistence({
2431
2689
  storage,
2432
2690
  autosave: features.autosave,
2691
+ templateMax: features.templates.max,
2692
+ templateManage: features.templates.manage,
2433
2693
  language,
2434
2694
  chrome,
2435
2695
  document: { title: doc.title, themeOverrides, sections: doc.sections },
@@ -2441,16 +2701,17 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2441
2701
  doc.resetToBlank();
2442
2702
  resetTheme();
2443
2703
  },
2704
+ onTemplateChange,
2444
2705
  ensureActiveSection: () => doc.setActiveSectionId((prev) => prev ?? doc.sections[0]?.id ?? null)
2445
2706
  });
2446
- const jsonDoc = {
2447
- version: 5,
2707
+ const jsonDoc = (0, import_react10.useMemo)(() => ({
2708
+ version: DOCUMENT_VERSION,
2448
2709
  title: doc.title,
2449
2710
  theme,
2450
2711
  themeOverrides,
2451
2712
  sections: doc.sections.map((s) => ({ id: s.id, title: s.title, background: s.background, collapsed: s.collapsed, fields: s.fields }))
2452
- };
2453
- const jsonString = JSON.stringify(jsonDoc, null, 2);
2713
+ }), [doc.title, doc.sections, theme, themeOverrides]);
2714
+ const jsonString = (0, import_react10.useMemo)(() => JSON.stringify(jsonDoc, null, 2), [jsonDoc]);
2454
2715
  function copyJson() {
2455
2716
  navigator.clipboard.writeText(jsonString).then(() => {
2456
2717
  setCopied(true);
@@ -2458,15 +2719,28 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2458
2719
  }).catch(() => {
2459
2720
  });
2460
2721
  }
2461
- (0, import_react8.useImperativeHandle)(ref, () => ({
2722
+ function applyDocument(migrated) {
2723
+ if (!migrated) return false;
2724
+ doc.loadDocument(migrated);
2725
+ replaceThemeOverrides(migrated.themeOverrides);
2726
+ return true;
2727
+ }
2728
+ function copyTemplate() {
2729
+ clipboard.copyTemplate(jsonDoc);
2730
+ setTemplateCopied(true);
2731
+ setTimeout(() => setTemplateCopied(false), 1500);
2732
+ }
2733
+ function pasteTemplate() {
2734
+ if (clipboard.readTemplate()) setPastePrompt(true);
2735
+ }
2736
+ (0, import_react10.useImperativeHandle)(ref, () => ({
2462
2737
  getDocument: () => jsonDoc,
2463
2738
  exportJson: () => jsonString,
2464
2739
  loadDocument: (raw) => {
2465
- const migrated = migrateDocument(raw);
2466
- if (!migrated) return;
2467
- doc.loadDocument(migrated);
2468
- replaceThemeOverrides(migrated.themeOverrides);
2469
- }
2740
+ applyDocument(migrateDocument(raw));
2741
+ },
2742
+ getTemplate: () => ({ __fpb: "template", v: TEMPLATE_FORMAT, document: jsonDoc }),
2743
+ loadTemplate: (input) => applyDocument(parseTemplate(typeof input === "string" ? input : JSON.stringify(input)))
2470
2744
  // eslint-disable-next-line react-hooks/exhaustive-deps
2471
2745
  }), [jsonDoc, jsonString]);
2472
2746
  const fieldCount = doc.sections.reduce((n, s) => n + s.fields.filter((f) => !getMeta(f.type).isContent).length, 0);
@@ -2493,9 +2767,9 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2493
2767
  "--fb-space-panel": `${theme.layout.panelPadding}px`,
2494
2768
  "--fb-space-ticket": `${theme.layout.ticketPadding}px`
2495
2769
  };
2496
- return /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "fb-root", style: rootStyle, children: [
2497
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("style", { children: css }),
2498
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
2770
+ return /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: "fb-root", style: rootStyle, children: [
2771
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("style", { children: css }),
2772
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2499
2773
  Toolbar,
2500
2774
  {
2501
2775
  title: doc.title,
@@ -2503,24 +2777,31 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2503
2777
  languages,
2504
2778
  mode,
2505
2779
  saveState: persistence.saveState,
2780
+ templateState: persistence.templateState,
2781
+ activeTemplateTitle: persistence.activeTemplateTitle,
2782
+ templateDirty: persistence.isTemplateDirty,
2506
2783
  chrome,
2507
2784
  features,
2508
2785
  savedFormsCount: persistence.savedForms.length,
2786
+ clipboardReady: clipboard.hasClipboard,
2787
+ templateCopied,
2509
2788
  onTitleChange: doc.updateTitle,
2510
2789
  onLanguageChange: setLanguage,
2511
2790
  onModeChange: setMode,
2512
2791
  onNewForm: persistence.newForm,
2513
2792
  onOpenLibrary: () => setShowLibrary(true),
2514
2793
  onSaveExisting: persistence.saveExisting,
2515
- onOpenJson: () => setShowJson(true)
2794
+ onOpenJson: () => setShowJson(true),
2795
+ onCopyTemplate: copyTemplate,
2796
+ onPasteTemplate: pasteTemplate
2516
2797
  }
2517
2798
  ),
2518
- persistence.loadingDraft ? /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { style: styles.loadingScreen, children: [
2519
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(import_lucide_react18.Loader2, { size: 20, className: "spin" }),
2520
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("span", { style: { marginTop: 8, fontSize: 13, color: "var(--fb-muted)" }, children: chrome.loadingDraft })
2521
- ] }) : mode === "build" ? /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "fb-work-area", style: styles.workArea, "data-mobile-panel": mobilePanel, children: [
2522
- /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "fb-mobile-bar", children: [
2523
- /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(
2799
+ persistence.loadingDraft ? /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { style: styles.loadingScreen, children: [
2800
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(import_lucide_react19.Loader2, { size: 20, className: "spin" }),
2801
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("span", { style: { marginTop: 8, fontSize: 13, color: "var(--fb-muted)" }, children: chrome.loadingDraft })
2802
+ ] }) : mode === "build" ? /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: "fb-work-area", style: styles.workArea, "data-mobile-panel": mobilePanel, children: [
2803
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: "fb-mobile-bar", children: [
2804
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(
2524
2805
  "button",
2525
2806
  {
2526
2807
  type: "button",
@@ -2528,13 +2809,13 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2528
2809
  "aria-pressed": mobilePanel === "palette",
2529
2810
  onClick: () => setMobilePanel((p) => p === "palette" ? "none" : "palette"),
2530
2811
  children: [
2531
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(import_lucide_react18.Menu, { size: 14 }),
2812
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(import_lucide_react19.Menu, { size: 14 }),
2532
2813
  " ",
2533
2814
  chrome.paletteTabBlocks
2534
2815
  ]
2535
2816
  }
2536
2817
  ),
2537
- /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(
2818
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(
2538
2819
  "button",
2539
2820
  {
2540
2821
  type: "button",
@@ -2542,16 +2823,16 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2542
2823
  "aria-pressed": mobilePanel === "inspector",
2543
2824
  onClick: () => setMobilePanel((p) => p === "inspector" ? "none" : "inspector"),
2544
2825
  children: [
2545
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(import_lucide_react18.SlidersHorizontal, { size: 14 }),
2826
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(import_lucide_react19.SlidersHorizontal, { size: 14 }),
2546
2827
  " ",
2547
2828
  chrome.properties
2548
2829
  ]
2549
2830
  }
2550
2831
  )
2551
2832
  ] }),
2552
- /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "fb-canvas-area", style: styles.workArea, children: [
2553
- mobilePanel !== "none" && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "fb-mobile-backdrop", onClick: () => setMobilePanel("none") }),
2554
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
2833
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: "fb-canvas-area", style: styles.workArea, children: [
2834
+ mobilePanel !== "none" && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("div", { className: "fb-mobile-backdrop", onClick: () => setMobilePanel("none") }),
2835
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2555
2836
  Palette,
2556
2837
  {
2557
2838
  activeSectionLabel,
@@ -2568,7 +2849,7 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2568
2849
  resetTheme
2569
2850
  }
2570
2851
  ),
2571
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
2852
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2572
2853
  Canvas,
2573
2854
  {
2574
2855
  sections: doc.sections,
@@ -2600,7 +2881,7 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2600
2881
  getDragHandleProps: drag.getDragHandleProps
2601
2882
  }
2602
2883
  ),
2603
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
2884
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2604
2885
  Inspector,
2605
2886
  {
2606
2887
  selected: doc.selected,
@@ -2615,7 +2896,7 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2615
2896
  }
2616
2897
  )
2617
2898
  ] })
2618
- ] }) : /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
2899
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2619
2900
  PreviewPane,
2620
2901
  {
2621
2902
  title: doc.title,
@@ -2629,29 +2910,55 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2629
2910
  onSubmit
2630
2911
  }
2631
2912
  ),
2632
- showJson && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(JsonModal, { chrome, jsonString, copied, onCopy: copyJson, onClose: () => setShowJson(false) }),
2633
- showLibrary && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
2913
+ showJson && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(JsonModal, { chrome, jsonString, copied, onCopy: copyJson, onClose: () => setShowJson(false) }),
2914
+ showLibrary && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2634
2915
  TemplatesModal,
2635
2916
  {
2636
2917
  chrome,
2637
2918
  savedForms: persistence.savedForms,
2638
2919
  currentFormId: persistence.currentFormId,
2920
+ manage: features.templates.manage,
2921
+ templateState: persistence.templateState,
2639
2922
  onOpen: async (id) => {
2640
- await persistence.loadForm(id);
2641
- setShowLibrary(false);
2923
+ if (await persistence.loadForm(id)) setShowLibrary(false);
2642
2924
  },
2643
2925
  onDelete: persistence.deleteForm,
2644
2926
  onClose: () => setShowLibrary(false)
2645
2927
  }
2646
2928
  ),
2647
- persistence.saveAsPrompt && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
2929
+ persistence.saveAsPrompt && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2648
2930
  SaveAsModal,
2649
2931
  {
2650
2932
  chrome,
2651
2933
  suggestedName: persistence.saveAsPrompt.suggestedName,
2934
+ templateState: persistence.templateState,
2652
2935
  onSave: persistence.saveAs,
2653
2936
  onClose: persistence.dismissSaveAsPrompt
2654
2937
  }
2938
+ ),
2939
+ pastePrompt && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2940
+ ConfirmModal,
2941
+ {
2942
+ chrome,
2943
+ title: chrome.pasteTemplate,
2944
+ message: chrome.pasteTemplateConfirm,
2945
+ confirmLabel: chrome.confirmReplace,
2946
+ tone: "danger",
2947
+ onConfirm: () => {
2948
+ applyDocument(clipboard.readTemplate());
2949
+ setPastePrompt(false);
2950
+ },
2951
+ onClose: () => setPastePrompt(false)
2952
+ }
2953
+ ),
2954
+ persistence.notice && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2955
+ ConfirmModal,
2956
+ {
2957
+ chrome,
2958
+ title: persistence.notice.title,
2959
+ message: persistence.notice.message,
2960
+ onClose: persistence.dismissNotice
2961
+ }
2655
2962
  )
2656
2963
  ] });
2657
2964
  });
@@ -2673,9 +2980,17 @@ var DARK_THEME = {
2673
2980
  };
2674
2981
  // Annotate the CommonJS export names for ESM import in node:
2675
2982
  0 && (module.exports = {
2983
+ CLIPBOARD_KEY,
2676
2984
  DARK_THEME,
2677
2985
  DEFAULT_THEME,
2986
+ DOCUMENT_VERSION,
2987
+ DRAFT_KEY,
2678
2988
  FormBuilder,
2679
- localStorageAdapter
2989
+ INDEX_KEY,
2990
+ formKey,
2991
+ localStorageAdapter,
2992
+ parseTemplate,
2993
+ savedFormId,
2994
+ serializeTemplate
2680
2995
  });
2681
2996
  //# sourceMappingURL=index.cjs.map