@leavepulse/ui 0.44.0 → 0.45.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -3
- package/dist/assets/{LpCommandPalette-DCP5g2ik.css → LpCommandPalette-HzAA4gri.css} +21 -21
- package/dist/assets/{LpRollingText-CR1mb0Vt.css → LpRollingText-BGnE6q9j.css} +3 -0
- package/dist/assets/{LpShortcutsDialog-ChUyU7oV.css → LpShortcutsDialog-CqYX3yy6.css} +28 -7
- package/dist/assets/{LpTable-DDQNdf86.css → LpTable-CPwWxTJS.css} +49 -9
- package/dist/chunks/{ContextMenuItems-CrfeNPWT.js → ContextMenuItems-BtKU7074.js} +2 -1
- package/dist/chunks/{LayoutCanvas-D-jPOAvg.js → LayoutCanvas-BkaGXTjx.js} +1 -1
- package/dist/chunks/{LayoutNode-D8Xy9N-o.js → LayoutNode-B3_9E1Xa.js} +9 -5
- package/dist/chunks/{LpActionBar-BbwCF6l5.js → LpActionBar-CnfFVIwC.js} +3 -3
- package/dist/chunks/{LpAddressInput-BrOqkZqg.js → LpAddressInput-CJOYGZlI.js} +7 -7
- package/dist/chunks/{LpAlert-DkOIjsbq.js → LpAlert-B6ilrM7T.js} +1 -1
- package/dist/chunks/{LpAppShell-BUKPcHco.js → LpAppShell-B07Lf48e.js} +4 -4
- package/dist/chunks/{LpAutocomplete-DxV3Y9h4.js → LpAutocomplete-C8IgSMsi.js} +2 -1
- package/dist/chunks/{LpAvatar-ClBbEYZN.js → LpAvatar-C1XStdOo.js} +3 -3
- package/dist/chunks/{LpBreadcrumbs-B8IJgFIc.js → LpBreadcrumbs-CQJd73Gm.js} +1 -1
- package/dist/chunks/{LpButton-kqJBHIIb.js → LpButton-CKSwC6Mu.js} +1 -1
- package/dist/chunks/{LpCalendar-B7Ko5RdR.js → LpCalendar-DrA2McTU.js} +1 -1
- package/dist/chunks/{LpCard-CpRByR0q.js → LpCard-C2IvucmX.js} +1 -1
- package/dist/chunks/{LpCheckbox-DpqchzH4.js → LpCheckbox-Bkj13CbW.js} +1 -1
- package/dist/chunks/{LpCodeBlock-DeHJPq7p.js → LpCodeBlock-ca7ANFyW.js} +1 -1
- package/dist/chunks/{LpColorPicker-Bhf1qxhB.js → LpColorPicker-zz4VGkG4.js} +46 -20
- package/dist/chunks/{LpConfirmDialog-CIUOSnWo.js → LpConfirmDialog-BTVAR4tF.js} +3 -3
- package/dist/chunks/{LpConfirmHost-CDm0Wtpv.js → LpConfirmHost-Ci_uCHgQ.js} +1 -1
- package/dist/chunks/{LpContextMenu-DQw5dGfG.js → LpContextMenu-BqkAYa9X.js} +2 -1
- package/dist/chunks/{LpDatePicker-Drirx1AK.js → LpDatePicker-BrdFtSTy.js} +16 -10
- package/dist/chunks/LpDateRangePicker-Drd7EbIz.js +210 -0
- package/dist/chunks/{LpDisclosure-ChQYmjlR.js → LpDisclosure-Cc23-JOT.js} +1 -1
- package/dist/chunks/{LpDrawer-bScx-KMO.js → LpDrawer-CfRhHwmE.js} +3 -3
- package/dist/chunks/{LpDropdownMenu-DJpoqRCb.js → LpDropdownMenu-DdJGQzPn.js} +2 -1
- package/dist/chunks/{LpEmptyState-Dsdqz1Wj.js → LpEmptyState-BJbZXC8D.js} +1 -1
- package/dist/chunks/LpFieldNotice-wtieLUTj.js +64 -0
- package/dist/chunks/{LpFileTree-CSI_5SK2.js → LpFileTree-CNurOvkV.js} +3 -3
- package/dist/chunks/{LpFileTreeNode-B4MiSQX2.js → LpFileTreeNode-D_qRRdsH.js} +3 -3
- package/dist/chunks/{LpFormField-CIk5qosh.js → LpFormField-CtVYj6tO.js} +29 -15
- package/dist/chunks/{LpIcon-CCnX5_2j.js → LpIcon-DYtaid0K.js} +20 -6
- package/dist/chunks/{LpInput-C0e8y8_d.js → LpInput-D-YGQbEv.js} +39 -16
- package/dist/chunks/{LpLightbox-Mr0W_6HK.js → LpLightbox-DSDahF23.js} +1 -1
- package/dist/chunks/{LpLogViewer-YBOaFnc_.js → LpLogViewer-KypbgOyo.js} +252 -58
- package/dist/chunks/{LpMapPicker-NUzS9e37.js → LpMapPicker-Cc84LaM3.js} +1 -1
- package/dist/chunks/{LpMediaGallery-DfJmMW0I.js → LpMediaGallery-YKr-cQPN.js} +2 -2
- package/dist/chunks/{LpModal-DPwGKBPv.js → LpModal-BzTVeNqe.js} +3 -3
- package/dist/chunks/{LpModalNav-DPg5H9f3.js → LpModalNav-CjABSGCu.js} +14 -8
- package/dist/chunks/{LpNotificationBell-t5yn-siX.js → LpNotificationBell-CCwyJP5C.js} +26 -14
- package/dist/chunks/{LpNumberField-DWnQKktS.js → LpNumberField-B77O-z28.js} +1 -1
- package/dist/chunks/{LpPagination-DFngX7Xo.js → LpPagination-BcDlo3r_.js} +1 -1
- package/dist/chunks/{LpPasswordInput-CWVDuBJ5.js → LpPasswordInput-eKzQhbQP.js} +2 -2
- package/dist/chunks/{LpPhoneInput-CD-ZTeSd.js → LpPhoneInput-BCbrrhgE.js} +126 -23
- package/dist/chunks/{LpPie-BLco31Nx.js → LpPie-BEJ2SfI6.js} +9 -10
- package/dist/chunks/{LpPopover-p_e_shN3.js → LpPopover-DCKeqiVn.js} +1 -0
- package/dist/chunks/LpRangeCalendar-DZuaRV0W.js +331 -0
- package/dist/chunks/{LpRollingText-CM5gkSeF.js → LpRollingText-DB7upf-5.js} +1 -1
- package/dist/chunks/{LpScrollArea-D8xyKXcF.js → LpScrollArea-Bry1fdOM.js} +14 -2
- package/dist/chunks/{LpSection-aM6J_u51.js → LpSection-tKEH0jYf.js} +1 -1
- package/dist/chunks/{LpSegmented-Dejd7cIx.js → LpSegmented-C09EEam2.js} +1 -1
- package/dist/chunks/{LpSelect-DrRD9I7L.js → LpSelect-DNIL6MB9.js} +20 -23
- package/dist/chunks/{LpSidebar-BgwUNWpJ.js → LpSidebar-Bwws_X29.js} +3 -3
- package/dist/chunks/{LpSidebarNav-Bqxz8aWT.js → LpSidebarNav-kx-LdqNU.js} +3 -3
- package/dist/chunks/{LpStat-dXtyrdX9.js → LpStat-B-7udBRP.js} +1 -1
- package/dist/chunks/{LpStepper-Bk-XaQYU.js → LpStepper-C7XiVKZj.js} +34 -19
- package/dist/chunks/{LpSwitch-B1aLKulj.js → LpSwitch-OrcOJX9m.js} +1 -1
- package/dist/chunks/LpTableFilter-BV8PpLM9.js +158 -0
- package/dist/chunks/{LpTabs-_qMVaZJ6.js → LpTabs-29iW1XOx.js} +1 -1
- package/dist/chunks/LpTagsInput-D1SuqbhB.js +300 -0
- package/dist/chunks/{LpTextarea-yP8hGedR.js → LpTextarea-uuScWo6-.js} +1 -1
- package/dist/chunks/{LpThemeProvider-Wjm7Tuit.js → LpThemeProvider-Be4r2TQX.js} +1 -1
- package/dist/chunks/{LpThemeSwitcher-CKLc10KZ.js → LpThemeSwitcher-B4aVPbNC.js} +19 -22
- package/dist/chunks/{LpTimeField-C5HRgS5H.js → LpTimeField-Cqe7fXnh.js} +1 -1
- package/dist/chunks/{LpToaster--oT6lfhL.js → LpToaster-DTtWuKvq.js} +76 -9
- package/dist/chunks/{LpTooltip-CBdzeKNf.js → LpTooltip-CXVKcOXT.js} +3 -2
- package/dist/chunks/{LpTopologyCanvas-DocsLal3.js → LpTopologyCanvas-6ozNrQKh.js} +2 -1
- package/dist/chunks/{LpUptimeBar-DLtxOMRy.js → LpUptimeBar-ugcv25xj.js} +2 -2
- package/dist/chunks/icons-CkA0njzm.js +43 -0
- package/dist/chunks/{useTheme-C8JHUlPM.js → useTheme-DJm1JSsY.js} +47 -50
- package/dist/component-names.d.ts +1 -1
- package/dist/components/ContextMenuItems.vue.js +1 -1
- package/dist/components/LayoutCanvas.vue.js +1 -1
- package/dist/components/LayoutNode.vue.js +1 -1
- package/dist/components/LpActionBar.vue.js +1 -1
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAlert.vue.js +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpAutocomplete.vue.js +1 -1
- package/dist/components/LpAvatar.vue.js +1 -1
- package/dist/components/LpBreadcrumbs.vue.js +1 -1
- package/dist/components/LpButton.vue.d.ts +2 -2
- package/dist/components/LpButton.vue.js +1 -1
- package/dist/components/LpCalendar.vue.js +1 -1
- package/dist/components/LpCard.vue.js +1 -1
- package/dist/components/LpChart.vue.d.ts +16 -1
- package/dist/components/LpChart.vue.js +63 -5
- package/dist/components/LpCheckbox.vue.js +1 -1
- package/dist/components/LpCodeBlock.vue.js +1 -1
- package/dist/components/LpColorPicker.vue.d.ts +2 -2
- package/dist/components/LpColorPicker.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.d.ts +6 -0
- package/dist/components/LpCommandPalette.vue.js +62 -11
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpConfirmHost.vue.js +1 -1
- package/dist/components/LpContextMenu.vue.js +1 -1
- package/dist/components/LpDatePicker.vue.js +1 -1
- package/dist/components/LpDateRangePicker.vue.d.ts +60 -0
- package/dist/components/LpDateRangePicker.vue.js +4 -0
- package/dist/components/LpDisclosure.vue.js +1 -1
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpDropdownMenu.vue.js +1 -1
- package/dist/components/LpDropzone.vue.js +1 -1
- package/dist/components/LpEmptyState.vue.js +1 -1
- package/dist/components/LpFieldNotice.vue.d.ts +8 -0
- package/dist/components/LpFieldNotice.vue.js +4 -0
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpFileTreeNode.vue.js +1 -1
- package/dist/components/LpFormField.vue.d.ts +4 -1
- package/dist/components/LpFormField.vue.js +1 -1
- package/dist/components/LpIcon.vue.d.ts +0 -1
- package/dist/components/LpIcon.vue.js +1 -1
- package/dist/components/LpImageEditor.vue.js +2 -2
- package/dist/components/LpInput.vue.d.ts +19 -0
- package/dist/components/LpInput.vue.js +1 -1
- package/dist/components/LpLightbox.vue.js +1 -1
- package/dist/components/LpLogViewer.vue.d.ts +52 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpMapPicker.vue.js +1 -1
- package/dist/components/LpMediaGallery.vue.js +1 -1
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpModalNav.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpNumberField.vue.js +1 -1
- package/dist/components/LpPagination.vue.js +1 -1
- package/dist/components/LpPasswordInput.vue.js +1 -1
- package/dist/components/LpPhoneInput.vue.d.ts +13 -0
- package/dist/components/LpPhoneInput.vue.js +1 -1
- package/dist/components/LpPie.vue.js +1 -1
- package/dist/components/LpPopover.vue.js +1 -1
- package/dist/components/LpProgress.vue.d.ts +1 -1
- package/dist/components/LpRangeCalendar.vue.d.ts +51 -0
- package/dist/components/LpRangeCalendar.vue.js +4 -0
- package/dist/components/LpRollingText.vue.d.ts +1 -1
- package/dist/components/LpRollingText.vue.js +1 -1
- package/dist/components/LpScrollArea.vue.d.ts +2 -2
- package/dist/components/LpScrollArea.vue.js +1 -1
- package/dist/components/LpSection.vue.js +1 -1
- package/dist/components/LpSegmented.vue.js +1 -1
- package/dist/components/LpSelect.vue.js +1 -1
- package/dist/components/LpShortcutsDialog.vue.d.ts +6 -0
- package/dist/components/LpShortcutsDialog.vue.js +56 -14
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpStat.vue.js +1 -1
- package/dist/components/LpStepper.vue.js +1 -1
- package/dist/components/LpSwitch.vue.js +1 -1
- package/dist/components/LpTable.vue.d.ts +84 -2
- package/dist/components/LpTable.vue.js +481 -107
- package/dist/components/LpTableFilter.vue.d.ts +31 -0
- package/dist/components/LpTableFilter.vue.js +4 -0
- package/dist/components/LpTabs.vue.js +1 -1
- package/dist/components/LpTagsInput.vue.d.ts +77 -0
- package/dist/components/LpTagsInput.vue.js +4 -0
- package/dist/components/LpTextarea.vue.js +1 -1
- package/dist/components/LpThemeProvider.vue.js +1 -1
- package/dist/components/LpThemeSwitcher.vue.d.ts +2 -2
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/LpTimeField.vue.js +1 -1
- package/dist/components/LpToaster.vue.d.ts +2 -0
- package/dist/components/LpToaster.vue.js +1 -1
- package/dist/components/LpTooltip.vue.js +1 -1
- package/dist/components/LpTopologyCanvas.vue.js +1 -1
- package/dist/components/LpUptimeBar.vue.js +1 -1
- package/dist/components/chart.js +3 -2
- package/dist/components/commandSearch.js +3 -0
- package/dist/components/dateRange.d.ts +43 -0
- package/dist/components/dateRange.js +76 -0
- package/dist/components/exportData.d.ts +58 -0
- package/dist/components/exportData.js +165 -0
- package/dist/components/logSearch.d.ts +18 -0
- package/dist/components/logSearch.js +36 -0
- package/dist/components/phone.d.ts +41 -0
- package/dist/components/phone.js +80 -0
- package/dist/components/tableFilter.d.ts +36 -0
- package/dist/components/tableFilter.js +84 -0
- package/dist/components/tags.d.ts +20 -0
- package/dist/components/tags.js +37 -0
- package/dist/composables/useField.d.ts +4 -0
- package/dist/composables/useFieldNotice.d.ts +17 -0
- package/dist/composables/useFieldNotice.js +28 -0
- package/dist/composables/useForm.d.ts +53 -0
- package/dist/composables/useForm.js +207 -0
- package/dist/composables/useHotkeys.d.ts +65 -5
- package/dist/composables/useHotkeys.js +170 -9
- package/dist/composables/useInputMask.d.ts +59 -0
- package/dist/composables/useInputMask.js +170 -0
- package/dist/composables/useLocale.d.ts +2 -0
- package/dist/composables/useLocale.js +9 -0
- package/dist/composables/usePaintSelect.d.ts +22 -0
- package/dist/composables/usePaintSelect.js +169 -0
- package/dist/composables/useShift.js +7 -2
- package/dist/icons-all.d.ts +1 -0
- package/dist/icons-all.js +3 -0
- package/dist/icons.d.ts +14 -1
- package/dist/index.d.ts +29 -3
- package/dist/index.js +195 -138
- package/dist/kit-icons.d.ts +2 -0
- package/dist/theme/backdrop.d.ts +12 -0
- package/dist/theme/presets.d.ts +0 -2
- package/package.json +13 -4
- package/src/component-names.ts +4 -0
- package/src/components/ContextMenuItems.vue +1 -1
- package/src/components/LayoutNode.vue +11 -2
- package/src/components/LpActionBar.vue +1 -1
- package/src/components/LpAutocomplete.vue +1 -0
- package/src/components/LpAvatar.vue +2 -2
- package/src/components/LpButton.vue +1 -1
- package/src/components/LpChart.vue +80 -1
- package/src/components/LpColorPicker.vue +34 -1
- package/src/components/LpCommandPalette.vue +82 -8
- package/src/components/LpContextMenu.vue +1 -1
- package/src/components/LpDatePicker.vue +11 -4
- package/src/components/LpDateRangePicker.vue +226 -0
- package/src/components/LpDrawer.vue +1 -1
- package/src/components/LpDropdownMenu.vue +1 -0
- package/src/components/LpFieldNotice.vue +65 -0
- package/src/components/LpFormField.vue +33 -4
- package/src/components/LpIcon.vue +22 -4
- package/src/components/LpInput.vue +46 -6
- package/src/components/LpLogViewer.vue +225 -22
- package/src/components/LpModal.vue +2 -2
- package/src/components/LpModalNav.vue +13 -2
- package/src/components/LpNotificationBell.vue +15 -7
- package/src/components/LpPhoneInput.vue +179 -20
- package/src/components/LpPie.vue +8 -5
- package/src/components/LpPopover.vue +1 -0
- package/src/components/LpRangeCalendar.vue +365 -0
- package/src/components/LpRollingText.vue +3 -0
- package/src/components/LpScrollArea.vue +22 -2
- package/src/components/LpSelect.vue +0 -0
- package/src/components/LpShortcutsDialog.vue +77 -8
- package/src/components/LpStepper.vue +23 -10
- package/src/components/LpSwitch.vue +1 -1
- package/src/components/LpTable.vue +584 -65
- package/src/components/LpTableFilter.vue +175 -0
- package/src/components/LpTagsInput.vue +364 -0
- package/src/components/LpThemeSwitcher.vue +19 -23
- package/src/components/LpToaster.vue +91 -5
- package/src/components/LpTooltip.vue +9 -2
- package/src/components/LpTopologyCanvas.vue +7 -1
- package/src/components/chart.ts +5 -3
- package/src/components/commandSearch.ts +6 -0
- package/src/components/dateRange.ts +134 -0
- package/src/components/exportData.ts +228 -0
- package/src/components/logSearch.ts +46 -0
- package/src/components/phone.ts +140 -0
- package/src/components/tableFilter.ts +122 -0
- package/src/components/tags.ts +62 -0
- package/src/composables/useField.ts +4 -0
- package/src/composables/useFieldNotice.ts +59 -0
- package/src/composables/useForm.ts +314 -0
- package/src/composables/useHotkeys.ts +269 -13
- package/src/composables/useInputMask.ts +254 -0
- package/src/composables/useLocale.ts +14 -0
- package/src/composables/usePaintSelect.ts +243 -0
- package/src/composables/useShift.ts +13 -2
- package/src/iconSubset.d.mts +10 -0
- package/src/iconSubset.mjs +51 -0
- package/src/icons-all.ts +10 -0
- package/src/icons.ts +59 -8
- package/src/index.ts +37 -3
- package/src/kit-icons.ts +5 -0
- package/src/nuxt.ts +30 -2
- package/src/runtime/theme.plugin.ts +2 -2
- package/src/theme/backdrop.ts +69 -0
- package/src/theme/presets.ts +6 -64
- package/src/tokens/tokens.css +53 -4
|
@@ -11,17 +11,65 @@ import {
|
|
|
11
11
|
ToastTitle,
|
|
12
12
|
ToastViewport,
|
|
13
13
|
} from "reka-ui"
|
|
14
|
-
import { onBeforeUnmount, ref, watch } from "vue"
|
|
14
|
+
import { nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from "vue"
|
|
15
|
+
import { prefersReducedMotion } from "../composables/easing"
|
|
16
|
+
import { useClipboard } from "../composables/useClipboard"
|
|
15
17
|
import { useLabels } from "../composables/useLpConfig"
|
|
16
18
|
import { useToast, type ToastItem } from "../composables/useToast"
|
|
17
19
|
import { CLOSE_ICON } from "./dropdown"
|
|
18
20
|
import LpIcon from "./LpIcon.vue"
|
|
19
21
|
|
|
20
|
-
const props = defineProps<{ labels?: Partial<{ dismiss: string }> }>()
|
|
21
|
-
const text = useLabels("toast", { dismiss: "Dismiss" }, () => props.labels)
|
|
22
|
+
const props = defineProps<{ labels?: Partial<{ dismiss: string; copy: string; copied: string }> }>()
|
|
23
|
+
const text = useLabels("toast", { dismiss: "Dismiss", copy: "Copy message", copied: "Copied" }, () => props.labels)
|
|
22
24
|
|
|
23
25
|
const { queue, dismiss } = useToast()
|
|
24
26
|
|
|
27
|
+
/*
|
|
28
|
+
* The stack slides rather than jumps. A new toast lands at the bottom and the
|
|
29
|
+
* ones above used to leap up by its height in a single frame; one leaving let
|
|
30
|
+
* the rest drop the same way. Each toast's layout position (offsetTop, which
|
|
31
|
+
* ignores transforms) is remembered, and when the list changes every toast
|
|
32
|
+
* that moved is played from where it was to where it is (FLIP). The observer
|
|
33
|
+
* fires after the DOM changes and before the frame is painted, so the jump is
|
|
34
|
+
* never seen. Toasts render through reka's portal into the viewport, which is
|
|
35
|
+
* why this watches the DOM rather than a TransitionGroup.
|
|
36
|
+
*/
|
|
37
|
+
let observer: MutationObserver | undefined
|
|
38
|
+
const tops = new WeakMap<Element, number>()
|
|
39
|
+
|
|
40
|
+
function toastsIn(root: Element): HTMLElement[] {
|
|
41
|
+
return [...root.querySelectorAll<HTMLElement>("li[data-state]")]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function slide(root: Element) {
|
|
45
|
+
const reduce = prefersReducedMotion()
|
|
46
|
+
const height = (root as HTMLElement).offsetHeight
|
|
47
|
+
for (const el of toastsIn(root)) {
|
|
48
|
+
const before = tops.get(el)
|
|
49
|
+
const now = el.offsetTop - height
|
|
50
|
+
tops.set(el, now)
|
|
51
|
+
if (before === undefined || before === now || reduce || el.hasAttribute("data-swipe")) continue
|
|
52
|
+
el.animate([{ translate: `0 ${before - now}px` }, { translate: "0 0" }], {
|
|
53
|
+
duration: 220,
|
|
54
|
+
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The viewport's component root is a wrapper fragment, not the list, so the
|
|
60
|
+
// list is found by a mark of its own once it is teleported into place.
|
|
61
|
+
const viewportId = useId()
|
|
62
|
+
onMounted(() =>
|
|
63
|
+
nextTick(() => {
|
|
64
|
+
const root = document.querySelector(`[data-lp-toasts="${viewportId}"]`)
|
|
65
|
+
if (!root || typeof MutationObserver === "undefined") return
|
|
66
|
+
for (const el of toastsIn(root)) tops.set(el, el.offsetTop - (root as HTMLElement).offsetHeight)
|
|
67
|
+
observer = new MutationObserver(() => slide(root))
|
|
68
|
+
observer.observe(root, { childList: true, subtree: true })
|
|
69
|
+
}),
|
|
70
|
+
)
|
|
71
|
+
onBeforeUnmount(() => observer?.disconnect())
|
|
72
|
+
|
|
25
73
|
// A toast replaced through its `key` keeps its id and gets a new revision, so
|
|
26
74
|
// everything tied to one rendered toast goes by both.
|
|
27
75
|
const tag = (t: ToastItem) => `${t.id}:${t.rev}`
|
|
@@ -164,8 +212,32 @@ async function settle(t: ToastItem, run: () => void | Promise<void>) {
|
|
|
164
212
|
}
|
|
165
213
|
}
|
|
166
214
|
|
|
215
|
+
/*
|
|
216
|
+
* An error is something people need to pass on, so its text can be selected
|
|
217
|
+
* and copied. reka sets user-select: none on the toast and starts a swipe on
|
|
218
|
+
* any press, which made the message impossible to select: the description
|
|
219
|
+
* opts back into selection and keeps its presses from starting a swipe. A
|
|
220
|
+
* click that ends a selection is not a click on the toast.
|
|
221
|
+
*/
|
|
222
|
+
const { copy } = useClipboard()
|
|
223
|
+
const copiedId = ref<string | null>(null)
|
|
224
|
+
let copiedTimer: ReturnType<typeof setTimeout> | undefined
|
|
225
|
+
const copyable = (t: ToastItem) => t.variant === "error" || t.variant === "warning"
|
|
226
|
+
|
|
227
|
+
async function copyToast(t: ToastItem) {
|
|
228
|
+
if (!(await copy([t.title, t.message].filter(Boolean).join("\n")))) return
|
|
229
|
+
copiedId.value = tag(t)
|
|
230
|
+
clearTimeout(copiedTimer)
|
|
231
|
+
copiedTimer = setTimeout(() => (copiedId.value = null), 1500)
|
|
232
|
+
}
|
|
233
|
+
onBeforeUnmount(() => clearTimeout(copiedTimer))
|
|
234
|
+
|
|
235
|
+
function selecting(): boolean {
|
|
236
|
+
return Boolean(window.getSelection()?.toString())
|
|
237
|
+
}
|
|
238
|
+
|
|
167
239
|
function runClick(t: ToastItem) {
|
|
168
|
-
if (swiped === tag(t)) return
|
|
240
|
+
if (swiped === tag(t) || selecting()) return
|
|
169
241
|
if (t.onClick) return settle(t, t.onClick)
|
|
170
242
|
}
|
|
171
243
|
|
|
@@ -203,7 +275,10 @@ function runAction(t: ToastItem, action: NonNullable<ToastItem["actions"]>[numbe
|
|
|
203
275
|
<ToastTitle v-if="t.title" class="text-sm font-semibold text-ink">
|
|
204
276
|
{{ t.title }}
|
|
205
277
|
</ToastTitle>
|
|
206
|
-
<ToastDescription
|
|
278
|
+
<ToastDescription
|
|
279
|
+
class="cursor-text select-text break-words text-xs text-muted"
|
|
280
|
+
@pointerdown.stop
|
|
281
|
+
>
|
|
207
282
|
{{ t.message }}
|
|
208
283
|
</ToastDescription>
|
|
209
284
|
|
|
@@ -227,6 +302,16 @@ function runAction(t: ToastItem, action: NonNullable<ToastItem["actions"]>[numbe
|
|
|
227
302
|
</div>
|
|
228
303
|
</div>
|
|
229
304
|
|
|
305
|
+
<button
|
|
306
|
+
v-if="copyable(t)"
|
|
307
|
+
type="button"
|
|
308
|
+
class="-mt-1 rounded-md p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
|
|
309
|
+
:aria-label="copiedId === tag(t) ? text.copied : text.copy"
|
|
310
|
+
:title="copiedId === tag(t) ? text.copied : text.copy"
|
|
311
|
+
@click.stop="copyToast(t)"
|
|
312
|
+
>
|
|
313
|
+
<LpIcon :name="copiedId === tag(t) ? 'lucide:check' : 'lucide:copy'" :size="15" />
|
|
314
|
+
</button>
|
|
230
315
|
<button
|
|
231
316
|
type="button"
|
|
232
317
|
class="group -mr-1 -mt-1 rounded-md p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink"
|
|
@@ -256,6 +341,7 @@ function runAction(t: ToastItem, action: NonNullable<ToastItem["actions"]>[numbe
|
|
|
256
341
|
--z-toast (200) > --z-modal (110) wins as intended. -->
|
|
257
342
|
<Teleport to="body">
|
|
258
343
|
<ToastViewport
|
|
344
|
+
:data-lp-toasts="viewportId"
|
|
259
345
|
class="fixed bottom-0 right-0 z-(--z-toast) flex w-[min(92vw,22rem)] flex-col gap-2 p-4 outline-none"
|
|
260
346
|
/>
|
|
261
347
|
</Teleport>
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
TooltipRoot,
|
|
36
36
|
TooltipTrigger,
|
|
37
37
|
} from "reka-ui"
|
|
38
|
-
import {
|
|
38
|
+
import { onBeforeUnmount, ref, watch, type FunctionalComponent } from "vue"
|
|
39
39
|
import {
|
|
40
40
|
springSettled,
|
|
41
41
|
springStep,
|
|
@@ -108,6 +108,13 @@ const props = withDefaults(
|
|
|
108
108
|
* shared one (LpTooltipProvider at the root) the next tip opens at once.
|
|
109
109
|
*/
|
|
110
110
|
const shared = injectTooltipProviderContext(null)
|
|
111
|
+
/*
|
|
112
|
+
* Under a shared provider the tip renders straight into it. `Fragment` cannot
|
|
113
|
+
* stand in for the wrapper: given through `<component :is>` it takes slots,
|
|
114
|
+
* not children, and rendered nothing at all, so every tip inside an
|
|
115
|
+
* LpTooltipProvider vanished together with its trigger.
|
|
116
|
+
*/
|
|
117
|
+
const PassThrough: FunctionalComponent = (_, { slots }) => slots.default?.()
|
|
111
118
|
|
|
112
119
|
const reducedMotion = () =>
|
|
113
120
|
typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
|
@@ -221,7 +228,7 @@ onBeforeUnmount(() => {
|
|
|
221
228
|
</script>
|
|
222
229
|
|
|
223
230
|
<template>
|
|
224
|
-
<component :is="shared ?
|
|
231
|
+
<component :is="shared ? PassThrough : TooltipProvider" v-bind="shared ? {} : { delayDuration: delay ?? 200 }">
|
|
225
232
|
<!--
|
|
226
233
|
`disable-closing-trigger` in follow mode: reka dismisses a tip when its
|
|
227
234
|
trigger is clicked, which is right for a button (the tip explained the
|
|
@@ -417,8 +417,12 @@ defineExpose({ fitView, setViewport: applyViewport })
|
|
|
417
417
|
class="lp-topology"
|
|
418
418
|
>
|
|
419
419
|
<Background :gap="22" :size="1.4" pattern-color="var(--color-line-strong)" />
|
|
420
|
+
<!-- On a phone the minimap, the zoom controls and the legend would all
|
|
421
|
+
share the bottom of a canvas a few hundred pixels wide and cover each
|
|
422
|
+
other and the graph; pinch-zoom and panning do the minimap's job there. -->
|
|
420
423
|
<MiniMap
|
|
421
424
|
v-if="minimap"
|
|
425
|
+
class="max-sm:!hidden"
|
|
422
426
|
pannable
|
|
423
427
|
zoomable
|
|
424
428
|
node-color="var(--color-surface-soft)"
|
|
@@ -427,7 +431,9 @@ defineExpose({ fitView, setViewport: applyViewport })
|
|
|
427
431
|
<Controls v-if="controls" />
|
|
428
432
|
|
|
429
433
|
<!-- Edge-type legend: makes the colour/dash encoding self-explanatory. -->
|
|
430
|
-
|
|
434
|
+
<!-- On a phone the legend would sit on the graph itself; the edge styles
|
|
435
|
+
stay readable without it there. -->
|
|
436
|
+
<Panel v-if="legend" :position="legendPosition" :class="legendOffset" class="max-sm:hidden">
|
|
431
437
|
<div
|
|
432
438
|
class="flex flex-col gap-1.5 rounded-card border border-line bg-surface-overlay/90 px-3 py-2 text-[11px] backdrop-blur"
|
|
433
439
|
>
|
package/src/components/chart.ts
CHANGED
|
@@ -157,10 +157,12 @@ export const niceScale = (
|
|
|
157
157
|
): { min: number; max: number; step: number } => {
|
|
158
158
|
if (!Number.isFinite(min) || !Number.isFinite(max)) return { min: 0, max: 1, step: 1 }
|
|
159
159
|
if (min === max) {
|
|
160
|
-
// A flat series still needs a band to sit in
|
|
160
|
+
// A flat series still needs a band to sit in, but the band must not cross
|
|
161
|
+
// zero: an all-zero count would otherwise read as dipping to -0.5.
|
|
161
162
|
const pad = Math.abs(min) || 1
|
|
162
|
-
min
|
|
163
|
-
max
|
|
163
|
+
min = min >= 0 ? Math.max(0, min - pad / 2) : min - pad / 2
|
|
164
|
+
max = max <= 0 ? Math.min(0, max + pad / 2) : max + pad / 2
|
|
165
|
+
if (min === max) max = min + pad
|
|
164
166
|
}
|
|
165
167
|
const raw = (max - min) / Math.max(1, ticks)
|
|
166
168
|
const mag = 10 ** Math.floor(Math.log10(raw))
|
|
@@ -63,6 +63,12 @@ function score(command: SearchableCommand, terms: CommandSearchTerms): number {
|
|
|
63
63
|
if (!hit) return 0
|
|
64
64
|
total += hit
|
|
65
65
|
}
|
|
66
|
+
// A label that IS what was typed, or starts with it, is what the person is
|
|
67
|
+
// naming: "theme" must put the Theme command above ThemeSwitcher, which only
|
|
68
|
+
// contains the word but also matches its keywords and description.
|
|
69
|
+
const typed = terms.include.join(" ")
|
|
70
|
+
if (typed && label === typed) total += 6
|
|
71
|
+
else if (typed && label.startsWith(typed)) total += 2
|
|
66
72
|
return total
|
|
67
73
|
}
|
|
68
74
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CalendarDate,
|
|
3
|
+
endOfMonth,
|
|
4
|
+
getLocalTimeZone,
|
|
5
|
+
parseDate,
|
|
6
|
+
startOfMonth,
|
|
7
|
+
today,
|
|
8
|
+
} from "@internationalized/date"
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* Date ranges as the kit models them: two ISO "YYYY-MM-DD" strings, inclusive
|
|
12
|
+
* on both ends, the same shape LpDatePicker uses for a single day. Presets are
|
|
13
|
+
* plain functions so a consumer can add "This quarter" or "Year to date"
|
|
14
|
+
* without the kit knowing about it.
|
|
15
|
+
*/
|
|
16
|
+
export interface DateRange {
|
|
17
|
+
start: string
|
|
18
|
+
end: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DateRangePreset {
|
|
22
|
+
label: string
|
|
23
|
+
range: () => DateRange
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DateRangePresetLabels {
|
|
27
|
+
today: string
|
|
28
|
+
last7: string
|
|
29
|
+
last30: string
|
|
30
|
+
thisMonth: string
|
|
31
|
+
lastMonth: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function span(start: CalendarDate, end: CalendarDate): DateRange {
|
|
35
|
+
return { start: start.toString(), end: end.toString() }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The default preset list. `now` is injectable so the set can be tested. */
|
|
39
|
+
export function defaultRangePresets(
|
|
40
|
+
labels: DateRangePresetLabels,
|
|
41
|
+
now: () => CalendarDate = () => today(getLocalTimeZone()),
|
|
42
|
+
): DateRangePreset[] {
|
|
43
|
+
return [
|
|
44
|
+
{ label: labels.today, range: () => span(now(), now()) },
|
|
45
|
+
{ label: labels.last7, range: () => span(now().subtract({ days: 6 }), now()) },
|
|
46
|
+
{ label: labels.last30, range: () => span(now().subtract({ days: 29 }), now()) },
|
|
47
|
+
{ label: labels.thisMonth, range: () => span(startOfMonth(now()), endOfMonth(now())) },
|
|
48
|
+
{
|
|
49
|
+
label: labels.lastMonth,
|
|
50
|
+
range: () => {
|
|
51
|
+
const previous = now().subtract({ months: 1 })
|
|
52
|
+
return span(startOfMonth(previous), endOfMonth(previous))
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseIsoDay(iso: string | undefined): CalendarDate | undefined {
|
|
59
|
+
if (!iso) return undefined
|
|
60
|
+
try {
|
|
61
|
+
return parseDate(iso.slice(0, 10))
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A range with both ends parsed and in order, or undefined. */
|
|
68
|
+
export function normalizeRange(range: Partial<DateRange> | undefined): DateRange | undefined {
|
|
69
|
+
const start = parseIsoDay(range?.start)
|
|
70
|
+
const end = parseIsoDay(range?.end)
|
|
71
|
+
if (!start || !end) return undefined
|
|
72
|
+
return start.compare(end) <= 0 ? span(start, end) : span(end, start)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function sameRange(a: Partial<DateRange> | undefined, b: Partial<DateRange> | undefined): boolean {
|
|
76
|
+
const x = normalizeRange(a)
|
|
77
|
+
const y = normalizeRange(b)
|
|
78
|
+
return !!x && !!y && x.start === y.start && x.end === y.end
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Clip a preset to the allowed bounds; undefined when nothing of it is left. */
|
|
82
|
+
export function clampRange(range: DateRange, min?: string, max?: string): DateRange | undefined {
|
|
83
|
+
const r = normalizeRange(range)
|
|
84
|
+
if (!r) return undefined
|
|
85
|
+
const start = min && r.start < min ? min : r.start
|
|
86
|
+
const end = max && r.end > max ? max : r.end
|
|
87
|
+
return start <= end ? { start, end } : undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* "Jun 3 – 18, 2026" style text: Intl's own range formatting collapses the
|
|
92
|
+
* shared month and year, which is how a range reads naturally in every locale.
|
|
93
|
+
*/
|
|
94
|
+
export function formatRange(
|
|
95
|
+
range: DateRange,
|
|
96
|
+
locale?: string,
|
|
97
|
+
format: Intl.DateTimeFormatOptions = { year: "numeric", month: "short", day: "numeric" },
|
|
98
|
+
): string {
|
|
99
|
+
const start = parseIsoDay(range.start)
|
|
100
|
+
const end = parseIsoDay(range.end)
|
|
101
|
+
if (!start || !end) return ""
|
|
102
|
+
const zone = "UTC"
|
|
103
|
+
const fmt = new Intl.DateTimeFormat(locale, { ...format, timeZone: zone })
|
|
104
|
+
return fmt.formatRange(start.toDate(zone), end.toDate(zone))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ReshapeRules {
|
|
108
|
+
/** Whether the moving end may land on this day (min, max, disabled). */
|
|
109
|
+
allowed?: (iso: string) => boolean
|
|
110
|
+
/** Days a range may not include. */
|
|
111
|
+
unavailable?: (iso: string) => boolean
|
|
112
|
+
/** Longest range, in days, counting both ends. */
|
|
113
|
+
maximumDays?: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The range one end makes when it is dragged to `moving` while the other stays
|
|
118
|
+
* at `anchor`; passing the anchor flips which end is which. Null when the result
|
|
119
|
+
* would break a rule, so a drag simply does not follow onto that day.
|
|
120
|
+
*/
|
|
121
|
+
export function reshapeRange(anchor: string, moving: string, rules: ReshapeRules = {}): DateRange | null {
|
|
122
|
+
const a = parseIsoDay(anchor)
|
|
123
|
+
const m = parseIsoDay(moving)
|
|
124
|
+
if (!a || !m) return null
|
|
125
|
+
if (rules.allowed && !rules.allowed(m.toString())) return null
|
|
126
|
+
const [start, end] = a.compare(m) <= 0 ? [a, m] : [m, a]
|
|
127
|
+
if (rules.maximumDays && end.compare(start) + 1 > rules.maximumDays) return null
|
|
128
|
+
if (rules.unavailable) {
|
|
129
|
+
for (let d = start; d.compare(end) <= 0; d = d.add({ days: 1 })) {
|
|
130
|
+
if (rules.unavailable(d.toString())) return null
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { start: start.toString(), end: end.toString() }
|
|
134
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Data export shared by the table and the chart: CSV text, a file download,
|
|
3
|
+
* and an SVG rasterised with its theme resolved.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface CsvColumn<Row> {
|
|
7
|
+
key: string
|
|
8
|
+
/** Header cell. Defaults to the key. */
|
|
9
|
+
label?: string
|
|
10
|
+
/** The value written for a row; the row's `key` field when omitted. */
|
|
11
|
+
value?: (row: Row) => unknown
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CsvOptions {
|
|
15
|
+
/** Field separator. Default ",". Some locales' spreadsheets expect ";". */
|
|
16
|
+
separator?: string
|
|
17
|
+
/** Prefix a UTF-8 byte order mark so Excel reads non-ASCII text correctly. */
|
|
18
|
+
bom?: boolean
|
|
19
|
+
/**
|
|
20
|
+
* Neutralise cells a spreadsheet would run as a formula (leading = + - @,
|
|
21
|
+
* tab or CR) by prefixing a quote. On by default: exported data is often
|
|
22
|
+
* user-supplied, and opening it is enough to trigger a formula.
|
|
23
|
+
*/
|
|
24
|
+
guardFormulas?: boolean
|
|
25
|
+
/** Line ending. Default "\r\n", as RFC 4180 specifies. */
|
|
26
|
+
newline?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const FORMULA_START = /^[=+\-@\t\r]/
|
|
30
|
+
|
|
31
|
+
function cellText(value: unknown): string {
|
|
32
|
+
if (value == null) return ""
|
|
33
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? "" : value.toISOString()
|
|
34
|
+
if (typeof value === "object") return JSON.stringify(value)
|
|
35
|
+
return String(value)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One CSV field, quoted when it holds a separator, a quote or a line break. */
|
|
39
|
+
export function csvField(value: unknown, options: CsvOptions = {}): string {
|
|
40
|
+
const separator = options.separator ?? ","
|
|
41
|
+
let text = cellText(value)
|
|
42
|
+
if ((options.guardFormulas ?? true) && typeof value === "string" && FORMULA_START.test(text)) {
|
|
43
|
+
text = `'${text}`
|
|
44
|
+
}
|
|
45
|
+
const needsQuotes = text.includes(separator) || /["\r\n]/.test(text) || /^\s|\s$/.test(text)
|
|
46
|
+
return needsQuotes ? `"${text.replace(/"/g, '""')}"` : text
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Rows as CSV text, a header line first. */
|
|
50
|
+
export function toCsv<Row>(rows: readonly Row[], columns: readonly CsvColumn<Row>[], options: CsvOptions = {}): string {
|
|
51
|
+
const separator = options.separator ?? ","
|
|
52
|
+
const newline = options.newline ?? "\r\n"
|
|
53
|
+
const line = (cells: unknown[]) => cells.map((c) => csvField(c, options)).join(separator)
|
|
54
|
+
const out = [line(columns.map((c) => c.label ?? c.key))]
|
|
55
|
+
for (const row of rows) {
|
|
56
|
+
out.push(
|
|
57
|
+
line(columns.map((c) => (c.value ? c.value(row) : (row as Record<string, unknown>)[c.key]))),
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
return (options.bom ? "" : "") + out.join(newline) + newline
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Hand the viewer a file to save. Does nothing outside a browser, so a call
|
|
65
|
+
* that runs during SSR is harmless.
|
|
66
|
+
*/
|
|
67
|
+
export function downloadFile(name: string, data: Blob | string, type = "text/plain;charset=utf-8"): void {
|
|
68
|
+
if (typeof document === "undefined" || typeof URL === "undefined") return
|
|
69
|
+
const blob = typeof data === "string" ? new Blob([data], { type }) : data
|
|
70
|
+
const url = URL.createObjectURL(blob)
|
|
71
|
+
const a = document.createElement("a")
|
|
72
|
+
a.href = url
|
|
73
|
+
a.download = name
|
|
74
|
+
a.rel = "noopener"
|
|
75
|
+
a.style.display = "none"
|
|
76
|
+
document.body.append(a)
|
|
77
|
+
a.click()
|
|
78
|
+
a.remove()
|
|
79
|
+
setTimeout(() => URL.revokeObjectURL(url), 0)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const PAINT_PROPS = [
|
|
83
|
+
"fill",
|
|
84
|
+
"fill-opacity",
|
|
85
|
+
"fill-rule",
|
|
86
|
+
"stroke",
|
|
87
|
+
"stroke-opacity",
|
|
88
|
+
"stroke-width",
|
|
89
|
+
"stroke-dasharray",
|
|
90
|
+
"stroke-dashoffset",
|
|
91
|
+
"stroke-linecap",
|
|
92
|
+
"stroke-linejoin",
|
|
93
|
+
"opacity",
|
|
94
|
+
"stop-color",
|
|
95
|
+
"stop-opacity",
|
|
96
|
+
"color",
|
|
97
|
+
"font-family",
|
|
98
|
+
"font-size",
|
|
99
|
+
"font-weight",
|
|
100
|
+
"letter-spacing",
|
|
101
|
+
"text-anchor",
|
|
102
|
+
"dominant-baseline",
|
|
103
|
+
"visibility",
|
|
104
|
+
"display",
|
|
105
|
+
] as const
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A standalone copy of a live SVG: every element carries its computed paint as
|
|
109
|
+
* an inline style, so CSS custom properties and classes from the page's
|
|
110
|
+
* stylesheet survive being rendered as an image, and running SMIL animations
|
|
111
|
+
* are frozen at their end state.
|
|
112
|
+
*/
|
|
113
|
+
export function inlineSvg(svg: SVGSVGElement): SVGSVGElement {
|
|
114
|
+
const clone = svg.cloneNode(true) as SVGSVGElement
|
|
115
|
+
const source = [svg, ...svg.querySelectorAll("*")]
|
|
116
|
+
const target = [clone, ...clone.querySelectorAll("*")]
|
|
117
|
+
source.forEach((el, i) => {
|
|
118
|
+
const copy = target[i]
|
|
119
|
+
if (!(copy instanceof Element)) return
|
|
120
|
+
const computed = getComputedStyle(el)
|
|
121
|
+
const style = PAINT_PROPS.map((p) => `${p}:${computed.getPropertyValue(p)}`)
|
|
122
|
+
.filter((d) => !d.endsWith(":"))
|
|
123
|
+
.join(";")
|
|
124
|
+
copy.setAttribute("style", style)
|
|
125
|
+
copy.removeAttribute("class")
|
|
126
|
+
})
|
|
127
|
+
// Comment nodes (Vue leaves them in development builds) may hold "--", which
|
|
128
|
+
// is invalid inside an XML comment and makes the whole image fail to load.
|
|
129
|
+
const comments = document.createTreeWalker(clone, NodeFilter.SHOW_COMMENT)
|
|
130
|
+
const dead: Node[] = []
|
|
131
|
+
while (comments.nextNode()) dead.push(comments.currentNode)
|
|
132
|
+
for (const node of dead) node.parentNode?.removeChild(node)
|
|
133
|
+
for (const anim of clone.querySelectorAll("animate")) {
|
|
134
|
+
const attr = anim.getAttribute("attributeName")
|
|
135
|
+
const to = anim.getAttribute("to")
|
|
136
|
+
if (attr && to != null) anim.parentElement?.setAttribute(attr, to)
|
|
137
|
+
anim.remove()
|
|
138
|
+
}
|
|
139
|
+
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg")
|
|
140
|
+
const { width, height } = svg.getBoundingClientRect()
|
|
141
|
+
clone.setAttribute("width", String(width))
|
|
142
|
+
clone.setAttribute("height", String(height))
|
|
143
|
+
clone.style.overflow = "visible"
|
|
144
|
+
return clone
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface RasterOptions {
|
|
148
|
+
/** Device pixels per CSS pixel. Default: the screen's ratio, at least 2. */
|
|
149
|
+
scale?: number
|
|
150
|
+
/** Fill behind the drawing; transparent when omitted. */
|
|
151
|
+
background?: string
|
|
152
|
+
/** Extra space around the drawing, in CSS pixels. */
|
|
153
|
+
padding?: number
|
|
154
|
+
/** Drawn above the SVG, in the page's own font. */
|
|
155
|
+
header?: (ctx: CanvasRenderingContext2D, width: number) => number
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Rasterise an SVG element to a PNG blob with its theme resolved. */
|
|
159
|
+
export async function svgToPng(svg: SVGSVGElement, options: RasterOptions = {}): Promise<Blob> {
|
|
160
|
+
const copy = inlineSvg(svg)
|
|
161
|
+
const width = Number(copy.getAttribute("width"))
|
|
162
|
+
const height = Number(copy.getAttribute("height"))
|
|
163
|
+
const markup = new XMLSerializer().serializeToString(copy)
|
|
164
|
+
const url = URL.createObjectURL(new Blob([markup], { type: "image/svg+xml;charset=utf-8" }))
|
|
165
|
+
try {
|
|
166
|
+
const img = new Image()
|
|
167
|
+
img.decoding = "async"
|
|
168
|
+
const loaded = new Promise<void>((resolve, reject) => {
|
|
169
|
+
img.onload = () => resolve()
|
|
170
|
+
img.onerror = () => reject(new Error("SVG could not be rasterised"))
|
|
171
|
+
})
|
|
172
|
+
img.src = url
|
|
173
|
+
await loaded
|
|
174
|
+
|
|
175
|
+
const scale = options.scale ?? Math.max(2, globalThis.devicePixelRatio || 1)
|
|
176
|
+
const pad = options.padding ?? 0
|
|
177
|
+
const measure = document.createElement("canvas").getContext("2d")
|
|
178
|
+
const headerHeight = measure && options.header ? options.header(measure, width) : 0
|
|
179
|
+
const canvas = document.createElement("canvas")
|
|
180
|
+
canvas.width = Math.ceil((width + pad * 2) * scale)
|
|
181
|
+
canvas.height = Math.ceil((height + headerHeight + pad * 2) * scale)
|
|
182
|
+
const ctx = canvas.getContext("2d")
|
|
183
|
+
if (!ctx) throw new Error("Canvas 2D is unavailable")
|
|
184
|
+
ctx.scale(scale, scale)
|
|
185
|
+
if (options.background) {
|
|
186
|
+
ctx.fillStyle = options.background
|
|
187
|
+
ctx.fillRect(0, 0, width + pad * 2, height + headerHeight + pad * 2)
|
|
188
|
+
}
|
|
189
|
+
ctx.translate(pad, pad)
|
|
190
|
+
if (options.header) options.header(ctx, width)
|
|
191
|
+
ctx.drawImage(img, 0, headerHeight, width, height)
|
|
192
|
+
return await new Promise<Blob>((resolve, reject) =>
|
|
193
|
+
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("PNG encoding failed"))), "image/png"),
|
|
194
|
+
)
|
|
195
|
+
} finally {
|
|
196
|
+
URL.revokeObjectURL(url)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The first non-transparent background at or above an element. */
|
|
201
|
+
export function backgroundOf(el: Element | null): string | undefined {
|
|
202
|
+
for (let node = el; node; node = node.parentElement) {
|
|
203
|
+
const bg = getComputedStyle(node).backgroundColor
|
|
204
|
+
if (bg && bg !== "transparent" && !/rgba\([^)]*,\s*0\)$/.test(bg)) return bg
|
|
205
|
+
}
|
|
206
|
+
return undefined
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A colour as the browser resolves it where `within` sits, for APIs that cannot
|
|
211
|
+
* read custom properties (canvas, an SVG drawn as an image).
|
|
212
|
+
*/
|
|
213
|
+
export function resolveCssColor(value: string, within: Element): string {
|
|
214
|
+
if (!value.includes("var(") && !value.toLowerCase().includes("currentcolor")) return value
|
|
215
|
+
const probe = document.createElement("span")
|
|
216
|
+
probe.style.color = value
|
|
217
|
+
probe.style.display = "none"
|
|
218
|
+
within.append(probe)
|
|
219
|
+
const resolved = getComputedStyle(probe).color
|
|
220
|
+
probe.remove()
|
|
221
|
+
return resolved || value
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A filesystem-safe name from a title. */
|
|
225
|
+
export function fileStem(title: string | undefined, fallback: string): string {
|
|
226
|
+
const stem = (title ?? "").trim().replace(/[\\/:*?"<>|\u0000-\u001f]+/g, "").replace(/\s+/g, "-")
|
|
227
|
+
return stem || fallback
|
|
228
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The log viewer's search and filtering, kept free of the component so the
|
|
3
|
+
* rules (what counts as a match, how the cursor wraps) can be tested alone.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The last `max` lines, and how many were cut from the front. */
|
|
7
|
+
export function ringWindow<T>(lines: readonly T[], max: number | undefined): { lines: readonly T[]; offset: number } {
|
|
8
|
+
if (!max || max <= 0 || lines.length <= max) return { lines, offset: 0 }
|
|
9
|
+
return { lines: lines.slice(lines.length - max), offset: lines.length - max }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Lines per level, a line with no level counting as `fallback`. */
|
|
13
|
+
export function countLevels<L extends string>(
|
|
14
|
+
lines: readonly { level?: L }[],
|
|
15
|
+
fallback: L,
|
|
16
|
+
): Map<L, number> {
|
|
17
|
+
const counts = new Map<L, number>()
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
const level = line.level ?? fallback
|
|
20
|
+
counts.set(level, (counts.get(level) ?? 0) + 1)
|
|
21
|
+
}
|
|
22
|
+
return counts
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Case-insensitive substring test; an empty term matches nothing. */
|
|
26
|
+
export function matchesTerm(message: string, term: string): boolean {
|
|
27
|
+
const needle = term.trim().toLowerCase()
|
|
28
|
+
return needle.length > 0 && message.toLowerCase().includes(needle)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Positions (into `rows`) of the rows whose message or source holds the term. */
|
|
32
|
+
export function matchPositions(rows: readonly { message: string; source?: string }[], term: string): number[] {
|
|
33
|
+
if (!term.trim()) return []
|
|
34
|
+
const out: number[] = []
|
|
35
|
+
rows.forEach((row, i) => {
|
|
36
|
+
if (matchesTerm(row.message, term) || (row.source !== undefined && matchesTerm(row.source, term))) out.push(i)
|
|
37
|
+
})
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The next match cursor, wrapping at both ends; -1 when there are none. */
|
|
42
|
+
export function stepMatch(current: number, total: number, direction: 1 | -1): number {
|
|
43
|
+
if (total <= 0) return -1
|
|
44
|
+
if (current < 0 || current >= total) return direction === 1 ? 0 : total - 1
|
|
45
|
+
return (current + direction + total) % total
|
|
46
|
+
}
|