@bagelink/vue 1.16.0 → 1.17.6

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 (128) hide show
  1. package/dist/assetTransport.d.ts +28 -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 +65 -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/AddressSearch.vue.d.ts +40 -36
  10. package/dist/components/AddressSearch.vue.d.ts.map +1 -1
  11. package/dist/components/ContextMenu.vue.d.ts +518 -0
  12. package/dist/components/ContextMenu.vue.d.ts.map +1 -0
  13. package/dist/components/Dropdown.types.d.ts +25 -0
  14. package/dist/components/Dropdown.types.d.ts.map +1 -0
  15. package/dist/components/Dropdown.vue.d.ts +20 -15
  16. package/dist/components/Dropdown.vue.d.ts.map +1 -1
  17. package/dist/components/FilterQuery.vue.d.ts +1 -1
  18. package/dist/components/FilterQuery.vue.d.ts.map +1 -1
  19. package/dist/components/Icon/Icon.vue.d.ts +0 -1
  20. package/dist/components/Icon/Icon.vue.d.ts.map +1 -1
  21. package/dist/components/Icon/constants.d.ts +0 -1
  22. package/dist/components/Icon/constants.d.ts.map +1 -1
  23. package/dist/components/Icon/types.d.ts +2 -3
  24. package/dist/components/Icon/types.d.ts.map +1 -1
  25. package/dist/components/Image.vue.d.ts +7 -1
  26. package/dist/components/Image.vue.d.ts.map +1 -1
  27. package/dist/components/Swiper.vue.d.ts +1 -1
  28. package/dist/components/draggable/useDraggable.d.ts.map +1 -1
  29. package/dist/components/form/inputs/IconPickerInput.vue.d.ts +48 -37
  30. package/dist/components/form/inputs/IconPickerInput.vue.d.ts.map +1 -1
  31. package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts.map +1 -1
  32. package/dist/components/form/inputs/RichText/composables/useCommands.d.ts.map +1 -1
  33. package/dist/components/form/inputs/RichText/composables/useEditor.d.ts +3 -0
  34. package/dist/components/form/inputs/RichText/composables/useEditor.d.ts.map +1 -1
  35. package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts +1 -1
  36. package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts.map +1 -1
  37. package/dist/components/form/inputs/RichText/index.vue.d.ts.map +1 -1
  38. package/dist/components/form/inputs/RichText/utils/commands.d.ts.map +1 -1
  39. package/dist/components/form/inputs/RichText/utils/selection.d.ts +2 -0
  40. package/dist/components/form/inputs/RichText/utils/selection.d.ts.map +1 -1
  41. package/dist/components/form/inputs/RichText/utils/table.d.ts +34 -8
  42. package/dist/components/form/inputs/RichText/utils/table.d.ts.map +1 -1
  43. package/dist/components/form/inputs/SelectInput.vue.d.ts +71 -63
  44. package/dist/components/form/inputs/SelectInput.vue.d.ts.map +1 -1
  45. package/dist/components/form/inputs/TelInput.vue.d.ts +2 -2
  46. package/dist/components/form/inputs/Upload/AssetUploadInput.vue.d.ts +11 -0
  47. package/dist/components/form/inputs/Upload/AssetUploadInput.vue.d.ts.map +1 -0
  48. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts +2 -2
  49. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts.map +1 -1
  50. package/dist/components/index.d.ts +3 -1
  51. package/dist/components/index.d.ts.map +1 -1
  52. package/dist/components/lightbox/AssetPreview.vue.d.ts +9 -0
  53. package/dist/components/lightbox/AssetPreview.vue.d.ts.map +1 -0
  54. package/dist/components/lightbox/Lightbox.vue.d.ts.map +1 -1
  55. package/dist/components/lightbox/LightboxImage.vue.d.ts +2 -1
  56. package/dist/components/lightbox/LightboxImage.vue.d.ts.map +1 -1
  57. package/dist/components/lightbox/index.d.ts.map +1 -1
  58. package/dist/components/lightbox/lightbox.types.d.ts +7 -0
  59. package/dist/components/lightbox/lightbox.types.d.ts.map +1 -1
  60. package/dist/composables/index.d.ts +1 -1
  61. package/dist/composables/index.d.ts.map +1 -1
  62. package/dist/composables/useBoundedRectDrag.d.ts.map +1 -1
  63. package/dist/composables/useImageSrc.d.ts +8 -4
  64. package/dist/composables/useImageSrc.d.ts.map +1 -1
  65. package/dist/composables/usePointerSession.d.ts +14 -3
  66. package/dist/composables/usePointerSession.d.ts.map +1 -1
  67. package/dist/core.css +3 -3
  68. package/dist/directives/vResize.d.ts.map +1 -1
  69. package/dist/form-flow/FormFlowPlayer.vue.d.ts +3 -3
  70. package/dist/form-flow/FormFlowPlayer.vue.d.ts.map +1 -1
  71. package/dist/form-flow/form-flow.d.ts +13 -6
  72. package/dist/form-flow/form-flow.d.ts.map +1 -1
  73. package/dist/form-flow/schema-fields.d.ts +6 -0
  74. package/dist/form-flow/schema-fields.d.ts.map +1 -1
  75. package/dist/index.cjs +240 -153
  76. package/dist/index.css +1 -1
  77. package/dist/index.d.ts +3 -0
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.mjs +24167 -23417
  80. package/dist/style.css +3 -3
  81. package/dist/types/BagelForm.d.ts +5 -1
  82. package/dist/types/BagelForm.d.ts.map +1 -1
  83. package/package.json +1 -1
  84. package/src/assetTransport.test.ts +75 -0
  85. package/src/assetTransport.ts +98 -0
  86. package/src/assets.test.ts +82 -0
  87. package/src/assets.ts +162 -0
  88. package/src/components/ContextMenu.vue +113 -0
  89. package/src/components/Dropdown.types.ts +27 -0
  90. package/src/components/Dropdown.vue +129 -64
  91. package/src/components/FilterQuery.vue +1 -1
  92. package/src/components/Icon/Icon.vue +8 -30
  93. package/src/components/Icon/constants.ts +0 -166
  94. package/src/components/Icon/types.ts +2 -3
  95. package/src/components/Image.vue +19 -3
  96. package/src/components/draggable/useDraggable.ts +67 -100
  97. package/src/components/form/inputs/IconPickerInput.vue +14 -9
  98. package/src/components/form/inputs/RichText/components/EditorToolbar.vue +43 -111
  99. package/src/components/form/inputs/RichText/composables/useCommands.ts +32 -54
  100. package/src/components/form/inputs/RichText/composables/useEditor.ts +45 -171
  101. package/src/components/form/inputs/RichText/composables/useEditorKeyboard.ts +90 -14
  102. package/src/components/form/inputs/RichText/index.vue +1195 -1210
  103. package/src/components/form/inputs/RichText/utils/commands.ts +689 -1028
  104. package/src/components/form/inputs/RichText/utils/selection.ts +66 -41
  105. package/src/components/form/inputs/RichText/utils/table.ts +798 -136
  106. package/src/components/form/inputs/SelectInput.vue +1 -1
  107. package/src/components/form/inputs/Upload/AssetUploadInput.vue +67 -0
  108. package/src/components/form/inputs/Upload/UploadInput.vue +5 -1
  109. package/src/components/index.ts +3 -1
  110. package/src/components/lightbox/AssetPreview.vue +16 -0
  111. package/src/components/lightbox/Lightbox.vue +8 -3
  112. package/src/components/lightbox/LightboxImage.vue +4 -3
  113. package/src/components/lightbox/index.ts +29 -4
  114. package/src/components/lightbox/lightbox.types.ts +5 -1
  115. package/src/composables/index.ts +1 -1
  116. package/src/composables/useBoundedRectDrag.ts +9 -12
  117. package/src/composables/useImageSrc.ts +47 -37
  118. package/src/composables/usePointerSession.ts +57 -12
  119. package/src/directives/vResize.ts +6 -7
  120. package/src/form-flow/form-flow.ts +31 -7
  121. package/src/form-flow/schema-fields.ts +28 -1
  122. package/src/index.ts +3 -0
  123. package/src/styles/core.css +1 -18
  124. package/src/types/BagelForm.ts +5 -1
  125. package/dist/components/form/inputs/RichText/utils/formatting.d.ts +0 -10
  126. package/dist/components/form/inputs/RichText/utils/formatting.d.ts.map +0 -1
  127. package/src/components/form/inputs/RichText/components/gridBox.vue +0 -58
  128. package/src/components/form/inputs/RichText/utils/formatting.ts +0 -331
@@ -1,6 +1,6 @@
1
1
  <script lang="ts" setup>
2
2
  import type { IconType, Option } from '../../../index'
3
- import type { AlignedPlacement } from '../../Dropdown.vue'
3
+ import type { AlignedPlacement } from '../../Dropdown.types'
4
4
  import { Btn, Card, Skeleton, Dropdown, Icon, TextInput, useSearch, useI18n, resolveI18n } from '../../../index'
5
5
  import { computed, onMounted, ref, useId, watch } from 'vue'
6
6
  import { getOptionIcon, getOptionLabel, getOptionSubtitle, getOptionValue } from '../../../utils/options'
@@ -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, { visibility: props.assetVisibility }))
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" 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
  >
@@ -19,6 +19,8 @@ export { default as DragOver } from './DragOver.vue'
19
19
  export { useFileDropzone } from './file-drop/useFileDropzone'
20
20
  export type { UseFileDropzoneOptions } from './file-drop/useFileDropzone'
21
21
  export { default as Dropdown } from './Dropdown.vue'
22
+ export type { AlignedPlacement, AnchorPoint, DropdownItem } from './Dropdown.types'
23
+ export { default as ContextMenu } from './ContextMenu.vue'
22
24
  export { default as EmptyState } from './EmptyState.vue'
23
25
  export { default as FieldSetVue } from './FieldSetVue.vue'
24
26
  export { default as KanbanBoard } from './kanban/KanbanBoard.vue'
@@ -30,7 +32,7 @@ export { default as FlipBook } from './FlipBook.vue'
30
32
  export { default as FloatingPanel } from './FloatingPanel.vue'
31
33
  export * from './form'
32
34
  export { default as Icon } from './Icon/Icon.vue'
33
- export { FONT_AWESOME_ICONS, FONT_AWESOME_BRANDS_ICONS, MATERIAL_ICONS } from './Icon/constants'
35
+ export { FONT_AWESOME_BRANDS_ICONS, MATERIAL_ICONS } from './Icon/constants'
34
36
  export { default as IframeVue } from './IframeVue.vue'
35
37
  export { default as Image } from './Image.vue'
36
38
  export * from './layout'
@@ -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
@@ -16,7 +16,7 @@ export { useImageSrc } from './useImageSrc'
16
16
  export { useLocalStore } from './useLocalStore'
17
17
  export { usePolling } from './usePolling'
18
18
  export { createPointerSession, usePointerSession } from './usePointerSession'
19
- export type { PointerSessionOptions } from './usePointerSession'
19
+ export type { PointerSessionMovePayload, PointerSessionOptions } from './usePointerSession'
20
20
  export { resizeRect, snapGridDelta } from './resizeGeometry'
21
21
  export type { ResizeRect } from './resizeGeometry'
22
22
  export { usePrefersReducedMotion } from './usePrefersReducedMotion'
@@ -29,18 +29,14 @@ export function useBoundedRectDrag() {
29
29
  if (!Number.isFinite(scale) || scale <= 0) return false
30
30
  const initial = { ...options.rect }
31
31
  const bounds = { ...options.bounds }
32
- const startX = event.clientX
33
- const startY = event.clientY
34
32
  let moved = false
35
33
  let previewed = false
36
- function move(next: PointerEvent) {
37
- const clientDx = next.clientX - startX
38
- const clientDy = next.clientY - startY
39
- if (!moved && Math.hypot(clientDx, clientDy) < (options.threshold ?? 0)) return
40
- if (!moved && clientDx === 0 && clientDy === 0) return
34
+ function applyMove(dxClient: number, dyClient: number) {
35
+ if (!moved && Math.hypot(dxClient, dyClient) < (options.threshold ?? 0)) return
36
+ if (!moved && dxClient === 0 && dyClient === 0) return
41
37
  moved = true
42
- const dx = clientDx / scale
43
- const dy = clientDy / scale
38
+ const dx = dxClient / scale
39
+ const dy = dyClient / scale
44
40
  const rect = options.mode === 'resize'
45
41
  ? resizeRect(initial, { x: dx, y: dy }, 'se', bounds, { width: options.minWidth ?? 0, height: options.minHeight ?? 0 })
46
42
  : {
@@ -52,9 +48,10 @@ export function useBoundedRectDrag() {
52
48
  options.onUpdate(rect)
53
49
  }
54
50
  const started = session.start(event, {
55
- onMove: move,
56
- onEnd: next => {
57
- try { move(next); options.onCommit?.(moved) } finally { active.value = false }
51
+ threshold: options.threshold,
52
+ onMove: (_next, { dx, dy }) => applyMove(dx, dy),
53
+ onEnd: (_next, { dx, dy }) => {
54
+ try { applyMove(dx, dy); options.onCommit?.(moved) } finally { active.value = false }
58
55
  },
59
56
  onCancel: () => {
60
57
  active.value = false
@@ -1,6 +1,7 @@
1
1
  import type { MaybeRefOrGetter } from 'vue'
2
2
  import { appendScript, awaitGlobal, normalizeURL, pathKeyToURL } from '../index'
3
- import { ref, toValue, watch } from 'vue'
3
+ import { computed, onScopeDispose, ref, toValue, watch } from 'vue'
4
+ import { useAssetDelivery, type AssetDeliveryRequest } from '../assets'
4
5
 
5
6
  declare global {
6
7
  interface Window {
@@ -8,74 +9,76 @@ declare global {
8
9
  }
9
10
  }
10
11
 
11
- async function getCachedImage(url: string): Promise<string | undefined> {
12
- if (!('caches' in window)) { return undefined }
13
- try {
14
- const imgCache = await window.caches.open('img-cache')
15
- const cachedResponse = await imgCache.match(url)
16
- if (cachedResponse) {
17
- return URL.createObjectURL(await cachedResponse.blob())
18
- }
19
- } catch (error) {
20
- console.warn('Cache access error:', error)
21
- }
22
- return undefined
23
- }
24
-
25
- async function cacheImage(url: string, blob: Blob) {
26
- if (!('caches' in window)) { return }
27
- try {
28
- const imgCache = await window.caches.open('img-cache')
29
- await imgCache.put(url, new Response(blob))
30
- } catch (error) {
31
- console.warn('Cache write error:', error)
32
- }
12
+ let legacyCacheCleanup: Promise<boolean> | undefined
13
+ /** Remove only the retired sensitive image cache, once per browser session. */
14
+ export function clearLegacyImageCache() {
15
+ if (typeof window === 'undefined' || !('caches' in window)) return
16
+ legacyCacheCleanup ??= window.caches.delete('img-cache').catch(() => false)
17
+ return legacyCacheCleanup
33
18
  }
34
19
 
35
- async function convertHeicImage(url: string): Promise<string> {
20
+ async function convertHeicImage(url: string, signal: AbortSignal): Promise<string> {
36
21
  await appendScript('https://cdnjs.cloudflare.com/ajax/libs/heic2any/0.0.1/index.min.js')
37
22
  const heic2any = await awaitGlobal<(opts: { blob: Blob }) => Promise<Blob>>('heic2any')
38
- const response = await fetch(normalizeURL(url))
23
+ const response = await fetch(normalizeURL(url), { signal, cache: 'no-store' })
24
+ if (!response.ok) throw new Error('Image request failed')
39
25
  const blob = await response.blob()
40
26
  const convertedBlob = await heic2any({ blob })
41
- await cacheImage(url, convertedBlob)
42
27
  return URL.createObjectURL(convertedBlob)
43
28
  }
44
29
 
45
30
  /**
46
31
  * Resolve an image source for display: turns a pathKey/URL into a usable URL via
47
32
  * `pathKeyToURL`, and transparently converts `.heic` files to a displayable blob
48
- * (with a cache) since browsers can't render HEIC in <img>. Shared by <Image>
33
+ * (in memory only) since browsers can't render HEIC in <img>. Shared by <Image>
49
34
  * and the lightbox so both get the same resolution + HEIC handling.
50
35
  *
51
36
  * @param source ref/getter of the raw src or pathKey
52
37
  * @returns `{ imageSrc, loadingError }` reactive refs
53
38
  */
54
- export function useImageSrc(source: MaybeRefOrGetter<string | undefined>) {
39
+ export function useImageSrc(source: MaybeRefOrGetter<string | AssetDeliveryRequest | undefined>) {
40
+ void clearLegacyImageCache()
41
+ const asset = useAssetDelivery(() => {
42
+ const value = toValue(source)
43
+ return typeof value === 'object' ? { ...value, representation: value.representation ?? 'preview' } : undefined
44
+ })
55
45
  const imageSrc = ref<string | undefined>(undefined)
56
46
  const loadingError = ref(false)
47
+ let controller: AbortController | undefined
48
+ let generation = 0
49
+ let objectUrl: string | undefined
50
+ function cleanup() {
51
+ controller?.abort()
52
+ if (objectUrl) URL.revokeObjectURL(objectUrl)
53
+ objectUrl = undefined
54
+ }
57
55
 
58
56
  async function load() {
57
+ const current = ++generation
58
+ cleanup()
59
59
  loadingError.value = false
60
- const url = pathKeyToURL(toValue(source))
60
+ imageSrc.value = undefined
61
+ const value = toValue(source)
62
+ if (typeof value === 'object') return
63
+ const url = pathKeyToURL(value)
61
64
  if (url === undefined || url === '') {
62
65
  imageSrc.value = undefined
63
66
  return
64
67
  }
65
68
 
66
69
  try {
70
+ controller = new AbortController()
67
71
  const ext = url.split('.').pop()?.toLowerCase().split('?')[0]
68
72
  if (ext === 'heic') {
69
- const cachedSrc = await getCachedImage(url)
70
- if (cachedSrc !== undefined && cachedSrc !== '') {
71
- imageSrc.value = cachedSrc
72
- return
73
- }
74
- imageSrc.value = await convertHeicImage(url)
73
+ const converted = await convertHeicImage(url, controller.signal)
74
+ if (current !== generation) { URL.revokeObjectURL(converted); return }
75
+ objectUrl = converted
76
+ imageSrc.value = converted
75
77
  } else {
76
78
  imageSrc.value = url
77
79
  }
78
80
  } catch (error) {
81
+ if (current !== generation) return
79
82
  console.error('Image loading error:', error)
80
83
  loadingError.value = true
81
84
  imageSrc.value = undefined
@@ -83,6 +86,13 @@ export function useImageSrc(source: MaybeRefOrGetter<string | undefined>) {
83
86
  }
84
87
 
85
88
  watch(() => toValue(source), load, { immediate: true })
86
-
87
- return { imageSrc, loadingError }
89
+ onScopeDispose(() => { generation++; cleanup() })
90
+ return {
91
+ imageSrc: computed(() => typeof toValue(source) === 'object' ? asset.url.value : imageSrc.value),
92
+ loadingError: computed(() => typeof toValue(source) === 'object' ? asset.error.value : loadingError.value),
93
+ onError: () => {
94
+ if (typeof toValue(source) === 'object') asset.onError()
95
+ else loadingError.value = true
96
+ },
97
+ }
88
98
  }
@@ -1,10 +1,22 @@
1
1
  import { getCurrentScope, onScopeDispose } from 'vue'
2
2
 
3
+ export interface PointerSessionMovePayload {
4
+ dx: number
5
+ dy: number
6
+ distance: number
7
+ exceededThreshold: boolean
8
+ }
9
+
3
10
  export interface PointerSessionOptions {
4
11
  cursor?: string
5
- onMove: (event: PointerEvent) => void
6
- /** Commit receives the release coordinates, even when no move event preceded it. */
7
- onEnd?: (event: PointerEvent) => void
12
+ /** Optional distance in client pixels before `exceededThreshold` becomes true. */
13
+ threshold?: number
14
+ /** Override the target element or document when starting from a delegated listener. */
15
+ target?: Element | Document | null
16
+ onThreshold?: (event: PointerEvent, payload: PointerSessionMovePayload) => void
17
+ onMove: (event: PointerEvent, payload: PointerSessionMovePayload) => void
18
+ /** Commit receives the release coordinates and movement summary, even when no move event preceded it. */
19
+ onEnd?: (event: PointerEvent, payload: PointerSessionMovePayload) => void
8
20
  onCancel?: () => void
9
21
  }
10
22
 
@@ -14,17 +26,46 @@ export function createPointerSession() {
14
26
  function cancel() { finish?.() }
15
27
  function start(event: PointerEvent, options: PointerSessionOptions): boolean {
16
28
  if (finish || event.button !== 0 || event.isPrimary === false) return false
17
- const target = event.currentTarget as Element | null
18
- const doc = target?.ownerDocument
29
+ const rawTarget = (options.target ?? event.currentTarget ?? event.target) as Element | Document | null
30
+ const doc = rawTarget && 'ownerDocument' in rawTarget
31
+ ? (rawTarget.ownerDocument ?? (rawTarget as unknown as Document))
32
+ : (rawTarget as Document | null)
33
+ const targetEl = rawTarget && 'ownerDocument' in rawTarget && rawTarget.ownerDocument
34
+ ? (rawTarget as Element)
35
+ : null
19
36
  const view = doc?.defaultView
20
37
  if (!view) return false
38
+
21
39
  const id = event.pointerId
40
+ const startX = event.clientX
41
+ const startY = event.clientY
42
+ const threshold = options.threshold ?? 0
43
+ let exceededThreshold = threshold <= 0
44
+
22
45
  const style = doc.body?.style
23
46
  const cursor = style?.cursor ?? ''
24
47
  const selection = style?.userSelect ?? ''
48
+
49
+ function buildPayload(next: PointerEvent): PointerSessionMovePayload {
50
+ const dx = (next.clientX ?? startX) - startX
51
+ const dy = (next.clientY ?? startY) - startY
52
+ const distance = Math.hypot(dx, dy)
53
+ if (!exceededThreshold && distance > threshold) {
54
+ exceededThreshold = true
55
+ options.onThreshold?.(next, { dx, dy, distance, exceededThreshold: true })
56
+ }
57
+ return { dx, dy, distance, exceededThreshold }
58
+ }
59
+
25
60
  function move(next: PointerEvent) {
26
61
  if (next.pointerId !== id) return
27
- try { options.onMove(next) } catch (error) { cancel(); throw error }
62
+ try {
63
+ const payload = buildPayload(next)
64
+ options.onMove(next, payload)
65
+ } catch (error) {
66
+ cancel()
67
+ throw error
68
+ }
28
69
  }
29
70
  function up(next: PointerEvent) { if (next.pointerId === id) finish?.(next) }
30
71
  function interrupted(next: PointerEvent) { if (next.pointerId === id) cancel() }
@@ -38,13 +79,17 @@ export function createPointerSession() {
38
79
  view!.removeEventListener('pointercancel', interrupted)
39
80
  view!.removeEventListener('blur', cancel)
40
81
  view!.removeEventListener('keydown', keydown)
41
- target?.removeEventListener('lostpointercapture', interrupted as EventListener)
82
+ targetEl?.removeEventListener('lostpointercapture', interrupted as EventListener)
42
83
  try {
43
- if (target?.hasPointerCapture?.(id)) target.releasePointerCapture(id)
84
+ if (targetEl?.hasPointerCapture?.(id)) targetEl.releasePointerCapture(id)
44
85
  } catch { /* Detached targets may already have lost capture. */ }
45
86
  if (style) { style.cursor = cursor; style.userSelect = selection }
46
- if (next) options.onEnd?.(next)
47
- else options.onCancel?.()
87
+ if (next) {
88
+ const payload = buildPayload(next)
89
+ options.onEnd?.(next, payload)
90
+ } else {
91
+ options.onCancel?.()
92
+ }
48
93
  }
49
94
  event.preventDefault()
50
95
  if (style) {
@@ -56,8 +101,8 @@ export function createPointerSession() {
56
101
  view.addEventListener('pointercancel', interrupted)
57
102
  view.addEventListener('blur', cancel)
58
103
  view.addEventListener('keydown', keydown)
59
- target?.addEventListener('lostpointercapture', interrupted as EventListener)
60
- try { target?.setPointerCapture?.(id) } catch { /* Window listeners remain a capture fallback. */ }
104
+ targetEl?.addEventListener('lostpointercapture', interrupted as EventListener)
105
+ try { targetEl?.setPointerCapture?.(id) } catch { /* Window listeners remain a capture fallback. */ }
61
106
  return true
62
107
  }
63
108
  return { start, cancel }
@@ -47,8 +47,8 @@ function initResize(el: HTMLElement, binding: { value?: ResizeOptions }) {
47
47
  if (options.maxHeight !== undefined) target.setAttribute('aria-valuemax', String(options.maxHeight))
48
48
  target.setAttribute('aria-valuenow', String(el.offsetHeight))
49
49
 
50
- function update(clientY: number, startY: number, initialHeight: number): ResizeEvent {
51
- const event = { clientY, deltaY: clientY - startY, initialHeight }
50
+ function update(clientY: number, deltaY: number, initialHeight: number): ResizeEvent {
51
+ const event = { clientY, deltaY, initialHeight }
52
52
  const proposed = initialHeight + event.deltaY
53
53
  const snapped = options.gridSize && options.gridSize > 0 ? Math.round(proposed / options.gridSize) * options.gridSize : proposed
54
54
  const height = Math.min(options.maxHeight ?? Infinity, Math.max(options.minHeight ?? 0, snapped))
@@ -61,16 +61,15 @@ function initResize(el: HTMLElement, binding: { value?: ResizeOptions }) {
61
61
  function handleStart(event: PointerEvent) {
62
62
  if (event.button !== 0 || event.isPrimary === false) return
63
63
  pointer.cancel()
64
- const startY = event.clientY
65
64
  const initialHeight = el.offsetHeight
66
65
  const originalHeight = el.style.height
67
66
  const originalValue = target.getAttribute('aria-valuenow')
68
67
  options.onStart?.(event)
69
68
  pointer.start(event, {
70
69
  cursor: 'ns-resize',
71
- onMove: e => update(e.clientY, startY, initialHeight),
72
- onEnd(e) {
73
- const result = update(e.clientY, startY, initialHeight)
70
+ onMove: (e, { dy }) => update(e.clientY, dy, initialHeight),
71
+ onEnd(e, { dy }) {
72
+ const result = update(e.clientY, dy, initialHeight)
74
73
  options.onEnd?.(result)
75
74
  },
76
75
  onCancel() {
@@ -92,7 +91,7 @@ function initResize(el: HTMLElement, binding: { value?: ResizeOptions }) {
92
91
  : event.key === 'End' ? options.maxHeight!
93
92
  : initialHeight + (event.key === 'ArrowUp' ? -step : step)
94
93
  options.onStart?.(event)
95
- const result = update(height - initialHeight, 0, initialHeight)
94
+ const result = update(height - initialHeight, height - initialHeight, initialHeight)
96
95
  options.onEnd?.(result)
97
96
  }
98
97