@templatical/editor 0.27.4 → 0.27.6

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.
Files changed (167) hide show
  1. package/dist/{AiChatSidebar-LstHjwBU.js → AiChatSidebar-jbpwD_4k.js} +7 -7
  2. package/dist/{AiFeatureMenu-DVQrznsN.js → AiFeatureMenu-BeZR46ZG.js} +4 -4
  3. package/dist/{BlockIssueBadge-DKaFQNxW.js → BlockIssueBadge-B8wQY1tM.js} +2 -2
  4. package/dist/{BlockPreviewCanvas-D9eC04OA.js → BlockPreviewCanvas-CIjts7SP.js} +1 -1
  5. package/dist/{CloudHeaderExtras-DX0j3CJ3.js → CloudHeaderExtras-mMqnnTi1.js} +5 -5
  6. package/dist/{CloudPanels-DCeNSwXy.js → CloudPanels-fiTneIeJ.js} +4 -4
  7. package/dist/{CollaboratorBar-BZ-JCjdM.js → CollaboratorBar-C48K85la.js} +2 -2
  8. package/dist/{ColorPicker-j0TQu5lE.js → ColorPicker-Bc1lGovM.js} +2 -2
  9. package/dist/{CommentsPanel-c5LmzlNN.js → CommentsPanel-D5Ljv1oj.js} +1 -1
  10. package/dist/{CommentsSidebar-Bm1DxFb5.js → CommentsSidebar-C5mDJVjC.js} +10 -10
  11. package/dist/{CountdownToolbar-UPdwNvVI.js → CountdownToolbar-B-9EdQvm.js} +3 -3
  12. package/dist/{DesignReferenceSidebar-C8WTApc5.js → DesignReferenceSidebar-DOy4ENYv.js} +5 -5
  13. package/dist/{IssuesPanel-D49aJjLS.js → IssuesPanel-DZUehVZS.js} +5 -5
  14. package/dist/{MergeTagModeToggle-CVzJY9b2.js → MergeTagModeToggle-Fn9Q1SrJ.js} +2 -2
  15. package/dist/{MergeTagSuggestion-DGha0WRp.js → MergeTagSuggestion-DflUPfkT.js} +2 -2
  16. package/dist/{NumberWithSuffix-D9mPtMJB.js → NumberWithSuffix-DZAktKby.js} +1 -1
  17. package/dist/{ParagraphEditor-C9-jQ5zP.js → ParagraphEditor-BwTQoxxF.js} +20 -20
  18. package/dist/{RestoreVersionDialog-Ds6gEF5u.js → RestoreVersionDialog-CVuUY3CK.js} +5 -5
  19. package/dist/{RichTextEditorContent-CO1se6ln.js → RichTextEditorContent-CoXoAfZ1.js} +3 -3
  20. package/dist/{SaveBlockDialog-DJIcncsU.js → SaveBlockDialog-Bn5JtRSE.js} +18 -19
  21. package/dist/{SavedBlocksBrowserModal-BUhhkGEb.js → SavedBlocksBrowserModal-WWGhUC8R.js} +10 -11
  22. package/dist/{SavedBlocksPanels-DBfPz4iB.js → SavedBlocksPanels-R_4C58EN.js} +1 -1
  23. package/dist/{SavedBlocksPickBar-D9NsfzCB.js → SavedBlocksPickBar-D5KZVvvu.js} +2 -2
  24. package/dist/{TemplateScoringPanel-BFsUqnXR.js → TemplateScoringPanel-Cz25F9Cf.js} +10 -10
  25. package/dist/{TestEmailModal-DARwBqpV.js → TestEmailModal-C_C228mX.js} +8 -8
  26. package/dist/{TestEmailPanel-BkgAew1S.js → TestEmailPanel-D7YOLrOb.js} +1 -1
  27. package/dist/{TitleEditor-BWLwZh84.js → TitleEditor-XOQQlb1V.js} +8 -8
  28. package/dist/{TplModal-CJLs8as_.js → TplModal-DLXIufsn.js} +6 -4
  29. package/dist/{VersionHistoryMenu-CrIVG_SN.js → VersionHistoryMenu-Bk5zEClP.js} +10 -10
  30. package/dist/{VersionHistoryPanels-s45U7XY-.js → VersionHistoryPanels-D-km60IA.js} +1 -1
  31. package/dist/{VersionPreviewBanner-BIy95B34.js → VersionPreviewBanner-PMaxy3tA.js} +2 -2
  32. package/dist/{blockTypeIcons-BjUkQsAe.js → blockTypeIcons-BG5c5gc8.js} +2 -2
  33. package/dist/bundle-stats.json +6 -6
  34. package/dist/cdn/chunks/{AiChatSidebar-tZqbhmuJ.js → AiChatSidebar-hq9GgASn.js} +2 -2
  35. package/dist/cdn/chunks/{AiChatSidebar-tZqbhmuJ.js.map → AiChatSidebar-hq9GgASn.js.map} +1 -1
  36. package/dist/cdn/chunks/{AiFeatureMenu-BhtNkpIq.js → AiFeatureMenu-B8B-XgQB.js} +3 -3
  37. package/dist/cdn/chunks/{AiFeatureMenu-BhtNkpIq.js.map → AiFeatureMenu-B8B-XgQB.js.map} +1 -1
  38. package/dist/cdn/chunks/{BlockIssueBadge-D9L8j7Db.js → BlockIssueBadge-DOvq0xJb.js} +2 -2
  39. package/dist/cdn/chunks/{BlockIssueBadge-D9L8j7Db.js.map → BlockIssueBadge-DOvq0xJb.js.map} +1 -1
  40. package/dist/cdn/chunks/{BlockPreviewCanvas-Bt7fFzIQ.js → BlockPreviewCanvas-ZN-5MezA.js} +2 -2
  41. package/dist/cdn/chunks/{BlockPreviewCanvas-Bt7fFzIQ.js.map → BlockPreviewCanvas-ZN-5MezA.js.map} +1 -1
  42. package/dist/cdn/chunks/{CloudHeaderExtras-CScGpeLl.js → CloudHeaderExtras-Dbd6brlz.js} +6 -6
  43. package/dist/cdn/chunks/CloudHeaderExtras-Dbd6brlz.js.map +1 -0
  44. package/dist/cdn/chunks/{CloudPanels-DVtJkWsj.js → CloudPanels-CxkGH1dy.js} +5 -5
  45. package/dist/cdn/chunks/CloudPanels-CxkGH1dy.js.map +1 -0
  46. package/dist/cdn/chunks/{CollaboratorBar-BH1lM_Ta.js → CollaboratorBar-DpP8oKTU.js} +3 -3
  47. package/dist/cdn/chunks/{CollaboratorBar-BH1lM_Ta.js.map → CollaboratorBar-DpP8oKTU.js.map} +1 -1
  48. package/dist/cdn/chunks/{ColorPicker-d4kY7WWd.js → ColorPicker-BsYEtL7B.js} +3 -3
  49. package/dist/cdn/chunks/{ColorPicker-d4kY7WWd.js.map → ColorPicker-BsYEtL7B.js.map} +1 -1
  50. package/dist/cdn/chunks/{CommentsPanel-COhQMagD.js → CommentsPanel-B1B_UQ2z.js} +2 -2
  51. package/dist/cdn/chunks/{CommentsPanel-COhQMagD.js.map → CommentsPanel-B1B_UQ2z.js.map} +1 -1
  52. package/dist/cdn/chunks/{CommentsSidebar-vzLX-_h3.js → CommentsSidebar-B4waNT5L.js} +2 -2
  53. package/dist/cdn/chunks/{CommentsSidebar-vzLX-_h3.js.map → CommentsSidebar-B4waNT5L.js.map} +1 -1
  54. package/dist/cdn/chunks/{CountdownToolbar-BRDbI1mM.js → CountdownToolbar-DhJg2sQa.js} +4 -4
  55. package/dist/cdn/chunks/{CountdownToolbar-BRDbI1mM.js.map → CountdownToolbar-DhJg2sQa.js.map} +1 -1
  56. package/dist/cdn/chunks/{DesignReferenceSidebar-npLxDRJI.js → DesignReferenceSidebar-4Ds4D0M0.js} +2 -2
  57. package/dist/cdn/chunks/{DesignReferenceSidebar-npLxDRJI.js.map → DesignReferenceSidebar-4Ds4D0M0.js.map} +1 -1
  58. package/dist/cdn/chunks/{IssuesPanel-CTGlv7HF.js → IssuesPanel-CyGgRHuI.js} +2 -2
  59. package/dist/cdn/chunks/{IssuesPanel-CTGlv7HF.js.map → IssuesPanel-CyGgRHuI.js.map} +1 -1
  60. package/dist/cdn/chunks/{MergeTagModeToggle-CkpKFx9N.js → MergeTagModeToggle-B_oHsH0i.js} +2 -2
  61. package/dist/cdn/chunks/{MergeTagModeToggle-CkpKFx9N.js.map → MergeTagModeToggle-B_oHsH0i.js.map} +1 -1
  62. package/dist/cdn/chunks/{MergeTagSuggestion-DhllqTA3.js → MergeTagSuggestion-Df7jcLpS.js} +2 -2
  63. package/dist/cdn/chunks/{MergeTagSuggestion-DhllqTA3.js.map → MergeTagSuggestion-Df7jcLpS.js.map} +1 -1
  64. package/dist/cdn/chunks/{NumberWithSuffix-mm6AfIv4.js → NumberWithSuffix-DNBrbl4H.js} +2 -2
  65. package/dist/cdn/chunks/{NumberWithSuffix-mm6AfIv4.js.map → NumberWithSuffix-DNBrbl4H.js.map} +1 -1
  66. package/dist/cdn/chunks/{ParagraphEditor-BgeeE0Gj.js → ParagraphEditor-9GPXDRns.js} +18 -18
  67. package/dist/cdn/chunks/{ParagraphEditor-BgeeE0Gj.js.map → ParagraphEditor-9GPXDRns.js.map} +1 -1
  68. package/dist/cdn/chunks/{RestoreVersionDialog-tfoAQAeP.js → RestoreVersionDialog-BWewwJIZ.js} +5 -5
  69. package/dist/cdn/chunks/RestoreVersionDialog-BWewwJIZ.js.map +1 -0
  70. package/dist/cdn/chunks/{RichTextEditorContent-DvLBtUcW.js → RichTextEditorContent-DWD1--Yi.js} +3 -3
  71. package/dist/cdn/chunks/{RichTextEditorContent-DvLBtUcW.js.map → RichTextEditorContent-DWD1--Yi.js.map} +1 -1
  72. package/dist/cdn/chunks/{SaveBlockDialog-D67K2lHn.js → SaveBlockDialog-DUyrK4nE.js} +17 -18
  73. package/dist/cdn/chunks/SaveBlockDialog-DUyrK4nE.js.map +1 -0
  74. package/dist/cdn/chunks/{SavedBlocksBrowserModal-BxVR-yDt.js → SavedBlocksBrowserModal-DZrk2Fxx.js} +9 -10
  75. package/dist/cdn/chunks/SavedBlocksBrowserModal-DZrk2Fxx.js.map +1 -0
  76. package/dist/cdn/chunks/{SavedBlocksPanels-VkKCO0uY.js → SavedBlocksPanels-iSAXI4IM.js} +2 -2
  77. package/dist/cdn/chunks/{SavedBlocksPanels-VkKCO0uY.js.map → SavedBlocksPanels-iSAXI4IM.js.map} +1 -1
  78. package/dist/cdn/chunks/{SavedBlocksPickBar-BJHQDgUT.js → SavedBlocksPickBar-CFeccSg9.js} +3 -3
  79. package/dist/cdn/chunks/{SavedBlocksPickBar-BJHQDgUT.js.map → SavedBlocksPickBar-CFeccSg9.js.map} +1 -1
  80. package/dist/cdn/chunks/{TemplateScoringPanel-MvdrkgqL.js → TemplateScoringPanel-Ct5p7X_i.js} +7 -7
  81. package/dist/cdn/chunks/{TemplateScoringPanel-MvdrkgqL.js.map → TemplateScoringPanel-Ct5p7X_i.js.map} +1 -1
  82. package/dist/cdn/chunks/{TestEmailModal-CEBFCevy.js → TestEmailModal-BJo6q_Dm.js} +8 -8
  83. package/dist/cdn/chunks/TestEmailModal-BJo6q_Dm.js.map +1 -0
  84. package/dist/cdn/chunks/{TestEmailPanel-C35nqIIl.js → TestEmailPanel-LuLd-ErE.js} +2 -2
  85. package/dist/cdn/chunks/{TestEmailPanel-C35nqIIl.js.map → TestEmailPanel-LuLd-ErE.js.map} +1 -1
  86. package/dist/cdn/chunks/{TitleEditor-c2aQCyds.js → TitleEditor-BsGfiDXQ.js} +8 -8
  87. package/dist/cdn/chunks/{TitleEditor-c2aQCyds.js.map → TitleEditor-BsGfiDXQ.js.map} +1 -1
  88. package/dist/cdn/chunks/{TplModal-DSTVZKuI.js → TplModal-lUdHsbjL.js} +7 -5
  89. package/dist/cdn/chunks/TplModal-lUdHsbjL.js.map +1 -0
  90. package/dist/cdn/chunks/{VersionHistoryMenu-BNYSIjVj.js → VersionHistoryMenu-CXMPsVMR.js} +4 -4
  91. package/dist/cdn/chunks/{VersionHistoryMenu-BNYSIjVj.js.map → VersionHistoryMenu-CXMPsVMR.js.map} +1 -1
  92. package/dist/cdn/chunks/{VersionHistoryPanels-CvWySHUE.js → VersionHistoryPanels-oTKA6TUS.js} +2 -2
  93. package/dist/cdn/chunks/{VersionHistoryPanels-CvWySHUE.js.map → VersionHistoryPanels-oTKA6TUS.js.map} +1 -1
  94. package/dist/cdn/chunks/{VersionPreviewBanner-CBVDWnhq.js → VersionPreviewBanner-7DZLUVaE.js} +3 -3
  95. package/dist/cdn/chunks/{VersionPreviewBanner-CBVDWnhq.js.map → VersionPreviewBanner-7DZLUVaE.js.map} +1 -1
  96. package/dist/cdn/chunks/{blockTypeIcons-A9qFb1cR.js → blockTypeIcons-D8dtoELC.js} +2 -2
  97. package/dist/cdn/chunks/{blockTypeIcons-A9qFb1cR.js.map → blockTypeIcons-D8dtoELC.js.map} +1 -1
  98. package/dist/cdn/chunks/{createCloudRuntime-DYDf_-rN.js → createCloudRuntime-CedWXagq.js} +2 -2
  99. package/dist/cdn/chunks/{createCloudRuntime-DYDf_-rN.js.map → createCloudRuntime-CedWXagq.js.map} +1 -1
  100. package/dist/cdn/chunks/{extensions-Byo2sA1r.js → extensions-P0lpi6k8.js} +3 -3
  101. package/dist/cdn/chunks/{extensions-Byo2sA1r.js.map → extensions-P0lpi6k8.js.map} +1 -1
  102. package/dist/cdn/chunks/{icons-Dl5bcXmT.js → icons-BrdgOeXm.js} +18 -18
  103. package/dist/cdn/chunks/{icons-Dl5bcXmT.js.map → icons-BrdgOeXm.js.map} +1 -1
  104. package/dist/cdn/chunks/{src-BDUi-9js.js → src-BS2ClsbB.js} +25 -25
  105. package/dist/cdn/chunks/src-BS2ClsbB.js.map +1 -0
  106. package/dist/cdn/chunks/{src-BAWlZu4B.js → src-rhf0R6xZ.js} +2 -2
  107. package/dist/cdn/chunks/{src-BAWlZu4B.js.map → src-rhf0R6xZ.js.map} +1 -1
  108. package/dist/cdn/chunks/{styleConstants-9oK5kn21.js → styleConstants-C_YAPdQS.js} +8 -8
  109. package/dist/cdn/chunks/{styleConstants-9oK5kn21.js.map → styleConstants-C_YAPdQS.js.map} +1 -1
  110. package/dist/cdn/chunks/{tiptap-DmpGap5g.js → tiptap-DWeSx_P8.js} +1012 -987
  111. package/dist/cdn/chunks/tiptap-DWeSx_P8.js.map +1 -0
  112. package/dist/cdn/chunks/{useEditorCore-D84laTMj.js → useEditorCore-CwJIw1Uy.js} +19 -19
  113. package/dist/cdn/chunks/{useEditorCore-D84laTMj.js.map → useEditorCore-CwJIw1Uy.js.map} +1 -1
  114. package/dist/cdn/editor.css +1 -1
  115. package/dist/cdn/editor.js +783 -787
  116. package/dist/cdn/editor.js.map +1 -1
  117. package/dist/{check-GRcTaHlZ.js → check-C2h94k_8.js} +1 -1
  118. package/dist/{chevron-down-NUIAa2Qf.js → chevron-down-ulZzvfvk.js} +1 -1
  119. package/dist/{chevron-up-Bjf2BQtL.js → chevron-up-8g-VtBW4.js} +1 -1
  120. package/dist/{circle-alert-FpQ8ykQL.js → circle-alert-BJFqBtRt.js} +1 -1
  121. package/dist/{clock-CaOM1DC-.js → clock-C-1JiH38.js} +1 -1
  122. package/dist/{createLucideIcon-CAmDKr5Z.js → createLucideIcon-DJb_1j1D.js} +2 -2
  123. package/dist/{dist-Ck9roB6K.js → dist-BLVvhIFj.js} +2 -2
  124. package/dist/{dist-DMJRSouD.js → dist-C145btxT.js} +255 -250
  125. package/dist/{dist-C27SeFL5.js → dist-CLc1cpN2.js} +2 -2
  126. package/dist/{dist-M7m1lYRe.js → dist-CQQNoHxz.js} +2 -2
  127. package/dist/{dist-g6cbzhZW.js → dist-CclsUQ7r.js} +2 -2
  128. package/dist/{dist-CYx-E82k.js → dist-CgXIMxZc.js} +2 -2
  129. package/dist/dist-D-ulkdvF.js +5 -0
  130. package/dist/{dist-BImfm-46.js → dist-D4Jb08yv.js} +2 -2
  131. package/dist/{dist-ChYLhoJ4.js → dist-DUn6jY0n.js} +35 -18
  132. package/dist/{dist-DfpeSdmE.js → dist-Dx-KOuOj.js} +2 -2
  133. package/dist/{dist-C08jiemd.js → dist-Dz7KUOSg.js} +2 -2
  134. package/dist/dist-Kf9u4ULN.js +5 -0
  135. package/dist/{extensions-BV9qfsYr.js → extensions-DWp4eXoE.js} +3 -3
  136. package/dist/{image-up-CIGT4cSb.js → image-up-0rvCz8rh.js} +1 -1
  137. package/dist/{info-9YwKinXj.js → info-CXvc-MZk.js} +1 -1
  138. package/dist/{loader-circle-QyURl47g.js → loader-circle-DQIuGTFk.js} +1 -1
  139. package/dist/{message-circle-C9pycEzV.js → message-circle-BqX2Y80d.js} +1 -1
  140. package/dist/{pencil-BvW86jVn.js → pencil-DpGX5hCB.js} +1 -1
  141. package/dist/{refresh-cw-BDAUomac.js → refresh-cw-B13pUg95.js} +1 -1
  142. package/dist/{scan-line-B5EvAPSG.js → scan-line-BDZEtA92.js} +1 -1
  143. package/dist/{send-BAUn7DLp.js → send-DQ52s_T6.js} +1 -1
  144. package/dist/{shield-check-CLGywlg0.js → shield-check-C4uQTy20.js} +1 -1
  145. package/dist/{sparkles-X6C0spo6.js → sparkles-CphMFXxu.js} +1 -1
  146. package/dist/style.css +1 -1
  147. package/dist/{styleConstants-9oK5kn21.js → styleConstants-C_YAPdQS.js} +7 -7
  148. package/dist/templatical-editor.js +961 -965
  149. package/dist/{text-align-start-C6FXOoEj.js → text-align-start-Cem3VGu8.js} +1 -1
  150. package/dist/{trash-2-balHkQP4.js → trash-2-ijNDbe1n.js} +1 -1
  151. package/dist/{triangle-alert-D2JBEr6k.js → triangle-alert-C1e0A199.js} +1 -1
  152. package/dist/{upload-D70OYprI.js → upload-CXwGBFi0.js} +1 -1
  153. package/dist/{useEditorCore-D7Gz7_CW.js → useEditorCore-BfPXdBsv.js} +9 -9
  154. package/dist/{useScrollToBlock-DcImB73T.js → useScrollToBlock-B3H6frtG.js} +1 -1
  155. package/dist/{x-DTUPbuwx.js → x-eZrtE6uf.js} +1 -1
  156. package/package.json +21 -21
  157. package/dist/cdn/chunks/CloudHeaderExtras-CScGpeLl.js.map +0 -1
  158. package/dist/cdn/chunks/CloudPanels-DVtJkWsj.js.map +0 -1
  159. package/dist/cdn/chunks/RestoreVersionDialog-tfoAQAeP.js.map +0 -1
  160. package/dist/cdn/chunks/SaveBlockDialog-D67K2lHn.js.map +0 -1
  161. package/dist/cdn/chunks/SavedBlocksBrowserModal-BxVR-yDt.js.map +0 -1
  162. package/dist/cdn/chunks/TestEmailModal-CEBFCevy.js.map +0 -1
  163. package/dist/cdn/chunks/TplModal-DSTVZKuI.js.map +0 -1
  164. package/dist/cdn/chunks/src-BDUi-9js.js.map +0 -1
  165. package/dist/cdn/chunks/tiptap-DmpGap5g.js.map +0 -1
  166. package/dist/dist-6YJAWMuf.js +0 -5
  167. package/dist/dist-8vlm9kfO.js +0 -5
@@ -1 +0,0 @@
1
- {"version":3,"file":"SavedBlocksBrowserModal-BxVR-yDt.js","names":[],"sources":["../../../src/components/SavedBlocksBrowserModal.vue","../../../src/components/SavedBlocksBrowserModal.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { primaryBtnClass } from \"../constants/styleConstants\";\nimport TplModal from \"./TplModal.vue\";\nimport { useI18n } from \"../composables\";\nimport { blockTypeIcons } from \"../utils/blockTypeIcons\";\nimport { formatRelativeTime } from \"../utils/formatRelativeTime\";\nimport { SAVED_BLOCKS_KEY, EDITOR_KEY, requireInject } from \"../keys\";\nimport type { SavedBlock } from \"@templatical/types\";\nimport { Package, Pencil, Search, Trash2, X } from \"@lucide/vue\";\nimport { computed, defineAsyncComponent, nextTick, ref, watch } from \"vue\";\n\nconst props = defineProps<{\n visible: boolean;\n}>();\n\nconst emit = defineEmits<{\n (e: \"close\"): void;\n (e: \"insert\", saved: SavedBlock, insertIndex: number | undefined): void;\n}>();\n\nconst BlockPreviewCanvas = defineAsyncComponent(\n () => import(\"./BlockPreviewCanvas.vue\"),\n);\n\nconst { t, format } = useI18n();\nconst savedBlocks = requireInject(SAVED_BLOCKS_KEY, \"SavedBlocksBrowserModal\");\nconst editor = requireInject(EDITOR_KEY, \"SavedBlocksBrowserModal\");\n\nconst searchQuery = ref(\"\");\n/** `\"\"` = no category filter. Matched exactly against `SavedBlock.category`. */\nconst categoryFilter = ref(\"\");\nconst selectedId = ref<string | null>(null);\nconst confirmDeleteId = ref<string | null>(null);\nconst renamingId = ref<string | null>(null);\nconst renameDraft = ref(\"\");\nconst renameCategoryDraft = ref(\"\");\nconst renameInput = ref<HTMLInputElement | null>(null);\n// 'end' = append, 'beginning' = index 0, or block id = after that block\nconst insertPosition = ref<string>(\"end\");\n\n// Order is the provider's to decide — the editor never re-sorts. Whatever\n// `list()` returns is what the user sees (the bundled localStorage adapter, for\n// instance, returns newest-first). Filters only narrow; they never reorder.\n//\n// Both filters run in memory over the loaded entries rather than round-tripping\n// to the provider: that keeps a BYO provider at four dumb methods, and it is\n// what lets the category options below be derived at all — a provider-filtered\n// response could not tell us which other categories exist.\nconst filtered = computed(() => {\n const query = searchQuery.value.trim().toLowerCase();\n const category = categoryFilter.value;\n return savedBlocks.savedBlocks.value.filter((b) => {\n if (query && !b.name.toLowerCase().includes(query)) return false;\n if (category && b.category !== category) return false;\n return true;\n });\n});\n\n/**\n * The list is fetched when this modal opens, not at editor mount, so a first\n * open can arrive before the provider answers. Show the skeleton only while\n * there is *nothing* to display: on a reopen the previous entries render\n * immediately and the refetch lands underneath them.\n *\n * Without this the empty state would claim \"No saved blocks yet\" for the whole\n * duration of the request — false, not merely unhelpful.\n */\nconst isInitialLoad = computed(\n () =>\n savedBlocks.isLoading.value && savedBlocks.savedBlocks.value.length === 0,\n);\n\n/** Whether any filter is narrowing the list — drives the empty-state copy. */\nconst isFiltering = computed(\n () => searchQuery.value.trim().length > 0 || categoryFilter.value !== \"\",\n);\n\nconst selected = computed(() => {\n if (!selectedId.value) return null;\n return (\n savedBlocks.savedBlocks.value.find((b) => b.id === selectedId.value) ?? null\n );\n});\n\ninterface PositionOption {\n value: string;\n label: string;\n}\n\nconst positionOptions = computed<PositionOption[]>(() => {\n const options: PositionOption[] = [\n { value: \"beginning\", label: t.savedBlocks.insertAtBeginning },\n ];\n const blocks = editor.content.value.blocks;\n for (let i = 0; i < blocks.length; i++) {\n const block = blocks[i];\n const typeKey = block.type as keyof typeof t.blocks;\n const label = t.blocks[typeKey] ?? block.type;\n options.push({\n value: block.id,\n label: format(t.savedBlocks.insertAfterBlock, {\n block: `${label} ${i + 1}`,\n }),\n });\n }\n options.push({ value: \"end\", label: t.savedBlocks.insertAtEnd });\n return options;\n});\n\nconst resolvedInsertIndex = computed<number | undefined>(() => {\n if (insertPosition.value === \"end\") return undefined;\n if (insertPosition.value === \"beginning\") return 0;\n const blocks = editor.content.value.blocks;\n const idx = blocks.findIndex((b) => b.id === insertPosition.value);\n if (idx !== -1) return idx + 1;\n return undefined;\n});\n\n// `immediate` matters: the modal is mounted lazily behind a `v-if` on the same\n// state that drives `visible`, so it can mount with `visible` already true and\n// would otherwise never resolve the default insert position.\nwatch(\n () => props.visible,\n (visible) => {\n if (visible) {\n searchQuery.value = \"\";\n categoryFilter.value = \"\";\n selectedId.value = null;\n confirmDeleteId.value = null;\n renamingId.value = null;\n // Default to after selected block, or end\n const selectedBlockId = editor.state.selectedBlockId;\n if (selectedBlockId) {\n const idx = editor.content.value.blocks.findIndex(\n (b) => b.id === selectedBlockId,\n );\n insertPosition.value = idx !== -1 ? selectedBlockId : \"end\";\n } else {\n insertPosition.value = \"end\";\n }\n }\n },\n { immediate: true },\n);\n\nfunction getBlockTypeIcons(\n saved: SavedBlock,\n): { type: string; icon: unknown }[] {\n const icons: { type: string; icon: unknown }[] = [];\n const seen = new Set<string>();\n for (const block of saved.content) {\n if (!seen.has(block.type) && blockTypeIcons[block.type]) {\n seen.add(block.type);\n icons.push({ type: block.type, icon: blockTypeIcons[block.type] });\n }\n if (icons.length >= 5) break;\n }\n return icons;\n}\n\nfunction getRemainingTypeCount(saved: SavedBlock): number {\n const types = new Set(saved.content.map((b) => b.type));\n return Math.max(0, types.size - 5);\n}\n\n/** Compact \"5m ago\" label, or \"\" when the provider supplies no usable timestamp. */\nfunction relativeLabel(saved: SavedBlock): string {\n const raw = saved.updatedAt ?? saved.createdAt;\n if (!raw) return \"\";\n return formatRelativeTime(raw, t.time, format) ?? \"\";\n}\n\n/** Absolute timestamp for the row's tooltip — locale-formatted, no i18n keys. */\nfunction absoluteLabel(saved: SavedBlock): string {\n const raw = saved.updatedAt ?? saved.createdAt;\n if (!raw) return \"\";\n const parsed = new Date(raw);\n return Number.isNaN(parsed.getTime()) ? \"\" : parsed.toLocaleString();\n}\n\nasync function handleDelete(id: string): Promise<void> {\n try {\n await savedBlocks.remove(id);\n if (selectedId.value === id) {\n selectedId.value = null;\n }\n } finally {\n confirmDeleteId.value = null;\n }\n}\n\nasync function startRename(saved: SavedBlock): Promise<void> {\n // The pencil is already hidden when editing isn't permitted; this stops a\n // programmatic call opening an editor whose commit could only fail.\n if (!savedBlocks.canUpdateBlock(saved)) return;\n renamingId.value = saved.id;\n renameDraft.value = saved.name;\n renameCategoryDraft.value = saved.category ?? \"\";\n confirmDeleteId.value = null;\n await nextTick();\n renameInput.value?.focus();\n renameInput.value?.select();\n}\n\nfunction cancelRename(): void {\n renamingId.value = null;\n renameDraft.value = \"\";\n renameCategoryDraft.value = \"\";\n}\n\nasync function commitRename(id: string): Promise<void> {\n // Guards two races: Escape sets `renamingId` to null and the unmounting\n // input can still emit `blur`, and Enter commits then blurs. Either would\n // otherwise fire a second, unwanted update.\n if (renamingId.value !== id) return;\n const nextName = renameDraft.value.trim();\n const nextCategory = renameCategoryDraft.value.trim();\n const current = savedBlocks.savedBlocks.value.find((b) => b.id === id);\n\n // An empty name is not a rename — fall back to what's stored, so clearing\n // the field and blurring can't wipe the entry's name.\n const patch: Partial<{ name: string; category: string }> = {};\n if (nextName && nextName !== current?.name) patch.name = nextName;\n // \"\" is meaningful here: it clears the category. Only skip when unchanged.\n if (nextCategory !== (current?.category ?? \"\")) patch.category = nextCategory;\n\n // Skip the round-trip when nothing actually changed.\n if (Object.keys(patch).length === 0) {\n cancelRename();\n return;\n }\n try {\n await savedBlocks.update(id, patch);\n } finally {\n cancelRename();\n }\n}\n\n/**\n * Commit when focus leaves the edit row entirely — not when it moves between\n * the name and category inputs. A plain `@blur` on each input would commit\n * (and unmount the row) the moment the user tabbed from one to the other.\n */\nfunction onEditFocusOut(event: FocusEvent, id: string): void {\n const row = event.currentTarget as HTMLElement;\n const next = event.relatedTarget as Node | null;\n if (next && row.contains(next)) return;\n void commitRename(id);\n}\n\nfunction handleInsert(): void {\n if (selected.value) {\n emit(\"insert\", selected.value, resolvedInsertIndex.value);\n }\n}\n\nfunction handleClose(): void {\n emit(\"close\");\n}\n\nfunction handleKeydown(event: KeyboardEvent): void {\n // While renaming, Enter/Escape belong to the inline input, not the dialog.\n if (renamingId.value) return;\n if (event.key === \"Escape\") {\n handleClose();\n }\n if (event.key === \"Enter\" && selected.value) {\n event.preventDefault();\n handleInsert();\n }\n}\n</script>\n\n<template>\n <TplModal :visible=\"visible\" @close=\"handleClose\" @keydown=\"handleKeydown\">\n <!-- Explicit width, not `w-full`: the modal's parent is shrink-to-fit, so\n `width: 100%` resolved against its own contents and the 600px preview\n canvas drove the dialog's size — it measured 523px with nothing selected\n and 965px with a block selected, nearly doubling on click. A pinned\n width makes every state identical and stops any future right-pane\n content from moving it. `max-w` keeps it inside narrow viewports, expressed\n against `--tpl-base-size` rather than `rem` so the editor stays immune to\n the host page's root font-size (issue #209); the preview scrolls within. -->\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n data-testid=\"saved-blocks-browser\"\n aria-labelledby=\"tpl-saved-blocks-browser-title\"\n class=\"tpl-scale-in tpl:mx-4 tpl:flex tpl:w-[1000px] tpl:max-w-[calc(100vw_-_2*var(--tpl-base-size))] tpl:flex-col tpl:rounded-[var(--tpl-radius-lg)]\"\n style=\"\n background-color: var(--tpl-bg-elevated);\n box-shadow: var(--tpl-shadow-xl);\n max-height: 90vh;\n \"\n >\n <!-- Header -->\n <div\n class=\"tpl:flex tpl:items-center tpl:justify-between tpl:border-b tpl:px-5 tpl:py-4 tpl:border-[var(--tpl-border)]\"\n >\n <h3\n id=\"tpl-saved-blocks-browser-title\"\n class=\"tpl:text-sm tpl:font-semibold tpl:text-[var(--tpl-text)]\"\n >\n {{ t.savedBlocks.browse }}\n </h3>\n <button\n :aria-label=\"t.savedBlocks.close\"\n class=\"tpl:cursor-pointer tpl:rounded-md tpl:border-none tpl:bg-transparent tpl:p-1 tpl:transition-colors tpl:text-[var(--tpl-text-dim)]\"\n @click=\"handleClose\"\n >\n <X :size=\"16\" :stroke-width=\"2\" />\n </button>\n </div>\n\n <!-- Body -->\n <div class=\"tpl:flex tpl:min-h-0 tpl:flex-1 tpl:overflow-hidden\">\n <!-- Left panel: saved block list -->\n <div\n class=\"tpl:flex tpl:w-[300px] tpl:shrink-0 tpl:flex-col tpl:overflow-hidden\"\n >\n <!-- Search + category filter -->\n <div\n class=\"tpl:flex tpl:flex-col tpl:gap-2 tpl:px-4 tpl:pt-4 tpl:pb-3\"\n >\n <div class=\"tpl:relative\">\n <Search\n :size=\"14\"\n :stroke-width=\"2\"\n class=\"tpl:pointer-events-none tpl:absolute tpl:left-3 tpl:top-1/2 tpl:-translate-y-1/2 tpl:text-[var(--tpl-text-dim)]\"\n />\n <input\n v-model=\"searchQuery\"\n type=\"text\"\n :placeholder=\"t.savedBlocks.search\"\n :disabled=\"isInitialLoad\"\n class=\"tpl:h-9 tpl:w-full tpl:rounded-md tpl:border tpl:pl-9 tpl:pr-3 tpl:text-sm tpl:outline-none tpl:disabled:opacity-50 tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n />\n </div>\n <!-- Only worth showing once something is actually categorised. -->\n <select\n v-if=\"savedBlocks.categories.value.length > 0\"\n v-model=\"categoryFilter\"\n data-testid=\"saved-blocks-category-filter\"\n :aria-label=\"t.savedBlocks.filterByCategory\"\n class=\"tpl:h-8 tpl:w-full tpl:rounded-md tpl:border tpl:px-2 tpl:text-xs tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n >\n <option value=\"\">{{ t.savedBlocks.allCategories }}</option>\n <option\n v-for=\"option in savedBlocks.categories.value\"\n :key=\"option\"\n :value=\"option\"\n >\n {{ option }}\n </option>\n </select>\n </div>\n\n <!-- Suggestions for the inline category editor, shared by every row -->\n <datalist id=\"tpl-saved-block-browser-categories\">\n <option\n v-for=\"option in savedBlocks.categories.value\"\n :key=\"option\"\n :value=\"option\"\n />\n </datalist>\n\n <!-- List -->\n <div class=\"tpl:flex-1 tpl:overflow-y-auto tpl:px-4 tpl:pb-4\">\n <!-- Skeleton rows: same height and gap as a real card, so the pane\n doesn't resize when the entries land. `aria-busy` on the region\n is what a screen reader announces; the bars are decorative. -->\n <div\n v-if=\"isInitialLoad\"\n data-testid=\"saved-blocks-loading\"\n class=\"tpl:flex tpl:flex-col tpl:gap-1\"\n role=\"status\"\n aria-busy=\"true\"\n :aria-label=\"t.savedBlocks.loading\"\n >\n <div\n v-for=\"n in 3\"\n :key=\"n\"\n aria-hidden=\"true\"\n class=\"tpl-saved-block-skeleton tpl:rounded-[var(--tpl-radius-md)] tpl:border tpl:px-3 tpl:py-2 tpl:border-[var(--tpl-border)]\"\n >\n <div\n class=\"tpl:h-3 tpl:w-1/2 tpl:rounded tpl:bg-[var(--tpl-bg-hover)]\"\n />\n <div\n class=\"tpl:mt-2 tpl:h-2.5 tpl:w-1/4 tpl:rounded tpl:bg-[var(--tpl-bg-hover)]\"\n />\n </div>\n </div>\n\n <div\n v-else-if=\"filtered.length > 0\"\n class=\"tpl:flex tpl:flex-col tpl:gap-1\"\n >\n <template v-for=\"item in filtered\" :key=\"item.id\">\n <!-- Inline rename replaces the whole row: an <input> nested\n inside the card <button> would be invalid markup and\n wouldn't focus reliably. -->\n <div\n v-if=\"renamingId === item.id\"\n class=\"tpl:flex tpl:w-full tpl:flex-col tpl:gap-1 tpl:rounded-[var(--tpl-radius-md)] tpl:border tpl:px-3 tpl:py-2 tpl:border-[var(--tpl-primary)]\"\n style=\"background-color: var(--tpl-primary-light)\"\n @focusout=\"onEditFocusOut($event, item.id)\"\n >\n <input\n :ref=\"(el) => (renameInput = el as HTMLInputElement | null)\"\n v-model=\"renameDraft\"\n type=\"text\"\n :aria-label=\"t.savedBlocks.rename\"\n class=\"tpl:h-7 tpl:w-full tpl:rounded-md tpl:border tpl:px-2 tpl:text-xs tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n @keydown.enter.prevent.stop=\"commitRename(item.id)\"\n @keydown.esc.prevent.stop=\"cancelRename()\"\n />\n <input\n v-model=\"renameCategoryDraft\"\n type=\"text\"\n data-testid=\"saved-blocks-edit-category\"\n :aria-label=\"t.savedBlocks.category\"\n :placeholder=\"t.savedBlocks.categoryPlaceholder\"\n list=\"tpl-saved-block-browser-categories\"\n class=\"tpl:h-7 tpl:w-full tpl:rounded-md tpl:border tpl:px-2 tpl:text-xs tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n @keydown.enter.prevent.stop=\"commitRename(item.id)\"\n @keydown.esc.prevent.stop=\"cancelRename()\"\n />\n </div>\n\n <button\n v-else\n type=\"button\"\n data-testid=\"saved-block-card\"\n :aria-pressed=\"selectedId === item.id\"\n class=\"tpl:group/card tpl:w-full tpl:cursor-pointer tpl:rounded-[var(--tpl-radius-md)] tpl:border tpl:bg-transparent tpl:px-3 tpl:py-2 tpl:text-left tpl:transition-all tpl:duration-[120ms]\"\n :style=\"{\n borderColor:\n selectedId === item.id\n ? 'var(--tpl-primary)'\n : 'var(--tpl-border)',\n backgroundColor:\n selectedId === item.id\n ? 'var(--tpl-primary-light)'\n : 'transparent',\n }\"\n @click=\"selectedId = item.id\"\n >\n <div class=\"tpl:flex tpl:items-center tpl:gap-2\">\n <span\n class=\"tpl:flex-1 tpl:truncate tpl:text-xs tpl:font-semibold tpl:text-[var(--tpl-text)]\"\n >\n {{ item.name }}\n </span>\n <span\n class=\"tpl:shrink-0 tpl:rounded-full tpl:px-1.5 tpl:py-0.5 tpl:text-[10px] tpl:font-medium tpl:bg-[var(--tpl-bg-hover)] tpl:text-[var(--tpl-text-muted)]\"\n >\n {{\n format(t.savedBlocks.blockCount, {\n count: item.content.length,\n })\n }}\n </span>\n </div>\n <div\n v-if=\"item.category\"\n class=\"tpl:mt-1 tpl:flex tpl:items-center\"\n >\n <span\n data-testid=\"saved-block-category\"\n class=\"tpl:max-w-full tpl:truncate tpl:rounded tpl:px-1.5 tpl:py-0.5 tpl:text-[10px] tpl:font-medium tpl:bg-[var(--tpl-primary-light)] tpl:text-[var(--tpl-primary)]\"\n >\n {{ item.category }}\n </span>\n </div>\n <div class=\"tpl:mt-1 tpl:flex tpl:items-center tpl:gap-1\">\n <component\n :is=\"icon.icon\"\n v-for=\"icon in getBlockTypeIcons(item)\"\n :key=\"icon.type\"\n :size=\"14\"\n :stroke-width=\"1.5\"\n class=\"tpl:text-[var(--tpl-text-dim)]\"\n />\n <span\n v-if=\"getRemainingTypeCount(item) > 0\"\n class=\"tpl:text-[10px] tpl:text-[var(--tpl-text-dim)]\"\n >\n +{{ getRemainingTypeCount(item) }}\n </span>\n <span\n v-if=\"relativeLabel(item)\"\n data-testid=\"saved-block-updated\"\n class=\"tpl:ml-1 tpl:truncate tpl:text-[10px] tpl:text-[var(--tpl-text-dim)]\"\n :title=\"absoluteLabel(item)\"\n >\n {{ relativeLabel(item) }}\n </span>\n <button\n v-if=\"confirmDeleteId === item.id\"\n :aria-label=\"t.savedBlocks.deleteConfirm\"\n class=\"tpl:ml-auto tpl:cursor-pointer tpl:rounded-md tpl:border tpl:px-2 tpl:py-0.5 tpl:text-[10px] tpl:font-medium tpl:transition-colors tpl:border-[var(--tpl-danger)] tpl:text-[var(--tpl-danger)]\"\n style=\"background-color: transparent\"\n @click.stop=\"handleDelete(item.id)\"\n >\n {{ t.savedBlocks.deleteConfirm }}\n </button>\n <!-- Hidden, not disabled: an action the user can't perform\n is better absent than greyed out. `ml-auto` moves to\n whichever button survives so the row stays aligned. -->\n <template v-else>\n <button\n v-if=\"savedBlocks.canUpdateBlock(item)\"\n class=\"tpl-saved-block-rename-btn tpl:ml-auto tpl:cursor-pointer tpl:rounded-md tpl:border-none tpl:bg-transparent tpl:p-0.5 tpl:transition-colors tpl:text-[var(--tpl-text-dim)]\"\n :aria-label=\"t.savedBlocks.rename\"\n :title=\"t.savedBlocks.rename\"\n @click.stop=\"startRename(item)\"\n >\n <Pencil :size=\"12\" :stroke-width=\"1.5\" />\n </button>\n <button\n v-if=\"savedBlocks.canDeleteBlock(item)\"\n class=\"tpl-saved-block-delete-btn tpl:cursor-pointer tpl:rounded-md tpl:border-none tpl:bg-transparent tpl:p-0.5 tpl:transition-colors tpl:text-[var(--tpl-text-dim)]\"\n :class=\"{\n 'tpl:ml-auto': !savedBlocks.canUpdateBlock(item),\n }\"\n :aria-label=\"t.savedBlocks.delete\"\n :title=\"t.savedBlocks.delete\"\n @click.stop=\"confirmDeleteId = item.id\"\n >\n <Trash2 :size=\"12\" :stroke-width=\"1.5\" />\n </button>\n </template>\n </div>\n </button>\n </template>\n </div>\n\n <!-- Empty state — only once we actually know the library is empty -->\n <div\n v-else\n class=\"tpl:flex tpl:flex-col tpl:items-center tpl:justify-center tpl:py-12\"\n >\n <Package\n :size=\"32\"\n :stroke-width=\"1\"\n class=\"tpl:text-[var(--tpl-text-dim)]\"\n />\n <p\n v-if=\"isFiltering\"\n class=\"tpl:mt-2 tpl:text-xs tpl:text-[var(--tpl-text-dim)]\"\n >\n {{ t.savedBlocks.noResults }}\n </p>\n <template v-else>\n <p class=\"tpl:mt-2 tpl:text-xs tpl:text-[var(--tpl-text-dim)]\">\n {{ t.savedBlocks.empty }}\n </p>\n <p\n class=\"tpl:mt-1 tpl:max-w-[220px] tpl:text-center tpl:text-xs tpl:text-[var(--tpl-text-dim)]\"\n >\n {{ t.savedBlocks.emptyHint }}\n </p>\n </template>\n </div>\n </div>\n </div>\n\n <!-- Right panel: preview -->\n <div\n class=\"tpl:flex tpl:flex-1 tpl:flex-col tpl:overflow-hidden tpl:border-l tpl:border-[var(--tpl-border)]\"\n >\n <div\n v-if=\"selected\"\n class=\"tpl:flex tpl:flex-1 tpl:flex-col tpl:overflow-hidden\"\n >\n <!-- Visual preview -->\n <div class=\"tpl:flex-1 tpl:overflow-y-auto tpl:p-4\">\n <BlockPreviewCanvas :blocks=\"selected.content\" />\n </div>\n </div>\n\n <!-- Empty preview state -->\n <div\n v-else\n class=\"tpl:flex tpl:flex-1 tpl:flex-col tpl:items-center tpl:justify-center tpl:px-4\"\n >\n <Package\n :size=\"32\"\n :stroke-width=\"1\"\n class=\"tpl:text-[var(--tpl-text-dim)]\"\n />\n <p\n class=\"tpl:mt-2 tpl:text-center tpl:text-xs tpl:text-[var(--tpl-text-dim)]\"\n >\n {{ t.savedBlocks.selectToPreview }}\n </p>\n </div>\n </div>\n </div>\n\n <!-- Footer -->\n <div\n class=\"tpl:flex tpl:items-center tpl:justify-between tpl:border-t tpl:px-5 tpl:py-3 tpl:border-[var(--tpl-border)]\"\n >\n <div class=\"tpl:flex tpl:items-center tpl:gap-2\">\n <label\n class=\"tpl:shrink-0 tpl:text-xs tpl:text-[var(--tpl-text-dim)]\"\n >\n {{ t.savedBlocks.insertPosition }}\n </label>\n <select\n v-model=\"insertPosition\"\n class=\"tpl:h-7 tpl:max-w-[220px] tpl:rounded-md tpl:border tpl:px-2 tpl:text-xs tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n >\n <option\n v-for=\"opt in positionOptions\"\n :key=\"opt.value\"\n :value=\"opt.value\"\n >\n {{ opt.label }}\n </option>\n </select>\n </div>\n <div class=\"tpl:flex tpl:gap-2\">\n <button\n type=\"button\"\n data-testid=\"saved-blocks-browser-close\"\n class=\"tpl:cursor-pointer tpl:rounded-md tpl:border tpl:px-3 tpl:py-1.5 tpl:text-sm tpl:font-medium tpl:shadow-[var(--tpl-shadow-sm)] tpl:transition-all tpl:border-[var(--tpl-border)] tpl:text-[var(--tpl-text)] tpl:bg-[var(--tpl-bg)]\"\n @click=\"handleClose\"\n >\n {{ t.savedBlocks.close }}\n </button>\n <button\n type=\"button\"\n :class=\"primaryBtnClass\"\n :disabled=\"!selected\"\n @click=\"handleInsert\"\n >\n {{ t.savedBlocks.insert }}\n </button>\n </div>\n </div>\n </div>\n </TplModal>\n</template>\n\n<style>\n/* Subtle breathing so the placeholders read as \"loading\" rather than as empty\n rows. `prefers-reduced-motion` holds them still at full opacity — the bars\n still communicate shape without animating. */\n.tpl-saved-block-skeleton {\n animation: tpl-saved-block-pulse 1.4s ease-in-out infinite;\n}\n\n@keyframes tpl-saved-block-pulse {\n 0%,\n 100% {\n opacity: 1;\n }\n 50% {\n opacity: 0.55;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .tpl-saved-block-skeleton {\n animation: none;\n }\n}\n\n.tpl-saved-block-delete-btn:hover {\n color: var(--tpl-danger) !important;\n}\n\n.tpl-saved-block-rename-btn:hover {\n color: var(--tpl-primary) !important;\n}\n</style>\n","<script setup lang=\"ts\">\nimport { primaryBtnClass } from \"../constants/styleConstants\";\nimport TplModal from \"./TplModal.vue\";\nimport { useI18n } from \"../composables\";\nimport { blockTypeIcons } from \"../utils/blockTypeIcons\";\nimport { formatRelativeTime } from \"../utils/formatRelativeTime\";\nimport { SAVED_BLOCKS_KEY, EDITOR_KEY, requireInject } from \"../keys\";\nimport type { SavedBlock } from \"@templatical/types\";\nimport { Package, Pencil, Search, Trash2, X } from \"@lucide/vue\";\nimport { computed, defineAsyncComponent, nextTick, ref, watch } from \"vue\";\n\nconst props = defineProps<{\n visible: boolean;\n}>();\n\nconst emit = defineEmits<{\n (e: \"close\"): void;\n (e: \"insert\", saved: SavedBlock, insertIndex: number | undefined): void;\n}>();\n\nconst BlockPreviewCanvas = defineAsyncComponent(\n () => import(\"./BlockPreviewCanvas.vue\"),\n);\n\nconst { t, format } = useI18n();\nconst savedBlocks = requireInject(SAVED_BLOCKS_KEY, \"SavedBlocksBrowserModal\");\nconst editor = requireInject(EDITOR_KEY, \"SavedBlocksBrowserModal\");\n\nconst searchQuery = ref(\"\");\n/** `\"\"` = no category filter. Matched exactly against `SavedBlock.category`. */\nconst categoryFilter = ref(\"\");\nconst selectedId = ref<string | null>(null);\nconst confirmDeleteId = ref<string | null>(null);\nconst renamingId = ref<string | null>(null);\nconst renameDraft = ref(\"\");\nconst renameCategoryDraft = ref(\"\");\nconst renameInput = ref<HTMLInputElement | null>(null);\n// 'end' = append, 'beginning' = index 0, or block id = after that block\nconst insertPosition = ref<string>(\"end\");\n\n// Order is the provider's to decide — the editor never re-sorts. Whatever\n// `list()` returns is what the user sees (the bundled localStorage adapter, for\n// instance, returns newest-first). Filters only narrow; they never reorder.\n//\n// Both filters run in memory over the loaded entries rather than round-tripping\n// to the provider: that keeps a BYO provider at four dumb methods, and it is\n// what lets the category options below be derived at all — a provider-filtered\n// response could not tell us which other categories exist.\nconst filtered = computed(() => {\n const query = searchQuery.value.trim().toLowerCase();\n const category = categoryFilter.value;\n return savedBlocks.savedBlocks.value.filter((b) => {\n if (query && !b.name.toLowerCase().includes(query)) return false;\n if (category && b.category !== category) return false;\n return true;\n });\n});\n\n/**\n * The list is fetched when this modal opens, not at editor mount, so a first\n * open can arrive before the provider answers. Show the skeleton only while\n * there is *nothing* to display: on a reopen the previous entries render\n * immediately and the refetch lands underneath them.\n *\n * Without this the empty state would claim \"No saved blocks yet\" for the whole\n * duration of the request — false, not merely unhelpful.\n */\nconst isInitialLoad = computed(\n () =>\n savedBlocks.isLoading.value && savedBlocks.savedBlocks.value.length === 0,\n);\n\n/** Whether any filter is narrowing the list — drives the empty-state copy. */\nconst isFiltering = computed(\n () => searchQuery.value.trim().length > 0 || categoryFilter.value !== \"\",\n);\n\nconst selected = computed(() => {\n if (!selectedId.value) return null;\n return (\n savedBlocks.savedBlocks.value.find((b) => b.id === selectedId.value) ?? null\n );\n});\n\ninterface PositionOption {\n value: string;\n label: string;\n}\n\nconst positionOptions = computed<PositionOption[]>(() => {\n const options: PositionOption[] = [\n { value: \"beginning\", label: t.savedBlocks.insertAtBeginning },\n ];\n const blocks = editor.content.value.blocks;\n for (let i = 0; i < blocks.length; i++) {\n const block = blocks[i];\n const typeKey = block.type as keyof typeof t.blocks;\n const label = t.blocks[typeKey] ?? block.type;\n options.push({\n value: block.id,\n label: format(t.savedBlocks.insertAfterBlock, {\n block: `${label} ${i + 1}`,\n }),\n });\n }\n options.push({ value: \"end\", label: t.savedBlocks.insertAtEnd });\n return options;\n});\n\nconst resolvedInsertIndex = computed<number | undefined>(() => {\n if (insertPosition.value === \"end\") return undefined;\n if (insertPosition.value === \"beginning\") return 0;\n const blocks = editor.content.value.blocks;\n const idx = blocks.findIndex((b) => b.id === insertPosition.value);\n if (idx !== -1) return idx + 1;\n return undefined;\n});\n\n// `immediate` matters: the modal is mounted lazily behind a `v-if` on the same\n// state that drives `visible`, so it can mount with `visible` already true and\n// would otherwise never resolve the default insert position.\nwatch(\n () => props.visible,\n (visible) => {\n if (visible) {\n searchQuery.value = \"\";\n categoryFilter.value = \"\";\n selectedId.value = null;\n confirmDeleteId.value = null;\n renamingId.value = null;\n // Default to after selected block, or end\n const selectedBlockId = editor.state.selectedBlockId;\n if (selectedBlockId) {\n const idx = editor.content.value.blocks.findIndex(\n (b) => b.id === selectedBlockId,\n );\n insertPosition.value = idx !== -1 ? selectedBlockId : \"end\";\n } else {\n insertPosition.value = \"end\";\n }\n }\n },\n { immediate: true },\n);\n\nfunction getBlockTypeIcons(\n saved: SavedBlock,\n): { type: string; icon: unknown }[] {\n const icons: { type: string; icon: unknown }[] = [];\n const seen = new Set<string>();\n for (const block of saved.content) {\n if (!seen.has(block.type) && blockTypeIcons[block.type]) {\n seen.add(block.type);\n icons.push({ type: block.type, icon: blockTypeIcons[block.type] });\n }\n if (icons.length >= 5) break;\n }\n return icons;\n}\n\nfunction getRemainingTypeCount(saved: SavedBlock): number {\n const types = new Set(saved.content.map((b) => b.type));\n return Math.max(0, types.size - 5);\n}\n\n/** Compact \"5m ago\" label, or \"\" when the provider supplies no usable timestamp. */\nfunction relativeLabel(saved: SavedBlock): string {\n const raw = saved.updatedAt ?? saved.createdAt;\n if (!raw) return \"\";\n return formatRelativeTime(raw, t.time, format) ?? \"\";\n}\n\n/** Absolute timestamp for the row's tooltip — locale-formatted, no i18n keys. */\nfunction absoluteLabel(saved: SavedBlock): string {\n const raw = saved.updatedAt ?? saved.createdAt;\n if (!raw) return \"\";\n const parsed = new Date(raw);\n return Number.isNaN(parsed.getTime()) ? \"\" : parsed.toLocaleString();\n}\n\nasync function handleDelete(id: string): Promise<void> {\n try {\n await savedBlocks.remove(id);\n if (selectedId.value === id) {\n selectedId.value = null;\n }\n } finally {\n confirmDeleteId.value = null;\n }\n}\n\nasync function startRename(saved: SavedBlock): Promise<void> {\n // The pencil is already hidden when editing isn't permitted; this stops a\n // programmatic call opening an editor whose commit could only fail.\n if (!savedBlocks.canUpdateBlock(saved)) return;\n renamingId.value = saved.id;\n renameDraft.value = saved.name;\n renameCategoryDraft.value = saved.category ?? \"\";\n confirmDeleteId.value = null;\n await nextTick();\n renameInput.value?.focus();\n renameInput.value?.select();\n}\n\nfunction cancelRename(): void {\n renamingId.value = null;\n renameDraft.value = \"\";\n renameCategoryDraft.value = \"\";\n}\n\nasync function commitRename(id: string): Promise<void> {\n // Guards two races: Escape sets `renamingId` to null and the unmounting\n // input can still emit `blur`, and Enter commits then blurs. Either would\n // otherwise fire a second, unwanted update.\n if (renamingId.value !== id) return;\n const nextName = renameDraft.value.trim();\n const nextCategory = renameCategoryDraft.value.trim();\n const current = savedBlocks.savedBlocks.value.find((b) => b.id === id);\n\n // An empty name is not a rename — fall back to what's stored, so clearing\n // the field and blurring can't wipe the entry's name.\n const patch: Partial<{ name: string; category: string }> = {};\n if (nextName && nextName !== current?.name) patch.name = nextName;\n // \"\" is meaningful here: it clears the category. Only skip when unchanged.\n if (nextCategory !== (current?.category ?? \"\")) patch.category = nextCategory;\n\n // Skip the round-trip when nothing actually changed.\n if (Object.keys(patch).length === 0) {\n cancelRename();\n return;\n }\n try {\n await savedBlocks.update(id, patch);\n } finally {\n cancelRename();\n }\n}\n\n/**\n * Commit when focus leaves the edit row entirely — not when it moves between\n * the name and category inputs. A plain `@blur` on each input would commit\n * (and unmount the row) the moment the user tabbed from one to the other.\n */\nfunction onEditFocusOut(event: FocusEvent, id: string): void {\n const row = event.currentTarget as HTMLElement;\n const next = event.relatedTarget as Node | null;\n if (next && row.contains(next)) return;\n void commitRename(id);\n}\n\nfunction handleInsert(): void {\n if (selected.value) {\n emit(\"insert\", selected.value, resolvedInsertIndex.value);\n }\n}\n\nfunction handleClose(): void {\n emit(\"close\");\n}\n\nfunction handleKeydown(event: KeyboardEvent): void {\n // While renaming, Enter/Escape belong to the inline input, not the dialog.\n if (renamingId.value) return;\n if (event.key === \"Escape\") {\n handleClose();\n }\n if (event.key === \"Enter\" && selected.value) {\n event.preventDefault();\n handleInsert();\n }\n}\n</script>\n\n<template>\n <TplModal :visible=\"visible\" @close=\"handleClose\" @keydown=\"handleKeydown\">\n <!-- Explicit width, not `w-full`: the modal's parent is shrink-to-fit, so\n `width: 100%` resolved against its own contents and the 600px preview\n canvas drove the dialog's size — it measured 523px with nothing selected\n and 965px with a block selected, nearly doubling on click. A pinned\n width makes every state identical and stops any future right-pane\n content from moving it. `max-w` keeps it inside narrow viewports, expressed\n against `--tpl-base-size` rather than `rem` so the editor stays immune to\n the host page's root font-size (issue #209); the preview scrolls within. -->\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n data-testid=\"saved-blocks-browser\"\n aria-labelledby=\"tpl-saved-blocks-browser-title\"\n class=\"tpl-scale-in tpl:mx-4 tpl:flex tpl:w-[1000px] tpl:max-w-[calc(100vw_-_2*var(--tpl-base-size))] tpl:flex-col tpl:rounded-[var(--tpl-radius-lg)]\"\n style=\"\n background-color: var(--tpl-bg-elevated);\n box-shadow: var(--tpl-shadow-xl);\n max-height: 90vh;\n \"\n >\n <!-- Header -->\n <div\n class=\"tpl:flex tpl:items-center tpl:justify-between tpl:border-b tpl:px-5 tpl:py-4 tpl:border-[var(--tpl-border)]\"\n >\n <h3\n id=\"tpl-saved-blocks-browser-title\"\n class=\"tpl:text-sm tpl:font-semibold tpl:text-[var(--tpl-text)]\"\n >\n {{ t.savedBlocks.browse }}\n </h3>\n <button\n :aria-label=\"t.savedBlocks.close\"\n class=\"tpl:cursor-pointer tpl:rounded-md tpl:border-none tpl:bg-transparent tpl:p-1 tpl:transition-colors tpl:text-[var(--tpl-text-dim)]\"\n @click=\"handleClose\"\n >\n <X :size=\"16\" :stroke-width=\"2\" />\n </button>\n </div>\n\n <!-- Body -->\n <div class=\"tpl:flex tpl:min-h-0 tpl:flex-1 tpl:overflow-hidden\">\n <!-- Left panel: saved block list -->\n <div\n class=\"tpl:flex tpl:w-[300px] tpl:shrink-0 tpl:flex-col tpl:overflow-hidden\"\n >\n <!-- Search + category filter -->\n <div\n class=\"tpl:flex tpl:flex-col tpl:gap-2 tpl:px-4 tpl:pt-4 tpl:pb-3\"\n >\n <div class=\"tpl:relative\">\n <Search\n :size=\"14\"\n :stroke-width=\"2\"\n class=\"tpl:pointer-events-none tpl:absolute tpl:left-3 tpl:top-1/2 tpl:-translate-y-1/2 tpl:text-[var(--tpl-text-dim)]\"\n />\n <input\n v-model=\"searchQuery\"\n type=\"text\"\n :placeholder=\"t.savedBlocks.search\"\n :disabled=\"isInitialLoad\"\n class=\"tpl:h-9 tpl:w-full tpl:rounded-md tpl:border tpl:pl-9 tpl:pr-3 tpl:text-sm tpl:outline-none tpl:disabled:opacity-50 tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n />\n </div>\n <!-- Only worth showing once something is actually categorised. -->\n <select\n v-if=\"savedBlocks.categories.value.length > 0\"\n v-model=\"categoryFilter\"\n data-testid=\"saved-blocks-category-filter\"\n :aria-label=\"t.savedBlocks.filterByCategory\"\n class=\"tpl:h-8 tpl:w-full tpl:rounded-md tpl:border tpl:px-2 tpl:text-xs tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n >\n <option value=\"\">{{ t.savedBlocks.allCategories }}</option>\n <option\n v-for=\"option in savedBlocks.categories.value\"\n :key=\"option\"\n :value=\"option\"\n >\n {{ option }}\n </option>\n </select>\n </div>\n\n <!-- Suggestions for the inline category editor, shared by every row -->\n <datalist id=\"tpl-saved-block-browser-categories\">\n <option\n v-for=\"option in savedBlocks.categories.value\"\n :key=\"option\"\n :value=\"option\"\n />\n </datalist>\n\n <!-- List -->\n <div class=\"tpl:flex-1 tpl:overflow-y-auto tpl:px-4 tpl:pb-4\">\n <!-- Skeleton rows: same height and gap as a real card, so the pane\n doesn't resize when the entries land. `aria-busy` on the region\n is what a screen reader announces; the bars are decorative. -->\n <div\n v-if=\"isInitialLoad\"\n data-testid=\"saved-blocks-loading\"\n class=\"tpl:flex tpl:flex-col tpl:gap-1\"\n role=\"status\"\n aria-busy=\"true\"\n :aria-label=\"t.savedBlocks.loading\"\n >\n <div\n v-for=\"n in 3\"\n :key=\"n\"\n aria-hidden=\"true\"\n class=\"tpl-saved-block-skeleton tpl:rounded-[var(--tpl-radius-md)] tpl:border tpl:px-3 tpl:py-2 tpl:border-[var(--tpl-border)]\"\n >\n <div\n class=\"tpl:h-3 tpl:w-1/2 tpl:rounded tpl:bg-[var(--tpl-bg-hover)]\"\n />\n <div\n class=\"tpl:mt-2 tpl:h-2.5 tpl:w-1/4 tpl:rounded tpl:bg-[var(--tpl-bg-hover)]\"\n />\n </div>\n </div>\n\n <div\n v-else-if=\"filtered.length > 0\"\n class=\"tpl:flex tpl:flex-col tpl:gap-1\"\n >\n <template v-for=\"item in filtered\" :key=\"item.id\">\n <!-- Inline rename replaces the whole row: an <input> nested\n inside the card <button> would be invalid markup and\n wouldn't focus reliably. -->\n <div\n v-if=\"renamingId === item.id\"\n class=\"tpl:flex tpl:w-full tpl:flex-col tpl:gap-1 tpl:rounded-[var(--tpl-radius-md)] tpl:border tpl:px-3 tpl:py-2 tpl:border-[var(--tpl-primary)]\"\n style=\"background-color: var(--tpl-primary-light)\"\n @focusout=\"onEditFocusOut($event, item.id)\"\n >\n <input\n :ref=\"(el) => (renameInput = el as HTMLInputElement | null)\"\n v-model=\"renameDraft\"\n type=\"text\"\n :aria-label=\"t.savedBlocks.rename\"\n class=\"tpl:h-7 tpl:w-full tpl:rounded-md tpl:border tpl:px-2 tpl:text-xs tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n @keydown.enter.prevent.stop=\"commitRename(item.id)\"\n @keydown.esc.prevent.stop=\"cancelRename()\"\n />\n <input\n v-model=\"renameCategoryDraft\"\n type=\"text\"\n data-testid=\"saved-blocks-edit-category\"\n :aria-label=\"t.savedBlocks.category\"\n :placeholder=\"t.savedBlocks.categoryPlaceholder\"\n list=\"tpl-saved-block-browser-categories\"\n class=\"tpl:h-7 tpl:w-full tpl:rounded-md tpl:border tpl:px-2 tpl:text-xs tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n @keydown.enter.prevent.stop=\"commitRename(item.id)\"\n @keydown.esc.prevent.stop=\"cancelRename()\"\n />\n </div>\n\n <button\n v-else\n type=\"button\"\n data-testid=\"saved-block-card\"\n :aria-pressed=\"selectedId === item.id\"\n class=\"tpl:group/card tpl:w-full tpl:cursor-pointer tpl:rounded-[var(--tpl-radius-md)] tpl:border tpl:bg-transparent tpl:px-3 tpl:py-2 tpl:text-left tpl:transition-all tpl:duration-[120ms]\"\n :style=\"{\n borderColor:\n selectedId === item.id\n ? 'var(--tpl-primary)'\n : 'var(--tpl-border)',\n backgroundColor:\n selectedId === item.id\n ? 'var(--tpl-primary-light)'\n : 'transparent',\n }\"\n @click=\"selectedId = item.id\"\n >\n <div class=\"tpl:flex tpl:items-center tpl:gap-2\">\n <span\n class=\"tpl:flex-1 tpl:truncate tpl:text-xs tpl:font-semibold tpl:text-[var(--tpl-text)]\"\n >\n {{ item.name }}\n </span>\n <span\n class=\"tpl:shrink-0 tpl:rounded-full tpl:px-1.5 tpl:py-0.5 tpl:text-[10px] tpl:font-medium tpl:bg-[var(--tpl-bg-hover)] tpl:text-[var(--tpl-text-muted)]\"\n >\n {{\n format(t.savedBlocks.blockCount, {\n count: item.content.length,\n })\n }}\n </span>\n </div>\n <div\n v-if=\"item.category\"\n class=\"tpl:mt-1 tpl:flex tpl:items-center\"\n >\n <span\n data-testid=\"saved-block-category\"\n class=\"tpl:max-w-full tpl:truncate tpl:rounded tpl:px-1.5 tpl:py-0.5 tpl:text-[10px] tpl:font-medium tpl:bg-[var(--tpl-primary-light)] tpl:text-[var(--tpl-primary)]\"\n >\n {{ item.category }}\n </span>\n </div>\n <div class=\"tpl:mt-1 tpl:flex tpl:items-center tpl:gap-1\">\n <component\n :is=\"icon.icon\"\n v-for=\"icon in getBlockTypeIcons(item)\"\n :key=\"icon.type\"\n :size=\"14\"\n :stroke-width=\"1.5\"\n class=\"tpl:text-[var(--tpl-text-dim)]\"\n />\n <span\n v-if=\"getRemainingTypeCount(item) > 0\"\n class=\"tpl:text-[10px] tpl:text-[var(--tpl-text-dim)]\"\n >\n +{{ getRemainingTypeCount(item) }}\n </span>\n <span\n v-if=\"relativeLabel(item)\"\n data-testid=\"saved-block-updated\"\n class=\"tpl:ml-1 tpl:truncate tpl:text-[10px] tpl:text-[var(--tpl-text-dim)]\"\n :title=\"absoluteLabel(item)\"\n >\n {{ relativeLabel(item) }}\n </span>\n <button\n v-if=\"confirmDeleteId === item.id\"\n :aria-label=\"t.savedBlocks.deleteConfirm\"\n class=\"tpl:ml-auto tpl:cursor-pointer tpl:rounded-md tpl:border tpl:px-2 tpl:py-0.5 tpl:text-[10px] tpl:font-medium tpl:transition-colors tpl:border-[var(--tpl-danger)] tpl:text-[var(--tpl-danger)]\"\n style=\"background-color: transparent\"\n @click.stop=\"handleDelete(item.id)\"\n >\n {{ t.savedBlocks.deleteConfirm }}\n </button>\n <!-- Hidden, not disabled: an action the user can't perform\n is better absent than greyed out. `ml-auto` moves to\n whichever button survives so the row stays aligned. -->\n <template v-else>\n <button\n v-if=\"savedBlocks.canUpdateBlock(item)\"\n class=\"tpl-saved-block-rename-btn tpl:ml-auto tpl:cursor-pointer tpl:rounded-md tpl:border-none tpl:bg-transparent tpl:p-0.5 tpl:transition-colors tpl:text-[var(--tpl-text-dim)]\"\n :aria-label=\"t.savedBlocks.rename\"\n :title=\"t.savedBlocks.rename\"\n @click.stop=\"startRename(item)\"\n >\n <Pencil :size=\"12\" :stroke-width=\"1.5\" />\n </button>\n <button\n v-if=\"savedBlocks.canDeleteBlock(item)\"\n class=\"tpl-saved-block-delete-btn tpl:cursor-pointer tpl:rounded-md tpl:border-none tpl:bg-transparent tpl:p-0.5 tpl:transition-colors tpl:text-[var(--tpl-text-dim)]\"\n :class=\"{\n 'tpl:ml-auto': !savedBlocks.canUpdateBlock(item),\n }\"\n :aria-label=\"t.savedBlocks.delete\"\n :title=\"t.savedBlocks.delete\"\n @click.stop=\"confirmDeleteId = item.id\"\n >\n <Trash2 :size=\"12\" :stroke-width=\"1.5\" />\n </button>\n </template>\n </div>\n </button>\n </template>\n </div>\n\n <!-- Empty state — only once we actually know the library is empty -->\n <div\n v-else\n class=\"tpl:flex tpl:flex-col tpl:items-center tpl:justify-center tpl:py-12\"\n >\n <Package\n :size=\"32\"\n :stroke-width=\"1\"\n class=\"tpl:text-[var(--tpl-text-dim)]\"\n />\n <p\n v-if=\"isFiltering\"\n class=\"tpl:mt-2 tpl:text-xs tpl:text-[var(--tpl-text-dim)]\"\n >\n {{ t.savedBlocks.noResults }}\n </p>\n <template v-else>\n <p class=\"tpl:mt-2 tpl:text-xs tpl:text-[var(--tpl-text-dim)]\">\n {{ t.savedBlocks.empty }}\n </p>\n <p\n class=\"tpl:mt-1 tpl:max-w-[220px] tpl:text-center tpl:text-xs tpl:text-[var(--tpl-text-dim)]\"\n >\n {{ t.savedBlocks.emptyHint }}\n </p>\n </template>\n </div>\n </div>\n </div>\n\n <!-- Right panel: preview -->\n <div\n class=\"tpl:flex tpl:flex-1 tpl:flex-col tpl:overflow-hidden tpl:border-l tpl:border-[var(--tpl-border)]\"\n >\n <div\n v-if=\"selected\"\n class=\"tpl:flex tpl:flex-1 tpl:flex-col tpl:overflow-hidden\"\n >\n <!-- Visual preview -->\n <div class=\"tpl:flex-1 tpl:overflow-y-auto tpl:p-4\">\n <BlockPreviewCanvas :blocks=\"selected.content\" />\n </div>\n </div>\n\n <!-- Empty preview state -->\n <div\n v-else\n class=\"tpl:flex tpl:flex-1 tpl:flex-col tpl:items-center tpl:justify-center tpl:px-4\"\n >\n <Package\n :size=\"32\"\n :stroke-width=\"1\"\n class=\"tpl:text-[var(--tpl-text-dim)]\"\n />\n <p\n class=\"tpl:mt-2 tpl:text-center tpl:text-xs tpl:text-[var(--tpl-text-dim)]\"\n >\n {{ t.savedBlocks.selectToPreview }}\n </p>\n </div>\n </div>\n </div>\n\n <!-- Footer -->\n <div\n class=\"tpl:flex tpl:items-center tpl:justify-between tpl:border-t tpl:px-5 tpl:py-3 tpl:border-[var(--tpl-border)]\"\n >\n <div class=\"tpl:flex tpl:items-center tpl:gap-2\">\n <label\n class=\"tpl:shrink-0 tpl:text-xs tpl:text-[var(--tpl-text-dim)]\"\n >\n {{ t.savedBlocks.insertPosition }}\n </label>\n <select\n v-model=\"insertPosition\"\n class=\"tpl:h-7 tpl:max-w-[220px] tpl:rounded-md tpl:border tpl:px-2 tpl:text-xs tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n >\n <option\n v-for=\"opt in positionOptions\"\n :key=\"opt.value\"\n :value=\"opt.value\"\n >\n {{ opt.label }}\n </option>\n </select>\n </div>\n <div class=\"tpl:flex tpl:gap-2\">\n <button\n type=\"button\"\n data-testid=\"saved-blocks-browser-close\"\n class=\"tpl:cursor-pointer tpl:rounded-md tpl:border tpl:px-3 tpl:py-1.5 tpl:text-sm tpl:font-medium tpl:shadow-[var(--tpl-shadow-sm)] tpl:transition-all tpl:border-[var(--tpl-border)] tpl:text-[var(--tpl-text)] tpl:bg-[var(--tpl-bg)]\"\n @click=\"handleClose\"\n >\n {{ t.savedBlocks.close }}\n </button>\n <button\n type=\"button\"\n :class=\"primaryBtnClass\"\n :disabled=\"!selected\"\n @click=\"handleInsert\"\n >\n {{ t.savedBlocks.insert }}\n </button>\n </div>\n </div>\n </div>\n </TplModal>\n</template>\n\n<style>\n/* Subtle breathing so the placeholders read as \"loading\" rather than as empty\n rows. `prefers-reduced-motion` holds them still at full opacity — the bars\n still communicate shape without animating. */\n.tpl-saved-block-skeleton {\n animation: tpl-saved-block-pulse 1.4s ease-in-out infinite;\n}\n\n@keyframes tpl-saved-block-pulse {\n 0%,\n 100% {\n opacity: 1;\n }\n 50% {\n opacity: 0.55;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .tpl-saved-block-skeleton {\n animation: none;\n }\n}\n\n.tpl-saved-block-delete-btn:hover {\n color: var(--tpl-danger) !important;\n}\n\n.tpl-saved-block-rename-btn:hover {\n color: var(--tpl-primary) !important;\n}\n</style>\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"TestEmailModal-CEBFCevy.js","names":[],"sources":["../../../src/utils/validateEmailShape.ts","../../../src/components/TestEmailModal.vue","../../../src/components/TestEmailModal.vue"],"sourcesContent":["/**\n * Characters that cannot appear in a domain but routinely arrive attached to one.\n *\n * A **denylist**, not an allowlist, so unicode domains keep working — an\n * allowlist of `[a-z0-9.-]` would be the stricter, more obviously \"correct\"\n * choice and would reject them. What this actually targets is the punctuation\n * that comes from pasting out of a list or a mail client: `user@example.com,`\n * and `<user@example.com>`.\n */\nconst IMPOSSIBLE_DOMAIN_CHARS = /[,;<>()[\\]\\\\\"'`|/?#@!$%^&*+=~{}]/;\n\n/**\n * Does this string look like an email address?\n *\n * Deliberately loose: non-empty, no whitespace, exactly one `@`, a non-empty\n * local part, and a domain containing a dot with something either side and no\n * impossible characters. That is enough to catch the mistakes a typo actually\n * produces — a missing `@`, a trailing comma, a half-typed domain — before the\n * user pays for a round-trip.\n *\n * **Do not \"improve\" this into an RFC-ish regex.** Those reliably reject valid\n * addresses (plus-addressing, long new gTLDs, unicode domains) and the resulting\n * bug reports cost far more than the malformed addresses they catch. Only the\n * receiving mail server can really tell, and the sending backend has to validate\n * anyway — this is a fast-feedback affordance, not a gate. `validateEmailShape`\n * has its own test file pinning the addresses that must keep passing.\n */\nexport function looksLikeEmail(value: string): boolean {\n const trimmed = value.trim();\n if (trimmed.length === 0) return false;\n if (/\\s/.test(trimmed)) return false;\n\n const parts = trimmed.split(\"@\");\n if (parts.length !== 2) return false;\n\n const [local, domain] = parts;\n if (local.length === 0) return false;\n if (IMPOSSIBLE_DOMAIN_CHARS.test(domain)) return false;\n\n // A dot with at least one character either side, so `a@b` and `a@b.` fail but\n // `a@b.c` and `a@sub.example.museum` pass.\n const dot = domain.indexOf(\".\");\n return dot > 0 && dot < domain.length - 1;\n}\n","<script setup lang=\"ts\">\nimport { primaryBtnClass } from \"../constants/styleConstants\";\n/**\n * Send-a-test dialog, shared by the OSS and Cloud editors.\n *\n * The recipient control has three shapes, driven entirely by whether the\n * provider configured an allowlist:\n *\n * - **no allowlist** — free text, validated for shape only. This branch exists\n * for BYO senders; Cloud never takes it, because its list comes from a signed\n * JWT.\n * - **exactly one** — a read-only field. Preserved verbatim from the Cloud-only\n * version; there is nothing to choose, so offering an editable field would\n * imply otherwise.\n * - **several** — a picker of exactly those addresses.\n *\n * An empty allowlist never reaches here: the feature reports itself unavailable,\n * so no trigger renders. (The Cloud-only version fell through to `v-else` and\n * rendered a select with no options and a permanently disabled Send.)\n */\nimport TplModal from \"./TplModal.vue\";\n// Statically imported: this modal is already lazy-loaded as a whole, so the\n// preview rides its chunk rather than costing a second round-trip and flashing\n// an empty frame. Still shared with the saved-blocks surfaces, which lazy-load\n// it themselves.\nimport BlockPreviewCanvas from \"./BlockPreviewCanvas.vue\";\n// The editor header's own control, reused verbatim so the two viewport switches\n// are the same component rather than two things that resemble each other.\nimport ViewportToggle from \"./ViewportToggle.vue\";\nimport MergeTagModeToggle from \"./MergeTagModeToggle.vue\";\nimport { useI18n } from \"../composables/useI18n\";\nimport { looksLikeEmail } from \"../utils/validateEmailShape\";\nimport { Check, LoaderCircle } from \"@lucide/vue\";\nimport { computed, inject, ref, watch } from \"vue\";\nimport { hasMergeTagSamples, type ViewportSize } from \"@templatical/types\";\nimport {\n EDITOR_KEY,\n MERGE_TAGS_KEY,\n MERGE_TAG_SAMPLE_MODE_KEY,\n RESOLVE_PREVIEW_KEY,\n} from \"../keys\";\nimport { usePreviewResolution } from \"../composables/usePreviewResolution\";\nimport type { TestEmailError } from \"../composables/useTestEmailFeature\";\n\nconst props = defineProps<{\n visible: boolean;\n /** Omitted / undefined means unrestricted, so the field accepts free text. */\n allowedRecipients?: string[];\n defaultRecipient?: string;\n isSending: boolean;\n justSent: boolean;\n error: TestEmailError | null;\n}>();\n\nconst emit = defineEmits<{\n (e: \"send\", recipient: string): void;\n (e: \"close\"): void;\n}>();\n\nconst { t } = useI18n();\nconst editor = inject(EDITOR_KEY, null);\n\nconst recipient = ref(\"\");\n\nconst previewViewport = ref<ViewportSize>(\"desktop\");\n\n/**\n * Its own resolution instance rather than the editor's, because this dialog has\n * a recipient and the editor's preview does not — resolving for \"whoever is\n * selected here\" is a different question from resolving for the canvas.\n * `isActive` is the dialog's own visibility.\n */\nconst resolvePreviewHook = inject(RESOLVE_PREVIEW_KEY, undefined);\nconst previewResolution = usePreviewResolution({\n resolvePreview: resolvePreviewHook ?? undefined,\n getContent: () =>\n editor?.content.value ?? { blocks: [], settings: {} as never },\n isActive: () => props.visible,\n getRecipient: () => recipient.value || undefined,\n});\n\n/**\n * Shared with the editor's own preview, so switching here and switching there\n * are the same choice — it is a property of how the user wants to read a\n * preview, not of one dialog. Falls back to a local ref for headless mounts.\n */\nconst sharedSampleMode = inject(MERGE_TAG_SAMPLE_MODE_KEY, null);\n// Seeded from availability, matching how `useEditorCore` seeds the shared ref.\n// A blanket `true` would make the dialog claim tags \"show example values\" for a\n// consumer who configured none — false, and with no toggle rendered to correct\n// it, since the toggle hides itself when nothing has a sample.\nconst localSampleMode = ref(hasMergeTagSamples(inject(MERGE_TAGS_KEY, [])));\nconst sampleMode = computed({\n get: () =>\n // Off entirely while a resolver is configured — same rule as the canvas.\n previewResolution.isConfigured\n ? false\n : (sharedSampleMode?.value ?? localSampleMode.value),\n set: (on: boolean) => {\n if (sharedSampleMode) sharedSampleMode.value = on;\n else localSampleMode.value = on;\n },\n});\n\nconst previewBlocks = computed(\n () => previewResolution.content.value.blocks ?? [],\n);\n\nconst hasAllowlist = computed(\n () =>\n props.allowedRecipients !== undefined && props.allowedRecipients.length > 0,\n);\nconst isSingleRecipient = computed(() => props.allowedRecipients?.length === 1);\n\n// Mounted behind a `v-if` on the same state that drives `visible`, so it can\n// mount with `visible` already true — without `immediate` the field would never\n// be seeded.\nwatch(\n () => props.visible,\n (visible) => {\n if (!visible) return;\n recipient.value =\n props.defaultRecipient ?? props.allowedRecipients?.[0] ?? \"\";\n },\n { immediate: true },\n);\n\n/** Shape-check free text only; a picker can't produce a malformed value. */\nconst isRecipientValid = computed(() =>\n hasAllowlist.value\n ? recipient.value.length > 0\n : looksLikeEmail(recipient.value),\n);\n\nconst canSend = computed(\n () => isRecipientValid.value && !props.isSending && !props.justSent,\n);\n\n/** Free text that's been typed into but isn't yet a plausible address. */\nconst showShapeHint = computed(\n () =>\n !hasAllowlist.value &&\n recipient.value.trim().length > 0 &&\n !isRecipientValid.value,\n);\n\nconst errorText = computed(() => {\n if (!props.error) return null;\n return props.error.kind === \"provider\"\n ? props.error.message\n : t.testEmail.recipientNotAllowed;\n});\n\nfunction handleSend(): void {\n if (!canSend.value) return;\n emit(\"send\", recipient.value.trim());\n}\n\nfunction handleClose(): void {\n // Sending is the one state that blocks dismissal; the success state closes\n // immediately rather than making the user wait out the timer.\n if (props.isSending) return;\n emit(\"close\");\n}\n\nfunction handleKeydown(event: KeyboardEvent): void {\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n handleSend();\n }\n if (event.key === \"Escape\") {\n handleClose();\n }\n}\n</script>\n\n<template>\n <TplModal :visible=\"visible\" @close=\"handleClose\" @keydown=\"handleKeydown\">\n <!-- Grows to `max-w-2xl` only while the preview is open, so a 600px email\n renders at roughly true size — the same reason SaveBlockDialog picked\n that width. Collapsed, it stays the compact form it was. `max-h-[90vh]`\n with a `min-h-0 overflow-y-auto` preview region is the pattern the\n saved-blocks dialogs use: `min-h-0` is what lets a flex child shrink\n below its content and actually scroll. -->\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n :aria-busy=\"isSending\"\n aria-labelledby=\"tpl-test-email-title\"\n class=\"tpl-scale-in tpl:mx-4 tpl:flex tpl:max-h-[90vh] tpl:w-full tpl:max-w-2xl tpl:flex-col tpl:rounded-[var(--tpl-radius-lg)] tpl:p-5\"\n style=\"\n background-color: var(--tpl-bg-elevated);\n box-shadow: var(--tpl-shadow-xl);\n \"\n >\n <h3\n id=\"tpl-test-email-title\"\n class=\"tpl:mb-4 tpl:shrink-0 tpl:text-sm tpl:font-semibold tpl:text-[var(--tpl-text)]\"\n >\n {{ t.testEmail.title }}\n </h3>\n\n <!-- Recipient -->\n <div class=\"tpl:mb-3 tpl:shrink-0\">\n <label\n for=\"tpl-test-email-recipient\"\n class=\"tpl:mb-1.5 tpl:block tpl:text-sm tpl:font-medium tpl:text-[var(--tpl-text-muted)]\"\n >\n {{ t.testEmail.recipientLabel }}\n </label>\n\n <!-- Exactly one allowed address: nothing to choose. -->\n <input\n v-if=\"isSingleRecipient\"\n id=\"tpl-test-email-recipient\"\n type=\"text\"\n :value=\"recipient\"\n disabled\n data-testid=\"test-email-recipient\"\n class=\"tpl:h-9 tpl:w-full tpl:rounded-md tpl:border tpl:px-3 tpl:py-1 tpl:text-sm tpl:opacity-70 tpl:shadow-[var(--tpl-shadow-sm)] tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n />\n\n <!-- Several allowed addresses: pick one. -->\n <select\n v-else-if=\"hasAllowlist\"\n id=\"tpl-test-email-recipient\"\n v-model=\"recipient\"\n :disabled=\"isSending || justSent\"\n data-testid=\"test-email-recipient\"\n class=\"tpl:h-9 tpl:w-full tpl:rounded-md tpl:border tpl:px-3 tpl:py-1 tpl:text-sm tpl:shadow-[var(--tpl-shadow-sm)] tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n >\n <option\n v-for=\"email in allowedRecipients\"\n :key=\"email\"\n :value=\"email\"\n >\n {{ email }}\n </option>\n </select>\n\n <!-- No allowlist: free text. -->\n <input\n v-else\n id=\"tpl-test-email-recipient\"\n v-model=\"recipient\"\n type=\"email\"\n autocomplete=\"email\"\n spellcheck=\"false\"\n :placeholder=\"t.testEmail.recipientPlaceholder\"\n :disabled=\"isSending || justSent\"\n data-testid=\"test-email-recipient\"\n class=\"tpl:h-9 tpl:w-full tpl:rounded-md tpl:border tpl:px-3 tpl:py-1 tpl:text-sm tpl:shadow-[var(--tpl-shadow-sm)] tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n />\n\n <p\n v-if=\"showShapeHint\"\n class=\"tpl:mt-1.5 tpl:text-xs tpl:text-[var(--tpl-text-dim)]\"\n >\n {{ t.testEmail.invalidAddress }}\n </p>\n </div>\n\n <!-- Preview header: a label plus the viewport switch. Always shown — the\n preview is part of the dialog rather than something to opt into. -->\n <div class=\"tpl:mb-2 tpl:shrink-0\">\n <!-- Two segmented controls: `flex-wrap` so they drop to their own row\n rather than crushing the label on a narrow viewport. -->\n <div\n class=\"tpl:flex tpl:flex-wrap tpl:items-center tpl:justify-between tpl:gap-2\"\n >\n <span\n class=\"tpl:text-sm tpl:font-medium tpl:text-[var(--tpl-text-muted)]\"\n >\n {{ t.testEmail.preview }}\n </span>\n\n <div class=\"tpl:flex tpl:items-center tpl:gap-2\">\n <MergeTagModeToggle\n v-if=\"!previewResolution.supersedesSamples.value\"\n :sample-mode=\"sampleMode\"\n @change=\"sampleMode = $event\"\n />\n\n <!-- Without this the preview would silently show only the desktop\n variant of any responsive block. -->\n <ViewportToggle\n :viewport=\"previewViewport\"\n @change=\"previewViewport = $event\"\n />\n </div>\n </div>\n\n <!-- The hint has to follow the mode: \"shown unresolved\" is simply false\n once every tag renders a realistic value. -->\n <p\n data-testid=\"test-email-preview-hint\"\n class=\"tpl:mt-1 tpl:text-xs tpl:text-[var(--tpl-text-dim)]\"\n >\n {{\n previewResolution.supersedesSamples.value\n ? t.previewResolution.hint\n : sampleMode\n ? t.testEmail.previewHintSample\n : t.testEmail.previewHint\n }}\n </p>\n </div>\n\n <!-- The one region allowed to grow: `flex-1 min-h-0` so it absorbs the\n remaining height and scrolls, leaving the form and actions fixed. -->\n <div\n id=\"tpl-test-email-preview\"\n data-testid=\"test-email-preview\"\n class=\"tpl:mb-3 tpl:min-h-0 tpl:flex-1 tpl:overflow-y-auto tpl:rounded-[var(--tpl-radius-sm)] tpl:p-3 tpl:bg-[var(--tpl-canvas-bg)]\"\n >\n <!-- Skeleton while a *first* resolve is outstanding, so the dialog never\n flashes the unresolved template before the resolved one. Sized like\n the frame it replaces to avoid a layout jump. A re-resolve (recipient\n change) keeps the previous result instead — `isInitialResolve`. -->\n <div\n v-if=\"previewResolution.isInitialResolve.value\"\n role=\"status\"\n aria-busy=\"true\"\n data-testid=\"preview-resolution-loading\"\n class=\"tpl:flex tpl:flex-col tpl:gap-3\"\n >\n <span class=\"tpl:sr-only\">{{ t.previewResolution.resolving }}</span>\n <div\n v-for=\"n in 3\"\n :key=\"n\"\n aria-hidden=\"true\"\n class=\"tpl-pulse tpl:h-16 tpl:rounded-md tpl:bg-[var(--tpl-bg-hover)]\"\n ></div>\n </div>\n\n <!-- Resolution failed: the unresolved template renders below, so the\n dialog degrades rather than showing an empty frame. -->\n <p\n v-else-if=\"previewResolution.hasFailed.value\"\n role=\"status\"\n data-testid=\"preview-resolution-failed\"\n class=\"tpl:mb-2 tpl:rounded-md tpl:px-3 tpl:py-2 tpl:text-xs tpl:text-[var(--tpl-text-muted)] tpl:bg-[var(--tpl-bg-hover)]\"\n >\n {{ t.previewResolution.failed }}\n </p>\n\n <BlockPreviewCanvas\n v-if=\"!previewResolution.isInitialResolve.value\"\n :blocks=\"previewBlocks\"\n :viewport=\"previewViewport\"\n :apply-condition-filter=\"!previewResolution.isConfigured\"\n />\n </div>\n\n <!-- Success. Announced politely; the dialog closes itself shortly after. -->\n <p\n v-if=\"justSent\"\n role=\"status\"\n data-testid=\"test-email-success\"\n class=\"tpl:mb-3 tpl:flex tpl:shrink-0 tpl:items-center tpl:gap-1.5 tpl:text-xs tpl:text-[var(--tpl-success,var(--tpl-primary))]\"\n >\n <Check :size=\"13\" :stroke-width=\"2.5\" />\n {{ t.testEmail.success }}\n </p>\n\n <!-- Failure. Stays open so the user can retry. -->\n <p\n v-else-if=\"errorText\"\n role=\"alert\"\n data-testid=\"test-email-error\"\n class=\"tpl:mb-3 tpl:shrink-0 tpl:text-xs tpl:text-[var(--tpl-danger)]\"\n >\n {{ errorText }}\n </p>\n\n <!-- Actions -->\n <div class=\"tpl:flex tpl:shrink-0 tpl:justify-end tpl:gap-2\">\n <button\n type=\"button\"\n data-testid=\"test-email-cancel\"\n class=\"tpl:cursor-pointer tpl:rounded-md tpl:border tpl:px-3 tpl:py-1.5 tpl:text-sm tpl:font-medium tpl:shadow-[var(--tpl-shadow-sm)] tpl:transition-all tpl:border-[var(--tpl-border)] tpl:text-[var(--tpl-text)] tpl:bg-[var(--tpl-bg)]\"\n :disabled=\"isSending\"\n :class=\"{ 'tpl:cursor-not-allowed tpl:opacity-50': isSending }\"\n @click=\"handleClose\"\n >\n {{ t.testEmail.cancel }}\n </button>\n <button\n type=\"button\"\n data-testid=\"test-email-send\"\n :class=\"primaryBtnClass\"\n :disabled=\"!canSend\"\n @click=\"handleSend\"\n >\n <span v-if=\"isSending\" class=\"tpl:flex tpl:items-center tpl:gap-1.5\">\n <LoaderCircle\n class=\"tpl:animate-spin\"\n :size=\"12\"\n :stroke-width=\"2\"\n />\n {{ t.testEmail.sending }}\n </span>\n <span v-else>\n {{ t.testEmail.send }}\n </span>\n </button>\n </div>\n </div>\n </TplModal>\n</template>\n","<script setup lang=\"ts\">\nimport { primaryBtnClass } from \"../constants/styleConstants\";\n/**\n * Send-a-test dialog, shared by the OSS and Cloud editors.\n *\n * The recipient control has three shapes, driven entirely by whether the\n * provider configured an allowlist:\n *\n * - **no allowlist** — free text, validated for shape only. This branch exists\n * for BYO senders; Cloud never takes it, because its list comes from a signed\n * JWT.\n * - **exactly one** — a read-only field. Preserved verbatim from the Cloud-only\n * version; there is nothing to choose, so offering an editable field would\n * imply otherwise.\n * - **several** — a picker of exactly those addresses.\n *\n * An empty allowlist never reaches here: the feature reports itself unavailable,\n * so no trigger renders. (The Cloud-only version fell through to `v-else` and\n * rendered a select with no options and a permanently disabled Send.)\n */\nimport TplModal from \"./TplModal.vue\";\n// Statically imported: this modal is already lazy-loaded as a whole, so the\n// preview rides its chunk rather than costing a second round-trip and flashing\n// an empty frame. Still shared with the saved-blocks surfaces, which lazy-load\n// it themselves.\nimport BlockPreviewCanvas from \"./BlockPreviewCanvas.vue\";\n// The editor header's own control, reused verbatim so the two viewport switches\n// are the same component rather than two things that resemble each other.\nimport ViewportToggle from \"./ViewportToggle.vue\";\nimport MergeTagModeToggle from \"./MergeTagModeToggle.vue\";\nimport { useI18n } from \"../composables/useI18n\";\nimport { looksLikeEmail } from \"../utils/validateEmailShape\";\nimport { Check, LoaderCircle } from \"@lucide/vue\";\nimport { computed, inject, ref, watch } from \"vue\";\nimport { hasMergeTagSamples, type ViewportSize } from \"@templatical/types\";\nimport {\n EDITOR_KEY,\n MERGE_TAGS_KEY,\n MERGE_TAG_SAMPLE_MODE_KEY,\n RESOLVE_PREVIEW_KEY,\n} from \"../keys\";\nimport { usePreviewResolution } from \"../composables/usePreviewResolution\";\nimport type { TestEmailError } from \"../composables/useTestEmailFeature\";\n\nconst props = defineProps<{\n visible: boolean;\n /** Omitted / undefined means unrestricted, so the field accepts free text. */\n allowedRecipients?: string[];\n defaultRecipient?: string;\n isSending: boolean;\n justSent: boolean;\n error: TestEmailError | null;\n}>();\n\nconst emit = defineEmits<{\n (e: \"send\", recipient: string): void;\n (e: \"close\"): void;\n}>();\n\nconst { t } = useI18n();\nconst editor = inject(EDITOR_KEY, null);\n\nconst recipient = ref(\"\");\n\nconst previewViewport = ref<ViewportSize>(\"desktop\");\n\n/**\n * Its own resolution instance rather than the editor's, because this dialog has\n * a recipient and the editor's preview does not — resolving for \"whoever is\n * selected here\" is a different question from resolving for the canvas.\n * `isActive` is the dialog's own visibility.\n */\nconst resolvePreviewHook = inject(RESOLVE_PREVIEW_KEY, undefined);\nconst previewResolution = usePreviewResolution({\n resolvePreview: resolvePreviewHook ?? undefined,\n getContent: () =>\n editor?.content.value ?? { blocks: [], settings: {} as never },\n isActive: () => props.visible,\n getRecipient: () => recipient.value || undefined,\n});\n\n/**\n * Shared with the editor's own preview, so switching here and switching there\n * are the same choice — it is a property of how the user wants to read a\n * preview, not of one dialog. Falls back to a local ref for headless mounts.\n */\nconst sharedSampleMode = inject(MERGE_TAG_SAMPLE_MODE_KEY, null);\n// Seeded from availability, matching how `useEditorCore` seeds the shared ref.\n// A blanket `true` would make the dialog claim tags \"show example values\" for a\n// consumer who configured none — false, and with no toggle rendered to correct\n// it, since the toggle hides itself when nothing has a sample.\nconst localSampleMode = ref(hasMergeTagSamples(inject(MERGE_TAGS_KEY, [])));\nconst sampleMode = computed({\n get: () =>\n // Off entirely while a resolver is configured — same rule as the canvas.\n previewResolution.isConfigured\n ? false\n : (sharedSampleMode?.value ?? localSampleMode.value),\n set: (on: boolean) => {\n if (sharedSampleMode) sharedSampleMode.value = on;\n else localSampleMode.value = on;\n },\n});\n\nconst previewBlocks = computed(\n () => previewResolution.content.value.blocks ?? [],\n);\n\nconst hasAllowlist = computed(\n () =>\n props.allowedRecipients !== undefined && props.allowedRecipients.length > 0,\n);\nconst isSingleRecipient = computed(() => props.allowedRecipients?.length === 1);\n\n// Mounted behind a `v-if` on the same state that drives `visible`, so it can\n// mount with `visible` already true — without `immediate` the field would never\n// be seeded.\nwatch(\n () => props.visible,\n (visible) => {\n if (!visible) return;\n recipient.value =\n props.defaultRecipient ?? props.allowedRecipients?.[0] ?? \"\";\n },\n { immediate: true },\n);\n\n/** Shape-check free text only; a picker can't produce a malformed value. */\nconst isRecipientValid = computed(() =>\n hasAllowlist.value\n ? recipient.value.length > 0\n : looksLikeEmail(recipient.value),\n);\n\nconst canSend = computed(\n () => isRecipientValid.value && !props.isSending && !props.justSent,\n);\n\n/** Free text that's been typed into but isn't yet a plausible address. */\nconst showShapeHint = computed(\n () =>\n !hasAllowlist.value &&\n recipient.value.trim().length > 0 &&\n !isRecipientValid.value,\n);\n\nconst errorText = computed(() => {\n if (!props.error) return null;\n return props.error.kind === \"provider\"\n ? props.error.message\n : t.testEmail.recipientNotAllowed;\n});\n\nfunction handleSend(): void {\n if (!canSend.value) return;\n emit(\"send\", recipient.value.trim());\n}\n\nfunction handleClose(): void {\n // Sending is the one state that blocks dismissal; the success state closes\n // immediately rather than making the user wait out the timer.\n if (props.isSending) return;\n emit(\"close\");\n}\n\nfunction handleKeydown(event: KeyboardEvent): void {\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n handleSend();\n }\n if (event.key === \"Escape\") {\n handleClose();\n }\n}\n</script>\n\n<template>\n <TplModal :visible=\"visible\" @close=\"handleClose\" @keydown=\"handleKeydown\">\n <!-- Grows to `max-w-2xl` only while the preview is open, so a 600px email\n renders at roughly true size — the same reason SaveBlockDialog picked\n that width. Collapsed, it stays the compact form it was. `max-h-[90vh]`\n with a `min-h-0 overflow-y-auto` preview region is the pattern the\n saved-blocks dialogs use: `min-h-0` is what lets a flex child shrink\n below its content and actually scroll. -->\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n :aria-busy=\"isSending\"\n aria-labelledby=\"tpl-test-email-title\"\n class=\"tpl-scale-in tpl:mx-4 tpl:flex tpl:max-h-[90vh] tpl:w-full tpl:max-w-2xl tpl:flex-col tpl:rounded-[var(--tpl-radius-lg)] tpl:p-5\"\n style=\"\n background-color: var(--tpl-bg-elevated);\n box-shadow: var(--tpl-shadow-xl);\n \"\n >\n <h3\n id=\"tpl-test-email-title\"\n class=\"tpl:mb-4 tpl:shrink-0 tpl:text-sm tpl:font-semibold tpl:text-[var(--tpl-text)]\"\n >\n {{ t.testEmail.title }}\n </h3>\n\n <!-- Recipient -->\n <div class=\"tpl:mb-3 tpl:shrink-0\">\n <label\n for=\"tpl-test-email-recipient\"\n class=\"tpl:mb-1.5 tpl:block tpl:text-sm tpl:font-medium tpl:text-[var(--tpl-text-muted)]\"\n >\n {{ t.testEmail.recipientLabel }}\n </label>\n\n <!-- Exactly one allowed address: nothing to choose. -->\n <input\n v-if=\"isSingleRecipient\"\n id=\"tpl-test-email-recipient\"\n type=\"text\"\n :value=\"recipient\"\n disabled\n data-testid=\"test-email-recipient\"\n class=\"tpl:h-9 tpl:w-full tpl:rounded-md tpl:border tpl:px-3 tpl:py-1 tpl:text-sm tpl:opacity-70 tpl:shadow-[var(--tpl-shadow-sm)] tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n />\n\n <!-- Several allowed addresses: pick one. -->\n <select\n v-else-if=\"hasAllowlist\"\n id=\"tpl-test-email-recipient\"\n v-model=\"recipient\"\n :disabled=\"isSending || justSent\"\n data-testid=\"test-email-recipient\"\n class=\"tpl:h-9 tpl:w-full tpl:rounded-md tpl:border tpl:px-3 tpl:py-1 tpl:text-sm tpl:shadow-[var(--tpl-shadow-sm)] tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n >\n <option\n v-for=\"email in allowedRecipients\"\n :key=\"email\"\n :value=\"email\"\n >\n {{ email }}\n </option>\n </select>\n\n <!-- No allowlist: free text. -->\n <input\n v-else\n id=\"tpl-test-email-recipient\"\n v-model=\"recipient\"\n type=\"email\"\n autocomplete=\"email\"\n spellcheck=\"false\"\n :placeholder=\"t.testEmail.recipientPlaceholder\"\n :disabled=\"isSending || justSent\"\n data-testid=\"test-email-recipient\"\n class=\"tpl:h-9 tpl:w-full tpl:rounded-md tpl:border tpl:px-3 tpl:py-1 tpl:text-sm tpl:shadow-[var(--tpl-shadow-sm)] tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n />\n\n <p\n v-if=\"showShapeHint\"\n class=\"tpl:mt-1.5 tpl:text-xs tpl:text-[var(--tpl-text-dim)]\"\n >\n {{ t.testEmail.invalidAddress }}\n </p>\n </div>\n\n <!-- Preview header: a label plus the viewport switch. Always shown — the\n preview is part of the dialog rather than something to opt into. -->\n <div class=\"tpl:mb-2 tpl:shrink-0\">\n <!-- Two segmented controls: `flex-wrap` so they drop to their own row\n rather than crushing the label on a narrow viewport. -->\n <div\n class=\"tpl:flex tpl:flex-wrap tpl:items-center tpl:justify-between tpl:gap-2\"\n >\n <span\n class=\"tpl:text-sm tpl:font-medium tpl:text-[var(--tpl-text-muted)]\"\n >\n {{ t.testEmail.preview }}\n </span>\n\n <div class=\"tpl:flex tpl:items-center tpl:gap-2\">\n <MergeTagModeToggle\n v-if=\"!previewResolution.supersedesSamples.value\"\n :sample-mode=\"sampleMode\"\n @change=\"sampleMode = $event\"\n />\n\n <!-- Without this the preview would silently show only the desktop\n variant of any responsive block. -->\n <ViewportToggle\n :viewport=\"previewViewport\"\n @change=\"previewViewport = $event\"\n />\n </div>\n </div>\n\n <!-- The hint has to follow the mode: \"shown unresolved\" is simply false\n once every tag renders a realistic value. -->\n <p\n data-testid=\"test-email-preview-hint\"\n class=\"tpl:mt-1 tpl:text-xs tpl:text-[var(--tpl-text-dim)]\"\n >\n {{\n previewResolution.supersedesSamples.value\n ? t.previewResolution.hint\n : sampleMode\n ? t.testEmail.previewHintSample\n : t.testEmail.previewHint\n }}\n </p>\n </div>\n\n <!-- The one region allowed to grow: `flex-1 min-h-0` so it absorbs the\n remaining height and scrolls, leaving the form and actions fixed. -->\n <div\n id=\"tpl-test-email-preview\"\n data-testid=\"test-email-preview\"\n class=\"tpl:mb-3 tpl:min-h-0 tpl:flex-1 tpl:overflow-y-auto tpl:rounded-[var(--tpl-radius-sm)] tpl:p-3 tpl:bg-[var(--tpl-canvas-bg)]\"\n >\n <!-- Skeleton while a *first* resolve is outstanding, so the dialog never\n flashes the unresolved template before the resolved one. Sized like\n the frame it replaces to avoid a layout jump. A re-resolve (recipient\n change) keeps the previous result instead — `isInitialResolve`. -->\n <div\n v-if=\"previewResolution.isInitialResolve.value\"\n role=\"status\"\n aria-busy=\"true\"\n data-testid=\"preview-resolution-loading\"\n class=\"tpl:flex tpl:flex-col tpl:gap-3\"\n >\n <span class=\"tpl:sr-only\">{{ t.previewResolution.resolving }}</span>\n <div\n v-for=\"n in 3\"\n :key=\"n\"\n aria-hidden=\"true\"\n class=\"tpl-pulse tpl:h-16 tpl:rounded-md tpl:bg-[var(--tpl-bg-hover)]\"\n ></div>\n </div>\n\n <!-- Resolution failed: the unresolved template renders below, so the\n dialog degrades rather than showing an empty frame. -->\n <p\n v-else-if=\"previewResolution.hasFailed.value\"\n role=\"status\"\n data-testid=\"preview-resolution-failed\"\n class=\"tpl:mb-2 tpl:rounded-md tpl:px-3 tpl:py-2 tpl:text-xs tpl:text-[var(--tpl-text-muted)] tpl:bg-[var(--tpl-bg-hover)]\"\n >\n {{ t.previewResolution.failed }}\n </p>\n\n <BlockPreviewCanvas\n v-if=\"!previewResolution.isInitialResolve.value\"\n :blocks=\"previewBlocks\"\n :viewport=\"previewViewport\"\n :apply-condition-filter=\"!previewResolution.isConfigured\"\n />\n </div>\n\n <!-- Success. Announced politely; the dialog closes itself shortly after. -->\n <p\n v-if=\"justSent\"\n role=\"status\"\n data-testid=\"test-email-success\"\n class=\"tpl:mb-3 tpl:flex tpl:shrink-0 tpl:items-center tpl:gap-1.5 tpl:text-xs tpl:text-[var(--tpl-success,var(--tpl-primary))]\"\n >\n <Check :size=\"13\" :stroke-width=\"2.5\" />\n {{ t.testEmail.success }}\n </p>\n\n <!-- Failure. Stays open so the user can retry. -->\n <p\n v-else-if=\"errorText\"\n role=\"alert\"\n data-testid=\"test-email-error\"\n class=\"tpl:mb-3 tpl:shrink-0 tpl:text-xs tpl:text-[var(--tpl-danger)]\"\n >\n {{ errorText }}\n </p>\n\n <!-- Actions -->\n <div class=\"tpl:flex tpl:shrink-0 tpl:justify-end tpl:gap-2\">\n <button\n type=\"button\"\n data-testid=\"test-email-cancel\"\n class=\"tpl:cursor-pointer tpl:rounded-md tpl:border tpl:px-3 tpl:py-1.5 tpl:text-sm tpl:font-medium tpl:shadow-[var(--tpl-shadow-sm)] tpl:transition-all tpl:border-[var(--tpl-border)] tpl:text-[var(--tpl-text)] tpl:bg-[var(--tpl-bg)]\"\n :disabled=\"isSending\"\n :class=\"{ 'tpl:cursor-not-allowed tpl:opacity-50': isSending }\"\n @click=\"handleClose\"\n >\n {{ t.testEmail.cancel }}\n </button>\n <button\n type=\"button\"\n data-testid=\"test-email-send\"\n :class=\"primaryBtnClass\"\n :disabled=\"!canSend\"\n @click=\"handleSend\"\n >\n <span v-if=\"isSending\" class=\"tpl:flex tpl:items-center tpl:gap-1.5\">\n <LoaderCircle\n class=\"tpl:animate-spin\"\n :size=\"12\"\n :stroke-width=\"2\"\n />\n {{ t.testEmail.sending }}\n </span>\n <span v-else>\n {{ t.testEmail.send }}\n </span>\n </button>\n </div>\n </div>\n </TplModal>\n</template>\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"TplModal-DSTVZKuI.js","names":[],"sources":["../../../src/components/TplModal.vue","../../../src/components/TplModal.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { useFocusTrap } from \"../composables\";\nimport { usePopoverRoot } from \"../composables/usePopoverRoot\";\nimport { THEME_STYLES_KEY, UI_THEME_KEY } from \"../keys\";\nimport { computed, inject, ref } from \"vue\";\n\nconst props = defineProps<{\n visible: boolean;\n}>();\n\nconst emit = defineEmits<{\n (e: \"close\"): void;\n (e: \"keydown\", event: KeyboardEvent): void;\n}>();\n\nconst dialogRef = ref<HTMLElement | null>(null);\nconst isVisible = computed(() => props.visible);\nuseFocusTrap(dialogRef, isVisible);\n\nconst tplUiTheme = inject(UI_THEME_KEY);\n\n// The backdrop below carries the bare `tpl` class, which re-declares every\n// `--tpl-*` token from the base `.tpl` rule. A custom property declared on an\n// element beats one inherited from an ancestor, so that re-declaration shadows\n// the consumer's `theme` config — which lives as inline styles on the editor\n// root — for this whole teleported subtree. Re-applying `themeStyles` here is\n// what restores it, and doing it on the backdrop covers every dialog rendered\n// through this wrapper rather than each one remembering to. `data-tpl-theme`\n// does the same job for the dark token block. See the invariant in\n// `tests/theme-token-scope.test.ts` (issue #487).\nconst themeStyles = inject(THEME_STYLES_KEY, null);\nconst popoverRoot = usePopoverRoot();\n\nfunction handleKeydown(event: KeyboardEvent): void {\n if (event.key === \"Escape\") {\n emit(\"close\");\n }\n emit(\"keydown\", event);\n}\n</script>\n\n<template>\n <Teleport v-if=\"popoverRoot\" :to=\"popoverRoot\">\n <Transition\n enter-active-class=\"tpl:transition\"\n enter-from-class=\"tpl:opacity-0\"\n enter-to-class=\"tpl:opacity-100\"\n leave-active-class=\"tpl:transition tpl:duration-100\"\n leave-from-class=\"tpl:opacity-100\"\n leave-to-class=\"tpl:opacity-0\"\n >\n <div\n v-if=\"visible\"\n :data-tpl-theme=\"tplUiTheme\"\n class=\"tpl tpl:fixed tpl:inset-0 tpl:z-modal tpl:flex tpl:items-center tpl:justify-center\"\n style=\"\n background-color: var(--tpl-overlay);\n backdrop-filter: blur(8px);\n -webkit-backdrop-filter: blur(8px);\n \"\n :style=\"themeStyles\"\n @click.self=\"emit('close')\"\n @keydown=\"handleKeydown\"\n >\n <div ref=\"dialogRef\">\n <slot />\n </div>\n </div>\n </Transition>\n </Teleport>\n</template>\n","<script setup lang=\"ts\">\nimport { useFocusTrap } from \"../composables\";\nimport { usePopoverRoot } from \"../composables/usePopoverRoot\";\nimport { THEME_STYLES_KEY, UI_THEME_KEY } from \"../keys\";\nimport { computed, inject, ref } from \"vue\";\n\nconst props = defineProps<{\n visible: boolean;\n}>();\n\nconst emit = defineEmits<{\n (e: \"close\"): void;\n (e: \"keydown\", event: KeyboardEvent): void;\n}>();\n\nconst dialogRef = ref<HTMLElement | null>(null);\nconst isVisible = computed(() => props.visible);\nuseFocusTrap(dialogRef, isVisible);\n\nconst tplUiTheme = inject(UI_THEME_KEY);\n\n// The backdrop below carries the bare `tpl` class, which re-declares every\n// `--tpl-*` token from the base `.tpl` rule. A custom property declared on an\n// element beats one inherited from an ancestor, so that re-declaration shadows\n// the consumer's `theme` config — which lives as inline styles on the editor\n// root — for this whole teleported subtree. Re-applying `themeStyles` here is\n// what restores it, and doing it on the backdrop covers every dialog rendered\n// through this wrapper rather than each one remembering to. `data-tpl-theme`\n// does the same job for the dark token block. See the invariant in\n// `tests/theme-token-scope.test.ts` (issue #487).\nconst themeStyles = inject(THEME_STYLES_KEY, null);\nconst popoverRoot = usePopoverRoot();\n\nfunction handleKeydown(event: KeyboardEvent): void {\n if (event.key === \"Escape\") {\n emit(\"close\");\n }\n emit(\"keydown\", event);\n}\n</script>\n\n<template>\n <Teleport v-if=\"popoverRoot\" :to=\"popoverRoot\">\n <Transition\n enter-active-class=\"tpl:transition\"\n enter-from-class=\"tpl:opacity-0\"\n enter-to-class=\"tpl:opacity-100\"\n leave-active-class=\"tpl:transition tpl:duration-100\"\n leave-from-class=\"tpl:opacity-100\"\n leave-to-class=\"tpl:opacity-0\"\n >\n <div\n v-if=\"visible\"\n :data-tpl-theme=\"tplUiTheme\"\n class=\"tpl tpl:fixed tpl:inset-0 tpl:z-modal tpl:flex tpl:items-center tpl:justify-center\"\n style=\"\n background-color: var(--tpl-overlay);\n backdrop-filter: blur(8px);\n -webkit-backdrop-filter: blur(8px);\n \"\n :style=\"themeStyles\"\n @click.self=\"emit('close')\"\n @keydown=\"handleKeydown\"\n >\n <div ref=\"dialogRef\">\n <slot />\n </div>\n </div>\n </Transition>\n </Teleport>\n</template>\n"],"mappings":";;;;;;;;;;EAMA,IAAM,IAAQ,GAIR,IAAO,GAKP,IAAY,EAAwB,IAAI,GACxC,IAAY,QAAe,EAAM,OAAO;EAC9C,EAAa,GAAW,CAAS;EAEjC,IAAM,IAAa,EAAO,CAAY,GAWhC,IAAc,EAAO,GAAkB,IAAI,GAC3C,IAAc,EAAe;EAEnC,SAAS,EAAc,GAA4B;GAIjD,AAHI,EAAM,QAAQ,YAChB,EAAK,OAAO,GAEd,EAAK,WAAW,CAAK;EACvB;mBAIkB,EAAA,CAAA,KAAhB,EAAA,GAAA,EA2BW,GAAA;;GA3BmB,IAAI,EAAA,CAAA;EAChC,GAAA,CAAA,EAyBa,GAAA;GAxBX,sBAAmB;GACnB,oBAAiB;GACjB,kBAAe;GACf,sBAAmB;GACnB,oBAAiB;GACjB,kBAAe;;GAEf,SAAA,QAgBM,CAfE,EAAA,WADR,EAAA,GAAA,EAgBM,OAAA;;IAdH,kBAAgB,EAAA,CAAA;IACjB,OAAM;IACN,OAIC,EAAA,CAJD;KAAA,oBAAA;KAAA,mBAAA;KAAA,2BAAA;IAAA,GAKQ,EAAA,CAAA,CAAW,CAAA;IAClB,SAAK,AAAA,EAAA,OAAA,GAAA,MAAO,EAAI,OAAA,GAAA,CAAA,MAAA,CAAA;IAChB,WAAS;GAEV,GAAA,CAAA,EAEM,OAAA;IAFG,SAAA;IAAJ,KAAI;GACP,GAAA,CAAA,EAAQ,EAAA,QAAA,SAAA,CAAA,GAAA,GAAA,CAAA,GAAA,IAAA,CAAA,KAAA,EAAA,IAAA,EAAA,CAAA,CAAA"}