@leavepulse/ui 0.28.1 → 0.28.2

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.
@@ -97,17 +97,29 @@ const props = withDefaults(
97
97
  * gap to the other end. Interpolating here and handing both the same number
98
98
  * keeps them exactly level, because there is only one number.
99
99
  */
100
- const eased = ref(props.animateOnMount ? 0 : 1)
100
+ /*
101
+ * `shown` is the position on screen, and it is the thing that is animated —
102
+ * not a phase that something else multiplies by.
103
+ *
104
+ * The earlier shape kept an eased 0..1 and derived the position from it and a
105
+ * `from` captured in the watcher. That could not work: `from` was read off a
106
+ * computed over `props.value`, so by the time the watcher ran it already
107
+ * reported the DESTINATION, and every sweep after the first interpolated from
108
+ * the answer to the answer. The entrance still animated, which is what made
109
+ * the bug look like a decision — the bar filled beautifully once and jumped
110
+ * forever after.
111
+ *
112
+ * Animating the position directly removes the question. Wherever the bar is is
113
+ * where the next sweep starts, including one that interrupts another midway.
114
+ */
115
+ const shown = ref(props.animateOnMount ? 0 : props.value)
101
116
  let raf = 0
102
117
 
103
- /* Where the current sweep began, so one interrupted mid-way continues from the
104
- arc's present position rather than snapping back to empty. */
105
- let from = props.animateOnMount ? 0 : props.value
106
-
107
- const runSweep = (ms: number) => {
118
+ const runSweep = (to: number, ms: number) => {
108
119
  if (raf) cancelAnimationFrame(raf)
109
- if (prefersReducedMotion() || ms <= 0) {
110
- eased.value = 1
120
+ const from = shown.value
121
+ if (prefersReducedMotion() || ms <= 0 || from === to) {
122
+ shown.value = to
111
123
  return
112
124
  }
113
125
  const t0 = performance.now()
@@ -115,7 +127,7 @@ const runSweep = (ms: number) => {
115
127
  const t = Math.min(1, (now - t0) / ms)
116
128
  // The kit's shared curve — the same function LpNumberFlow eases on, so the
117
129
  // arc and the counter agree by construction rather than by comment.
118
- eased.value = easeOut(t)
130
+ shown.value = from + (to - from) * easeOut(t)
119
131
  raf = t < 1 ? requestAnimationFrame(step) : 0
120
132
  }
121
133
  raf = requestAnimationFrame(step)
@@ -140,22 +152,19 @@ const bindReveal = (instance: unknown) => {
140
152
  revealAnchor.value = (instance as { $el?: Element } | null)?.$el ?? null
141
153
  }
142
154
 
143
- watch(revealed, (visible) => {
144
- if (visible && props.animateOnMount) runSweep(props.duration)
145
- }, { immediate: true })
146
-
147
- /* A later change re-runs the sweep from where it stands, so the ring eases to
148
- its new value instead of jumping. */
149
155
  watch(
150
- () => [props.value, props.max],
151
- () => {
152
- from = shown.value
153
- runSweep(props.duration)
156
+ revealed,
157
+ (visible) => {
158
+ if (visible && props.animateOnMount) runSweep(props.value, props.duration)
154
159
  },
160
+ { immediate: true },
155
161
  )
156
162
 
157
- /** The value both the arc and the counter render: one interpolation, one truth. */
158
- const shown = computed(() => from + (props.value - from) * eased.value)
163
+ /* A later change eases to the new value from wherever the bar stands. */
164
+ watch(
165
+ () => [props.value, props.max],
166
+ () => runSweep(props.value, props.duration),
167
+ )
159
168
 
160
169
  const pct = computed(() =>
161
170
  Math.max(0, Math.min(100, (shown.value / props.max) * 100)),
@@ -111,9 +111,38 @@ function matches(event: KeyboardEvent, binding: HotkeyBinding): boolean {
111
111
  * may come and go with the state that owns them — a getter is the point, not a
112
112
  * formality.
113
113
  */
114
- export function useHotkeys(bindings: MaybeRefOrGetter<HotkeyBinding[]>) {
114
+ export interface UseHotkeysOptions {
115
+ /**
116
+ * Only act while the focus is inside this element.
117
+ *
118
+ * The default is the whole window, which is right for a command palette and
119
+ * wrong for a control: two of the same component on one page would both
120
+ * answer the same key, and a control beside a form would answer keys aimed
121
+ * at the form. Scoping it here rather than in each component because every
122
+ * one of them was about to write the same `document.activeElement` check and
123
+ * decline by hand.
124
+ *
125
+ * Undefined or null means the window, so the palette case stays the default.
126
+ */
127
+ scope?: MaybeRefOrGetter<HTMLElement | null | undefined>
128
+ }
129
+
130
+ export function useHotkeys(
131
+ bindings: MaybeRefOrGetter<HotkeyBinding[]>,
132
+ options: UseHotkeysOptions = {},
133
+ ) {
134
+ function inScope(target: EventTarget | null): boolean {
135
+ const el = toValue(options.scope)
136
+ if (!el) return true
137
+ const node = target instanceof Node ? target : null
138
+ // The element itself counts: a focusable canvas holds the focus without
139
+ // containing anything that could.
140
+ return !!node && (el === node || el.contains(node))
141
+ }
142
+
115
143
  function onKeydown(event: KeyboardEvent) {
116
144
  if (event.isComposing) return
145
+ if (!inScope(event.target)) return
117
146
  const inTextEntry = isTextEntry(event.target)
118
147
  for (const binding of toValue(bindings)) {
119
148
  if (inTextEntry && !binding.allowInInput) continue
@@ -25,6 +25,23 @@ export interface UseZoomPanOptions {
25
25
 
26
26
  export interface UseZoomPan {
27
27
  scale: Ref<number>
28
+ /**
29
+ * Pan offset in CONTENT pixels — what `style` puts inside `translate()`,
30
+ * before the scale magnifies it. Exposed because anything that has to know
31
+ * *which part* is visible needs the number, not the CSS string: an exporter
32
+ * writing the cropped region, a minimap drawing the viewport rectangle, a
33
+ * caller restoring a saved position. Parsing it back out of `transform` is
34
+ * the alternative, and that breaks the moment this composable changes how it
35
+ * composes the transform.
36
+ */
37
+ offset: ComputedRef<{ x: number; y: number }>
38
+ /**
39
+ * Put the view somewhere — restoring a saved position, or carrying one
40
+ * through a change that re-frames the content. Clamped like any other pan,
41
+ * so a caller cannot place the content off the viewport. Omitting `scale`
42
+ * keeps the current one.
43
+ */
44
+ setView: (view: { x: number; y: number; scale?: number }) => void
28
45
  /** True once zoomed past the minimum — the point at which panning matters. */
29
46
  zoomed: ComputedRef<boolean>
30
47
  /** True while a drag is in flight, so the caller can swap the cursor. */
@@ -194,8 +211,21 @@ export function useZoomPan(options: UseZoomPanOptions = {}): UseZoomPan {
194
211
  transition: panning.value || pinchStartDistance > 0 ? "none" : undefined,
195
212
  }))
196
213
 
214
+ const offset = computed(() => ({ x: x.value, y: y.value }))
215
+
216
+ function setView(view: { x: number; y: number; scale?: number }) {
217
+ if (view.scale !== undefined) {
218
+ scale.value = Math.min(max, Math.max(min, view.scale))
219
+ }
220
+ x.value = view.x
221
+ y.value = view.y
222
+ clampPan()
223
+ }
224
+
197
225
  return {
198
226
  scale,
227
+ offset,
228
+ setView,
199
229
  zoomed,
200
230
  panning,
201
231
  style,
package/src/index.ts CHANGED
@@ -55,6 +55,7 @@ export { default as LpDivider } from "./components/LpDivider.vue"
55
55
  export { default as LpDrawer } from "./components/LpDrawer.vue"
56
56
  export { default as LpDropdownMenu } from "./components/LpDropdownMenu.vue"
57
57
  export type { MenuItem } from "./components/LpDropdownMenu.vue"
58
+ export { default as LpDropzone } from "./components/LpDropzone.vue"
58
59
  export { default as LpEmptyState } from "./components/LpEmptyState.vue"
59
60
  export { default as LpFileTree } from "./components/LpFileTree.vue"
60
61
  export type { FileTreeSummary } from "./components/LpFileTree.vue"
@@ -73,6 +74,7 @@ export {
73
74
  } from "./components/fileTree"
74
75
  export { default as LpFormField } from "./components/LpFormField.vue"
75
76
  export { default as LpIcon } from "./components/LpIcon.vue"
77
+ export { default as LpImageEditor } from "./components/LpImageEditor.vue"
76
78
  export { default as LpInfraNode } from "./components/LpInfraNode.vue"
77
79
  export type { InfraNodeData } from "./components/LpInfraNode.vue"
78
80
  export { default as LpInput } from "./components/LpInput.vue"