@bagelink/vue 1.15.668 → 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.
- package/LICENSE +21 -0
- package/dist/assetTransport.d.ts +50 -0
- package/dist/assetTransport.d.ts.map +1 -0
- package/dist/assetTransport.test.d.ts +2 -0
- package/dist/assetTransport.test.d.ts.map +1 -0
- package/dist/assets.d.ts +46 -0
- package/dist/assets.d.ts.map +1 -0
- package/dist/assets.test.d.ts +2 -0
- package/dist/assets.test.d.ts.map +1 -0
- package/dist/components/Image.vue.d.ts +7 -1
- package/dist/components/Image.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/Upload/AssetUploadInput.vue.d.ts +11 -0
- package/dist/components/form/inputs/Upload/AssetUploadInput.vue.d.ts.map +1 -0
- package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts +2 -2
- package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts.map +1 -1
- package/dist/components/lightbox/AssetPreview.vue.d.ts +9 -0
- package/dist/components/lightbox/AssetPreview.vue.d.ts.map +1 -0
- package/dist/components/lightbox/Lightbox.vue.d.ts.map +1 -1
- package/dist/components/lightbox/LightboxImage.vue.d.ts +2 -1
- package/dist/components/lightbox/LightboxImage.vue.d.ts.map +1 -1
- package/dist/components/lightbox/index.d.ts.map +1 -1
- package/dist/components/lightbox/lightbox.types.d.ts +7 -0
- package/dist/components/lightbox/lightbox.types.d.ts.map +1 -1
- package/dist/composables/useImageSrc.d.ts +8 -4
- package/dist/composables/useImageSrc.d.ts.map +1 -1
- package/dist/core.css +1 -1
- package/dist/form-flow/FormFlowPlayer.vue.d.ts +3 -3
- package/dist/form-flow/FormFlowPlayer.vue.d.ts.map +1 -1
- package/dist/form-flow/form-flow.d.ts +11 -6
- package/dist/form-flow/form-flow.d.ts.map +1 -1
- package/dist/form-flow/schema-fields.d.ts +3 -0
- package/dist/form-flow/schema-fields.d.ts.map +1 -1
- package/dist/index.cjs +62 -62
- package/dist/index.css +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +19554 -18982
- package/dist/style.css +1 -1
- package/dist/types/BagelForm.d.ts +3 -1
- package/dist/types/BagelForm.d.ts.map +1 -1
- package/package.json +104 -105
- package/src/assetTransport.test.ts +125 -0
- package/src/assetTransport.ts +164 -0
- package/src/assets.test.ts +123 -0
- package/src/assets.ts +245 -0
- package/src/components/Image.vue +19 -3
- package/src/components/form/inputs/Upload/AssetUploadInput.vue +67 -0
- package/src/components/form/inputs/Upload/UploadInput.vue +5 -1
- package/src/components/lightbox/AssetPreview.vue +16 -0
- package/src/components/lightbox/Lightbox.vue +8 -3
- package/src/components/lightbox/LightboxImage.vue +4 -3
- package/src/components/lightbox/index.ts +29 -4
- package/src/components/lightbox/lightbox.types.ts +5 -1
- package/src/composables/useImageSrc.ts +47 -37
- package/src/form-flow/form-flow.ts +26 -7
- package/src/form-flow/schema-fields.ts +23 -1
- package/src/index.ts +3 -0
- package/src/types/BagelForm.ts +3 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { createAssetClient, ASSET_CLIENT_KEY, useAssetDelivery, type AssetDelivery, type AssetTransport } from './assets'
|
|
4
|
+
import { mount } from '@vue/test-utils'
|
|
5
|
+
import { defineComponent, nextTick } from 'vue'
|
|
6
|
+
|
|
7
|
+
const delivery = (version_id = 'v1'): AssetDelivery => ({
|
|
8
|
+
url: 'https://delivery.example/private',
|
|
9
|
+
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
10
|
+
version_id,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
function setup() {
|
|
14
|
+
const resolve = vi.fn<AssetTransport['resolve']>(async references => references.map(() => delivery()))
|
|
15
|
+
const upload = vi.fn<AssetTransport['upload']>(async () => ({ asset_id: 'a', version_id: 'v1' }))
|
|
16
|
+
return { resolve, upload, client: createAssetClient({ resolve, upload }) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('asset client', () => {
|
|
20
|
+
it('keeps the displayed URL while refreshing, but clears immediately on session change', async () => {
|
|
21
|
+
vi.useFakeTimers()
|
|
22
|
+
const { client, resolve } = setup()
|
|
23
|
+
let finish!: (value: AssetDelivery[]) => void
|
|
24
|
+
let state!: ReturnType<typeof useAssetDelivery>
|
|
25
|
+
const component = mount(defineComponent({
|
|
26
|
+
setup() { state = useAssetDelivery(() => ({ asset_id: 'a' })); return () => null },
|
|
27
|
+
}), { global: { provide: { [ASSET_CLIENT_KEY as symbol]: client } } })
|
|
28
|
+
try {
|
|
29
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
30
|
+
expect(state.url.value).toBe(delivery().url)
|
|
31
|
+
resolve.mockImplementationOnce(() => new Promise(done => { finish = done }))
|
|
32
|
+
await vi.advanceTimersByTimeAsync(48_000)
|
|
33
|
+
expect(resolve).toHaveBeenCalledTimes(2)
|
|
34
|
+
expect(state.url.value).toBe(delivery().url)
|
|
35
|
+
client.clearSession()
|
|
36
|
+
expect(state.url.value).toBeUndefined()
|
|
37
|
+
finish([delivery()])
|
|
38
|
+
await nextTick()
|
|
39
|
+
expect(state.url.value).toBeUndefined()
|
|
40
|
+
} finally {
|
|
41
|
+
component.unmount()
|
|
42
|
+
vi.useRealTimers()
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('deduplicates in-flight requests without sharing subscriber cancellation', async () => {
|
|
47
|
+
const { client, resolve } = setup()
|
|
48
|
+
let finish!: (value: AssetDelivery[]) => void
|
|
49
|
+
resolve.mockImplementationOnce(() => new Promise(done => { finish = done }))
|
|
50
|
+
const controller = new AbortController()
|
|
51
|
+
const first = client.resolve({ asset_id: 'a' }, controller.signal)
|
|
52
|
+
const rejected = expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
|
53
|
+
await Promise.resolve()
|
|
54
|
+
const second = client.resolve({ asset_id: 'a', representation: 'original' })
|
|
55
|
+
controller.abort()
|
|
56
|
+
await rejected
|
|
57
|
+
expect(resolve.mock.calls[0]![1].aborted).toBe(false)
|
|
58
|
+
finish([delivery()])
|
|
59
|
+
await expect(second).resolves.toMatchObject({ version_id: 'v1' })
|
|
60
|
+
expect(resolve).toHaveBeenCalledTimes(1)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('aborts unused requests and starts fresh after a session change', async () => {
|
|
64
|
+
const { client, resolve } = setup()
|
|
65
|
+
resolve.mockImplementationOnce(() => new Promise(() => {}))
|
|
66
|
+
const first = client.resolve({ asset_id: 'a' })
|
|
67
|
+
const rejected = expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
|
68
|
+
await Promise.resolve()
|
|
69
|
+
client.clearSession()
|
|
70
|
+
await rejected
|
|
71
|
+
expect(resolve.mock.calls[0]![1].aborted).toBe(true)
|
|
72
|
+
await expect(client.resolve({ asset_id: 'a' })).resolves.toMatchObject({ version_id: 'v1' })
|
|
73
|
+
expect(resolve).toHaveBeenCalledTimes(2)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('batches same-turn resolutions and preserves pins', async () => {
|
|
77
|
+
const { client, resolve } = setup()
|
|
78
|
+
await Promise.all([client.resolve({ asset_id: 'a', version_id: 'v1' }), client.resolve({ asset_id: 'b' })])
|
|
79
|
+
expect(resolve).toHaveBeenCalledTimes(1)
|
|
80
|
+
expect(resolve.mock.calls[0]![0][0]).toEqual({ asset_id: 'a', version_id: 'v1' })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('does not cache grants between calls', async () => {
|
|
84
|
+
const { client, resolve } = setup()
|
|
85
|
+
await client.resolve({ asset_id: 'a' })
|
|
86
|
+
await client.resolve({ asset_id: 'a' })
|
|
87
|
+
expect(resolve).toHaveBeenCalledTimes(2)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('rejects expired grants and mismatched pinned versions', async () => {
|
|
91
|
+
const { client, resolve } = setup()
|
|
92
|
+
await expect(client.resolve({ asset_id: 'a', version_id: 'v2' })).rejects.toThrow('Invalid asset delivery')
|
|
93
|
+
resolve.mockResolvedValueOnce([{ ...delivery(), expires_at: '2000-01-01T00:00:00Z' }])
|
|
94
|
+
await expect(client.resolve({ asset_id: 'a' })).rejects.toThrow('Invalid asset delivery')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('cancels queued requests on session change', async () => {
|
|
98
|
+
const { client, resolve } = setup()
|
|
99
|
+
const request = client.resolve({ asset_id: 'a' })
|
|
100
|
+
client.clearSession()
|
|
101
|
+
await expect(request).rejects.toMatchObject({ name: 'AbortError' })
|
|
102
|
+
expect(resolve).not.toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('rejects stale in-flight results even if transport ignores abort', async () => {
|
|
106
|
+
const { client, resolve } = setup()
|
|
107
|
+
let finish!: (value: AssetDelivery[]) => void
|
|
108
|
+
resolve.mockImplementationOnce(() => new Promise(done => { finish = done }))
|
|
109
|
+
const request = client.resolve({ asset_id: 'a' })
|
|
110
|
+
await Promise.resolve()
|
|
111
|
+
client.clearSession()
|
|
112
|
+
finish([delivery()])
|
|
113
|
+
await expect(request).rejects.toMatchObject({ name: 'AbortError' })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('does not dispatch already-cancelled requests', async () => {
|
|
117
|
+
const { client, resolve } = setup()
|
|
118
|
+
const controller = new AbortController()
|
|
119
|
+
controller.abort()
|
|
120
|
+
await expect(client.resolve({ asset_id: 'a' }, controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
|
|
121
|
+
expect(resolve).not.toHaveBeenCalled()
|
|
122
|
+
})
|
|
123
|
+
})
|
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
|
+
}
|
package/src/components/Image.vue
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 =>
|
|
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)
|