@leavepulse/ui 0.46.0 → 0.48.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/assets/HighlightText-BwC5xbqn.css +6 -0
- package/dist/assets/{LpCommandPalette-5miKYsb-.css → LpCommandPalette-DmQUsJqA.css} +21 -21
- package/dist/assets/LpKeys-BxRhfQda.css +36 -0
- package/dist/assets/LpPaletteFacts-B9FdVmIO.css +93 -0
- package/dist/assets/LpPaletteHeader-BVOu1UX5.css +70 -0
- package/dist/assets/LpPalettePreview-CJmAKgYW.css +43 -0
- package/dist/assets/LpPaletteStats-ClE-37MK.css +33 -0
- package/dist/assets/LpPaletteSwatches-CA0B8y2E.css +21 -0
- package/dist/assets/{LpShortcutsDialog-CqYX3yy6.css → LpShortcutsDialog-BUp_BX4s.css} +7 -28
- package/dist/assets/{LpTable-BoeA_TgK.css → LpTable-xWwI4KPZ.css} +17 -17
- package/dist/chunks/HighlightText-BvnYZkhi.js +27 -0
- package/dist/chunks/{LpAvatar-DeSb6H0c.js → LpAvatar-Cb1jtOVv.js} +21 -3
- package/dist/chunks/{LpLightbox-DSDahF23.js → LpLightbox-EVXzAq62.js} +3 -3
- package/dist/chunks/{LpMediaGallery-YKr-cQPN.js → LpMediaGallery-BnlkN_1f.js} +1 -1
- package/dist/chunks/LpPalettePreview-CFav_7co.js +19 -0
- package/dist/chunks/LpRangeSlider-C1yF9-nP.js +86 -0
- package/dist/chunks/LpVideoBackdrop-DN6xPUav.js +251 -0
- package/dist/component-names.d.ts +1 -1
- package/dist/components/HighlightText.vue.d.ts +7 -0
- package/dist/components/HighlightText.vue.js +4 -0
- package/dist/components/LpAvatar.vue.d.ts +12 -0
- package/dist/components/LpAvatar.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.d.ts +152 -15
- package/dist/components/LpCommandPalette.vue.js +178 -92
- package/dist/components/LpKeys.vue.d.ts +22 -0
- package/dist/components/LpKeys.vue.js +52 -0
- package/dist/components/LpLightbox.vue.js +1 -1
- package/dist/components/LpMediaGallery.vue.js +1 -1
- package/dist/components/LpPaletteFacts.vue.d.ts +25 -0
- package/dist/components/LpPaletteFacts.vue.js +85 -0
- package/dist/components/LpPaletteHeader.vue.d.ts +32 -0
- package/dist/components/LpPaletteHeader.vue.js +103 -0
- package/dist/components/LpPalettePreview.vue.d.ts +19 -0
- package/dist/components/LpPalettePreview.vue.js +4 -0
- package/dist/components/LpPaletteStats.vue.d.ts +11 -0
- package/dist/components/LpPaletteStats.vue.js +47 -0
- package/dist/components/LpPaletteSwatches.vue.d.ts +10 -0
- package/dist/components/LpPaletteSwatches.vue.js +34 -0
- package/dist/components/LpRangeSlider.vue.d.ts +39 -0
- package/dist/components/LpRangeSlider.vue.js +4 -0
- package/dist/components/LpShortcutsDialog.vue.js +8 -28
- package/dist/components/LpTable.vue.js +5 -3
- package/dist/components/LpVideoBackdrop.vue.d.ts +61 -0
- package/dist/components/LpVideoBackdrop.vue.js +4 -0
- package/dist/components/commandPalette.d.ts +157 -0
- package/dist/components/commandPalette.js +103 -0
- package/dist/components/commandSearch.d.ts +32 -0
- package/dist/components/commandSearch.js +48 -0
- package/dist/components/keys.d.ts +2 -0
- package/dist/components/keys.js +11 -0
- package/dist/components/videoBackdrop.d.ts +37 -0
- package/dist/components/videoBackdrop.js +40 -0
- package/dist/index.d.ts +16 -3
- package/dist/index.js +167 -134
- package/package.json +1 -1
- package/src/component-names.ts +9 -1
- package/src/components/HighlightText.vue +28 -0
- package/src/components/LpAvatar.vue +21 -2
- package/src/components/LpCommandPalette.vue +387 -87
- package/src/components/LpKeys.vue +79 -0
- package/src/components/LpLightbox.vue +4 -1
- package/src/components/LpPaletteFacts.vue +165 -0
- package/src/components/LpPaletteHeader.vue +155 -0
- package/src/components/LpPalettePreview.vue +77 -0
- package/src/components/LpPaletteStats.vue +81 -0
- package/src/components/LpPaletteSwatches.vue +56 -0
- package/src/components/LpRangeSlider.vue +96 -0
- package/src/components/LpShortcutsDialog.vue +2 -45
- package/src/components/LpTable.vue +6 -1
- package/src/components/LpVideoBackdrop.vue +303 -0
- package/src/components/commandPalette.ts +264 -0
- package/src/components/commandSearch.ts +79 -0
- package/src/components/keys.ts +13 -0
- package/src/components/videoBackdrop.ts +73 -0
- package/src/index.ts +16 -3
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { applyQueryFilters, parsePaletteQuery, type CommandSearchTerms, type HighlightPart, type SearchableCommand } from "./commandSearch"
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The parts of LpCommandPalette that are plain logic, kept out of the SFC so
|
|
5
|
+
* they can be tested without mounting a dialog.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The fields of a command that decide whether the preview pane opens for it. */
|
|
9
|
+
export interface PreviewableCommand {
|
|
10
|
+
preview?: string
|
|
11
|
+
excerpt?: string
|
|
12
|
+
noPreview?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether the pane has something to show for a row. `noPreview` on the row
|
|
17
|
+
* wins, then the app's `hasPreview`; without either, a row qualifies when the
|
|
18
|
+
* app supplied a `#preview` slot or the row carries text of its own.
|
|
19
|
+
*/
|
|
20
|
+
export function commandHasPreview<T extends PreviewableCommand>(
|
|
21
|
+
command: T | undefined,
|
|
22
|
+
options: { slot: boolean; hasPreview?: (command: T) => boolean },
|
|
23
|
+
): boolean {
|
|
24
|
+
if (!command || command.noPreview) return false
|
|
25
|
+
if (options.hasPreview) return options.hasPreview(command)
|
|
26
|
+
return options.slot || !!(command.preview || command.excerpt)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One line of LpPaletteFacts: what it is, and what it is set to now. */
|
|
30
|
+
export interface PaletteFact {
|
|
31
|
+
/** Text, or the preview slot's `highlight(text)` to mark the query. */
|
|
32
|
+
label: string | HighlightPart[]
|
|
33
|
+
/** Right-aligned value; a change rolls in rather than swapping. */
|
|
34
|
+
value?: string | number
|
|
35
|
+
/** A second, quieter line under the label. */
|
|
36
|
+
hint?: string | HighlightPart[]
|
|
37
|
+
icon?: string
|
|
38
|
+
/** A colour dot in place of the icon: a role colour, a status. */
|
|
39
|
+
color?: string
|
|
40
|
+
/** Marks the row that is in effect now: the current theme, the open tab. */
|
|
41
|
+
current?: boolean
|
|
42
|
+
/** Draw the value in the monospace face: ids, paths, versions. */
|
|
43
|
+
mono?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One tile of LpPaletteStats. */
|
|
47
|
+
export interface PaletteStat {
|
|
48
|
+
label: string
|
|
49
|
+
/** A number counts to its new value; text rolls. */
|
|
50
|
+
value: number | string
|
|
51
|
+
/** Abbreviate a number: 1.2k, 3.4M. */
|
|
52
|
+
compact?: boolean
|
|
53
|
+
suffix?: string
|
|
54
|
+
format?: (value: number) => string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The fields of a command that let typing enter its mode. */
|
|
58
|
+
export interface PrefixedCommand {
|
|
59
|
+
prefix?: string
|
|
60
|
+
children?: unknown
|
|
61
|
+
disabled?: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The mode a query opens by its prefix, and the query left after it. Only a
|
|
66
|
+
* command with `children` and a non-empty `prefix` qualifies; letters match
|
|
67
|
+
* in any case, and the longest prefix wins, so "mr " and "m" can live side by
|
|
68
|
+
* side. Leading spaces are ignored: they are never what someone meant.
|
|
69
|
+
*/
|
|
70
|
+
export function matchModePrefix<T extends PrefixedCommand>(
|
|
71
|
+
query: string,
|
|
72
|
+
commands: readonly T[],
|
|
73
|
+
): { command: T; rest: string } | null {
|
|
74
|
+
const typed = query.trimStart().toLowerCase()
|
|
75
|
+
let best: T | null = null
|
|
76
|
+
for (const command of commands) {
|
|
77
|
+
const prefix = command.prefix?.toLowerCase()
|
|
78
|
+
if (!prefix || !command.children || command.disabled || !typed.startsWith(prefix)) continue
|
|
79
|
+
if (!best || prefix.length > (best.prefix?.length ?? 0)) best = command
|
|
80
|
+
}
|
|
81
|
+
if (!best) return null
|
|
82
|
+
return { command: best, rest: query.trimStart().slice(best.prefix!.length) }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The fields of a command that make it a "search elsewhere" row. */
|
|
86
|
+
export interface FallbackCommand extends SearchableCommand {
|
|
87
|
+
fallback?: boolean
|
|
88
|
+
hidden?: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The fallback rows to show for a query, with their labels filled in. They
|
|
93
|
+
* appear only while positive words are typed, whatever those words are: the
|
|
94
|
+
* row offers to search somewhere else for them, so matching it against them
|
|
95
|
+
* would hide it exactly when it is needed. Exclusions and `in:` scopes still
|
|
96
|
+
* apply, to the row's own label, keywords and group. `{query}` in the label
|
|
97
|
+
* becomes the positive words, without the operators.
|
|
98
|
+
*/
|
|
99
|
+
export function fallbackRows<T extends FallbackCommand>(
|
|
100
|
+
items: readonly T[],
|
|
101
|
+
raw: string,
|
|
102
|
+
): { command: T; label: string }[] {
|
|
103
|
+
const parsed = parsePaletteQuery(raw.trim())
|
|
104
|
+
if (!parsed.text) return []
|
|
105
|
+
return applyQueryFilters(
|
|
106
|
+
items.filter((c) => c.fallback),
|
|
107
|
+
parsed,
|
|
108
|
+
).map((command) => ({ command, label: command.label.split("{query}").join(parsed.text) }))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A remembered pick that is not a root command: one chosen inside a mode, or
|
|
113
|
+
* a result a search fetched. It keeps what the row needs to be drawn again
|
|
114
|
+
* and where it was found, so the palette can find the live command next time
|
|
115
|
+
* (`path` through static children, or the app's `resolveRecent`), and fall
|
|
116
|
+
* back to its `href` when neither can.
|
|
117
|
+
*/
|
|
118
|
+
export interface RecentEntry {
|
|
119
|
+
id: string
|
|
120
|
+
label: string
|
|
121
|
+
description?: string
|
|
122
|
+
icon?: string
|
|
123
|
+
image?: string | { src?: string | null; alt?: string; fallback?: string; icon?: string; shape?: "circle" | "soft" | "square" }
|
|
124
|
+
href?: string
|
|
125
|
+
/** Ids of the modes it was picked in, outermost first. */
|
|
126
|
+
path: string[]
|
|
127
|
+
/** The command's `recentData`, kept as given for `resolveRecent`. */
|
|
128
|
+
data?: unknown
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A stored history item: a root command id (the old format), or an entry. */
|
|
132
|
+
export type RecentItem = string | RecentEntry
|
|
133
|
+
|
|
134
|
+
/** The fields of a command a history entry is built from and resolved to. */
|
|
135
|
+
export interface RecentSource {
|
|
136
|
+
id: string
|
|
137
|
+
label: string
|
|
138
|
+
description?: string
|
|
139
|
+
icon?: string
|
|
140
|
+
image?: RecentEntry["image"]
|
|
141
|
+
href?: string
|
|
142
|
+
recentData?: unknown
|
|
143
|
+
disabled?: boolean
|
|
144
|
+
children?: unknown
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isEntry(value: unknown): value is RecentEntry {
|
|
148
|
+
if (!value || typeof value !== "object") return false
|
|
149
|
+
const v = value as Record<string, unknown>
|
|
150
|
+
return (
|
|
151
|
+
typeof v.id === "string"
|
|
152
|
+
&& typeof v.label === "string"
|
|
153
|
+
&& Array.isArray(v.path)
|
|
154
|
+
&& v.path.every((step) => typeof step === "string")
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Stored history, with anything malformed dropped rather than trusted. */
|
|
159
|
+
export function readRecent(stored: unknown): RecentItem[] {
|
|
160
|
+
if (!Array.isArray(stored)) return []
|
|
161
|
+
return stored.filter((v): v is RecentItem => typeof v === "string" || isEntry(v))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function recentKey(item: RecentItem): string {
|
|
165
|
+
return typeof item === "string" ? item : [...item.path, item.id].join("\u0000")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The history item for a pick. A root pick stays a bare id, the format older
|
|
170
|
+
* versions wrote and still read; anything deeper becomes an entry with only
|
|
171
|
+
* the fields that survive JSON.
|
|
172
|
+
*/
|
|
173
|
+
export function toRecentItem(command: RecentSource, path: readonly string[]): RecentItem {
|
|
174
|
+
if (!path.length) return command.id
|
|
175
|
+
const entry: RecentEntry = { id: command.id, label: command.label, path: [...path] }
|
|
176
|
+
if (command.description) entry.description = command.description
|
|
177
|
+
if (command.icon) entry.icon = command.icon
|
|
178
|
+
if (typeof command.image === "string") entry.image = command.image
|
|
179
|
+
else if (command.image) {
|
|
180
|
+
const { src, alt, fallback, icon, shape } = command.image
|
|
181
|
+
entry.image = { src, alt, fallback, icon, shape }
|
|
182
|
+
}
|
|
183
|
+
if (command.href) entry.href = command.href
|
|
184
|
+
if (command.recentData !== undefined) entry.data = command.recentData
|
|
185
|
+
return entry
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The history with `item` first and any earlier copy of it dropped. */
|
|
189
|
+
export function pushRecent(list: readonly RecentItem[], item: RecentItem, keep: number): RecentItem[] {
|
|
190
|
+
const key = recentKey(item)
|
|
191
|
+
return [item, ...list.filter((v) => recentKey(v) !== key)].slice(0, keep)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The live commands a history stands for, newest first. A bare id is looked
|
|
196
|
+
* up among the root commands; an entry is followed down its path through
|
|
197
|
+
* static children, then offered to `resolve` (for results a fetcher found),
|
|
198
|
+
* and last rebuilt from its own fields when it has an `href` to follow.
|
|
199
|
+
* Whatever none of these can find is skipped, so a stored history never
|
|
200
|
+
* resurrects something that cannot run.
|
|
201
|
+
*/
|
|
202
|
+
export function resolveRecentItems<T extends RecentSource>(
|
|
203
|
+
items: readonly RecentItem[],
|
|
204
|
+
commands: readonly T[],
|
|
205
|
+
options: { limit: number; resolve?: (entry: RecentEntry) => T | null | undefined; fromEntry: (entry: RecentEntry) => T },
|
|
206
|
+
): { command: T; path: string[] }[] {
|
|
207
|
+
const out: { command: T; path: string[] }[] = []
|
|
208
|
+
const seen = new Set<string>()
|
|
209
|
+
for (const item of items) {
|
|
210
|
+
if (out.length >= options.limit) break
|
|
211
|
+
const key = recentKey(item)
|
|
212
|
+
if (seen.has(key)) continue
|
|
213
|
+
const found = typeof item === "string" ? commands.find((c) => c.id === item) : findEntry(item, commands, options)
|
|
214
|
+
if (!found || found.disabled || found.children) continue
|
|
215
|
+
seen.add(key)
|
|
216
|
+
out.push({ command: found, path: typeof item === "string" ? [] : [...item.path] })
|
|
217
|
+
}
|
|
218
|
+
return out
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function findEntry<T extends RecentSource>(
|
|
222
|
+
entry: RecentEntry,
|
|
223
|
+
commands: readonly T[],
|
|
224
|
+
options: { resolve?: (entry: RecentEntry) => T | null | undefined; fromEntry: (entry: RecentEntry) => T },
|
|
225
|
+
): T | undefined {
|
|
226
|
+
let level: readonly T[] | null = commands
|
|
227
|
+
for (const step of entry.path) {
|
|
228
|
+
const mode: T | undefined = level?.find((c) => c.id === step)
|
|
229
|
+
level = mode && Array.isArray(mode.children) ? (mode.children as T[]) : null
|
|
230
|
+
}
|
|
231
|
+
const live = level?.find((c) => c.id === entry.id)
|
|
232
|
+
if (live) return live
|
|
233
|
+
const resolved = options.resolve?.(entry)
|
|
234
|
+
if (resolved) return resolved
|
|
235
|
+
return entry.href ? options.fromEntry(entry) : undefined
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Whether a recent row survives the operators typed so far. Recent rows show
|
|
240
|
+
* while no positive words are typed, so "-theme" or "in:recent" filters them
|
|
241
|
+
* like any other row; an `in:` scope matches the row's own group or the
|
|
242
|
+
* Recent heading it sits under.
|
|
243
|
+
*/
|
|
244
|
+
export function recentVisible(command: SearchableCommand, terms: CommandSearchTerms, recentLabel: string): boolean {
|
|
245
|
+
if (!applyQueryFilters([{ ...command, group: undefined }], { ...terms, groups: [] }).length) return false
|
|
246
|
+
return terms.groups.every((term) =>
|
|
247
|
+
[command.group, recentLabel].some((group) => group?.toLowerCase().includes(term)),
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The name a mode's chip shows. `modeLabel` when given; otherwise the label,
|
|
253
|
+
* with a `{query}` placeholder and the quotes around it taken out, since the
|
|
254
|
+
* words it stood for are in the box now ("Search mods for "{query}"" reads
|
|
255
|
+
* "Search mods").
|
|
256
|
+
*/
|
|
257
|
+
export function modeChipLabel(command: { label: string; modeLabel?: string }): string {
|
|
258
|
+
if (command.modeLabel) return command.modeLabel
|
|
259
|
+
if (!command.label.includes("{query}")) return command.label
|
|
260
|
+
return command.label
|
|
261
|
+
.replace(/\s*["'“”«»]?\{query\}["'“”«»]?/g, "")
|
|
262
|
+
.replace(/\s+(for|in|on|:)$/i, "")
|
|
263
|
+
.trim()
|
|
264
|
+
}
|
|
@@ -79,3 +79,82 @@ export function rankCommandItems<T extends SearchableCommand>(items: T[], terms:
|
|
|
79
79
|
.sort((a, b) => b.score - a.score || a.command.label.localeCompare(b.command.label))
|
|
80
80
|
.map((entry) => entry.command)
|
|
81
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A query as a fetcher receives it. `text` is what a remote search should be
|
|
85
|
+
* sent: the positive words and phrases, in the case they were typed, without
|
|
86
|
+
* the operators a server would not understand. The operators come alongside
|
|
87
|
+
* as data, so a provider that can scope or exclude on the server may.
|
|
88
|
+
*/
|
|
89
|
+
export interface PaletteQuery extends CommandSearchTerms {
|
|
90
|
+
/** The box as typed, operators and all. */
|
|
91
|
+
raw: string
|
|
92
|
+
/** Positive terms joined by spaces, case kept, quotes dropped. */
|
|
93
|
+
text: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parsePaletteQuery(raw: string): PaletteQuery {
|
|
97
|
+
const terms = parseCommandQuery(raw)
|
|
98
|
+
const words: string[] = []
|
|
99
|
+
QUERY_TOKEN.lastIndex = 0
|
|
100
|
+
for (let match; (match = QUERY_TOKEN.exec(raw)); ) {
|
|
101
|
+
const text = (match[3] ?? match[4] ?? "").trim()
|
|
102
|
+
if (text && !match[1] && !match[2]) words.push(text)
|
|
103
|
+
}
|
|
104
|
+
return { ...terms, raw, text: words.join(" ") }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The operators applied to results someone else matched: a server answered
|
|
109
|
+
* the positive words, and `-word` and `in:group` still have to hold for what
|
|
110
|
+
* it sent back. Positive terms are not re-checked here; the server may match
|
|
111
|
+
* word forms the text does not contain.
|
|
112
|
+
*/
|
|
113
|
+
export function applyQueryFilters<T extends SearchableCommand>(items: T[], terms: CommandSearchTerms): T[] {
|
|
114
|
+
if (!terms.exclude.length && !terms.groups.length) return items
|
|
115
|
+
return items.filter((command) => {
|
|
116
|
+
const group = command.group?.toLowerCase() ?? ""
|
|
117
|
+
return !terms.exclude.some((term) => matches(command, term)) && terms.groups.every((term) => group.includes(term))
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A run of text, and whether it matched the query. */
|
|
122
|
+
export interface HighlightPart {
|
|
123
|
+
text: string
|
|
124
|
+
hit: boolean
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Text split around the terms that matched, for <mark> without v-html. Terms
|
|
129
|
+
* shorter than two characters are skipped (one letter would mark half the
|
|
130
|
+
* text), longer ones win where two start at the same place, and case is
|
|
131
|
+
* ignored. Pass positive terms only: an excluded word is never a match.
|
|
132
|
+
*/
|
|
133
|
+
export function highlightParts(value: string, terms: readonly string[]): HighlightPart[] {
|
|
134
|
+
const sorted = [...new Set(terms.map((t) => t.toLowerCase()).filter((t) => t.length >= 2))].sort(
|
|
135
|
+
(a, b) => b.length - a.length,
|
|
136
|
+
)
|
|
137
|
+
if (!value || sorted.length === 0) return value ? [{ text: value, hit: false }] : []
|
|
138
|
+
const lower = value.toLowerCase()
|
|
139
|
+
const parts: HighlightPart[] = []
|
|
140
|
+
let i = 0
|
|
141
|
+
while (i < value.length) {
|
|
142
|
+
let at = -1
|
|
143
|
+
let term = ""
|
|
144
|
+
for (const t of sorted) {
|
|
145
|
+
const found = lower.indexOf(t, i)
|
|
146
|
+
if (found !== -1 && (at === -1 || found < at || (found === at && t.length > term.length))) {
|
|
147
|
+
at = found
|
|
148
|
+
term = t
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (at === -1) {
|
|
152
|
+
parts.push({ text: value.slice(i), hit: false })
|
|
153
|
+
break
|
|
154
|
+
}
|
|
155
|
+
if (at > i) parts.push({ text: value.slice(i, at), hit: false })
|
|
156
|
+
parts.push({ text: value.slice(at, at + term.length), hit: true })
|
|
157
|
+
i = at + term.length
|
|
158
|
+
}
|
|
159
|
+
return parts
|
|
160
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* How a written combo splits into caps. One key per "+", except a "+" that is
|
|
3
|
+
* the key itself: "Ctrl++" is Ctrl and plus, not Ctrl and two blanks. Spaces
|
|
4
|
+
* separate the steps of a sequence: "G I" reads "G then I".
|
|
5
|
+
*/
|
|
6
|
+
export function comboKeys(chord: string): string[] {
|
|
7
|
+
return chord.split(/\+(?!$)/)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function comboSteps(combo: string): string[][] {
|
|
11
|
+
const trimmed = combo.trim()
|
|
12
|
+
return trimmed ? trimmed.split(/\s+/).map(comboKeys) : []
|
|
13
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The parts of LpVideoBackdrop that are plain arithmetic, kept out of the SFC
|
|
3
|
+
* so they can be tested without a media element.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The stretch of a clip that loops, and how long two passes overlap at the seam. */
|
|
7
|
+
export interface LoopPlan {
|
|
8
|
+
/** Where every pass starts, in seconds of media time. */
|
|
9
|
+
from: number
|
|
10
|
+
/** Where every pass ends, in seconds of media time. */
|
|
11
|
+
to: number
|
|
12
|
+
/** Media seconds over which the next pass fades in over the ending one; 0 cuts. */
|
|
13
|
+
fade: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The loop a backdrop plays, from what was asked and what the clip has.
|
|
18
|
+
*
|
|
19
|
+
* `start` and `end` are clamped into the clip and put in order; a stretch
|
|
20
|
+
* shorter than `minLength` is widened from its start (or, at the clip's end,
|
|
21
|
+
* backwards), since a loop of a few frames reads as a flicker. The overlap
|
|
22
|
+
* is at most half the stretch: a seam longer than that would have the next
|
|
23
|
+
* pass begin before the current one had shown anything of its own.
|
|
24
|
+
*/
|
|
25
|
+
export function planLoop(
|
|
26
|
+
duration: number,
|
|
27
|
+
options: { start?: number; end?: number; crossfade?: number; minLength?: number } = {},
|
|
28
|
+
): LoopPlan {
|
|
29
|
+
if (!Number.isFinite(duration) || duration <= 0) return { from: 0, to: 0, fade: 0 }
|
|
30
|
+
const clamp = (value: number) => Math.min(duration, Math.max(0, value))
|
|
31
|
+
let from = clamp(options.start ?? 0)
|
|
32
|
+
let to = clamp(options.end ?? duration)
|
|
33
|
+
if (to < from) [from, to] = [to, from]
|
|
34
|
+
const minLength = Math.min(duration, Math.max(0, options.minLength ?? 0.5))
|
|
35
|
+
if (to - from < minLength) {
|
|
36
|
+
to = Math.min(duration, from + minLength)
|
|
37
|
+
from = Math.max(0, to - minLength)
|
|
38
|
+
}
|
|
39
|
+
const fade = Math.min(Math.max(0, options.crossfade ?? 0), (to - from) / 2)
|
|
40
|
+
return { from, to, fade }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether the pass at `time` has reached the point where the next one must begin. */
|
|
44
|
+
export function seamReached(plan: LoopPlan, time: number): boolean {
|
|
45
|
+
return plan.to > plan.from && time >= plan.to - plan.fade
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* How far the incoming pass is through the seam, 0 to 1, from its own media
|
|
50
|
+
* time: it starts at `from` and is fully in once it has played `fade` seconds.
|
|
51
|
+
* Measured on the media clock rather than the wall clock, so the fade keeps
|
|
52
|
+
* pace with a playback rate and holds still while playback stalls.
|
|
53
|
+
*/
|
|
54
|
+
export function seamProgress(plan: LoopPlan, incomingTime: number): number {
|
|
55
|
+
if (plan.fade <= 0) return 1
|
|
56
|
+
return Math.min(1, Math.max(0, (incomingTime - plan.from) / plan.fade))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whether the whole clip loops with nothing cut and no overlap, which the media element does natively. */
|
|
60
|
+
export function isNativeLoop(plan: LoopPlan, duration: number): boolean {
|
|
61
|
+
return plan.fade === 0 && plan.from === 0 && plan.to >= duration
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** "1:05", "0:07.5": a position on a clip as a range slider labels it. */
|
|
65
|
+
export function formatClipTime(seconds: number, precise = false): string {
|
|
66
|
+
const safe = Math.max(0, Number.isFinite(seconds) ? seconds : 0)
|
|
67
|
+
const minutes = Math.floor(safe / 60)
|
|
68
|
+
const rest = safe - minutes * 60
|
|
69
|
+
const whole = Math.floor(rest)
|
|
70
|
+
const tenth = Math.floor((rest - whole) * 10)
|
|
71
|
+
const secs = String(whole).padStart(2, "0")
|
|
72
|
+
return precise && tenth ? `${minutes}:${secs}.${tenth}` : `${minutes}:${secs}`
|
|
73
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -71,9 +71,18 @@ export {
|
|
|
71
71
|
} from "./components/color"
|
|
72
72
|
export type { ColorFormat, Hsv, Rgb } from "./components/color"
|
|
73
73
|
export { default as LpCommandPalette } from "./components/LpCommandPalette.vue"
|
|
74
|
-
export type { Command, PaletteLabels } from "./components/LpCommandPalette.vue"
|
|
75
|
-
export { parseCommandQuery, rankCommandItems } from "./components/commandSearch"
|
|
76
|
-
export
|
|
74
|
+
export type { Command, CommandFetcher, CommandImage, PaletteLabels } from "./components/LpCommandPalette.vue"
|
|
75
|
+
export { applyQueryFilters, highlightParts, parseCommandQuery, parsePaletteQuery, rankCommandItems } from "./components/commandSearch"
|
|
76
|
+
export { commandHasPreview, pushRecent, readRecent, toRecentItem } from "./components/commandPalette"
|
|
77
|
+
export type { PaletteFact, PaletteStat, PreviewableCommand, RecentEntry, RecentItem } from "./components/commandPalette"
|
|
78
|
+
export { default as LpPalettePreview } from "./components/LpPalettePreview.vue"
|
|
79
|
+
export { default as LpPaletteHeader } from "./components/LpPaletteHeader.vue"
|
|
80
|
+
export { default as LpPaletteFacts } from "./components/LpPaletteFacts.vue"
|
|
81
|
+
export { default as LpPaletteStats } from "./components/LpPaletteStats.vue"
|
|
82
|
+
export { default as LpPaletteSwatches } from "./components/LpPaletteSwatches.vue"
|
|
83
|
+
export { default as LpKeys } from "./components/LpKeys.vue"
|
|
84
|
+
export { comboKeys, comboSteps } from "./components/keys"
|
|
85
|
+
export type { CommandSearchTerms, HighlightPart, PaletteQuery, SearchableCommand } from "./components/commandSearch"
|
|
77
86
|
export { default as LpConfirmDialog } from "./components/LpConfirmDialog.vue"
|
|
78
87
|
export type { ConfirmLabels } from "./components/LpConfirmDialog.vue"
|
|
79
88
|
export { default as LpConfirmHost } from "./components/LpConfirmHost.vue"
|
|
@@ -171,6 +180,10 @@ export { default as LpSidebar } from "./components/LpSidebar.vue"
|
|
|
171
180
|
export type { SidebarItem, SidebarSection } from "./components/LpSidebar.vue"
|
|
172
181
|
export { default as LpSkeleton } from "./components/LpSkeleton.vue"
|
|
173
182
|
export { default as LpSlider } from "./components/LpSlider.vue"
|
|
183
|
+
export { default as LpRangeSlider } from "./components/LpRangeSlider.vue"
|
|
184
|
+
export { default as LpVideoBackdrop } from "./components/LpVideoBackdrop.vue"
|
|
185
|
+
export { formatClipTime, isNativeLoop, planLoop, seamProgress, seamReached } from "./components/videoBackdrop"
|
|
186
|
+
export type { LoopPlan } from "./components/videoBackdrop"
|
|
174
187
|
export { default as LpSparkline } from "./components/LpSparkline.vue"
|
|
175
188
|
export { default as LpStat } from "./components/LpStat.vue"
|
|
176
189
|
export { default as LpStepper } from "./components/LpStepper.vue"
|