@bagelink/vue 1.16.0 → 1.17.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.
Files changed (57) hide show
  1. package/dist/assetTransport.d.ts +50 -0
  2. package/dist/assetTransport.d.ts.map +1 -0
  3. package/dist/assetTransport.test.d.ts +2 -0
  4. package/dist/assetTransport.test.d.ts.map +1 -0
  5. package/dist/assets.d.ts +46 -0
  6. package/dist/assets.d.ts.map +1 -0
  7. package/dist/assets.test.d.ts +2 -0
  8. package/dist/assets.test.d.ts.map +1 -0
  9. package/dist/components/Image.vue.d.ts +7 -1
  10. package/dist/components/Image.vue.d.ts.map +1 -1
  11. package/dist/components/form/inputs/Upload/AssetUploadInput.vue.d.ts +11 -0
  12. package/dist/components/form/inputs/Upload/AssetUploadInput.vue.d.ts.map +1 -0
  13. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts +2 -2
  14. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts.map +1 -1
  15. package/dist/components/lightbox/AssetPreview.vue.d.ts +9 -0
  16. package/dist/components/lightbox/AssetPreview.vue.d.ts.map +1 -0
  17. package/dist/components/lightbox/Lightbox.vue.d.ts.map +1 -1
  18. package/dist/components/lightbox/LightboxImage.vue.d.ts +2 -1
  19. package/dist/components/lightbox/LightboxImage.vue.d.ts.map +1 -1
  20. package/dist/components/lightbox/index.d.ts.map +1 -1
  21. package/dist/components/lightbox/lightbox.types.d.ts +7 -0
  22. package/dist/components/lightbox/lightbox.types.d.ts.map +1 -1
  23. package/dist/composables/useImageSrc.d.ts +8 -4
  24. package/dist/composables/useImageSrc.d.ts.map +1 -1
  25. package/dist/core.css +1 -1
  26. package/dist/form-flow/FormFlowPlayer.vue.d.ts +3 -3
  27. package/dist/form-flow/FormFlowPlayer.vue.d.ts.map +1 -1
  28. package/dist/form-flow/form-flow.d.ts +11 -6
  29. package/dist/form-flow/form-flow.d.ts.map +1 -1
  30. package/dist/form-flow/schema-fields.d.ts +3 -0
  31. package/dist/form-flow/schema-fields.d.ts.map +1 -1
  32. package/dist/index.cjs +62 -62
  33. package/dist/index.css +1 -1
  34. package/dist/index.d.ts +3 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.mjs +19554 -18982
  37. package/dist/style.css +1 -1
  38. package/dist/types/BagelForm.d.ts +3 -1
  39. package/dist/types/BagelForm.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/src/assetTransport.test.ts +125 -0
  42. package/src/assetTransport.ts +164 -0
  43. package/src/assets.test.ts +123 -0
  44. package/src/assets.ts +245 -0
  45. package/src/components/Image.vue +19 -3
  46. package/src/components/form/inputs/Upload/AssetUploadInput.vue +67 -0
  47. package/src/components/form/inputs/Upload/UploadInput.vue +5 -1
  48. package/src/components/lightbox/AssetPreview.vue +16 -0
  49. package/src/components/lightbox/Lightbox.vue +8 -3
  50. package/src/components/lightbox/LightboxImage.vue +4 -3
  51. package/src/components/lightbox/index.ts +29 -4
  52. package/src/components/lightbox/lightbox.types.ts +5 -1
  53. package/src/composables/useImageSrc.ts +47 -37
  54. package/src/form-flow/form-flow.ts +26 -7
  55. package/src/form-flow/schema-fields.ts +23 -1
  56. package/src/index.ts +3 -0
  57. package/src/types/BagelForm.ts +3 -1
package/src/assets.ts ADDED
@@ -0,0 +1,245 @@
1
+ import { inject, onScopeDispose, ref, toValue, watch, type InjectionKey, type MaybeRefOrGetter } from 'vue'
2
+
3
+ /** Persist this reference, never an AssetDelivery. Version pins are immutable. */
4
+ export interface AssetReference {
5
+ asset_id: string
6
+ version_id?: string
7
+ }
8
+
9
+ export interface AssetDelivery {
10
+ url: string
11
+ expires_at: string
12
+ version_id: string
13
+ }
14
+
15
+ export type AssetPurpose = 'original' | 'preview' | 'thumbnail' | 'website' | 'social'
16
+
17
+ export interface AssetDeliveryRequest extends AssetReference {
18
+ /** Backend-approved preset, e.g. an image preview; never arbitrary transforms. */
19
+ representation?: AssetPurpose
20
+ }
21
+
22
+ /** A separate facade: generated SDK namespaces remain unchanged. */
23
+ export interface AssetTransport {
24
+ /** Return deliveries in request order; adapt the SDK's `{items}` envelope here. */
25
+ resolve(references: readonly AssetDeliveryRequest[], signal: AbortSignal): Promise<AssetDelivery[]>
26
+ upload(file: File, signal: AbortSignal): Promise<AssetReference>
27
+ }
28
+
29
+ interface Pending {
30
+ reference: AssetDeliveryRequest
31
+ signal?: AbortSignal
32
+ resolve: (delivery: AssetDelivery) => void
33
+ reject: (reason: unknown) => void
34
+ }
35
+
36
+ const abortError = () => new DOMException('Asset request cancelled', 'AbortError')
37
+
38
+ /** App/session scoped, with no persistent storage or delivery cache. */
39
+ export function createAssetClient(transport: AssetTransport) {
40
+ const revision = ref(0)
41
+ const controllers = new Set<AbortController>()
42
+ let pending: Pending[] = []
43
+ let scheduled = false
44
+ const inFlight = new Map<string, { controller: AbortController; promise: Promise<AssetDelivery>; users: number }>()
45
+
46
+ async function flush() {
47
+ scheduled = false
48
+ const batch = pending
49
+ pending = []
50
+ const active = batch.filter(item => !item.signal?.aborted)
51
+ for (const item of batch) if (item.signal?.aborted) item.reject(abortError())
52
+ if (!active.length) return
53
+ const controller = new AbortController()
54
+ controllers.add(controller)
55
+ const cancelIfUnused = () => {
56
+ for (const item of active) if (item.signal?.aborted) item.reject(abortError())
57
+ if (active.every(item => item.signal?.aborted)) controller.abort()
58
+ }
59
+ const rejectAborted = () => {
60
+ for (const item of active) item.reject(abortError())
61
+ }
62
+ controller.signal.addEventListener('abort', rejectAborted, { once: true })
63
+ for (const item of active) item.signal?.addEventListener('abort', cancelIfUnused)
64
+ try {
65
+ const deliveries = await transport.resolve(active.map(item => item.reference), controller.signal)
66
+ if (controller.signal.aborted) throw abortError()
67
+ if (deliveries.length !== active.length) throw new Error('Incomplete asset delivery batch')
68
+ active.forEach((item, index) => {
69
+ const delivery = deliveries[index]!
70
+ if (item.signal?.aborted) item.reject(abortError())
71
+ else if (!Number.isFinite(Date.parse(delivery.expires_at))
72
+ || Date.parse(delivery.expires_at) <= Date.now()
73
+ || !/^https?:\/\//.test(delivery.url)
74
+ || !delivery.version_id
75
+ || (item.reference.version_id && item.reference.version_id !== delivery.version_id)) {
76
+ item.reject(new Error('Invalid asset delivery'))
77
+ } else item.resolve(delivery)
78
+ })
79
+ } catch (error) {
80
+ for (const item of active) item.reject(error)
81
+ } finally {
82
+ controllers.delete(controller)
83
+ controller.signal.removeEventListener('abort', rejectAborted)
84
+ for (const item of active) item.signal?.removeEventListener('abort', cancelIfUnused)
85
+ }
86
+ }
87
+
88
+ return {
89
+ revision,
90
+ resolve(reference: AssetDeliveryRequest, signal?: AbortSignal): Promise<AssetDelivery> {
91
+ if (signal?.aborted) return Promise.reject(abortError())
92
+ const key = JSON.stringify([reference.asset_id, reference.version_id ?? null, reference.representation ?? 'original'])
93
+ let entry = inFlight.get(key)
94
+ if (!entry) {
95
+ const controller = new AbortController()
96
+ const promise = new Promise<AssetDelivery>((resolve, reject) => {
97
+ pending.push({ reference: { ...reference }, signal: controller.signal, resolve, reject })
98
+ if (!scheduled) { scheduled = true; queueMicrotask(() => { void flush() }) }
99
+ })
100
+ entry = { controller, promise, users: 0 }
101
+ inFlight.set(key, entry)
102
+ const current = entry
103
+ void promise.then(() => {
104
+ if (inFlight.get(key) === current) inFlight.delete(key)
105
+ }, () => {
106
+ if (inFlight.get(key) === current) inFlight.delete(key)
107
+ })
108
+ }
109
+ const shared = entry
110
+ shared.users++
111
+ return new Promise((resolve, reject) => {
112
+ let settled = false
113
+ const finish = () => {
114
+ if (settled) return false
115
+ settled = true
116
+ signal?.removeEventListener('abort', abort)
117
+ shared.users--
118
+ if (!shared.users) {
119
+ if (inFlight.get(key) === shared) inFlight.delete(key)
120
+ shared.controller.abort()
121
+ }
122
+ return true
123
+ }
124
+ const abort = () => { if (finish()) reject(abortError()) }
125
+ signal?.addEventListener('abort', abort, { once: true })
126
+ void shared.promise.then(value => { if (finish()) resolve(value) }, error => { if (finish()) reject(error) })
127
+ })
128
+ },
129
+ async upload(file: File, signal?: AbortSignal): Promise<AssetReference> {
130
+ const controller = new AbortController()
131
+ const abort = () => controller.abort()
132
+ signal?.addEventListener('abort', abort, { once: true })
133
+ controllers.add(controller)
134
+ try {
135
+ if (signal?.aborted) controller.abort()
136
+ if (controller.signal.aborted) throw abortError()
137
+ const reference = await transport.upload(file, controller.signal)
138
+ if (controller.signal.aborted) throw abortError()
139
+ return { asset_id: reference.asset_id, version_id: reference.version_id }
140
+ } finally {
141
+ controllers.delete(controller)
142
+ signal?.removeEventListener('abort', abort)
143
+ }
144
+ },
145
+ /** Call synchronously before logout/tenant/account transitions. */
146
+ clearSession() {
147
+ inFlight.clear()
148
+ for (const controller of controllers) controller.abort()
149
+ for (const item of pending) item.reject(abortError())
150
+ pending = []
151
+ revision.value++
152
+ },
153
+ }
154
+ }
155
+
156
+ export type AssetClient = ReturnType<typeof createAssetClient>
157
+ export const ASSET_CLIENT_KEY: InjectionKey<AssetClient> = Symbol('bagel:assets')
158
+
159
+ /** Opt-in upload state; original bytes are handed unchanged to the transport. */
160
+ export function useAssetUpload() {
161
+ const client = inject(ASSET_CLIENT_KEY, undefined)
162
+ const uploading = ref(false)
163
+ const reference = ref<AssetReference>()
164
+ let controller: AbortController | undefined
165
+ let generation = 0
166
+ function cancel() {
167
+ generation++
168
+ controller?.abort()
169
+ uploading.value = false
170
+ reference.value = undefined
171
+ }
172
+ async function upload(file: File): Promise<AssetReference> {
173
+ cancel()
174
+ if (!client) throw new Error('Asset client is not configured')
175
+ const current = generation
176
+ controller = new AbortController()
177
+ uploading.value = true
178
+ try {
179
+ const result = await client.upload(file, controller.signal)
180
+ if (current !== generation) throw abortError()
181
+ reference.value = result
182
+ return result
183
+ } finally {
184
+ if (current === generation) uploading.value = false
185
+ }
186
+ }
187
+ const stop = client ? watch(client.revision, cancel, { flush: 'sync' }) : undefined
188
+ onScopeDispose(() => { cancel(); stop?.() })
189
+ return { upload, cancel, uploading, reference }
190
+ }
191
+
192
+ /** Private resolution stays inside display primitives, not persisted domain data. */
193
+ export function useAssetDelivery(source: MaybeRefOrGetter<AssetDeliveryRequest | undefined>) {
194
+ const client = inject(ASSET_CLIENT_KEY, undefined)
195
+ const url = ref<string>()
196
+ const error = ref(false)
197
+ let controller: AbortController | undefined
198
+ let timer: ReturnType<typeof setTimeout> | undefined
199
+ let generation = 0
200
+ let retried = false
201
+ let invalidated = false
202
+
203
+ function cancel(keepUrl = false) {
204
+ generation++
205
+ controller?.abort()
206
+ clearTimeout(timer)
207
+ if (!keepUrl) url.value = undefined
208
+ }
209
+ async function load(refresh = false) {
210
+ cancel(refresh)
211
+ error.value = false
212
+ if (invalidated) return
213
+ const reference = toValue(source)
214
+ if (!reference) return
215
+ if (!client) { error.value = true; return }
216
+ const current = generation
217
+ controller = new AbortController()
218
+ try {
219
+ const delivery = await client.resolve(reference, controller.signal)
220
+ if (current !== generation) return
221
+ url.value = delivery.url
222
+ const remaining = Date.parse(delivery.expires_at) - Date.now()
223
+ timer = setTimeout(() => { void load(true) }, Math.min(2_147_483_647, Math.max(1, remaining * 0.8)))
224
+ } catch {
225
+ if (current === generation) { error.value = true; url.value = undefined }
226
+ }
227
+ }
228
+ function onError() {
229
+ if (retried) { cancel(); error.value = true; return }
230
+ retried = true
231
+ void load()
232
+ }
233
+ watch(() => [toValue(source)?.asset_id, toValue(source)?.version_id, toValue(source)?.representation], () => {
234
+ retried = false
235
+ invalidated = false
236
+ void load()
237
+ }, { immediate: true })
238
+ // Never automatically reauthorize old references under a different session.
239
+ const stop = client ? watch(client.revision, () => {
240
+ invalidated = true
241
+ cancel()
242
+ }, { flush: 'sync' }) : undefined
243
+ onScopeDispose(() => { cancel(); stop?.() })
244
+ return { url, error, onError }
245
+ }
@@ -1,10 +1,16 @@
1
1
  <script setup lang="ts">
2
2
  defineOptions({ name: 'BglImage', inheritAttrs: false })
3
3
  import { Skeleton, normalizeDimension, Icon, useImageSrc } from '../index'
4
+ import type { AssetDeliveryRequest, AssetPurpose } from '../assets'
4
5
  import { Comment, computed, ref, useSlots, watch, type StyleValue, type VNode } from 'vue'
5
6
 
6
7
  interface ImageProps {
7
- src?: string
8
+ src?: string | AssetDeliveryRequest
9
+ /** Stable asset UUID; requires an injected asset client. */
10
+ assetId?: string
11
+ versionId?: string
12
+ /** Server preset; width/height below control layout only. */
13
+ purpose?: AssetPurpose
8
14
  pathKey?: string
9
15
  alt?: string
10
16
  width?: string | number
@@ -25,10 +31,17 @@ interface ImageProps {
25
31
 
26
32
  const props = defineProps<ImageProps>()
27
33
 
28
- const { imageSrc, loadingError } = useImageSrc(() => props.src ?? props.pathKey ?? props.modelValue)
34
+ const { imageSrc, loadingError, onError } = useImageSrc(() => props.assetId
35
+ ? { asset_id: props.assetId, version_id: props.versionId, representation: props.purpose }
36
+ : typeof props.src === 'object'
37
+ ? { ...props.src, representation: props.purpose ?? props.src.representation }
38
+ : props.src ?? props.pathKey ?? props.modelValue)
29
39
  const imageLoaded = ref(false)
30
40
 
31
- watch(imageSrc, () => {
41
+ watch(imageSrc, value => {
42
+ if (!value) imageLoaded.value = false
43
+ })
44
+ watch(() => [props.assetId, props.versionId, props.purpose, props.src, props.pathKey, props.modelValue], () => {
32
45
  imageLoaded.value = false
33
46
  })
34
47
 
@@ -101,6 +114,7 @@ const imgFit = computed(() => props.fit ?? 'cover')
101
114
  :class="[frameHasHeight ? 'absolute-fill' : 'bgl-image-frame-img--flow', { 'opacity-0': !imageLoaded }]"
102
115
  :style="{ objectFit: imgFit, mixBlendMode: blend } as StyleValue"
103
116
  @load="onImageLoad"
117
+ @error="onError"
104
118
  >
105
119
  <Skeleton v-if="!imageLoaded && !loadingError" class="absolute-fill" />
106
120
  <div v-else-if="loadingError && !gradientCss" class="flex-center absolute-fill error-image">
@@ -120,6 +134,7 @@ const imgFit = computed(() => props.fit ?? 'cover')
120
134
  :height="normalizeDimension(height)"
121
135
  :class="{ 'opacity-0': !imageLoaded }"
122
136
  @load="onImageLoad"
137
+ @error="onError"
123
138
  >
124
139
  <Skeleton
125
140
  v-if="!imageLoaded && !loadingError"
@@ -144,6 +159,7 @@ const imgFit = computed(() => props.fit ?? 'cover')
144
159
  :class="{ 'opacity-0': !imageLoaded }"
145
160
  :style="{ objectFit: imgFit }"
146
161
  @load="onImageLoad"
162
+ @error="onError"
147
163
  >
148
164
  <div v-if="loadingError" class="flex-center absolute-fill error-image">
149
165
  <Icon name="broken_image" />
@@ -0,0 +1,67 @@
1
+ <script setup lang="ts">
2
+ import { computed, onScopeDispose, ref } from 'vue'
3
+ import { Btn, Image, Card, Loading } from '../../../../index'
4
+ import { useAssetUpload, type AssetReference } from '../../../../assets'
5
+ import { useFileUpload } from './useFileUpload'
6
+ import { useFileDropzone } from '../../../file-drop/useFileDropzone'
7
+ import type { UploadInputProps } from '../../../../types/BagelForm'
8
+
9
+ const props = defineProps<UploadInputProps>()
10
+ const emit = defineEmits<{
11
+ 'update:modelValue': [value: AssetReference | AssetReference[] | undefined]
12
+ addFileStart: []
13
+ }>()
14
+ const { upload, uploading, cancel } = useAssetUpload()
15
+ const failure = ref('')
16
+ const values = computed<AssetReference[]>(() => [props.modelValue].flat().filter(
17
+ (value): value is AssetReference => !!value && typeof value === 'object' && 'asset_id' in value,
18
+ ))
19
+ const disabled = computed(() => props.disabled || props.readonly || uploading.value)
20
+ function output(items: AssetReference[]) {
21
+ emit('update:modelValue', props.multiple ? items : items[0])
22
+ }
23
+ const { browse, addFile, fileQueue } = useFileUpload({
24
+ multiple: props.multiple, accept: props.accept, capture: props.capture,
25
+ onFileQueued: async queued => {
26
+ if (disabled.value) {
27
+ for (const item of queued) if (item.url) URL.revokeObjectURL(item.url)
28
+ fileQueue.value = []
29
+ return
30
+ }
31
+ failure.value = ''
32
+ emit('addFileStart')
33
+ const next = props.multiple ? [...values.value] : []
34
+ try {
35
+ for (const item of props.multiple ? queued : queued.slice(0, 1)) {
36
+ next.push(await upload(item.file))
37
+ output([...next])
38
+ }
39
+ } catch (error) {
40
+ if (!(error instanceof DOMException && error.name === 'AbortError')) failure.value = 'Upload failed. Please try again.'
41
+ } finally {
42
+ for (const item of queued) if (item.url) URL.revokeObjectURL(item.url)
43
+ fileQueue.value = []
44
+ }
45
+ },
46
+ })
47
+ const { handlers } = useFileDropzone({ disabled, onDrop: files => addFile(files) })
48
+ onScopeDispose(() => {
49
+ for (const item of fileQueue.value) if (item.url) URL.revokeObjectURL(item.url)
50
+ })
51
+ </script>
52
+
53
+ <template>
54
+ <Card outline class="flex column gap-1 p-1" v-on="handlers">
55
+ <label v-if="label">{{ label }}</label>
56
+ <div v-for="(value, index) in values" :key="`${value.asset_id}:${value.version_id}`" class="flex gap-1 align-items-center">
57
+ <Image v-if="accept?.startsWith('image/')" :src="value" width="80" height="80" />
58
+ <span class="ellipsis-1">{{ value.asset_id }}</span>
59
+ <Btn v-if="!readonly" flat icon="close" :disabled="disabled" aria-label="Remove attachment"
60
+ @click="output(values.filter((_, i) => i !== index))" />
61
+ </div>
62
+ <Loading v-if="uploading" />
63
+ <Btn v-if="!readonly" :disabled="disabled" icon="upload" :value="btnPlaceholder || 'Upload file'" @click="browse()" />
64
+ <Btn v-if="uploading" flat value="Cancel" @click="cancel" />
65
+ <p v-if="failure || error" role="alert">{{ failure || error }}</p>
66
+ </Card>
67
+ </template>
@@ -5,6 +5,7 @@ import { watch, computed, ref, useSlots } from 'vue'
5
5
  import { useFileDropzone } from '../../../file-drop/useFileDropzone'
6
6
  import { useFileUpload } from './useFileUpload'
7
7
  import { useBagelInputShell } from '../bagelInputShell'
8
+ import AssetUploadInput from './AssetUploadInput.vue'
8
9
 
9
10
  const props = withDefaults(defineProps<UploadInputProps>(), {
10
11
  height: '215px',
@@ -32,7 +33,7 @@ const {
32
33
  pk,
33
34
  } = useFileUpload({
34
35
  disabled: props.disabled,
35
- dirPath: props.dirPath,
36
+ dirPath: props.assetMode ? undefined : props.dirPath,
36
37
  multiple: props.multiple,
37
38
  accept: props.accept,
38
39
  capture: props.capture,
@@ -121,7 +122,10 @@ function fileName(pathKey: string) {
121
122
  </script>
122
123
 
123
124
  <template>
125
+ <AssetUploadInput v-if="assetMode" v-bind="props"
126
+ @update:model-value="emit('update:modelValue', $event)" @add-file-start="emit('addFileStart')" />
124
127
  <div
128
+ v-else
125
129
  class="bagel-input" :class="[shellClass, { 'has-error': !!error, 'has-value': hasValue }]"
126
130
  :style="shellStyle" :data-field="name || id"
127
131
  >
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ import { useAssetDelivery, type AssetReference } from '../../assets'
3
+ import { Btn, Image } from '../../index'
4
+ const props = defineProps<{ asset: AssetReference, type: string, name?: string }>()
5
+ const { url, error, onError } = useAssetDelivery(() => props.asset)
6
+ </script>
7
+
8
+ <template>
9
+ <div class="flex column gap-1">
10
+ <Image v-if="type === 'image'" :src="asset" />
11
+ <video v-else-if="type === 'video' && url" :src="url" controls @error="onError" />
12
+ <iframe v-else-if="type === 'pdf' && url" :src="url" :title="name || 'PDF preview'" referrerpolicy="no-referrer" />
13
+ <Btn v-if="url" :href="url" target="_blank" rel="noopener noreferrer" value="Open / download" />
14
+ <p v-if="error" role="alert">File preview unavailable.</p>
15
+ </div>
16
+ </template>
@@ -4,6 +4,7 @@ import type { LightboxItem } from './lightbox.types'
4
4
  import { BglVideo, Btn, Icon, Zoomer, Image, normalizeURL, Swiper, downloadFile, useEscapeKey } from '../../index'
5
5
  import { computed, nextTick, ref, watch } from 'vue'
6
6
  import LightboxImage from './LightboxImage.vue'
7
+ import AssetPreview from './AssetPreview.vue'
7
8
 
8
9
  const isOpen = ref(false)
9
10
  const dialogRef = ref<HTMLDialogElement | null>(null)
@@ -29,7 +30,10 @@ function open(item: LightboxItem, groupItems?: LightboxItem[]) {
29
30
  && groupItem.src === item.src
30
31
  const hasPathMatch = item.pathKey !== undefined && item.pathKey !== null && item.pathKey !== ''
31
32
  && groupItem.pathKey === item.pathKey
32
- return hasSrcMatch || hasPathMatch
33
+ const hasAssetMatch = item.asset && groupItem.asset
34
+ && item.asset.asset_id === groupItem.asset.asset_id
35
+ && item.asset.version_id === groupItem.asset.version_id
36
+ return hasSrcMatch || hasPathMatch || hasAssetMatch
33
37
  })
34
38
  if (currentIndex.value === -1) currentIndex.value = 0
35
39
  zoom.value = 1
@@ -98,9 +102,10 @@ defineExpose({ open, close })
98
102
  <Zoomer v-if="item.type === 'image'" v-model:zoom="zoom" :disabled="!item?.enableZoom"
99
103
  :mouse-wheel-to-zoom="false" :double-click-to-zoom="true" :max-scale="5" :min-scale="1"
100
104
  :aspect-ratio="0" :limit-translation="true" @click.stop>
101
- <LightboxImage :src="item?.src" />
105
+ <LightboxImage :src="item?.asset ?? item?.src ?? item?.pathKey" />
102
106
  </Zoomer>
103
107
 
108
+ <AssetPreview v-else-if="item.asset" :asset="item.asset" :type="item.type" :name="item.name" />
104
109
  <BglVideo v-else-if="item?.type === 'video' && item?.src" :src="item?.src" autoplay controls
105
110
  class="lightbox-video" />
106
111
 
@@ -143,7 +148,7 @@ defineExpose({ open, close })
143
148
 
144
149
  <div v-if="group && group.length > 1" class="lightbox-thumbnails" @click.stop>
145
150
  <template v-for="(item, index) in group" :key="index">
146
- <Image v-if="item.type === 'image'" class="thumbnail" :src="item.src" alt=""
151
+ <Image v-if="item.type === 'image'" class="thumbnail" :src="item.asset ?? item.src ?? item.pathKey" alt=""
147
152
  :class="{ active: currentIndex === index }" @click.stop="selectItem(index)" />
148
153
  <Icon v-else class="thumbnail thumbnail-icon" icon="description"
149
154
  :class="{ active: currentIndex === index }" @click.stop="selectItem(index)" />
@@ -4,11 +4,12 @@
4
4
  // so the natural dimensions drive layout — <Image>'s framed mode renders an
5
5
  // absolute-fill img that collapses the zoomer to 0×0.
6
6
  import { useImageSrc } from '../../index'
7
+ import type { AssetReference } from '../../assets'
7
8
 
8
- const props = defineProps<{ src?: string }>()
9
- const { imageSrc } = useImageSrc(() => props.src)
9
+ const props = defineProps<{ src?: string | AssetReference }>()
10
+ const { imageSrc, onError } = useImageSrc(() => props.src)
10
11
  </script>
11
12
 
12
13
  <template>
13
- <img :draggable="false" :src="imageSrc" alt="Preview" class="lightbox-image">
14
+ <img :draggable="false" :src="imageSrc" alt="Preview" class="lightbox-image" @error="onError">
14
15
  </template>
@@ -1,8 +1,9 @@
1
1
  import type { App, Directive, DirectiveBinding, Plugin } from 'vue'
2
+ import { ASSET_CLIENT_KEY, type AssetClient } from '../../assets'
2
3
 
3
4
  import type { LightboxItem } from './lightbox.types'
4
5
  import { IMAGE_FORMATS_REGEXP, pathKeyToURL, VIDEO_FORMATS_REGEXP } from '../../index'
5
- import { createApp } from 'vue'
6
+ import { createApp, inject } from 'vue'
6
7
  import Lightbox from './Lightbox.vue'
7
8
 
8
9
  type OpenFunction = (item: LightboxItem, groupItems?: LightboxItem[]) => void
@@ -36,7 +37,9 @@ const lightboxDirective: Directive = {
36
37
  function groupHandler(item: LightboxItem) {
37
38
  if (item.group) {
38
39
  if (!groups[item.group]) { groups[item.group] = [] }
39
- const currentIndex = groups[item.group].findIndex(i => i.src === item.src)
40
+ const currentIndex = groups[item.group].findIndex(i => item.asset
41
+ ? i.asset?.asset_id === item.asset.asset_id && i.asset?.version_id === item.asset.version_id
42
+ : i.src === item.src)
40
43
  if (currentIndex === -1) { groups[item.group].push(item) }
41
44
  }
42
45
  }
@@ -44,7 +47,7 @@ function groupHandler(item: LightboxItem) {
44
47
  function openClickHandler(e: MouseEvent, el: HTMLElement, binding: DirectiveBinding) {
45
48
  e.stopPropagation()
46
49
  const item = bindingToItem(binding, el)
47
- const lightboxInstance = getLightboxInstance()
50
+ const lightboxInstance = getLightboxInstance(binding)
48
51
  if (!lightboxInstance || !lightboxInstance.open) { return }
49
52
  const open = lightboxInstance.open as OpenFunction
50
53
  const group = item && item.group ? groups[item.group] : undefined
@@ -52,6 +55,9 @@ function openClickHandler(e: MouseEvent, el: HTMLElement, binding: DirectiveBind
52
55
  }
53
56
 
54
57
  function bindingToItem(binding: DirectiveBinding, el: HTMLElement): LightboxItem {
58
+ if (binding.value?.asset) {
59
+ return { ...binding.value, src: undefined, pathKey: undefined, type: binding.value.type || 'image', name: binding.value.name || '' }
60
+ }
55
61
  let { group, src, type, name, thumbnail, enableZoom, openFile, download, pathKey } = binding.value || {}
56
62
  src = pathKeyToURL(src || pathKey) || binding.value || el.getAttribute('src') || ''
57
63
  type = type || determineFileType(src)
@@ -82,10 +88,25 @@ function determineFileType(url: any): string {
82
88
  return extension ?? 'unknown'
83
89
  }
84
90
 
91
+ const instances = new WeakMap<App, InstanceType<typeof Lightbox>>()
85
92
  let lightboxInstance: InstanceType<typeof Lightbox> | undefined
86
93
 
87
- function getLightboxInstance(): InstanceType<typeof Lightbox> | undefined {
94
+ function getLightboxInstance(binding: DirectiveBinding): InstanceType<typeof Lightbox> | undefined {
88
95
  if (typeof document === 'undefined') { return undefined }
96
+ const owner = binding.instance?.$?.appContext.app
97
+ if (owner) {
98
+ const existing = instances.get(owner)
99
+ if (existing) return existing
100
+ const element = document.createElement('div')
101
+ document.body.prepend(element)
102
+ const child = createApp(Lightbox)
103
+ const client = owner.runWithContext(() => injectAssetClient())
104
+ if (client) child.provide(ASSET_CLIENT_KEY, client)
105
+ const instance = child.mount(element) as InstanceType<typeof Lightbox>
106
+ instances.set(owner, instance)
107
+ owner.onUnmount(() => { child.unmount(); element.remove(); instances.delete(owner) })
108
+ return instance
109
+ }
89
110
  if (!lightboxInstance) {
90
111
  const lightboxEl = document.createElement('div')
91
112
  document.body.prepend(lightboxEl)
@@ -95,6 +116,10 @@ function getLightboxInstance(): InstanceType<typeof Lightbox> | undefined {
95
116
  return lightboxInstance
96
117
  }
97
118
 
119
+ function injectAssetClient(): AssetClient | undefined {
120
+ return inject(ASSET_CLIENT_KEY, undefined)
121
+ }
122
+
98
123
  const lightboxPlugin: Plugin = {
99
124
  install(app: App) {
100
125
  app.directive('lightbox', lightboxDirective)
@@ -1,4 +1,8 @@
1
- export type XORSrcPath = { src: string, pathKey?: never } | { src?: never, pathKey: string }
1
+ import type { AssetReference } from '../../assets'
2
+
3
+ export type XORSrcPath = { src: string, pathKey?: never, asset?: never }
4
+ | { src?: never, pathKey: string, asset?: never }
5
+ | { src?: never, pathKey?: never, asset: AssetReference }
2
6
 
3
7
  export type LightboxItem = XORSrcPath & {
4
8
  type: string