@leavepulse/ui 0.56.0 → 0.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunks/{ContextMenuItems-BtKU7074.js → ContextMenuItems-BY5sTVQF.js} +1 -1
- package/dist/chunks/{LayoutCanvas-BkaGXTjx.js → LayoutCanvas-BcGLJVBq.js} +1 -1
- package/dist/chunks/{LayoutNode-B3_9E1Xa.js → LayoutNode-BkBn6dvM.js} +1 -1
- package/dist/chunks/{LpActionBar-C3hkVsFz.js → LpActionBar-DCN-_pZP.js} +2 -2
- package/dist/chunks/{LpAddressInput-NUHmplCd.js → LpAddressInput-Dio6Sqed.js} +7 -7
- package/dist/chunks/{LpAlert-B6ilrM7T.js → LpAlert-CXMn1GYE.js} +1 -1
- package/dist/chunks/{LpAppShell-BEGjSqFl.js → LpAppShell-Be6T96td.js} +3 -3
- package/dist/chunks/{LpAutocomplete-C8IgSMsi.js → LpAutocomplete-hJ_6CHlh.js} +1 -1
- package/dist/chunks/{LpAvatar-LTvy0OkE.js → LpAvatar-BxNs23Xu.js} +2 -2
- package/dist/chunks/{LpBreadcrumbs-D4TsoFju.js → LpBreadcrumbs-BLzOEgAF.js} +1 -1
- package/dist/chunks/{LpButton-BdfZFNqi.js → LpButton-vhlwmpBy.js} +1 -1
- package/dist/chunks/{LpCalendar-DrA2McTU.js → LpCalendar-BLoE8Qm7.js} +1 -1
- package/dist/chunks/{LpCard-DbjO5yaF.js → LpCard-DhR7u0d8.js} +1 -1
- package/dist/chunks/{LpCheckbox-DvVYeOD-.js → LpCheckbox-B_C8sOoT.js} +1 -1
- package/dist/chunks/{LpCodeBlock-ca7ANFyW.js → LpCodeBlock-CXsqJwDy.js} +1 -1
- package/dist/chunks/{LpColorPicker-D1sQqtXS.js → LpColorPicker-DQ88niXq.js} +2 -2
- package/dist/chunks/{LpConfirmDialog-CrbUZgJE.js → LpConfirmDialog-DxlSdjmk.js} +3 -3
- package/dist/chunks/{LpConfirmHost-BgMIV8r9.js → LpConfirmHost-BcrmTNu_.js} +1 -1
- package/dist/chunks/{LpContextMenu-EF_90u3g.js → LpContextMenu-B1NGfpp1.js} +2 -2
- package/dist/chunks/{LpDatePicker-BrdFtSTy.js → LpDatePicker-B-VMHMgy.js} +3 -3
- package/dist/chunks/{LpDateRangePicker-Drd7EbIz.js → LpDateRangePicker-B68KPKPl.js} +6 -15
- package/dist/chunks/{LpDisclosure-Cc23-JOT.js → LpDisclosure-Ct-VBHxt.js} +1 -1
- package/dist/chunks/{LpDrawer-CBFVD624.js → LpDrawer-DSknigxH.js} +5 -7
- package/dist/chunks/{LpDropdownMenu-DrpJJBp0.js → LpDropdownMenu-C7oHVlKY.js} +2 -2
- package/dist/chunks/{LpEmptyState-BJbZXC8D.js → LpEmptyState-CBwwkkdA.js} +1 -1
- package/dist/chunks/{LpFieldNotice-wtieLUTj.js → LpFieldNotice-9hH4ITZa.js} +1 -1
- package/dist/chunks/{LpFileTree-3E4w4Rjr.js → LpFileTree-CMA0_rsB.js} +2 -2
- package/dist/chunks/{LpFileTreeNode-B4uB8Ks_.js → LpFileTreeNode-B4T258hF.js} +3 -3
- package/dist/chunks/{LpFormField-CtVYj6tO.js → LpFormField-BpQ6WJPe.js} +1 -1
- package/dist/chunks/LpHintLayer-B5-6FyEf.js +154 -0
- package/dist/chunks/LpHints-BqoZXHZk.js +109 -0
- package/dist/chunks/{LpIcon-DYtaid0K.js → LpIcon-B5guGKrW.js} +1 -1
- package/dist/chunks/{LpInput-D-YGQbEv.js → LpInput-WvyP02ID.js} +2 -2
- package/dist/chunks/{LpLightbox-EVXzAq62.js → LpLightbox-Dq-e36uT.js} +1 -1
- package/dist/chunks/{LpLogViewer-BfgtXVKm.js → LpLogViewer-DwiYE32z.js} +2 -2
- package/dist/chunks/{LpMapPicker-Cc84LaM3.js → LpMapPicker-BH5csaBt.js} +1 -1
- package/dist/chunks/{LpMediaGallery-BnlkN_1f.js → LpMediaGallery-B1kDypJ-.js} +2 -2
- package/dist/chunks/{LpModal-JNa3B_Lr.js → LpModal-BxYssnXg.js} +1 -1
- package/dist/chunks/{LpModalNav-DXBilXr0.js → LpModalNav-Bn2ws64b.js} +4 -4
- package/dist/chunks/{LpNotificationBell-DEL-RoHS.js → LpNotificationBell-DJfs4JjQ.js} +4 -4
- package/dist/chunks/{LpNumberField-B77O-z28.js → LpNumberField-CJPjmoDz.js} +1 -1
- package/dist/chunks/{LpPagination-BcDlo3r_.js → LpPagination-DehAaJLY.js} +1 -1
- package/dist/chunks/{LpPasswordInput-eKzQhbQP.js → LpPasswordInput-Bg5uTZbx.js} +2 -2
- package/dist/chunks/{LpPhoneInput-BCbrrhgE.js → LpPhoneInput-DStwcbnQ.js} +2 -2
- package/dist/chunks/{LpPie-B7ti5qCH.js → LpPie-DeTkbbOK.js} +3 -3
- package/dist/chunks/{LpRangeCalendar-DZuaRV0W.js → LpRangeCalendar-D5sDID9W.js} +1 -1
- package/dist/chunks/{LpSection-tKEH0jYf.js → LpSection-B19VPYmr.js} +1 -1
- package/dist/chunks/{LpSegmented-C09EEam2.js → LpSegmented-D3Eam8sa.js} +1 -1
- package/dist/chunks/{LpSelect-DNIL6MB9.js → LpSelect-DynjWK1A.js} +1 -1
- package/dist/chunks/{LpSidebar-C2oolgwy.js → LpSidebar-CshW0fqC.js} +3 -3
- package/dist/chunks/{LpSidebarNav-C9dHu-2b.js → LpSidebarNav-C9b3i9G2.js} +2 -2
- package/dist/chunks/{LpStat-B-7udBRP.js → LpStat-BS2TEXYK.js} +1 -1
- package/dist/chunks/{LpStepper-C7XiVKZj.js → LpStepper-BSglWF45.js} +1 -1
- package/dist/chunks/{LpTableFilter-CrW7_Rj4.js → LpTableFilter-CwHXVxJt.js} +2 -2
- package/dist/chunks/{LpTableOfContents-BUcAfGPQ.js → LpTableOfContents-DYuqkByy.js} +2 -8
- package/dist/chunks/{LpTabs-BMSBB78h.js → LpTabs-BBGRHY38.js} +1 -1
- package/dist/chunks/{LpTagsInput-D1SuqbhB.js → LpTagsInput-CHbQoAAA.js} +3 -3
- package/dist/chunks/{LpTextarea-uuScWo6-.js → LpTextarea-Bz2IWhTH.js} +1 -1
- package/dist/chunks/{LpThemeSwitcher-Fz85BCRS.js → LpThemeSwitcher-B_qhSl7q.js} +2 -2
- package/dist/chunks/{LpTimeField-Cqe7fXnh.js → LpTimeField-DvGXgdES.js} +1 -1
- package/dist/chunks/{LpToaster-DTtWuKvq.js → LpToaster-Bo9Q4QBP.js} +1 -1
- package/dist/chunks/{LpTooltip-CXVKcOXT.js → LpTooltip-suFCM34F.js} +2 -2
- package/dist/chunks/LpTour-D7m9ZxNk.js +334 -0
- package/dist/chunks/{LpUptimeBar-ugcv25xj.js → LpUptimeBar-B4ltCfno.js} +1 -1
- package/dist/chunks/{MenuTitle-J3ZDYjpV.js → MenuTitle-DQP6hc2N.js} +1 -1
- package/dist/chunks/TourCard-DCHOjr6e.js +100 -0
- package/dist/chunks/icons-QqBscG5I.js +43 -0
- 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.js +1 -1
- package/dist/components/LpCalendar.vue.js +1 -1
- package/dist/components/LpCard.vue.js +1 -1
- package/dist/components/LpChart.vue.js +2 -2
- package/dist/components/LpCheckbox.vue.js +1 -1
- package/dist/components/LpCodeBlock.vue.js +1 -1
- package/dist/components/LpColorPicker.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.js +2 -2
- 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.js +1 -1
- 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.js +1 -1
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpFileTreeNode.vue.js +1 -1
- package/dist/components/LpFormField.vue.js +1 -1
- package/dist/components/LpHintLayer.vue.d.ts +33 -0
- package/dist/components/LpHintLayer.vue.js +4 -0
- package/dist/components/LpHints.vue.d.ts +20 -0
- package/dist/components/LpHints.vue.js +4 -0
- package/dist/components/LpIcon.vue.js +1 -1
- package/dist/components/LpImageEditor.vue.js +2 -2
- package/dist/components/LpInput.vue.js +1 -1
- package/dist/components/LpLightbox.vue.js +1 -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/LpPaletteFacts.vue.js +1 -1
- package/dist/components/LpPaletteHeader.vue.js +2 -2
- package/dist/components/LpPasswordInput.vue.js +1 -1
- package/dist/components/LpPhoneInput.vue.js +1 -1
- package/dist/components/LpPie.vue.js +1 -1
- package/dist/components/LpRangeCalendar.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.js +1 -1
- 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/LpTable.vue.js +6 -6
- package/dist/components/LpTableFilter.vue.js +1 -1
- package/dist/components/LpTableOfContents.vue.js +1 -1
- package/dist/components/LpTabs.vue.js +1 -1
- package/dist/components/LpTagsInput.vue.js +1 -1
- package/dist/components/LpTextarea.vue.js +1 -1
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/LpTimeField.vue.js +1 -1
- package/dist/components/LpToaster.vue.js +1 -1
- package/dist/components/LpTooltip.vue.js +1 -1
- package/dist/components/LpTour.vue.d.ts +98 -0
- package/dist/components/LpTour.vue.js +4 -0
- package/dist/components/LpUptimeBar.vue.js +1 -1
- package/dist/components/MenuTitle.vue.js +1 -1
- package/dist/components/TourCard.vue.d.ts +28 -0
- package/dist/components/TourCard.vue.js +4 -0
- package/dist/components/tour.d.ts +21 -0
- package/dist/components/tour.js +38 -0
- package/dist/composables/scroll.d.ts +17 -0
- package/dist/composables/scroll.js +29 -0
- package/dist/composables/useGamepadNav.js +2 -7
- package/dist/composables/useHintSystem.d.ts +195 -0
- package/dist/composables/useHintSystem.js +334 -0
- package/dist/composables/useHints.d.ts +18 -0
- package/dist/composables/useHints.js +120 -0
- package/dist/composables/useMediaQuery.d.ts +2 -0
- package/dist/composables/useMediaQuery.js +18 -0
- package/dist/composables/usePaintSelect.js +1 -7
- package/dist/icons-all.js +1 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.js +89 -64
- package/package.json +1 -1
- package/src/component-names.ts +3 -0
- package/src/components/LpDateRangePicker.vue +3 -12
- package/src/components/LpDrawer.vue +4 -7
- package/src/components/LpHintLayer.vue +148 -0
- package/src/components/LpHints.vue +116 -0
- package/src/components/LpTableOfContents.vue +2 -14
- package/src/components/LpTooltip.vue +2 -4
- package/src/components/LpTour.vue +358 -0
- package/src/components/TourCard.vue +41 -0
- package/src/components/tour.ts +57 -0
- package/src/composables/scroll.ts +54 -0
- package/src/composables/useGamepadNav.ts +2 -7
- package/src/composables/useHintSystem.ts +512 -0
- package/src/composables/useHints.ts +151 -0
- package/src/composables/useMediaQuery.ts +20 -0
- package/src/composables/usePaintSelect.ts +1 -8
- package/src/index.ts +27 -0
- package/src/kit-icons.ts +1 -1
- package/src/tokens/tokens.css +1 -0
- package/dist/chunks/icons-CkA0njzm.js +0 -43
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { computed, inject, provide, reactive, watch, type ComputedRef, type InjectionKey, type WatchStopHandle } from "vue"
|
|
2
|
+
import type { HintNote } from "../components/LpHintLayer.vue"
|
|
3
|
+
import type { TourContext, TourStep } from "../components/LpTour.vue"
|
|
4
|
+
import { anyModalOpen } from "../components/modalDepth"
|
|
5
|
+
import { hints } from "./useHints"
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* Onboarding that does not nag: a first-visit tour offer, quiet "new" dots,
|
|
9
|
+
* "what changed" after an update, and nudges toward unused features — all
|
|
10
|
+
* spending one shared attention budget.
|
|
11
|
+
*
|
|
12
|
+
* The app describes its features once (each tied to the `v-hint` element that
|
|
13
|
+
* represents it) and says which page is current; the system decides what to
|
|
14
|
+
* show and when, remembers what each person has seen, and renders through
|
|
15
|
+
* <LpHints>. Everything below is policy the app would otherwise re-derive:
|
|
16
|
+
*
|
|
17
|
+
* budget — at most one interruption (offer, changes, nudge) per session,
|
|
18
|
+
* never over a dialog, while typing, right after navigation, or
|
|
19
|
+
* when the user arrived with intent (palette, notification);
|
|
20
|
+
* memory — per key, a state that only climbs (seen → explained → used),
|
|
21
|
+
* so what was used is never explained again;
|
|
22
|
+
* fresh — a feature becomes "new" only after the page is known: when a
|
|
23
|
+
* page is first met, everything on it is quietly baselined, so a
|
|
24
|
+
* rollout does not light every control for existing users;
|
|
25
|
+
* news — "what changed" is for people who knew the page before: a
|
|
26
|
+
* newcomer's first visit marks the page's news as read, while a
|
|
27
|
+
* returning user (recognised by `known`) still gets it;
|
|
28
|
+
* retries — a declined tour is offered again at most `offersMax` times,
|
|
29
|
+
* `offerAgain` apart; a nudge waits `nudgeAgain` between showings.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export interface HintFeature {
|
|
33
|
+
page: string
|
|
34
|
+
title: string
|
|
35
|
+
text: string
|
|
36
|
+
order?: number
|
|
37
|
+
keys?: string
|
|
38
|
+
try?: boolean
|
|
39
|
+
side?: TourStep["side"]
|
|
40
|
+
/** Bump to announce a changed feature again: it reads as new to everyone. */
|
|
41
|
+
rev?: number
|
|
42
|
+
/** Keeps its dot until explained, even after "not now". */
|
|
43
|
+
required?: boolean
|
|
44
|
+
/**
|
|
45
|
+
* Rendered only in some states (data-dependent, behind a filter). Its first
|
|
46
|
+
* appearance is not news, so it is never marked new for merely showing up;
|
|
47
|
+
* a `rev` bump still announces it.
|
|
48
|
+
*/
|
|
49
|
+
conditional?: boolean
|
|
50
|
+
/** When the `v-hint` id is not the element to light (a column header, an opened menu). */
|
|
51
|
+
target?: () => Element | null
|
|
52
|
+
enter?: (context: TourContext) => void | Promise<void>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface HintPage {
|
|
56
|
+
title: string
|
|
57
|
+
/**
|
|
58
|
+
* A localStorage key prefix that proves the user worked on this page before
|
|
59
|
+
* the system existed (a remembered table layout). Such a page is treated as
|
|
60
|
+
* known on first sight: no tour offer, no dots for what was always there.
|
|
61
|
+
*/
|
|
62
|
+
known?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface HintNews {
|
|
66
|
+
id: string
|
|
67
|
+
date: string
|
|
68
|
+
page: string
|
|
69
|
+
text: string
|
|
70
|
+
feature?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface HintNudge {
|
|
74
|
+
id: string
|
|
75
|
+
feature: string
|
|
76
|
+
text: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type HintLevel = "seen" | "explained" | "used"
|
|
80
|
+
|
|
81
|
+
export interface HintRecord {
|
|
82
|
+
state: HintLevel
|
|
83
|
+
dismissed: boolean
|
|
84
|
+
count: number
|
|
85
|
+
assigned?: boolean
|
|
86
|
+
updatedAt: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface HintDelta {
|
|
90
|
+
key: string
|
|
91
|
+
state?: HintLevel
|
|
92
|
+
dismissed?: boolean
|
|
93
|
+
add?: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface HintStorage {
|
|
97
|
+
load(): Promise<Record<string, HintRecord>> | Record<string, HintRecord>
|
|
98
|
+
save(deltas: HintDelta[]): Promise<void> | void
|
|
99
|
+
/** Last chance on page hide (sendBeacon); falls back to `save`. */
|
|
100
|
+
saveOnLeave?(deltas: HintDelta[]): void
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface HintTiming {
|
|
104
|
+
settle: number
|
|
105
|
+
offerAgain: number
|
|
106
|
+
offersMax: number
|
|
107
|
+
freshLimit: number
|
|
108
|
+
nudgeAgain: number
|
|
109
|
+
flush: number
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface HintSystemOptions {
|
|
113
|
+
features: Record<string, HintFeature>
|
|
114
|
+
pages?: Record<string, HintPage>
|
|
115
|
+
news?: HintNews[]
|
|
116
|
+
storage?: HintStorage
|
|
117
|
+
/** The current page id, read reactively (map your route to it). */
|
|
118
|
+
page: () => string
|
|
119
|
+
sessionKey?: string
|
|
120
|
+
timing?: Partial<HintTiming>
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type HintOffer =
|
|
124
|
+
| { kind: "tour" | "assigned"; page: string }
|
|
125
|
+
| { kind: "news"; page: string; items: HintNews[] }
|
|
126
|
+
| { kind: "nudge"; page: string; nudge: HintNudge }
|
|
127
|
+
|
|
128
|
+
const RANK: Record<HintLevel, number> = { seen: 0, explained: 1, used: 2 }
|
|
129
|
+
const DAY = 24 * 3600 * 1000
|
|
130
|
+
const DEFAULT_TIMING: HintTiming = {
|
|
131
|
+
settle: 2500,
|
|
132
|
+
offerAgain: 3 * DAY,
|
|
133
|
+
offersMax: 2,
|
|
134
|
+
freshLimit: 3,
|
|
135
|
+
nudgeAgain: 7 * DAY,
|
|
136
|
+
flush: 2000,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Records in localStorage: for anonymous sessions and apps without a server. */
|
|
140
|
+
export function localHintStorage(key = "lp-hints"): HintStorage {
|
|
141
|
+
const read = (): Record<string, HintRecord> => {
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(localStorage.getItem(key) || "{}") as Record<string, HintRecord>
|
|
144
|
+
} catch {
|
|
145
|
+
return {}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
load: read,
|
|
150
|
+
save(deltas) {
|
|
151
|
+
const all = read()
|
|
152
|
+
for (const d of deltas) all[d.key] = merge(all[d.key], d)
|
|
153
|
+
try {
|
|
154
|
+
localStorage.setItem(key, JSON.stringify(all))
|
|
155
|
+
} catch {
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function merge(record: HintRecord | undefined, delta: Omit<HintDelta, "key">): HintRecord {
|
|
163
|
+
const base = record ?? { state: "seen" as HintLevel, dismissed: false, count: 0, updatedAt: "" }
|
|
164
|
+
return {
|
|
165
|
+
...base,
|
|
166
|
+
state: delta.state && RANK[delta.state] > RANK[base.state] ? delta.state : base.state,
|
|
167
|
+
dismissed: delta.dismissed ?? base.dismissed,
|
|
168
|
+
count: base.count + (delta.add ?? 0),
|
|
169
|
+
updatedAt: new Date().toISOString(),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Record keys, for apps that read or assign records directly (a "who knows what" view). */
|
|
174
|
+
export const hintKey = {
|
|
175
|
+
feature: (id: string, rev = 1) => `f:${id}:${rev}`,
|
|
176
|
+
page: (page: string) => `t:${page}`,
|
|
177
|
+
news: (id: string) => `c:${id}`,
|
|
178
|
+
nudge: (id: string) => `n:${id}`,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface HintSummary {
|
|
182
|
+
tour: "done" | "assigned" | "declined" | "offered" | "never"
|
|
183
|
+
offers: number
|
|
184
|
+
known: number
|
|
185
|
+
total: number
|
|
186
|
+
/** Titles of `required` features this person has not been shown. */
|
|
187
|
+
missing: string[]
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** What one person knows of a page, from their records: for a manager's view. */
|
|
191
|
+
export function hintSummary(
|
|
192
|
+
records: Map<string, Pick<HintRecord, "state" | "dismissed" | "count" | "assigned">>,
|
|
193
|
+
features: Record<string, HintFeature>,
|
|
194
|
+
page: string,
|
|
195
|
+
): HintSummary {
|
|
196
|
+
const own = Object.entries(features).filter(([, f]) => f.page === page)
|
|
197
|
+
const knows = (id: string, f: HintFeature) => RANK[records.get(hintKey.feature(id, f.rev))?.state ?? "seen"] >= RANK.explained
|
|
198
|
+
const tour = records.get(hintKey.page(page))
|
|
199
|
+
return {
|
|
200
|
+
tour: tour && RANK[tour.state] >= RANK.explained
|
|
201
|
+
? "done"
|
|
202
|
+
: tour?.assigned ? "assigned" : tour?.dismissed ? "declined" : tour ? "offered" : "never",
|
|
203
|
+
offers: tour?.count ?? 0,
|
|
204
|
+
known: own.filter(([id, f]) => knows(id, f)).length,
|
|
205
|
+
total: own.length,
|
|
206
|
+
missing: own.filter(([id, f]) => f.required && !knows(id, f)).map(([, f]) => f.title),
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function typing(): boolean {
|
|
211
|
+
const el = document.activeElement
|
|
212
|
+
return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el instanceof HTMLElement && el.isContentEditable)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function createHintSystem(options: HintSystemOptions) {
|
|
216
|
+
const timing = { ...DEFAULT_TIMING, ...options.timing }
|
|
217
|
+
const features = options.features
|
|
218
|
+
const pages = options.pages ?? {}
|
|
219
|
+
const news = options.news ?? []
|
|
220
|
+
const storage = options.storage ?? localHintStorage()
|
|
221
|
+
const sessionKey = options.sessionKey ?? "lp-hints:interrupted"
|
|
222
|
+
|
|
223
|
+
const state = reactive({
|
|
224
|
+
records: new Map<string, HintRecord>(),
|
|
225
|
+
loaded: false,
|
|
226
|
+
page: "",
|
|
227
|
+
tour: { open: false, step: 0, steps: [] as TourStep[], ids: [] as string[], page: "" },
|
|
228
|
+
offer: null as HintOffer | null,
|
|
229
|
+
candidate: null as (HintNudge & { page: string }) | null,
|
|
230
|
+
settledAt: 0,
|
|
231
|
+
intent: false,
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const pending = new Map<string, HintDelta>()
|
|
235
|
+
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
|
236
|
+
|
|
237
|
+
const featureKey = (id: string) => hintKey.feature(id, features[id]?.rev)
|
|
238
|
+
const pageKey = hintKey.page
|
|
239
|
+
const newsKey = hintKey.news
|
|
240
|
+
const nudgeKey = hintKey.nudge
|
|
241
|
+
|
|
242
|
+
function interrupted(): boolean {
|
|
243
|
+
try {
|
|
244
|
+
return sessionStorage.getItem(sessionKey) === "1"
|
|
245
|
+
} catch {
|
|
246
|
+
return false
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function spend(): void {
|
|
251
|
+
try {
|
|
252
|
+
sessionStorage.setItem(sessionKey, "1")
|
|
253
|
+
} catch {
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function flush(): Promise<void> {
|
|
259
|
+
clearTimeout(flushTimer)
|
|
260
|
+
flushTimer = undefined
|
|
261
|
+
if (!pending.size) return
|
|
262
|
+
const deltas = [...pending.values()]
|
|
263
|
+
pending.clear()
|
|
264
|
+
try {
|
|
265
|
+
await storage.save(deltas)
|
|
266
|
+
} catch {
|
|
267
|
+
for (const d of deltas) if (!pending.has(d.key)) pending.set(d.key, d)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function flushOnLeave(): void {
|
|
272
|
+
if (!pending.size) return
|
|
273
|
+
const deltas = [...pending.values()]
|
|
274
|
+
pending.clear()
|
|
275
|
+
if (storage.saveOnLeave) storage.saveOnLeave(deltas)
|
|
276
|
+
else void storage.save(deltas)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function record(key: string, delta: Omit<HintDelta, "key">): void {
|
|
280
|
+
state.records.set(key, merge(state.records.get(key), delta))
|
|
281
|
+
const queued = pending.get(key)
|
|
282
|
+
pending.set(key, {
|
|
283
|
+
key,
|
|
284
|
+
state: delta.state ?? queued?.state,
|
|
285
|
+
dismissed: delta.dismissed ?? queued?.dismissed,
|
|
286
|
+
add: (queued?.add ?? 0) + (delta.add ?? 0),
|
|
287
|
+
})
|
|
288
|
+
flushTimer ??= setTimeout(() => void flush(), timing.flush)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const present = (page: string) => Object.keys(features).filter((id) => features[id]!.page === page && hints.present(id))
|
|
292
|
+
|
|
293
|
+
function knownBefore(page: string): boolean {
|
|
294
|
+
const marker = pages[page]?.known
|
|
295
|
+
if (!marker) return false
|
|
296
|
+
try {
|
|
297
|
+
return Object.keys(localStorage).some((key) => key.startsWith(marker))
|
|
298
|
+
} catch {
|
|
299
|
+
return false
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function baseline(page: string): void {
|
|
304
|
+
for (const id of present(page)) if (!state.records.has(featureKey(id))) record(featureKey(id), { state: "seen" })
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const fresh: ComputedRef<string[]> = computed(() => {
|
|
308
|
+
const page = state.page
|
|
309
|
+
if (!page || !state.loaded || state.tour.open || !state.records.has(pageKey(page))) return []
|
|
310
|
+
return present(page)
|
|
311
|
+
.filter((id) => {
|
|
312
|
+
const f = features[id]!
|
|
313
|
+
const rec = state.records.get(featureKey(id))
|
|
314
|
+
if (!rec) return !f.conditional || (f.rev ?? 1) > 1
|
|
315
|
+
return !!f.required && RANK[rec.state] < RANK.explained && !rec.dismissed
|
|
316
|
+
})
|
|
317
|
+
.sort((a, b) => Number(!!features[b]!.required) - Number(!!features[a]!.required) || (features[a]!.order ?? 0) - (features[b]!.order ?? 0))
|
|
318
|
+
.slice(0, timing.freshLimit)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const notes: ComputedRef<Record<string, HintNote>> = computed(() =>
|
|
322
|
+
Object.fromEntries(Object.entries(features).map(([id, f]) => [id, { title: f.title, text: f.text }])),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
const hasTour = (page = state.page) => Object.values(features).some((f) => f.page === page)
|
|
326
|
+
|
|
327
|
+
function startTour(page = state.page, only?: string[]): void {
|
|
328
|
+
const entries = Object.entries(features)
|
|
329
|
+
.filter(([id, f]) => f.page === page && (!only || only.includes(id)))
|
|
330
|
+
.sort(([, a], [, b]) => (a.order ?? 0) - (b.order ?? 0))
|
|
331
|
+
if (!entries.length) return
|
|
332
|
+
state.offer = null
|
|
333
|
+
Object.assign(state.tour, {
|
|
334
|
+
open: true,
|
|
335
|
+
step: 0,
|
|
336
|
+
page,
|
|
337
|
+
ids: entries.map(([id]) => id),
|
|
338
|
+
steps: entries.map(([id, f]) => ({
|
|
339
|
+
target: f.target ?? id,
|
|
340
|
+
title: f.title,
|
|
341
|
+
text: f.text,
|
|
342
|
+
keys: f.keys,
|
|
343
|
+
try: f.try,
|
|
344
|
+
side: f.side,
|
|
345
|
+
enter: f.enter,
|
|
346
|
+
})),
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const unread = (page?: string) =>
|
|
351
|
+
news.filter((n) => (!page || n.page === page) && !state.records.has(newsKey(n.id)) && (!page || !n.feature || hints.present(n.feature)))
|
|
352
|
+
|
|
353
|
+
function readNews(page: string): void {
|
|
354
|
+
for (const n of unread(page)) record(newsKey(n.id), { state: "explained" })
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function considerOffer(): void {
|
|
358
|
+
const page = state.page
|
|
359
|
+
const ready = state.loaded && !state.tour.open && !state.offer && !anyModalOpen() && !typing()
|
|
360
|
+
&& Date.now() - state.settledAt > timing.settle
|
|
361
|
+
if (!page || !ready) return
|
|
362
|
+
const rec = state.records.get(pageKey(page))
|
|
363
|
+
if (rec?.assigned && RANK[rec.state] < RANK.explained && hasTour(page)) {
|
|
364
|
+
state.offer = { kind: "assigned", page }
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
if (state.intent || interrupted()) return
|
|
368
|
+
if (!rec && knownBefore(page)) {
|
|
369
|
+
record(pageKey(page), { state: "seen" })
|
|
370
|
+
baseline(page)
|
|
371
|
+
} else if (
|
|
372
|
+
!rec
|
|
373
|
+
|| (!rec.dismissed && RANK[rec.state] < RANK.explained && rec.count < timing.offersMax
|
|
374
|
+
&& Date.now() - Date.parse(rec.updatedAt || "0") > timing.offerAgain)
|
|
375
|
+
) {
|
|
376
|
+
if (!hasTour(page)) return
|
|
377
|
+
spend()
|
|
378
|
+
if (!rec) {
|
|
379
|
+
record(pageKey(page), { state: "seen" })
|
|
380
|
+
baseline(page)
|
|
381
|
+
readNews(page)
|
|
382
|
+
}
|
|
383
|
+
state.offer = { kind: "tour", page }
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
const items = unread(page)
|
|
387
|
+
if (items.length) {
|
|
388
|
+
spend()
|
|
389
|
+
state.offer = { kind: "news", page, items: items.slice(0, 3) }
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
const n = state.candidate
|
|
393
|
+
if (!n || n.page !== page) return
|
|
394
|
+
const seen = state.records.get(nudgeKey(n.id))
|
|
395
|
+
if (seen?.dismissed || state.records.get(featureKey(n.feature))?.state === "used" || !hints.present(n.feature)) return
|
|
396
|
+
if (seen && Date.now() - Date.parse(seen.updatedAt) < timing.nudgeAgain) return
|
|
397
|
+
state.candidate = null
|
|
398
|
+
spend()
|
|
399
|
+
record(nudgeKey(n.id), { add: 1 })
|
|
400
|
+
state.offer = { kind: "nudge", page, nudge: n }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let stops: (() => void)[] = []
|
|
404
|
+
|
|
405
|
+
function start(): void {
|
|
406
|
+
if (stops.length) return
|
|
407
|
+
const stopPage: WatchStopHandle = watch(
|
|
408
|
+
options.page,
|
|
409
|
+
(page) => {
|
|
410
|
+
state.page = page
|
|
411
|
+
state.settledAt = Date.now()
|
|
412
|
+
if (state.offer && state.offer.page !== page) state.offer = null
|
|
413
|
+
if (state.candidate && state.candidate.page !== page) state.candidate = null
|
|
414
|
+
setTimeout(() => (state.intent = false), timing.settle * 2)
|
|
415
|
+
},
|
|
416
|
+
{ immediate: true },
|
|
417
|
+
)
|
|
418
|
+
const poll = setInterval(() => {
|
|
419
|
+
try {
|
|
420
|
+
considerOffer()
|
|
421
|
+
} catch (error) {
|
|
422
|
+
console.warn("[hints] disabled after an error:", error)
|
|
423
|
+
stop()
|
|
424
|
+
}
|
|
425
|
+
}, 1000)
|
|
426
|
+
window.addEventListener("pagehide", flushOnLeave)
|
|
427
|
+
stops = [
|
|
428
|
+
stopPage,
|
|
429
|
+
hints.on("used", (id) => {
|
|
430
|
+
if (features[id]) record(featureKey(id), { state: "used", add: 1 })
|
|
431
|
+
}),
|
|
432
|
+
() => clearInterval(poll),
|
|
433
|
+
() => window.removeEventListener("pagehide", flushOnLeave),
|
|
434
|
+
]
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function stop(): void {
|
|
438
|
+
for (const s of stops.splice(0)) s()
|
|
439
|
+
flushOnLeave()
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
state,
|
|
444
|
+
fresh,
|
|
445
|
+
notes,
|
|
446
|
+
start,
|
|
447
|
+
stop,
|
|
448
|
+
hasTour,
|
|
449
|
+
startTour,
|
|
450
|
+
async load(): Promise<void> {
|
|
451
|
+
state.records.clear()
|
|
452
|
+
try {
|
|
453
|
+
for (const [key, rec] of Object.entries(await storage.load())) state.records.set(key, rec)
|
|
454
|
+
state.loaded = true
|
|
455
|
+
} catch {
|
|
456
|
+
state.loaded = false
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
/** Ask for a nudge; it shows when the budget and the page allow. */
|
|
460
|
+
nudge(n: HintNudge): void {
|
|
461
|
+
state.candidate = { ...n, page: state.page }
|
|
462
|
+
},
|
|
463
|
+
/** The user navigated on purpose (palette, notification): do not interrupt this arrival. */
|
|
464
|
+
markIntent(): void {
|
|
465
|
+
state.intent = true
|
|
466
|
+
},
|
|
467
|
+
unreadNews: (page?: string) => unread(page),
|
|
468
|
+
readNews,
|
|
469
|
+
pageTitle: (page: string) => pages[page]?.title ?? page,
|
|
470
|
+
readFresh(id: string): void {
|
|
471
|
+
record(featureKey(id), { state: "explained" })
|
|
472
|
+
},
|
|
473
|
+
tourShown(index: number): void {
|
|
474
|
+
const id = state.tour.ids[index]
|
|
475
|
+
if (id) record(featureKey(id), { state: "explained" })
|
|
476
|
+
},
|
|
477
|
+
tourEnded(finished: boolean): void {
|
|
478
|
+
record(pageKey(state.tour.page), finished ? { state: "explained" } : { add: 1 })
|
|
479
|
+
baseline(state.tour.page)
|
|
480
|
+
},
|
|
481
|
+
answer(choice: "show" | "later" | "never"): void {
|
|
482
|
+
const offer = state.offer
|
|
483
|
+
state.offer = null
|
|
484
|
+
if (!offer) return
|
|
485
|
+
if (offer.kind === "news") {
|
|
486
|
+
const ids = offer.items.map((n) => n.feature).filter((id): id is string => !!id)
|
|
487
|
+
readNews(offer.page)
|
|
488
|
+
if (choice === "show" && ids.length) startTour(offer.page, ids)
|
|
489
|
+
} else if (offer.kind === "nudge") {
|
|
490
|
+
if (choice === "show") startTour(features[offer.nudge.feature]!.page, [offer.nudge.feature])
|
|
491
|
+
else record(nudgeKey(offer.nudge.id), { dismissed: true })
|
|
492
|
+
} else if (choice === "show") {
|
|
493
|
+
startTour(offer.page)
|
|
494
|
+
} else {
|
|
495
|
+
record(pageKey(offer.page), { add: 1, dismissed: choice === "never" })
|
|
496
|
+
baseline(offer.page)
|
|
497
|
+
}
|
|
498
|
+
},
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export type HintSystem = ReturnType<typeof createHintSystem>
|
|
503
|
+
|
|
504
|
+
const HINT_SYSTEM: InjectionKey<HintSystem> = Symbol.for("lp-hint-system")
|
|
505
|
+
|
|
506
|
+
export function provideHintSystem(system: HintSystem): void {
|
|
507
|
+
provide(HINT_SYSTEM, system)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function useHintSystem(): HintSystem | null {
|
|
511
|
+
return inject(HINT_SYSTEM, null)
|
|
512
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { shallowReactive, type Directive } from "vue"
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Which parts of the interface the user can see right now, and which ones they
|
|
5
|
+
* actually use — the ground truth a tour, a "new" badge or a nudge needs.
|
|
6
|
+
*
|
|
7
|
+
* The element carries its own id (`v-hint="'branches.remind'"`), so a tour is
|
|
8
|
+
* built from what is on screen, in whatever order the app wants: a button the
|
|
9
|
+
* user has no right to is not rendered, therefore not hinted, and nobody keeps
|
|
10
|
+
* a second list of steps that drifts away from the page.
|
|
11
|
+
*
|
|
12
|
+
* Three signals, all per id:
|
|
13
|
+
* mounted — the element is in the DOM (reactive, for "is it here at all");
|
|
14
|
+
* seen — it stayed at least half on screen for SEEN_MS, once per mount,
|
|
15
|
+
* so scrolling past a row of buttons does not count as seeing them;
|
|
16
|
+
* used — the user clicked it. Non-click use (a shortcut, a saved view) is
|
|
17
|
+
* reported with `hints.use(id)`.
|
|
18
|
+
*
|
|
19
|
+
* The kit decides nothing beyond that. Whether to badge, explain or nudge is
|
|
20
|
+
* the app's call, made from these events.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type HintEvent = "seen" | "used" | "hover"
|
|
24
|
+
type Listener = (id: string, el: HTMLElement) => void
|
|
25
|
+
|
|
26
|
+
const SEEN_MS = 800
|
|
27
|
+
const SEEN_RATIO = 0.5
|
|
28
|
+
|
|
29
|
+
const mounted = shallowReactive(new Map<string, Set<HTMLElement>>())
|
|
30
|
+
const listeners: Record<HintEvent, Set<Listener>> = { seen: new Set(), used: new Set(), hover: new Set() }
|
|
31
|
+
const idOf = new WeakMap<HTMLElement, string>()
|
|
32
|
+
const timers = new WeakMap<HTMLElement, ReturnType<typeof setTimeout>>()
|
|
33
|
+
const seenOnce = new WeakSet<HTMLElement>()
|
|
34
|
+
|
|
35
|
+
function emit(event: HintEvent, id: string, el: HTMLElement) {
|
|
36
|
+
for (const listener of listeners[event]) listener(id, el)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let observer: IntersectionObserver | null = null
|
|
40
|
+
|
|
41
|
+
function watchSight(el: HTMLElement) {
|
|
42
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
43
|
+
seenOnce.add(el)
|
|
44
|
+
emit("seen", idOf.get(el)!, el)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
observer ??= new IntersectionObserver(
|
|
48
|
+
(entries) => {
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
const target = entry.target as HTMLElement
|
|
51
|
+
const id = idOf.get(target)
|
|
52
|
+
if (!id || seenOnce.has(target)) continue
|
|
53
|
+
clearTimeout(timers.get(target))
|
|
54
|
+
if (entry.intersectionRatio >= SEEN_RATIO) {
|
|
55
|
+
timers.set(
|
|
56
|
+
target,
|
|
57
|
+
setTimeout(() => {
|
|
58
|
+
seenOnce.add(target)
|
|
59
|
+
observer?.unobserve(target)
|
|
60
|
+
emit("seen", id, target)
|
|
61
|
+
}, SEEN_MS),
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
{ threshold: [0, SEEN_RATIO] },
|
|
67
|
+
)
|
|
68
|
+
observer.observe(el)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function onClick(event: Event) {
|
|
72
|
+
const el = event.currentTarget as HTMLElement
|
|
73
|
+
const id = idOf.get(el)
|
|
74
|
+
if (id) emit("used", id, el)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function onHover(event: Event) {
|
|
78
|
+
const el = event.currentTarget as HTMLElement
|
|
79
|
+
const id = idOf.get(el)
|
|
80
|
+
if (id) emit("hover", id, el)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function attach(el: HTMLElement, id: string) {
|
|
84
|
+
idOf.set(el, id)
|
|
85
|
+
el.dataset.hint = id
|
|
86
|
+
const set = mounted.get(id) ?? new Set<HTMLElement>()
|
|
87
|
+
set.add(el)
|
|
88
|
+
mounted.set(id, new Set(set))
|
|
89
|
+
el.addEventListener("click", onClick, { capture: true })
|
|
90
|
+
el.addEventListener("pointerenter", onHover)
|
|
91
|
+
el.addEventListener("focusin", onHover)
|
|
92
|
+
watchSight(el)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function detach(el: HTMLElement) {
|
|
96
|
+
const id = idOf.get(el)
|
|
97
|
+
if (!id) return
|
|
98
|
+
clearTimeout(timers.get(el))
|
|
99
|
+
observer?.unobserve(el)
|
|
100
|
+
el.removeEventListener("click", onClick, { capture: true })
|
|
101
|
+
el.removeEventListener("pointerenter", onHover)
|
|
102
|
+
el.removeEventListener("focusin", onHover)
|
|
103
|
+
const set = new Set(mounted.get(id))
|
|
104
|
+
set.delete(el)
|
|
105
|
+
if (set.size) mounted.set(id, set)
|
|
106
|
+
else mounted.delete(id)
|
|
107
|
+
idOf.delete(el)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `v-hint="'page.feature'"` on the element that represents a feature. */
|
|
111
|
+
export const vHint: Directive<HTMLElement, string | null | undefined> = {
|
|
112
|
+
mounted(el, binding) {
|
|
113
|
+
if (binding.value) attach(el, binding.value)
|
|
114
|
+
},
|
|
115
|
+
updated(el, binding) {
|
|
116
|
+
if (binding.value === idOf.get(el)) return
|
|
117
|
+
detach(el)
|
|
118
|
+
if (binding.value) attach(el, binding.value)
|
|
119
|
+
},
|
|
120
|
+
beforeUnmount(el) {
|
|
121
|
+
detach(el)
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const hints = {
|
|
126
|
+
/** Ids of everything hinted that is in the DOM now. Reactive. */
|
|
127
|
+
ids(): string[] {
|
|
128
|
+
return [...mounted.keys()]
|
|
129
|
+
},
|
|
130
|
+
/** Whether an element with this id is in the DOM. Reactive. */
|
|
131
|
+
present(id: string): boolean {
|
|
132
|
+
return mounted.has(id)
|
|
133
|
+
},
|
|
134
|
+
/** The first connected element with this id that has a box, or null. */
|
|
135
|
+
element(id: string): HTMLElement | null {
|
|
136
|
+
for (const el of mounted.get(id) ?? []) {
|
|
137
|
+
if (el.isConnected && el.getClientRects().length) return el
|
|
138
|
+
}
|
|
139
|
+
return null
|
|
140
|
+
},
|
|
141
|
+
/** Subscribe to a signal; returns the unsubscribe. */
|
|
142
|
+
on(event: HintEvent, listener: Listener): () => void {
|
|
143
|
+
listeners[event].add(listener)
|
|
144
|
+
return () => listeners[event].delete(listener)
|
|
145
|
+
},
|
|
146
|
+
/** Report use that is not a click on the element. */
|
|
147
|
+
use(id: string): void {
|
|
148
|
+
const el = hints.element(id)
|
|
149
|
+
for (const listener of listeners.used) listener(id, el ?? document.body)
|
|
150
|
+
},
|
|
151
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { onBeforeUnmount, onMounted, ref, type Ref } from "vue"
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* A media query as a ref. False until mounted, so the server and the hydrating
|
|
5
|
+
* client render the same thing and the client switches right after.
|
|
6
|
+
*/
|
|
7
|
+
export function useMediaQuery(query: string): Ref<boolean> {
|
|
8
|
+
const matches = ref(false)
|
|
9
|
+
let list: MediaQueryList | undefined
|
|
10
|
+
const update = () => {
|
|
11
|
+
matches.value = !!list?.matches
|
|
12
|
+
}
|
|
13
|
+
onMounted(() => {
|
|
14
|
+
list = window.matchMedia?.(query)
|
|
15
|
+
update()
|
|
16
|
+
list?.addEventListener("change", update)
|
|
17
|
+
})
|
|
18
|
+
onBeforeUnmount(() => list?.removeEventListener("change", update))
|
|
19
|
+
return matches
|
|
20
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { onBeforeUnmount, onMounted, readonly, ref, toValue, type MaybeRefOrGetter, type Ref } from "vue"
|
|
2
2
|
import { useHotkeys, type HotkeyHelp } from "./useHotkeys"
|
|
3
|
+
import { scrollParent } from "./scroll"
|
|
3
4
|
|
|
4
5
|
/*
|
|
5
6
|
* Paint selection: press on one item's tick and sweep across the others, and
|
|
@@ -53,14 +54,6 @@ interface Stroke<K> {
|
|
|
53
54
|
scroller: HTMLElement | null
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
function scrollParent(el: HTMLElement | null): HTMLElement | null {
|
|
57
|
-
for (let node = el?.parentElement; node; node = node.parentElement) {
|
|
58
|
-
const { overflowY } = getComputedStyle(node)
|
|
59
|
-
if ((overflowY === "auto" || overflowY === "scroll") && node.scrollHeight > node.clientHeight) return node
|
|
60
|
-
}
|
|
61
|
-
return null
|
|
62
|
-
}
|
|
63
|
-
|
|
64
57
|
export function usePaintSelect<K = string>(options: UsePaintSelectOptions<K>) {
|
|
65
58
|
const resolve = options.resolve ?? ((raw: string) => raw as unknown as K)
|
|
66
59
|
let stroke: Stroke<K> | null = null
|