@leavepulse/ui 0.81.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/dist/assets/LpVideoTrim-BcGs3Sh0.css +8 -0
  2. package/dist/assets/VideoFlash-VuPkC7QV.css +23 -0
  3. package/dist/chunks/{ContextMenuItems-DLK4q-mm.js → ContextMenuItems-h-apPY2d.js} +1 -1
  4. package/dist/chunks/{DropzoneLinkField-CcJu8De3.js → DropzoneLinkField-_Op8b_ee.js} +1 -1
  5. package/dist/chunks/{FieldLeading-CldGLINn.js → FieldLeading-mOMcwNqi.js} +1 -1
  6. package/dist/chunks/{FieldWrapper-Cj8ZgoKp.js → FieldWrapper-DtT-AIcn.js} +1 -1
  7. package/dist/chunks/{IconCloseButton-CV4iHRpy.js → IconCloseButton-YmisskyZ.js} +1 -1
  8. package/dist/chunks/{LayoutCanvas-BQbq7Ekw.js → LayoutCanvas-BrISZEBr.js} +1 -1
  9. package/dist/chunks/{LayoutNode-BMedRk3g.js → LayoutNode-CwMQ8ACa.js} +1 -1
  10. package/dist/chunks/{LpActionBar-C7g52Cyo.js → LpActionBar-CVVC6YbC.js} +1 -1
  11. package/dist/chunks/{LpAddressInput-1uakhQk5.js → LpAddressInput-tiuJ-bru.js} +7 -7
  12. package/dist/chunks/{LpAlert-DWFeR82L.js → LpAlert-8-x4H_Nk.js} +1 -1
  13. package/dist/chunks/{LpAppShell-BBINfN1v.js → LpAppShell-BW2YMq-A.js} +4 -4
  14. package/dist/chunks/{LpAutocomplete-DY3LvpB9.js → LpAutocomplete-BZODIops.js} +4 -4
  15. package/dist/chunks/{LpAvatar-CEZmMcGh.js → LpAvatar-BZq4nH53.js} +1 -1
  16. package/dist/chunks/{LpBadge-C_VqLHtg.js → LpBadge-Cz0pSSUu.js} +1 -1
  17. package/dist/chunks/{LpBottomNav-4I89sPDZ.js → LpBottomNav-DVdR5Mry.js} +1 -1
  18. package/dist/chunks/{LpBreadcrumbs-1BBzrfxG.js → LpBreadcrumbs-B2jphAel.js} +1 -1
  19. package/dist/chunks/{LpCalendar-D2dcXjUK.js → LpCalendar-C0_N5LVT.js} +1 -1
  20. package/dist/chunks/{LpCheckbox-CN-MEnbX.js → LpCheckbox-CwqLNdw4.js} +2 -2
  21. package/dist/chunks/{LpCodeBlock-Cj7zk4QE.js → LpCodeBlock-Dcss6bnl.js} +2 -2
  22. package/dist/chunks/{LpCodeDisplay-COzosm77.js → LpCodeDisplay-CLms1zDY.js} +4 -4
  23. package/dist/chunks/{LpColorPicker-BezW6DX1.js → LpColorPicker-CbIY450X.js} +3 -3
  24. package/dist/chunks/{LpConfirmDialog-D9GkDMYr.js → LpConfirmDialog-RmP4z6va.js} +2 -2
  25. package/dist/chunks/{LpConfirmHost-BH7kf0dm.js → LpConfirmHost-DheBgRMB.js} +1 -1
  26. package/dist/chunks/{LpContextMenu-bn5bc9t7.js → LpContextMenu-DIYhlap8.js} +2 -2
  27. package/dist/chunks/{LpContextSwitcher-CGr6ZAuq.js → LpContextSwitcher-fDA6ERl-.js} +2 -2
  28. package/dist/chunks/{LpCopyButton-BSr6YaFR.js → LpCopyButton-DSqKFGEb.js} +1 -1
  29. package/dist/chunks/{LpDataList-Cv7ZUlyy.js → LpDataList-f9UjSx9m.js} +2 -2
  30. package/dist/chunks/{LpDatePicker-CR7vsb5M.js → LpDatePicker-3kDQLVbg.js} +5 -5
  31. package/dist/chunks/{LpDateRangePicker-B9fRO3G6.js → LpDateRangePicker-DUPiTt0_.js} +3 -3
  32. package/dist/chunks/{LpDisclosure-CE4mneaE.js → LpDisclosure-C5WZjhbQ.js} +1 -1
  33. package/dist/chunks/{LpDrawer-DU_5MqBz.js → LpDrawer-CkYvIGIa.js} +1 -1
  34. package/dist/chunks/{LpDropdownMenu-_u6GPWhp.js → LpDropdownMenu-zSsK2Xra.js} +6 -3
  35. package/dist/chunks/{LpEditable-DYoMayjt.js → LpEditable-CP7Y_lKg.js} +2 -2
  36. package/dist/chunks/{LpEmptyState-DpdHeSfz.js → LpEmptyState-dsc5x-ya.js} +1 -1
  37. package/dist/chunks/{LpFieldNotice-DONFCWV7.js → LpFieldNotice-TOTbWvgv.js} +1 -1
  38. package/dist/chunks/{LpFileTree-f5ktGqsN.js → LpFileTree-BPjnOTln.js} +4 -4
  39. package/dist/chunks/{LpFileTreeNode-nbp-JwV9.js → LpFileTreeNode-_-MiWmie.js} +4 -4
  40. package/dist/chunks/{LpFormField-Bz50_LZW.js → LpFormField-BJwD1iEF.js} +1 -1
  41. package/dist/chunks/{LpHintLayer-B6bA5Cb_.js → LpHintLayer-CmfGnxYu.js} +1 -1
  42. package/dist/chunks/{LpHints-DyJ5hVcr.js → LpHints-C2Xvc1Fd.js} +3 -3
  43. package/dist/chunks/{LpIcon-BCQLuHSP.js → LpIcon-CoOLz1oJ.js} +1 -1
  44. package/dist/chunks/{LpInfraNode-BS1RXYAs.js → LpInfraNode-CW3qsM-U.js} +2 -2
  45. package/dist/chunks/{LpInput-BjbXdjcW.js → LpInput-BVWhkPuN.js} +3 -3
  46. package/dist/chunks/{LpInspector-BIyJyM6r.js → LpInspector-REmoX_es.js} +4 -4
  47. package/dist/chunks/{LpLightbox-CSAJuUaJ.js → LpLightbox-C3FFTYP5.js} +65 -20
  48. package/dist/chunks/{LpLogInspector-OJEz1vod.js → LpLogInspector-Cs8Z_fM0.js} +2 -2
  49. package/dist/chunks/{LpLogViewer-CRLwQpk0.js → LpLogViewer-DQRGe-ZK.js} +6 -6
  50. package/dist/chunks/{LpMapPicker-DW2EnrCJ.js → LpMapPicker-CQnQPgKm.js} +1 -1
  51. package/dist/chunks/{LpMediaGallery-BNEYctDP.js → LpMediaGallery-DkKqTy9P.js} +20 -8
  52. package/dist/chunks/{LpModal-DdTqdf4x.js → LpModal-DwejR7VP.js} +1 -1
  53. package/dist/chunks/{LpModalNav-BL7Dp0om.js → LpModalNav-D1-dSe6f.js} +3 -3
  54. package/dist/chunks/{LpNotificationBell-d0F6MWxD.js → LpNotificationBell-EKLkN0sp.js} +4 -4
  55. package/dist/chunks/{LpNotificationFeed-BIe5lapb.js → LpNotificationFeed-Dc-yQ4NH.js} +4 -4
  56. package/dist/chunks/{LpNumberField-D5Dx-hzD.js → LpNumberField-DvUjEMp-.js} +3 -3
  57. package/dist/chunks/{LpOtpInput-BVl74Lfl.js → LpOtpInput-xvDO14f4.js} +1 -1
  58. package/dist/chunks/{LpPagination-D0wb-Uru.js → LpPagination-BFZY8dFn.js} +1 -1
  59. package/dist/chunks/{LpPaletteTheme-DdilCBXA.js → LpPaletteTheme-EBJv0Bwz.js} +2 -2
  60. package/dist/chunks/{LpPasswordInput-DUtqWMkA.js → LpPasswordInput-DRJR3fF1.js} +2 -2
  61. package/dist/chunks/{LpPhoneInput-CalRjkqc.js → LpPhoneInput-BGOOtUoW.js} +3 -3
  62. package/dist/chunks/{LpPie-BVAEmVBT.js → LpPie-Deyn8h5J.js} +2 -2
  63. package/dist/chunks/{LpPipeline-Dk7_sRP1.js → LpPipeline-9ChjeNh5.js} +2 -2
  64. package/dist/chunks/{LpQuota-DKx0IZYA.js → LpQuota-CNh5eF_p.js} +1 -1
  65. package/dist/chunks/{LpRadioGroup-E3SP6j9h.js → LpRadioGroup-Br2Nl9Gr.js} +2 -2
  66. package/dist/chunks/{LpRangeCalendar-BFJFaen4.js → LpRangeCalendar-D66Y3U_w.js} +1 -1
  67. package/dist/chunks/{LpRangeSlider-b6ipywRB.js → LpRangeSlider-eX-fIlOw.js} +1 -1
  68. package/dist/chunks/{LpRepeater-BLrw62Bh.js → LpRepeater-0XvnlVd_.js} +2 -2
  69. package/dist/chunks/{LpSecretField-BV8BNjKc.js → LpSecretField-1RCooChq.js} +5 -5
  70. package/dist/chunks/{LpSection-CghNNxIv.js → LpSection-B9gbJJcP.js} +1 -1
  71. package/dist/chunks/{LpSegmented-DjvrHL_G.js → LpSegmented-hIjGzGTo.js} +2 -2
  72. package/dist/chunks/{LpSelect-DYPimzAZ.js → LpSelect-DugubwTc.js} +4 -4
  73. package/dist/chunks/{LpSidebar-D3S8rYFf.js → LpSidebar-DevSoN8W.js} +4 -4
  74. package/dist/chunks/{LpSidebarNav-Dlse8vxT.js → LpSidebarNav-DwQQ3KXe.js} +3 -3
  75. package/dist/chunks/{LpSidebarRow-Cqd_6tGG.js → LpSidebarRow-CAGgtJCS.js} +3 -3
  76. package/dist/chunks/{LpSlider-B9XIC75O.js → LpSlider-1fHpVxpB.js} +1 -1
  77. package/dist/chunks/{LpStat-kyLRmX-m.js → LpStat-Cm9-FaWQ.js} +2 -2
  78. package/dist/chunks/{LpStatus-szB2dWzM.js → LpStatus-CE8FSAT1.js} +1 -1
  79. package/dist/chunks/{LpStepMark-DP3CDSm9.js → LpStepMark-BWSlV69R.js} +1 -1
  80. package/dist/chunks/{LpStepper-BS3iaJIz.js → LpStepper-1D0ZA7Nx.js} +1 -1
  81. package/dist/chunks/{LpSwitch-DRFy8RNr.js → LpSwitch-D_kvE0oQ.js} +1 -1
  82. package/dist/chunks/{LpTabFace-C9-OperL.js → LpTabFace-CpbFKi-N.js} +2 -2
  83. package/dist/chunks/{LpTableFilter-CEIwLupk.js → LpTableFilter-BYHyCu38.js} +2 -2
  84. package/dist/chunks/{LpTagsInput-ClDeF_aU.js → LpTagsInput-Cqm38t5N.js} +5 -5
  85. package/dist/chunks/{LpTextarea-DEY6pUBy.js → LpTextarea-DaSKBkiV.js} +1 -1
  86. package/dist/chunks/{LpThemeStudio-WY72Z1Db.js → LpThemeStudio-BNdfi-Ds.js} +5 -5
  87. package/dist/chunks/{LpThemeSwitcher-9uCH9c-K.js → LpThemeSwitcher-CbC1nqBP.js} +3 -3
  88. package/dist/chunks/{LpTimeField-BmgxCzQO.js → LpTimeField-CCfPCWPT.js} +2 -2
  89. package/dist/chunks/{LpToaster-DLKO-efn.js → LpToaster-BhlI7h-h.js} +2 -2
  90. package/dist/chunks/{LpTopologyCanvas-B-PhPa7k.js → LpTopologyCanvas-DlKbu7E5.js} +4 -4
  91. package/dist/chunks/{LpTour-ODlBcat2.js → LpTour-CSLerA1B.js} +1 -1
  92. package/dist/chunks/{LpTruncate--nhT0iUo.js → LpTruncate-Ct26BSYB.js} +1 -1
  93. package/dist/chunks/{LpVideoBackdrop-Ct4St4ZL.js → LpVideoBackdrop-57i8RY8g.js} +1 -2
  94. package/dist/chunks/LpVideoPlayer-DcWd9DDW.js +813 -0
  95. package/dist/chunks/{LpWindow-DJxE8Zl-.js → LpWindow-YnF_knSj.js} +2 -2
  96. package/dist/chunks/{LpWorkspaceSwitcher-Bpf41Bw5.js → LpWorkspaceSwitcher-B-5UU6o4.js} +3 -3
  97. package/dist/chunks/{MenuTitle-CMjvLsvC.js → MenuTitle-BnZbFSwq.js} +1 -1
  98. package/dist/chunks/{OptionContent-DY8NaG8s.js → OptionContent-D5e_eklA.js} +1 -1
  99. package/dist/chunks/{PickerField-CgfkaBgg.js → PickerField-KpHiowNI.js} +1 -1
  100. package/dist/chunks/{RevealToggle-CPMXxaKv.js → RevealToggle-CE1hAIJB.js} +1 -1
  101. package/dist/chunks/SteadyText-CwH-kduj.js +25 -0
  102. package/dist/chunks/{TourCard-D4b3lMHi.js → TourCard-t2csiTDS.js} +1 -1
  103. package/dist/chunks/VideoThumb-Csf9bUz3.js +67 -0
  104. package/dist/chunks/VideoVolume-B4_DNmNi.js +68 -0
  105. package/dist/chunks/icons-CdSlCrle.js +59 -0
  106. package/dist/component-names.d.ts +1 -1
  107. package/dist/components/ContextMenuItems.vue.js +1 -1
  108. package/dist/components/DropzoneLinkField.vue.js +1 -1
  109. package/dist/components/FieldLeading.vue.js +1 -1
  110. package/dist/components/FieldWrapper.vue.js +1 -1
  111. package/dist/components/IconCloseButton.vue.js +1 -1
  112. package/dist/components/LayoutCanvas.vue.js +1 -1
  113. package/dist/components/LayoutNode.vue.js +1 -1
  114. package/dist/components/LpActionBar.vue.js +1 -1
  115. package/dist/components/LpAddressInput.vue.js +1 -1
  116. package/dist/components/LpAlert.vue.js +1 -1
  117. package/dist/components/LpAppShell.vue.js +1 -1
  118. package/dist/components/LpAutocomplete.vue.js +1 -1
  119. package/dist/components/LpAvatar.vue.js +1 -1
  120. package/dist/components/LpBadge.vue.js +1 -1
  121. package/dist/components/LpBottomNav.vue.js +1 -1
  122. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  123. package/dist/components/LpCalendar.vue.js +1 -1
  124. package/dist/components/LpChart.vue.js +2 -2
  125. package/dist/components/LpCheckbox.vue.js +1 -1
  126. package/dist/components/LpCodeBlock.vue.js +1 -1
  127. package/dist/components/LpCodeDisplay.vue.js +1 -1
  128. package/dist/components/LpColorPicker.vue.js +1 -1
  129. package/dist/components/LpCommandPalette.vue.js +2 -2
  130. package/dist/components/LpConfirmDialog.vue.js +1 -1
  131. package/dist/components/LpConfirmHost.vue.js +1 -1
  132. package/dist/components/LpContextMenu.vue.js +1 -1
  133. package/dist/components/LpContextSwitcher.vue.js +1 -1
  134. package/dist/components/LpCopyButton.vue.js +1 -1
  135. package/dist/components/LpDataList.vue.js +1 -1
  136. package/dist/components/LpDatePicker.vue.js +1 -1
  137. package/dist/components/LpDateRangePicker.vue.js +1 -1
  138. package/dist/components/LpDisclosure.vue.js +1 -1
  139. package/dist/components/LpDock.vue.js +1 -1
  140. package/dist/components/LpDrawer.vue.js +1 -1
  141. package/dist/components/LpDropdownMenu.vue.d.ts +5 -1
  142. package/dist/components/LpDropdownMenu.vue.js +1 -1
  143. package/dist/components/LpDropzone.vue.js +2 -2
  144. package/dist/components/LpEditable.vue.js +1 -1
  145. package/dist/components/LpEmptyState.vue.js +1 -1
  146. package/dist/components/LpFieldNotice.vue.js +1 -1
  147. package/dist/components/LpFileTree.vue.js +1 -1
  148. package/dist/components/LpFileTreeNode.vue.js +1 -1
  149. package/dist/components/LpFormField.vue.js +1 -1
  150. package/dist/components/LpHintLayer.vue.js +1 -1
  151. package/dist/components/LpHints.vue.js +1 -1
  152. package/dist/components/LpIcon.vue.js +1 -1
  153. package/dist/components/LpImageEditor.vue.js +1 -1
  154. package/dist/components/LpInfraNode.vue.js +1 -1
  155. package/dist/components/LpInput.vue.js +1 -1
  156. package/dist/components/LpInspector.vue.js +1 -1
  157. package/dist/components/LpLightbox.vue.js +1 -1
  158. package/dist/components/LpLogInspector.vue.js +1 -1
  159. package/dist/components/LpLogViewer.vue.js +1 -1
  160. package/dist/components/LpMapPicker.vue.js +1 -1
  161. package/dist/components/LpMediaGallery.vue.d.ts +1 -1
  162. package/dist/components/LpMediaGallery.vue.js +1 -1
  163. package/dist/components/LpModal.vue.js +1 -1
  164. package/dist/components/LpModalNav.vue.js +1 -1
  165. package/dist/components/LpNotificationBell.vue.js +1 -1
  166. package/dist/components/LpNotificationFeed.vue.js +1 -1
  167. package/dist/components/LpNumberField.vue.js +1 -1
  168. package/dist/components/LpOtpInput.vue.js +1 -1
  169. package/dist/components/LpPagination.vue.js +1 -1
  170. package/dist/components/LpPaletteFacts.vue.js +1 -1
  171. package/dist/components/LpPaletteHeader.vue.js +2 -2
  172. package/dist/components/LpPaletteTheme.vue.js +1 -1
  173. package/dist/components/LpPasswordInput.vue.js +1 -1
  174. package/dist/components/LpPhoneInput.vue.js +1 -1
  175. package/dist/components/LpPie.vue.js +1 -1
  176. package/dist/components/LpPipeline.vue.js +1 -1
  177. package/dist/components/LpQuota.vue.js +1 -1
  178. package/dist/components/LpRadioGroup.vue.js +1 -1
  179. package/dist/components/LpRangeCalendar.vue.js +1 -1
  180. package/dist/components/LpRangeSlider.vue.js +1 -1
  181. package/dist/components/LpRepeater.vue.js +1 -1
  182. package/dist/components/LpSecretField.vue.js +1 -1
  183. package/dist/components/LpSection.vue.js +1 -1
  184. package/dist/components/LpSegmented.vue.js +1 -1
  185. package/dist/components/LpSelect.vue.js +1 -1
  186. package/dist/components/LpShortcutsDialog.vue.js +1 -1
  187. package/dist/components/LpSidebar.vue.js +1 -1
  188. package/dist/components/LpSidebarNav.vue.js +1 -1
  189. package/dist/components/LpSidebarRow.vue.js +1 -1
  190. package/dist/components/LpSlider.vue.js +1 -1
  191. package/dist/components/LpStat.vue.js +1 -1
  192. package/dist/components/LpStatus.vue.js +1 -1
  193. package/dist/components/LpStepMark.vue.js +1 -1
  194. package/dist/components/LpStepper.vue.js +1 -1
  195. package/dist/components/LpSwitch.vue.js +1 -1
  196. package/dist/components/LpTabFace.vue.js +1 -1
  197. package/dist/components/LpTable.vue.js +9 -9
  198. package/dist/components/LpTableFilter.vue.js +1 -1
  199. package/dist/components/LpTabs.vue.js +2 -2
  200. package/dist/components/LpTagsInput.vue.js +1 -1
  201. package/dist/components/LpTextarea.vue.js +1 -1
  202. package/dist/components/LpThemeStudio.vue.js +1 -1
  203. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  204. package/dist/components/LpTimeField.vue.js +1 -1
  205. package/dist/components/LpToaster.vue.js +1 -1
  206. package/dist/components/LpTopologyCanvas.vue.js +1 -1
  207. package/dist/components/LpTour.vue.js +1 -1
  208. package/dist/components/LpTruncate.vue.js +1 -1
  209. package/dist/components/LpVideoBackdrop.vue.js +1 -1
  210. package/dist/components/LpVideoPlayer.vue.d.ts +111 -0
  211. package/dist/components/LpVideoPlayer.vue.js +4 -0
  212. package/dist/components/LpVideoTrim.vue.d.ts +82 -0
  213. package/dist/components/LpVideoTrim.vue.js +1106 -0
  214. package/dist/components/LpWindow.vue.js +1 -1
  215. package/dist/components/LpWorkspaceSwitcher.vue.js +1 -1
  216. package/dist/components/MenuTitle.vue.js +1 -1
  217. package/dist/components/OptionContent.vue.js +1 -1
  218. package/dist/components/PickerField.vue.js +1 -1
  219. package/dist/components/RevealToggle.vue.js +1 -1
  220. package/dist/components/SteadyText.vue.d.ts +7 -0
  221. package/dist/components/SteadyText.vue.js +4 -0
  222. package/dist/components/TourCard.vue.js +1 -1
  223. package/dist/components/VideoFlash.vue.d.ts +10 -0
  224. package/dist/components/VideoFlash.vue.js +33 -0
  225. package/dist/components/VideoThumb.vue.d.ts +12 -0
  226. package/dist/components/VideoThumb.vue.js +4 -0
  227. package/dist/components/VideoVolume.vue.d.ts +15 -0
  228. package/dist/components/VideoVolume.vue.js +4 -0
  229. package/dist/components/chartWindow.js +4 -8
  230. package/dist/components/lightbox.d.ts +13 -0
  231. package/dist/components/lightbox.js +11 -1
  232. package/dist/components/menuWhen.js +1 -1
  233. package/dist/components/videoBackdrop.d.ts +15 -2
  234. package/dist/components/videoBackdrop.js +8 -2
  235. package/dist/components/videoFrames.d.ts +37 -0
  236. package/dist/components/videoFrames.js +134 -0
  237. package/dist/components/videoMotion.d.ts +28 -0
  238. package/dist/components/videoMotion.js +26 -0
  239. package/dist/components/videoTrim.d.ts +68 -0
  240. package/dist/components/videoTrim.js +121 -0
  241. package/dist/composables/span.d.ts +10 -0
  242. package/dist/composables/span.js +15 -0
  243. package/dist/composables/useVideoMedia.d.ts +74 -0
  244. package/dist/composables/useVideoMedia.js +291 -0
  245. package/dist/icons-all.js +1 -1
  246. package/dist/index.d.ts +7 -1
  247. package/dist/index.js +186 -174
  248. package/package.json +1 -1
  249. package/src/component-names.ts +2 -0
  250. package/src/components/LpDropdownMenu.vue +4 -0
  251. package/src/components/LpLightbox.vue +61 -8
  252. package/src/components/LpMediaGallery.vue +19 -2
  253. package/src/components/LpVideoBackdrop.vue +1 -7
  254. package/src/components/LpVideoPlayer.vue +774 -0
  255. package/src/components/LpVideoTrim.vue +1066 -0
  256. package/src/components/SteadyText.vue +21 -0
  257. package/src/components/VideoFlash.vue +52 -0
  258. package/src/components/VideoThumb.vue +66 -0
  259. package/src/components/VideoVolume.vue +63 -0
  260. package/src/components/chartWindow.ts +4 -8
  261. package/src/components/lightbox.ts +23 -0
  262. package/src/components/videoBackdrop.ts +20 -3
  263. package/src/components/videoFrames.ts +171 -0
  264. package/src/components/videoMotion.ts +38 -0
  265. package/src/components/videoTrim.ts +186 -0
  266. package/src/composables/span.ts +21 -0
  267. package/src/composables/useVideoMedia.ts +411 -0
  268. package/src/index.ts +7 -1
  269. package/src/kit-icons.ts +1 -1
  270. package/dist/chunks/icons-C1vppR4f.js +0 -59
@@ -0,0 +1,1066 @@
1
+ <script lang="ts">
2
+ export default { inheritAttrs: false }
3
+ </script>
4
+
5
+ <script setup lang="ts">
6
+ /*
7
+ * Pick a stretch of a video, and optionally a rectangle on its picture, for a
8
+ * server to cut (ffmpeg) or blur. Nothing is cut here: the component reports
9
+ * `[start, end]` in seconds and a region in fractions of the frame.
10
+ *
11
+ * <LpVideoTrim v-model:range="range" :src="url" :max-clip="60" @change="save" />
12
+ *
13
+ * - The video plays from its address the way a <video> does: by range
14
+ * requests, never downloaded whole, so an hour or a gigabyte costs what is
15
+ * watched. A same-origin address keeps the session cookie (no `crossorigin`
16
+ * is set: it would drop it), and the timeline's stills need no CORS either.
17
+ * - The timeline zooms (wheel, pinch, the buttons) down to a few seconds
18
+ * across, so an hour-long recording still trims to the frame; when zoomed, a
19
+ * thin overview under it shows and moves the visible part.
20
+ * - Keys, wherever focus is inside: Space plays, ←/→ step a frame (Shift: a
21
+ * second), I and O set the start and end at the playhead. On a focused
22
+ * handle the arrows move that handle.
23
+ * - `update:range` follows every move; `change` fires once a move is done (a
24
+ * handle let go, a key, a typed time), for the save.
25
+ * - `maxClip` and `minClip` bound the length: a dragged handle stops at the
26
+ * limit, while I, O and a typed time move the other end along.
27
+ * - `#actions` puts the app's buttons over the picture's top right corner (a
28
+ * bin, a download), with `buttonClass` to look like the editor's own.
29
+ */
30
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"
31
+ import { startPointerDrag } from "../composables/pointerDrag"
32
+ import { isHTMLElement, nodeWindow } from "../composables/realm"
33
+ import { useLabels } from "../composables/useLpConfig"
34
+ import { useMergedAttrs } from "../composables/useMergedClass"
35
+ import { useResizeObserver } from "../composables/useResizeObserver"
36
+ import { usePrefersReducedMotion } from "../composables/useReducedMotion"
37
+ import { useVideoMedia } from "../composables/useVideoMedia"
38
+ import LpButton from "./LpButton.vue"
39
+ import LpIcon from "./LpIcon.vue"
40
+ import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
41
+ import LpInput from "./LpInput.vue"
42
+ import SteadyText from "./SteadyText.vue"
43
+ import VideoFlash from "./VideoFlash.vue"
44
+ import VideoVolume from "./VideoVolume.vue"
45
+ import { FADE, FADE_SCALE, ICON_SWAP, OVERLAY_BUTTON } from "./videoMotion"
46
+ import { useClipboard } from "../composables/useClipboard"
47
+ import { useToast } from "../composables/useToast"
48
+ import { formatClipTime, widestClipTime } from "./videoBackdrop"
49
+ import { FrameSampler, stillSpan } from "./videoFrames"
50
+ import {
51
+ adjustRegion,
52
+ containRect,
53
+ dragEnd,
54
+ normalizeRange,
55
+ parseClipTime,
56
+ regionBetween,
57
+ revealTime,
58
+ setEnd,
59
+ shiftView,
60
+ tickStep,
61
+ ticks,
62
+ zoomView,
63
+ type FrameRegion,
64
+ type RegionHandle,
65
+ type TimeRange,
66
+ type TimeView,
67
+ type VideoTrimErrorCause,
68
+ } from "./videoTrim"
69
+
70
+ const props = withDefaults(
71
+ defineProps<{
72
+ src: string
73
+ poster?: string
74
+ /** `[start, end]` in seconds; the whole video (or the first `maxClip`) when unset. */
75
+ range?: TimeRange | null
76
+ /** The longest clip allowed, in seconds (a chat's upload limit). */
77
+ maxClip?: number
78
+ /** The shortest clip allowed, in seconds. */
79
+ minClip?: number
80
+ /** Lets a rectangle be drawn on the picture (`v-model:region`), for a part to blur. */
81
+ regionSelect?: boolean
82
+ /** The marked rectangle, fractions of the frame (0..1); null when none. */
83
+ region?: FrameRegion | null
84
+ /** Frames per second, for the size of a frame step. */
85
+ frameRate?: number
86
+ /** More items for the right-click menu, given the time it was opened at (under the pointer on the timeline, the playhead elsewhere). */
87
+ menuItems?: (at: number) => ContextMenuItemDef[]
88
+ disabled?: boolean
89
+ labels?: Partial<Record<
90
+ | "play" | "pause" | "stepBack" | "stepForward" | "mute" | "unmute" | "zoomIn" | "zoomOut" | "zoomFit"
91
+ | "start" | "end" | "position" | "length" | "left" | "overview" | "timeline"
92
+ | "region" | "regionHint" | "regionClear" | "failed" | "unsupported" | "invalidTime"
93
+ | "menuAt" | "setStart" | "setEnd" | "goStart" | "goEnd" | "zoomHere" | "volume"
94
+ | "copyTime" | "copyFrame" | "copiedTime" | "copiedFrame" | "copyFailed",
95
+ string
96
+ >>
97
+ }>(),
98
+ { poster: undefined, range: undefined, maxClip: undefined, minClip: 0.1, region: null, frameRate: 30, labels: () => ({}) },
99
+ )
100
+
101
+ const emit = defineEmits<{
102
+ (e: "update:range", value: TimeRange): void
103
+ /** The range after a finished move: a handle let go, a key, a typed time. */
104
+ (e: "change", value: TimeRange): void
105
+ (e: "update:region", value: FrameRegion | null): void
106
+ (e: "error", error: { cause: VideoTrimErrorCause; message: string }): void
107
+ }>()
108
+
109
+ const text = useLabels(
110
+ "videoTrim",
111
+ {
112
+ play: "Play",
113
+ pause: "Pause",
114
+ stepBack: "Previous frame",
115
+ stepForward: "Next frame",
116
+ mute: "Mute",
117
+ unmute: "Unmute",
118
+ zoomIn: "Zoom in",
119
+ zoomOut: "Zoom out",
120
+ zoomFit: "Show whole video",
121
+ start: "Start",
122
+ end: "End",
123
+ position: "Position",
124
+ length: "Length",
125
+ /** `{left}` and `{max}` are replaced by times. */
126
+ left: "{left} left of {max}",
127
+ overview: "Visible part of the timeline",
128
+ timeline: "Timeline — Space plays, arrows step, I and O set start and end",
129
+ region: "Marked area — arrows move it, Shift+arrows resize, Delete removes",
130
+ regionHint: "Drag on the picture to mark an area",
131
+ regionClear: "Remove area",
132
+ failed: "This video couldn't be loaded.",
133
+ unsupported: "This browser can't play this video's format.",
134
+ invalidTime: "Type a time like 1:05.5",
135
+ /** `{time}` is replaced by the time the menu was opened at. */
136
+ menuAt: "At {time}",
137
+ setStart: "Start here",
138
+ setEnd: "End here",
139
+ goStart: "Go to the clip's start",
140
+ goEnd: "Go to the clip's end",
141
+ zoomHere: "Zoom in here",
142
+ volume: "Volume",
143
+ copyTime: "Copy time",
144
+ copyFrame: "Copy frame",
145
+ copiedTime: "Time copied",
146
+ copiedFrame: "Frame copied",
147
+ copyFailed: "Couldn't copy",
148
+ },
149
+ () => props.labels,
150
+ )
151
+
152
+ const { class: rootClass, attrs: rest } = useMergedAttrs("flex w-full min-w-0 flex-col gap-3 select-none")
153
+ const reduced = usePrefersReducedMotion()
154
+
155
+ const videoEl = ref<HTMLVideoElement | null>(null)
156
+ const samplerEl = ref<HTMLVideoElement | null>(null)
157
+ const stageEl = ref<HTMLElement | null>(null)
158
+ const trackEl = ref<HTMLElement | null>(null)
159
+
160
+ const media = useVideoMedia(videoEl, {
161
+ src: () => props.src,
162
+ onFrame: followPlayback,
163
+ onError: (cause) => emit("error", { cause, message: cause === "unsupported" ? text.value.unsupported : text.value.failed }),
164
+ })
165
+ const { duration, failure, time, playing, wantsPlay, muted, size: videoSize, seek } = media
166
+ /* Every pause the editor makes itself is for a seek, a step or a drag, where the frame must be exact: no easing. */
167
+ const pause = () => media.pause({ instant: true })
168
+ const stageSize = ref({ width: 0, height: 0 })
169
+ const trackWidth = ref(0)
170
+ const ready = computed(() => duration.value > 0 && !failure.value)
171
+ const off = computed(() => props.disabled || !ready.value)
172
+
173
+ const limits = computed(() => ({
174
+ duration: duration.value,
175
+ minClip: Math.max(0, props.minClip ?? 0),
176
+ maxClip: props.maxClip && props.maxClip > 0 ? props.maxClip : Infinity,
177
+ }))
178
+
179
+ /* ---------- range ---------- */
180
+
181
+ const local = ref<TimeRange>([0, 0])
182
+ /** Which handle a pointer holds, so the prop echoing back mid-drag does not fight it. */
183
+ const dragging = ref<0 | 1 | null>(null)
184
+
185
+ const same = (a: TimeRange | null | undefined, b: TimeRange) => !!a && a[0] === b[0] && a[1] === b[1]
186
+
187
+ watch(
188
+ [() => props.range, limits],
189
+ () => {
190
+ if (dragging.value !== null || !duration.value) return
191
+ const next = normalizeRange(props.range, limits.value)
192
+ local.value = next
193
+ // An unset or impossible range is replaced by the one the editor shows, so the model never disagrees with it.
194
+ if (!same(props.range, next)) emit("update:range", next)
195
+ },
196
+ { deep: true },
197
+ )
198
+
199
+ function put(next: TimeRange, commit: boolean) {
200
+ const changed = !same(local.value, next)
201
+ local.value = next
202
+ if (changed) emit("update:range", next)
203
+ if (commit) emit("change", next)
204
+ }
205
+
206
+ const clipLength = computed(() => local.value[1] - local.value[0])
207
+
208
+ /* ---------- playback ---------- */
209
+
210
+ /** Playback that began inside the range ends at its end, so Play previews the clip. */
211
+ let stopAtEnd = false
212
+ function followPlayback() {
213
+ if (stopAtEnd && time.value >= local.value[1]) {
214
+ pause()
215
+ seek(local.value[1])
216
+ return
217
+ }
218
+ if (zoomed.value) view.value = revealTime(view.value, time.value, duration.value, 0.05)
219
+ }
220
+
221
+ /* A press that plays or pauses is answered in the middle of the picture too (VideoFlash). */
222
+ const flash = ref<{ icon: string; n: number } | null>(null)
223
+ function togglePlay() {
224
+ if (!videoEl.value || off.value) return
225
+ flash.value = { icon: wantsPlay.value ? "lucide:pause" : "lucide:play", n: (flash.value?.n ?? 0) + 1 }
226
+ if (wantsPlay.value) {
227
+ media.pause()
228
+ return
229
+ }
230
+ const [a, b] = local.value
231
+ let from = time.value
232
+ if (from >= b - 1e-3 && from <= b + 1e-3) from = a
233
+ stopAtEnd = from >= a - 1e-3 && from < b
234
+ if (from !== time.value) seek(from)
235
+ media.play()
236
+ }
237
+ function step(seconds: number) {
238
+ if (off.value) return
239
+ pause()
240
+ seek(time.value + seconds)
241
+ if (zoomed.value) view.value = revealTime(view.value, time.value, duration.value)
242
+ }
243
+ function toggleMute() {
244
+ media.setMuted(!muted.value)
245
+ }
246
+
247
+ const frameStep = computed(() => 1 / Math.max(1, props.frameRate))
248
+
249
+ /* The window the component renders in: an LpFrame's own, inside one. */
250
+ const frameWindow = () => nodeWindow(trackEl.value ?? videoEl.value!)
251
+
252
+ /*
253
+ * Positions on the timeline glide to a value set from elsewhere and follow at
254
+ * once while something moves them continuously: the same split as the kit's
255
+ * sliders (SLIDER_GLIDE), over `left` and `width` here.
256
+ */
257
+ const TRACK_GLIDE =
258
+ "[&_[data-glide]]:transition-[left,width] [&_[data-glide]]:duration-(--duration-medium) [&_[data-glide]]:ease-(--ease-emphasized) data-[live]:[&_[data-glide]]:transition-none motion-reduce:[&_[data-glide]]:transition-none"
259
+ /* The first placement, once the length is known, is not a change: it lands where it belongs instead of travelling there from zero. */
260
+ const placed = ref(false)
261
+ watch(ready, (ok) => {
262
+ placed.value = false
263
+ if (ok) frameWindow().requestAnimationFrame(() => frameWindow().requestAnimationFrame(() => (placed.value = true)))
264
+ })
265
+ const live = computed(() => !placed.value || dragging.value !== null || scrubbing.value || pinching.value || wheeling.value || playing.value)
266
+
267
+ /* ---------- timeline view ---------- */
268
+
269
+ /* The narrowest view: a couple of seconds across still leaves frames a few pixels apart. */
270
+ const MIN_SPAN = 2
271
+ const view = ref<TimeView>({ start: 0, end: 0 })
272
+ const span = computed(() => Math.max(1e-6, view.value.end - view.value.start))
273
+ const zoomed = computed(() => duration.value > 0 && span.value < duration.value - 1e-6)
274
+ const canZoomIn = computed(() => span.value > Math.min(MIN_SPAN, duration.value) + 1e-6)
275
+
276
+ watch(duration, (d) => {
277
+ view.value = { start: 0, end: d }
278
+ })
279
+
280
+ const xOf = (t: number) => ((t - view.value.start) / span.value) * trackWidth.value
281
+ const pct = (t: number) => `${((t - view.value.start) / span.value) * 100}%`
282
+ function timeAt(clientX: number): number {
283
+ const box = trackEl.value?.getBoundingClientRect()
284
+ if (!box || !box.width) return 0
285
+ return view.value.start + ((clientX - box.left) / box.width) * span.value
286
+ }
287
+
288
+ /** Zoom around the playhead while it is in sight, else around the middle: a zoom must never carry the view off to somewhere else. */
289
+ function zoomAnchor(): number {
290
+ const { start, end } = view.value
291
+ return time.value >= start && time.value <= end ? time.value : (start + end) / 2
292
+ }
293
+ function zoomBy(factor: number, anchor = zoomAnchor()) {
294
+ if (!duration.value) return
295
+ view.value = zoomView(view.value, anchor, factor, duration.value, MIN_SPAN)
296
+ }
297
+ function fit() {
298
+ view.value = { start: 0, end: duration.value }
299
+ }
300
+
301
+ /* A wheel or touchpad sends a stream of small steps: each follows at once, as a drag does, rather than easing after the last. */
302
+ const wheeling = ref(false)
303
+ let wheelTimer: ReturnType<typeof setTimeout> | undefined
304
+ function markWheel() {
305
+ wheeling.value = true
306
+ clearTimeout(wheelTimer)
307
+ wheelTimer = setTimeout(() => (wheeling.value = false), 160)
308
+ }
309
+
310
+ function onWheel(event: WheelEvent) {
311
+ if (!ready.value) return
312
+ const dx = event.shiftKey ? event.deltaY : event.deltaX
313
+ // A sideways scroll (or Shift+wheel) pans; an upright one, and a pinch on a touchpad, zooms.
314
+ if (Math.abs(dx) > Math.abs(event.deltaY) || event.shiftKey) {
315
+ if (!zoomed.value) return
316
+ event.preventDefault()
317
+ markWheel()
318
+ view.value = shiftView(view.value, (dx / Math.max(1, trackWidth.value)) * span.value, duration.value)
319
+ return
320
+ }
321
+ event.preventDefault()
322
+ const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 400 : 1
323
+ const delta = event.deltaY * unit
324
+ /*
325
+ * A mouse wheel moves in notches (a line at a time, or a hundred pixels or
326
+ * so): each notch glides to its zoom like a button press. A touchpad or a
327
+ * pinch sends a stream of small steps that the view follows at once.
328
+ */
329
+ const notch = event.deltaMode !== 0 || (!event.ctrlKey && Math.abs(delta) >= 50)
330
+ if (!notch) markWheel()
331
+ zoomBy(Math.exp(-delta / (event.ctrlKey ? 100 : 300)), timeAt(event.clientX))
332
+ }
333
+
334
+ const marks = computed(() => (ready.value ? ticks(view.value, tickStep(span.value, trackWidth.value)) : []))
335
+ const markDigits = computed(() => tickStep(span.value, trackWidth.value) < 1)
336
+
337
+ /* ---------- timeline pointers ---------- */
338
+
339
+ const pointers = new Map<number, number>()
340
+ let pinch: { distance: number; anchor: number } | null = null
341
+ const pinching = ref(false)
342
+ let edgeFrame = 0
343
+ let lastX = 0
344
+ const scrubbing = ref(false)
345
+
346
+ function onHandleDown(end: 0 | 1, event: PointerEvent) {
347
+ if (off.value || event.button !== 0) return
348
+ event.stopPropagation()
349
+ ;(event.currentTarget as HTMLElement).focus({ preventScroll: true })
350
+ pause()
351
+ dragging.value = end
352
+ lastX = event.clientX
353
+ startPointerDrag(event, {
354
+ cursor: "ew-resize",
355
+ move: (e) => {
356
+ lastX = e.clientX
357
+ moveHandle()
358
+ edgeScroll()
359
+ },
360
+ end: () => {
361
+ dragging.value = null
362
+ if (edgeFrame) frameWindow().cancelAnimationFrame(edgeFrame)
363
+ edgeFrame = 0
364
+ emit("change", local.value)
365
+ },
366
+ })
367
+ }
368
+ function moveHandle() {
369
+ const end = dragging.value
370
+ if (end === null) return
371
+ const next = dragEnd(local.value, end, timeAt(lastX), limits.value)
372
+ put(next, false)
373
+ // The picture shows the frame being cut at.
374
+ seek(next[end])
375
+ }
376
+ /* Held past either edge of a zoomed timeline, the view travels, faster the further out. */
377
+ function edgeScroll() {
378
+ if (edgeFrame || !zoomed.value) return
379
+ const box = trackEl.value?.getBoundingClientRect()
380
+ if (!box) return
381
+ const over = lastX < box.left ? lastX - box.left : lastX > box.right ? lastX - box.right : 0
382
+ if (!over || (dragging.value === null && !scrubbing.value)) return
383
+ edgeFrame = frameWindow().requestAnimationFrame(() => {
384
+ edgeFrame = 0
385
+ view.value = shiftView(view.value, (Math.max(-60, Math.min(60, over)) / 6 / Math.max(1, box.width)) * span.value, duration.value)
386
+ if (dragging.value !== null) moveHandle()
387
+ else seek(timeAt(lastX))
388
+ edgeScroll()
389
+ })
390
+ }
391
+
392
+ function onTrackDown(event: PointerEvent) {
393
+ if (off.value || (event.pointerType === "mouse" && event.button !== 0)) return
394
+ ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
395
+ pointers.set(event.pointerId, event.clientX)
396
+ if (pointers.size === 2) {
397
+ scrubbing.value = false
398
+ const [a, b] = [...pointers.values()] as [number, number]
399
+ pinch = { distance: Math.max(8, Math.abs(a - b)), anchor: timeAt((a + b) / 2) }
400
+ pinching.value = true
401
+ return
402
+ }
403
+ if (pointers.size > 2) return
404
+ event.preventDefault()
405
+ trackEl.value?.querySelector<HTMLElement>("[data-lp-playhead]")?.focus({ preventScroll: true })
406
+ pause()
407
+ scrubbing.value = true
408
+ lastX = event.clientX
409
+ seek(timeAt(event.clientX))
410
+ }
411
+ function onTrackMove(event: PointerEvent) {
412
+ if (!pointers.has(event.pointerId)) return
413
+ pointers.set(event.pointerId, event.clientX)
414
+ if (pinch && pointers.size === 2) {
415
+ const [a, b] = [...pointers.values()] as [number, number]
416
+ const distance = Math.max(8, Math.abs(a - b))
417
+ zoomBy(distance / pinch.distance, pinch.anchor)
418
+ pinch.distance = distance
419
+ return
420
+ }
421
+ if (!scrubbing.value) return
422
+ lastX = event.clientX
423
+ seek(timeAt(event.clientX))
424
+ edgeScroll()
425
+ }
426
+ function onTrackUp(event: PointerEvent) {
427
+ pointers.delete(event.pointerId)
428
+ if (pointers.size < 2) {
429
+ pinch = null
430
+ pinching.value = false
431
+ }
432
+ if (!pointers.size) scrubbing.value = false
433
+ }
434
+
435
+ /* The overview: press or drag to centre the view there. */
436
+ function onOverviewDown(event: PointerEvent) {
437
+ if (off.value || event.button !== 0) return
438
+ const box = (event.currentTarget as HTMLElement).getBoundingClientRect()
439
+ const centreAt = (clientX: number) => {
440
+ const centre = ((clientX - box.left) / Math.max(1, box.width)) * duration.value
441
+ view.value = shiftView(view.value, centre - (view.value.start + view.value.end) / 2, duration.value)
442
+ }
443
+ centreAt(event.clientX)
444
+ startPointerDrag(event, { move: (e) => centreAt(e.clientX), end: () => {} })
445
+ }
446
+
447
+ /* ---------- stills ---------- */
448
+
449
+ const STILL_HEIGHT = 56
450
+ const stillWidth = computed(() => {
451
+ const { width, height } = videoSize.value
452
+ return Math.round(Math.min(160, Math.max(40, width && height ? (STILL_HEIGHT * width) / height : STILL_HEIGHT * (16 / 9))))
453
+ })
454
+ const stillSeconds = computed(() => stillSpan(span.value / Math.max(1, trackWidth.value), stillWidth.value))
455
+ const stills = computed(() => {
456
+ if (!ready.value || !trackWidth.value) return []
457
+ const step = stillSeconds.value
458
+ const first = Math.floor(view.value.start / step)
459
+ const last = Math.min(Math.ceil(view.value.end / step), Math.ceil(duration.value / step))
460
+ const out: { key: string; time: number; left: string; width: string }[] = []
461
+ for (let k = first; k < last && out.length < 200; k++) {
462
+ out.push({ key: `${step}:${k}`, time: Math.min(duration.value, k * step + step / 2), left: pct(k * step), width: `${(step / span.value) * 100}%` })
463
+ }
464
+ return out
465
+ })
466
+ const frames = shallowRef(new Map<string, HTMLCanvasElement>())
467
+ let sampler: FrameSampler | null = null
468
+ let stillTimer: ReturnType<typeof setTimeout> | undefined
469
+
470
+ watch(stills, (list) => {
471
+ clearTimeout(stillTimer)
472
+ // Wait for a zoom or a pan to come to rest before asking for stills of a view that may be gone in a moment.
473
+ stillTimer = setTimeout(() => {
474
+ if (!sampler) return
475
+ const centre = (view.value.start + view.value.end) / 2
476
+ sampler.want([...list].sort((a, b) => Math.abs(a.time - centre) - Math.abs(b.time - centre)))
477
+ }, 120)
478
+ })
479
+
480
+ /*
481
+ * A still in its tile. Until its own frame is taken, the tile shows a copy of
482
+ * the nearest one already taken (from any zoom), so zooming never empties the
483
+ * strip; the real frame then fades in over that stand-in.
484
+ */
485
+ function mountFrame(still: { key: string; time: number }, el: Element | null) {
486
+ if (!isHTMLElement(el)) return
487
+ const canvas = frames.value.get(still.key) ?? sampler?.cached(still.key)
488
+ if (canvas) {
489
+ if (canvas.parentElement === el) return
490
+ const standIns = [...el.children]
491
+ el.append(canvas)
492
+ if (!standIns.length || reduced.value) standIns.forEach((c) => c.remove())
493
+ else canvas.addEventListener("animationend", () => standIns.forEach((c) => c.remove()), { once: true })
494
+ return
495
+ }
496
+ if (el.children.length) return
497
+ const near = nearestFrame(still.time)
498
+ if (!near) return
499
+ const copy = el.ownerDocument.createElement("canvas")
500
+ copy.width = near.width
501
+ copy.height = near.height
502
+ copy.getContext("2d")?.drawImage(near, 0, 0)
503
+ copy.dataset.lpStandIn = ""
504
+ el.append(copy)
505
+ }
506
+ const frameTimes = new Map<string, number>()
507
+ function nearestFrame(t: number): HTMLCanvasElement | undefined {
508
+ let best: HTMLCanvasElement | undefined
509
+ let gap = Infinity
510
+ for (const [key, canvas] of frames.value) {
511
+ const d = Math.abs((frameTimes.get(key) ?? Infinity) - t)
512
+ if (d < gap) {
513
+ gap = d
514
+ best = canvas
515
+ }
516
+ }
517
+ return best
518
+ }
519
+
520
+ /* ---------- region ---------- */
521
+
522
+ const picture = computed(() => containRect(stageSize.value.width, stageSize.value.height, videoSize.value.width, videoSize.value.height))
523
+ const draft = ref<FrameRegion | null>(null)
524
+ const shownRegion = computed(() => draft.value ?? props.region ?? null)
525
+
526
+ function fractionAt(event: PointerEvent) {
527
+ const box = stageEl.value?.getBoundingClientRect()
528
+ const p = picture.value
529
+ if (!box || !p.width || !p.height) return { x: 0, y: 0 }
530
+ return { x: (event.clientX - box.left - p.x) / p.width, y: (event.clientY - box.top - p.y) / p.height }
531
+ }
532
+ function onRegionDown(handle: RegionHandle | "draw", event: PointerEvent) {
533
+ if (props.disabled || !props.regionSelect || (event.pointerType === "mouse" && event.button !== 0)) return
534
+ event.stopPropagation()
535
+ pause()
536
+ const start = fractionAt(event)
537
+ const from = props.region ?? null
538
+ startPointerDrag(event, {
539
+ move: (e) => {
540
+ const at = fractionAt(e)
541
+ draft.value = handle === "draw" || !from ? regionBetween(start.x, start.y, at.x, at.y) : adjustRegion(from, handle, at.x - start.x, at.y - start.y)
542
+ },
543
+ end: (completed) => {
544
+ const next = draft.value
545
+ draft.value = null
546
+ // A press without a drag is not a region: a stray click must not leave a sliver to blur.
547
+ if (!completed || !next || (handle === "draw" && (next.w < 0.01 || next.h < 0.01))) return
548
+ emit("update:region", next)
549
+ },
550
+ })
551
+ }
552
+ function onRegionKey(event: KeyboardEvent) {
553
+ const r = props.region
554
+ if (!r || props.disabled) return
555
+ if (event.key === "Delete" || event.key === "Backspace") {
556
+ event.preventDefault()
557
+ emit("update:region", null)
558
+ return
559
+ }
560
+ const d = event.altKey ? 0.002 : 0.01
561
+ const moves: Record<string, [number, number]> = { ArrowLeft: [-d, 0], ArrowRight: [d, 0], ArrowUp: [0, -d], ArrowDown: [0, d] }
562
+ const move = moves[event.key]
563
+ if (!move) return
564
+ event.preventDefault()
565
+ event.stopPropagation()
566
+ emit("update:region", adjustRegion(r, event.shiftKey ? "se" : "move", move[0], move[1]))
567
+ }
568
+
569
+ /* ---------- keys ---------- */
570
+
571
+ function onKeydown(event: KeyboardEvent) {
572
+ const target = event.target as HTMLElement
573
+ if (event.defaultPrevented || event.ctrlKey || event.metaKey || target.closest("input, textarea, select, [contenteditable='true']")) return
574
+ const handle = target.dataset.lpHandle
575
+ const end = handle === "0" ? 0 : handle === "1" ? 1 : null
576
+ // Another slider inside (the volume) owns its arrows.
577
+ if (target.getAttribute("role") === "slider" && end === null && target.dataset.lpPlayhead === undefined) return
578
+ if ((event.key === "m" || event.key === "M") && !event.altKey) {
579
+ if (!ready.value) return
580
+ event.preventDefault()
581
+ toggleMute()
582
+ return
583
+ }
584
+ const big = event.shiftKey ? 1 : frameStep.value
585
+ const delta: Record<string, number> = { ArrowLeft: -big, ArrowRight: big, ArrowDown: -big, ArrowUp: big, PageDown: -10, PageUp: 10 }
586
+ if (event.key === " " && target.tagName !== "BUTTON") {
587
+ event.preventDefault()
588
+ togglePlay()
589
+ } else if (event.key in delta || event.key === "Home" || event.key === "End") {
590
+ if (off.value) return
591
+ event.preventDefault()
592
+ if (end !== null) {
593
+ const from = local.value[end]
594
+ const to = event.key === "Home" ? 0 : event.key === "End" ? duration.value : from + delta[event.key]!
595
+ const next = dragEnd(local.value, end, to, limits.value)
596
+ put(next, true)
597
+ pause()
598
+ seek(next[end])
599
+ } else if (event.key === "Home" || event.key === "End") {
600
+ pause()
601
+ seek(event.key === "Home" ? 0 : duration.value)
602
+ } else step(delta[event.key]!)
603
+ if (zoomed.value) view.value = revealTime(view.value, time.value, duration.value)
604
+ } else if ((event.key === "i" || event.key === "I" || event.key === "o" || event.key === "O") && !event.altKey) {
605
+ if (off.value) return
606
+ event.preventDefault()
607
+ put(setEnd(local.value, event.key.toLowerCase() === "i" ? 0 : 1, time.value, limits.value), true)
608
+ }
609
+ }
610
+
611
+ /* ---------- typed times ---------- */
612
+
613
+ const drafts = ref<[string | null, string | null]>([null, null])
614
+ const invalid = ref<[boolean, boolean]>([false, false])
615
+ const fieldText = (end: 0 | 1) => drafts.value[end] ?? formatClipTime(local.value[end], "fixed")
616
+ function onFieldInput(end: 0 | 1, value: string) {
617
+ drafts.value[end] = value
618
+ invalid.value[end] = false
619
+ }
620
+ function commitField(end: 0 | 1) {
621
+ const typed = drafts.value[end]
622
+ if (typed === null) return
623
+ const t = parseClipTime(typed)
624
+ if (t === null) {
625
+ invalid.value[end] = true
626
+ return
627
+ }
628
+ drafts.value[end] = null
629
+ put(setEnd(local.value, end, t, limits.value), true)
630
+ seek(local.value[end])
631
+ if (zoomed.value) view.value = revealTime(view.value, local.value[end], duration.value)
632
+ }
633
+ function revertField(end: 0 | 1) {
634
+ drafts.value[end] = null
635
+ invalid.value[end] = false
636
+ }
637
+
638
+ /* ---------- mount ---------- */
639
+
640
+ useResizeObserver(trackEl, (_, el) => (trackWidth.value = (el as HTMLElement).clientWidth), { immediate: true })
641
+ useResizeObserver(stageEl, (_, el) => (stageSize.value = { width: (el as HTMLElement).clientWidth, height: (el as HTMLElement).clientHeight }), { immediate: true })
642
+
643
+ function startSampler() {
644
+ sampler?.dispose()
645
+ sampler = samplerEl.value
646
+ ? new FrameSampler(samplerEl.value, () => ({ width: stillWidth.value, height: STILL_HEIGHT }), (key, canvas) => {
647
+ const still = stills.value.find((s) => s.key === key)
648
+ if (still) frameTimes.set(key, still.time)
649
+ const next = new Map(frames.value)
650
+ next.set(key, canvas)
651
+ if (next.size > 240) next.delete(next.keys().next().value as string)
652
+ frames.value = next
653
+ })
654
+ : null
655
+ }
656
+
657
+ watch(
658
+ () => props.src,
659
+ () => {
660
+ frames.value = new Map()
661
+ void nextTick(startSampler)
662
+ },
663
+ )
664
+
665
+ onMounted(startSampler)
666
+ onBeforeUnmount(() => {
667
+ sampler?.dispose()
668
+ clearTimeout(stillTimer)
669
+ if (edgeFrame) frameWindow().cancelAnimationFrame(edgeFrame)
670
+ // The stills' element too: one merely detached keeps its connection open.
671
+ const stillsEl = samplerEl.value
672
+ if (stillsEl) {
673
+ stillsEl.removeAttribute("src")
674
+ stillsEl.load()
675
+ }
676
+ })
677
+
678
+ defineExpose({
679
+ play: () => (videoEl.value?.paused ? togglePlay() : undefined),
680
+ pause,
681
+ seek,
682
+ /** The playhead, in seconds. */
683
+ currentTime: () => time.value,
684
+ })
685
+
686
+ /* ---------- right-click ---------- */
687
+
688
+ const { copy } = useClipboard()
689
+ const { error: toastError } = useToast()
690
+
691
+ /* The picture's own menu: what is done to the video and to the frame on screen. */
692
+ const videoMenu = computed<ContextMenuItemDef[]>(() => {
693
+ if (!ready.value || props.disabled) return []
694
+ const items: ContextMenuItemDef[] = [
695
+ { label: wantsPlay.value ? text.value.pause : text.value.play, icon: wantsPlay.value ? "lucide:pause" : "lucide:play", shortcut: "Space", onSelect: togglePlay },
696
+ { label: muted.value ? text.value.unmute : text.value.mute, icon: muted.value ? "lucide:volume-2" : "lucide:volume-x", shortcut: "M", onSelect: toggleMute },
697
+ { label: text.value.stepBack, icon: "lucide:step-back", shortcut: "ArrowLeft", separatorBefore: true, onSelect: () => step(-frameStep.value) },
698
+ { label: text.value.stepForward, icon: "lucide:step-forward", shortcut: "ArrowRight", onSelect: () => step(frameStep.value) },
699
+ { label: text.value.copyTime, icon: "lucide:clock", separatorBefore: true, onSelect: () => void copy(formatClipTime(time.value, "fixed"), { toast: text.value.copiedTime, errorToast: text.value.copyFailed }) },
700
+ { label: text.value.copyFrame, icon: "lucide:image", onSelect: copyFrame },
701
+ ]
702
+ if (props.regionSelect && props.region) items.push({ label: text.value.regionClear, icon: "lucide:x", separatorBefore: true, onSelect: () => emit("update:region", null) })
703
+ return items
704
+ })
705
+ const clockTitle = computed(() => text.value.menuAt.replace("{time}", formatClipTime(time.value, "fixed")))
706
+
707
+ async function copyFrame() {
708
+ const blob = await media.captureFrame()
709
+ if (blob) void copy(blob, { toast: text.value.copiedFrame, errorToast: text.value.copyFailed })
710
+ else toastError(text.value.copyFailed)
711
+ }
712
+
713
+ /** Where the timeline's menu was opened: the moment under the pointer. */
714
+ const menuAt = ref(0)
715
+ function onTimeContextMenu(event: MouseEvent) {
716
+ const overview = (event.target as Element).closest?.("[data-lp-overview]")
717
+ if (overview) {
718
+ const box = overview.getBoundingClientRect()
719
+ menuAt.value = ((event.clientX - box.left) / Math.max(1, box.width)) * duration.value
720
+ } else menuAt.value = timeAt(event.clientX)
721
+ menuAt.value = Math.min(duration.value, Math.max(0, menuAt.value))
722
+ }
723
+ /* The timeline's menu: what is done at a moment. */
724
+ const timeMenu = computed<ContextMenuItemDef[]>(() => {
725
+ if (!ready.value || props.disabled) return []
726
+ const at = menuAt.value
727
+ const items: ContextMenuItemDef[] = [
728
+ {
729
+ label: text.value.setStart,
730
+ icon: "lucide:arrow-left-to-line",
731
+ shortcut: "I",
732
+ onSelect: () => {
733
+ put(setEnd(local.value, 0, at, limits.value), true)
734
+ seek(at)
735
+ },
736
+ },
737
+ {
738
+ label: text.value.setEnd,
739
+ icon: "lucide:arrow-right-to-line",
740
+ shortcut: "O",
741
+ onSelect: () => {
742
+ put(setEnd(local.value, 1, at, limits.value), true)
743
+ seek(at)
744
+ },
745
+ },
746
+ { label: text.value.goStart, separatorBefore: true, onSelect: () => seek(local.value[0]) },
747
+ { label: text.value.goEnd, onSelect: () => seek(local.value[1]) },
748
+ zoomed.value
749
+ ? { label: text.value.zoomFit, icon: "lucide:maximize-2", separatorBefore: true, onSelect: fit }
750
+ : { label: text.value.zoomHere, icon: "lucide:zoom-in", separatorBefore: true, disabled: !canZoomIn.value, onSelect: () => zoomBy(4, at) },
751
+ ]
752
+ const extra = props.menuItems?.(at) ?? []
753
+ return [...items, ...extra.map((item, i) => (i === 0 ? { ...item, separatorBefore: true } : item))]
754
+ })
755
+ const menuTitle = computed(() => text.value.menuAt.replace("{time}", formatClipTime(menuAt.value, "fixed")))
756
+
757
+ const handleText = (t: number) => formatClipTime(t, "fixed")
758
+ const widest = computed(() => widestClipTime(duration.value))
759
+ const leftOf = (left: string, max: string) => text.value.left.replace("{left}", left).replace("{max}", max)
760
+ const leftText = computed(() =>
761
+ Number.isFinite(limits.value.maxClip)
762
+ ? leftOf(formatClipTime(Math.max(0, limits.value.maxClip - clipLength.value), "fixed"), formatClipTime(limits.value.maxClip, "fixed"))
763
+ : "",
764
+ )
765
+ const widestLeft = computed(() => leftOf(widestClipTime(limits.value.maxClip), widestClipTime(limits.value.maxClip)))
766
+ </script>
767
+
768
+ <template>
769
+ <div :class="rootClass" v-bind="rest" :data-disabled="disabled || undefined" @keydown="onKeydown">
770
+ <!--
771
+ The picture. Its own controls are off: the region is drawn on it, and the
772
+ keys are the editor's. A right-click here is about the picture (play, the
773
+ sound, the frame); one on the timeline below is about a moment.
774
+ -->
775
+ <!-- The menu's trigger is a wrapper of its own: reka's as-child puts its ref on the element it wraps, which would take the stage's. -->
776
+ <LpContextMenu :items="videoMenu" :title="clockTitle" always>
777
+ <div>
778
+ <div ref="stageEl" class="relative aspect-video w-full overflow-hidden rounded-card bg-black">
779
+ <video
780
+ ref="videoEl"
781
+ class="absolute inset-0 size-full object-contain"
782
+ :src="src"
783
+ :poster="poster"
784
+ preload="metadata"
785
+ playsinline
786
+ v-on="media.listeners"
787
+ @click="!regionSelect && togglePlay()"
788
+ />
789
+ <VideoFlash :flash="flash" />
790
+ <!-- Stills come from a second element: seeking this one would move the picture under the person. -->
791
+ <video
792
+ ref="samplerEl"
793
+ class="pointer-events-none absolute size-px opacity-0"
794
+ :src="src"
795
+ preload="metadata"
796
+ muted
797
+ playsinline
798
+ aria-hidden="true"
799
+ tabindex="-1"
800
+ />
801
+ <Transition v-bind="FADE">
802
+ <div
803
+ v-if="regionSelect && ready"
804
+ class="absolute cursor-crosshair touch-none"
805
+ :style="{ left: `${picture.x}px`, top: `${picture.y}px`, width: `${picture.width}px`, height: `${picture.height}px` }"
806
+ data-lp-region-layer
807
+ @pointerdown="onRegionDown('draw', $event)"
808
+ >
809
+ <!-- Comes in under the pointer that draws it (no growing in: the drag already does that) and leaves with the dimming around it. -->
810
+ <Transition v-bind="FADE_SCALE" :enter-from-class="draft ? 'opacity-0' : FADE_SCALE.enterFromClass">
811
+ <div
812
+ v-if="shownRegion"
813
+ class="absolute cursor-move rounded-inner border-2 border-dashed border-white/90 bg-white/10 shadow-[0_0_0_9999px_rgb(0_0_0/0.35)] outline-none transition-[left,top,width,height] duration-(--duration-medium) ease-(--ease-emphasized) focus-visible:ring-2 focus-visible:ring-ring data-[live]:transition-none motion-reduce:transition-none"
814
+ :data-live="draft ? '' : undefined"
815
+ :style="{ left: `${shownRegion.x * 100}%`, top: `${shownRegion.y * 100}%`, width: `${shownRegion.w * 100}%`, height: `${shownRegion.h * 100}%` }"
816
+ tabindex="0"
817
+ role="group"
818
+ :aria-label="text.region"
819
+ data-lp-region
820
+ @pointerdown="onRegionDown('move', $event)"
821
+ @keydown="onRegionKey"
822
+ >
823
+ <span
824
+ v-for="corner in (['nw', 'ne', 'sw', 'se'] as const)"
825
+ :key="corner"
826
+ class="absolute grid size-6 place-items-center pointer-coarse:size-11"
827
+ :class="[
828
+ corner[0] === 'n' ? 'top-0 -translate-y-1/2' : 'bottom-0 translate-y-1/2',
829
+ corner[1] === 'w' ? 'left-0 -translate-x-1/2' : 'right-0 translate-x-1/2',
830
+ corner === 'nw' || corner === 'se' ? 'cursor-nwse-resize' : 'cursor-nesw-resize',
831
+ ]"
832
+ :data-lp-corner="corner"
833
+ @pointerdown="onRegionDown(corner, $event)"
834
+ >
835
+ <span class="size-3 rounded-full bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.3),0_1px_3px_rgb(0_0_0/0.35)]" />
836
+ </span>
837
+ </div>
838
+ </Transition>
839
+ </div>
840
+ </Transition>
841
+ <Transition v-bind="FADE">
842
+ <span
843
+ v-if="regionSelect && ready && !shownRegion && !playing && !disabled"
844
+ class="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded-pill bg-black/55 px-3 py-1 text-xs whitespace-nowrap text-white/85"
845
+ >{{ text.regionHint }}</span>
846
+ </Transition>
847
+ <!-- The app's own actions over the picture's top right corner: a bin that takes the video back out of a dropzone, say. -->
848
+ <div v-if="$slots.actions" class="absolute top-2 right-2 flex gap-0.5 rounded-control bg-black/45">
849
+ <slot name="actions" :button-class="OVERLAY_BUTTON" />
850
+ </div>
851
+ <Transition v-bind="FADE">
852
+ <div
853
+ v-if="failure"
854
+ role="alert"
855
+ class="absolute inset-0 flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-white/80"
856
+ >
857
+ <LpIcon name="lucide:video-off" :size="32" />
858
+ <p>{{ failure === "unsupported" ? text.unsupported : text.failed }}</p>
859
+ </div>
860
+ </Transition>
861
+ </div>
862
+ </div>
863
+ </LpContextMenu>
864
+
865
+ <!-- Transport, sound and zoom. One line at every width: a row that wraps re-wraps as its numbers change. -->
866
+ <div class="flex items-center gap-0.5">
867
+ <LpButton size="sm" variant="ghost" square :disabled="off" :aria-label="wantsPlay ? text.pause : text.play" :title="wantsPlay ? text.pause : text.play" @click="togglePlay">
868
+ <Transition v-bind="ICON_SWAP">
869
+ <LpIcon :key="wantsPlay ? 'pause' : 'play'" :name="wantsPlay ? 'lucide:pause' : 'lucide:play'" :size="16" />
870
+ </Transition>
871
+ </LpButton>
872
+ <LpButton size="sm" variant="ghost" square :disabled="off" :aria-label="text.stepBack" :title="text.stepBack" @click="step(-frameStep)">
873
+ <LpIcon name="lucide:step-back" :size="16" />
874
+ </LpButton>
875
+ <LpButton size="sm" variant="ghost" square :disabled="off" :aria-label="text.stepForward" :title="text.stepForward" @click="step(frameStep)">
876
+ <LpIcon name="lucide:step-forward" :size="16" />
877
+ </LpButton>
878
+ <span class="min-w-0 truncate px-1.5 font-mono text-xs text-muted" data-lp-clock>
879
+ <SteadyText class="text-ink" :text="formatClipTime(time, 'fixed')" :widest="widest" /> / {{ formatClipTime(duration, "fixed") }}
880
+ </span>
881
+ <VideoVolume :media="media" :labels="text" :disabled="!ready" />
882
+ <span class="flex-1" />
883
+ <Transition v-bind="FADE_SCALE">
884
+ <LpButton v-if="zoomed" size="sm" variant="ghost" square :disabled="off" :aria-label="text.zoomFit" :title="text.zoomFit" @click="fit">
885
+ <LpIcon name="lucide:maximize-2" :size="16" />
886
+ </LpButton>
887
+ </Transition>
888
+ <LpButton size="sm" variant="ghost" square :disabled="off || !zoomed" :aria-label="text.zoomOut" :title="text.zoomOut" @click="zoomBy(0.5)">
889
+ <LpIcon name="lucide:zoom-out" :size="16" />
890
+ </LpButton>
891
+ <LpButton size="sm" variant="ghost" square :disabled="off || !canZoomIn" :aria-label="text.zoomIn" :title="text.zoomIn" @click="zoomBy(2)">
892
+ <LpIcon name="lucide:zoom-in" :size="16" />
893
+ </LpButton>
894
+ </div>
895
+
896
+ <!--
897
+ Ruler, stills, range and playhead. Positions glide when they are set from
898
+ elsewhere (a key, a typed time, a zoom button) and follow the pointer with
899
+ no delay while it moves them, as the kit's sliders do. Marks and stills
900
+ that come into view or leave it fade rather than blink.
901
+ -->
902
+ <LpContextMenu :items="timeMenu" :title="menuTitle" always>
903
+ <div class="relative" :class="TRACK_GLIDE" :data-live="live || undefined" @wheel="onWheel" @contextmenu="onTimeContextMenu">
904
+ <TransitionGroup tag="div" class="relative h-4 overflow-hidden text-[10px] text-muted tabular-nums" aria-hidden="true" v-bind="FADE">
905
+ <span
906
+ v-for="m in marks"
907
+ :key="m"
908
+ class="absolute top-0 whitespace-nowrap"
909
+ :class="xOf(m) < 16 ? '' : xOf(m) > trackWidth - 16 ? '-translate-x-full' : '-translate-x-1/2'"
910
+ :style="{ left: pct(m) }"
911
+ data-glide
912
+ >{{ formatClipTime(m, markDigits) }}</span>
913
+ </TransitionGroup>
914
+ <div
915
+ ref="trackEl"
916
+ class="relative h-14 touch-none rounded-control bg-surface-soft transition-opacity duration-(--duration-fast) motion-reduce:transition-none"
917
+ :class="off ? 'opacity-60' : 'cursor-text'"
918
+ :aria-label="text.timeline"
919
+ role="group"
920
+ @pointerdown="onTrackDown"
921
+ @pointermove="onTrackMove"
922
+ @pointerup="onTrackUp"
923
+ @pointercancel="onTrackUp"
924
+ >
925
+ <div class="absolute inset-0 overflow-hidden rounded-control">
926
+ <TransitionGroup v-bind="FADE">
927
+ <div
928
+ v-for="s in stills"
929
+ :key="s.key"
930
+ :ref="(el) => mountFrame(s, el as Element | null)"
931
+ class="absolute inset-y-0 overflow-hidden [&>canvas]:absolute [&>canvas]:inset-0 [&>canvas]:size-full [&>canvas]:object-cover"
932
+ :class="{ 'lp-trim-still-in': !reduced }"
933
+ :style="{ left: s.left, width: s.width }"
934
+ :data-lp-still="frames.has(s.key) ? 'ready' : 'pending'"
935
+ data-glide
936
+ />
937
+ </TransitionGroup>
938
+ <!-- What falls outside the clip is dimmed, not hidden: it is still where the clip could move. -->
939
+ <div class="absolute inset-y-0 left-0 bg-black/55" :style="{ width: pct(local[0]) }" data-glide />
940
+ <div class="absolute inset-y-0 right-0 bg-black/55" :style="{ left: pct(local[1]) }" data-glide />
941
+ </div>
942
+ <!-- The range and its grips arrive with the video's length, faded in rather than popped onto the strip. -->
943
+ <div
944
+ class="pointer-events-none absolute inset-0 transition-opacity duration-(--duration-medium) ease-(--ease-emphasized) motion-reduce:transition-none"
945
+ :class="ready ? '' : 'opacity-0'"
946
+ >
947
+ <div
948
+ class="absolute inset-y-0 rounded-control border-y-2 border-white/85 shadow-[0_0_0_1px_rgb(0_0_0/0.25)]"
949
+ :style="{ left: pct(local[0]), width: `${(clipLength / span) * 100}%` }"
950
+ data-lp-range
951
+ data-glide
952
+ />
953
+ <!-- A grip as on LpChart's overview: narrow and neutral, lit only while it is held, hovered or focused. -->
954
+ <div
955
+ v-for="end in ([0, 1] as const)"
956
+ :key="end"
957
+ class="group/handle absolute inset-y-0 z-10 flex w-6 -translate-x-1/2 touch-none justify-center outline-none pointer-coarse:w-11"
958
+ :class="[off ? '' : 'cursor-ew-resize', ready ? 'pointer-events-auto' : '']"
959
+ :style="{ left: pct(local[end]) }"
960
+ role="slider"
961
+ :tabindex="off ? -1 : 0"
962
+ :aria-label="end === 0 ? text.start : text.end"
963
+ :aria-valuemin="0"
964
+ :aria-valuemax="Math.round(duration * 1000) / 1000"
965
+ :aria-valuenow="local[end]"
966
+ :aria-valuetext="handleText(local[end])"
967
+ :aria-disabled="off || undefined"
968
+ :aria-hidden="ready ? undefined : 'true'"
969
+ :data-lp-handle="end"
970
+ :data-active="dragging === end || undefined"
971
+ data-glide
972
+ @pointerdown="onHandleDown(end, $event)"
973
+ >
974
+ <span
975
+ class="my-1 flex w-2 items-center justify-center rounded-inner bg-white/90 shadow-[0_0_0_1px_rgb(0_0_0/0.3),0_1px_3px_rgb(0_0_0/0.35)] transition-[background-color,scale] duration-(--duration-fast) ease-(--ease-emphasized) group-hover/handle:bg-white group-data-active/handle:scale-x-125 group-data-active/handle:bg-white group-focus-visible/handle:ring-2 group-focus-visible/handle:ring-ring motion-reduce:transition-none motion-reduce:group-data-active/handle:scale-x-100"
976
+ >
977
+ <span class="h-4 w-px rounded-pill bg-black/35" />
978
+ </span>
979
+ </div>
980
+ </div>
981
+ <div
982
+ class="group/playhead absolute -top-4 bottom-0 z-20 w-0 outline-none"
983
+ :style="{ left: pct(time) }"
984
+ role="slider"
985
+ :tabindex="off ? -1 : 0"
986
+ :aria-label="text.position"
987
+ :aria-valuemin="0"
988
+ :aria-valuemax="Math.round(duration * 1000) / 1000"
989
+ :aria-valuenow="Math.round(time * 1000) / 1000"
990
+ :aria-valuetext="handleText(time)"
991
+ data-lp-playhead
992
+ :data-glide="playing ? undefined : ''"
993
+ >
994
+ <!-- Fades out past either edge of a zoomed view instead of vanishing there. -->
995
+ <span
996
+ class="pointer-events-none absolute inset-0 transition-opacity duration-(--duration-fast) motion-reduce:transition-none"
997
+ :class="ready && time >= view.start && time <= view.end ? '' : 'opacity-0'"
998
+ >
999
+ <span class="absolute inset-y-0 -left-px w-0.5 rounded-pill bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.35)]" />
1000
+ <span class="absolute -top-0.5 -left-1.5 size-3 rounded-full bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.3),0_1px_3px_rgb(0_0_0/0.35)] group-focus-visible/playhead:ring-2 group-focus-visible/playhead:ring-ring" />
1001
+ </span>
1002
+ </div>
1003
+ </div>
1004
+ <!-- The whole video while zoomed in, drawn like LpChart's overview: the window it shows, the clip within it. -->
1005
+ <Transition v-bind="FADE">
1006
+ <div
1007
+ v-if="zoomed"
1008
+ class="relative mt-1.5 h-2.5 cursor-pointer touch-none rounded-pill bg-surface-soft"
1009
+ :aria-label="text.overview"
1010
+ role="presentation"
1011
+ data-lp-overview
1012
+ @pointerdown="onOverviewDown"
1013
+ >
1014
+ <span class="absolute inset-y-0.5 rounded-pill bg-line-strong" :style="{ left: `${(local[0] / duration) * 100}%`, width: `${(clipLength / duration) * 100}%` }" data-glide />
1015
+ <span class="absolute inset-y-0 rounded-pill border border-ink/60" :style="{ left: `${(view.start / duration) * 100}%`, width: `${(span / duration) * 100}%` }" data-glide />
1016
+ </div>
1017
+ </Transition>
1018
+ </div>
1019
+ </LpContextMenu>
1020
+
1021
+ <!-- Exact times, typed, and the clip's length. Every value holds its width, so nothing on the line moves as they change. -->
1022
+ <div class="flex flex-wrap items-end gap-x-3 gap-y-2">
1023
+ <LpInput
1024
+ v-for="end in ([0, 1] as const)"
1025
+ :key="end"
1026
+ size="sm"
1027
+ class="w-28"
1028
+ :label="end === 0 ? text.start : text.end"
1029
+ :model-value="fieldText(end)"
1030
+ :disabled="off"
1031
+ :invalid="invalid[end]"
1032
+ :title="invalid[end] ? text.invalidTime : undefined"
1033
+ inputmode="decimal"
1034
+ spellcheck="false"
1035
+ autocomplete="off"
1036
+ :data-lp-time="end"
1037
+ @update:model-value="onFieldInput(end, $event)"
1038
+ @blur="commitField(end)"
1039
+ @keydown.enter.prevent="commitField(end)"
1040
+ @keydown.esc="revertField(end)"
1041
+ />
1042
+ <div class="flex flex-col gap-0.5 pb-0.5 text-xs">
1043
+ <span class="text-muted">{{ text.length }}</span>
1044
+ <span class="font-mono text-sm text-ink" data-lp-length><SteadyText :text="formatClipTime(clipLength, 'fixed')" :widest="widest" /></span>
1045
+ </div>
1046
+ <Transition v-bind="FADE">
1047
+ <span v-if="leftText" class="pb-1 font-mono text-xs text-muted" data-lp-left><SteadyText :text="leftText" :widest="widestLeft" /></span>
1048
+ </Transition>
1049
+ <span class="flex-1" />
1050
+ <Transition v-bind="FADE_SCALE">
1051
+ <LpButton v-if="regionSelect" size="sm" variant="outline" :disabled="disabled || !region" @click="emit('update:region', null)">
1052
+ <LpIcon name="lucide:x" :size="14" />{{ text.regionClear }}
1053
+ </LpButton>
1054
+ </Transition>
1055
+ </div>
1056
+ </div>
1057
+ </template>
1058
+
1059
+ <style scoped>
1060
+ .lp-trim-still-in > :deep(canvas:not([data-lp-stand-in])) {
1061
+ animation: lp-trim-still var(--duration-medium) var(--ease-emphasized);
1062
+ }
1063
+ @keyframes lp-trim-still {
1064
+ from { opacity: 0; }
1065
+ }
1066
+ </style>