@live-change/image-frontend 0.0.3

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.
@@ -0,0 +1,382 @@
1
+ <template>
2
+ <div class="w-full relative surface-400 overflow-hidden" ref="dragArea"
3
+ @mousedown="handleEditorMouseDown"
4
+ @mouseup="handleEditorMouseUp"
5
+ @mouseout="handleEditorMouseOut"
6
+ @mousemove="handleEditorMouseMove"
7
+ @touchstart="handleEditorTouchStart"
8
+ @touchend="handleEditorTouchEnd"
9
+ @touchcancel="handleEditorTouchCancel"
10
+ @touchmove="handleEditorTouchMove"
11
+ @wheel="handleEditorWheel">
12
+ <div class="p-6 md:p-8 lg:p-8">
13
+ <div class="w-auto relative"
14
+ :style="`aspect-ratio: ${aspectRatio}`" ref="cropArea">
15
+ <div class="absolute left-50 top-50 w-0 h-0">
16
+ <Image :image="sourceImage"
17
+ :style="{ width: `${rectSize?.x}px`, transform: imageTransform }"
18
+ @size="handleImageSize" />
19
+ </div>
20
+ </div>
21
+ </div>
22
+ <canvas class="absolute w-full h-full left-0 top-0 pointer-events-none" ref="overlayCanvas" />
23
+ </div>
24
+ </template>
25
+
26
+ <script setup>
27
+
28
+ import Image from "./Image.vue"
29
+
30
+ import { ref, reactive, watch, computed, onMounted, defineEmits, defineExpose } from 'vue'
31
+ import { toRefs, useResizeObserver, useDebounceFn } from '@vueuse/core'
32
+ import { getElementPositionInElement, getElementPositionInWindow } from "./dom"
33
+ import { loadImage, imageToCanvas } from "./imageUtils.js"
34
+
35
+ const props = defineProps({
36
+ sourceImage: { // image
37
+ type: String,
38
+ required: true
39
+ },
40
+ sourceUpload: {
41
+ type: Object,
42
+ default: null
43
+ },
44
+ aspectRatio: {
45
+ type: Number,
46
+ default: 1
47
+ },
48
+ fill: {
49
+ type: Boolean,
50
+ default: true
51
+ },
52
+ ready: {
53
+ },
54
+ type: {
55
+ type: String,
56
+ default: 'rect' // or 'circle'
57
+ },
58
+ crop: {
59
+ type: Object,
60
+ default: () => ({ x: 0, y: 0, zoom:1, orientation: 0 })
61
+ }
62
+ })
63
+
64
+ const emit = defineEmits(['update:ready'])
65
+
66
+ const { sourceImage, aspectRatio, fill, sourceUpload } = toRefs(props)
67
+
68
+ const overlayCanvas = ref()
69
+ const cropArea = ref()
70
+ const dragArea = ref()
71
+
72
+ const rectSize = ref()
73
+
74
+ function repaintOverlayCanvas() {
75
+ console.log('canvas', overlayCanvas)
76
+ const canvas = overlayCanvas.value
77
+ const content = cropArea.value
78
+ const editor = dragArea.value
79
+ if(!canvas) return
80
+ if(!content) return
81
+ if(!editor) return
82
+ const pixelRatio = window.devicePixelRatio || 1
83
+ if((canvas.width != Math.floor(canvas.clientWidth * pixelRatio))
84
+ || (canvas.height != Math.floor(canvas.clientHeight * pixelRatio))) {
85
+ canvas.width = Math.floor(canvas.clientWidth * pixelRatio)
86
+ canvas.height = Math.floor(canvas.clientHeight * pixelRatio)
87
+ }
88
+ const position = getElementPositionInElement(content, editor)
89
+ const size = { x: content.clientWidth, y: content.clientHeight }
90
+ rectSize.value = size
91
+ const context = canvas.getContext('2d')
92
+ context.clearRect(0, 0, canvas.width, canvas.height)
93
+ context.fillStyle = 'rgba(0, 0, 0, 0.5)'
94
+ context.fillRect(0, 0, canvas.width, canvas.height)
95
+ context.strokeStyle = "white"
96
+ context.lineWidth = 1.5 * pixelRatio;
97
+ if(props.type == 'circle') {
98
+ context.save()
99
+ context.globalCompositeOperation = 'destination-out'
100
+ context.fillStyle = '#000'
101
+ context.beginPath()
102
+ context.ellipse(
103
+ (position.x + size.x / 2) * pixelRatio,
104
+ (position.y + size.y / 2) * pixelRatio,
105
+ (size.x / 2) * pixelRatio,
106
+ (size.y / 2) * pixelRatio,
107
+ 0, 0, 2 * Math.PI
108
+ )
109
+ context.fill()
110
+ context.restore()
111
+ context.beginPath()
112
+ context.ellipse(
113
+ (position.x + size.x / 2) * pixelRatio,
114
+ (position.y + size.y / 2) * pixelRatio,
115
+ (size.x / 2) * pixelRatio,
116
+ (size.y / 2) * pixelRatio,
117
+ 0, 0, 2 * Math.PI
118
+ )
119
+ context.stroke()
120
+ } else {
121
+ context.clearRect(position.x * pixelRatio, position.y * pixelRatio,
122
+ size.x * pixelRatio, size.y * pixelRatio)
123
+ context.strokeRect(position.x * pixelRatio, position.y * pixelRatio,
124
+ size.x * pixelRatio, size.y * pixelRatio)
125
+ }
126
+ }
127
+
128
+ const repaintDebounced = useDebounceFn(() => repaintOverlayCanvas(), 300)
129
+
130
+ useResizeObserver(overlayCanvas, (entries) => repaintDebounced())
131
+ watch(() => [props.width, props.height, props.type], () => repaintDebounced())
132
+
133
+ const position = reactive({ x: props.crop.x, y: props.crop.y, scale: props.crop.zoom })
134
+ const imageTransform = computed(() => {
135
+ const x = - position.x * 50
136
+ const y = - position.y * 50
137
+ const s = position.scale
138
+ return `translate(-50%, -50%) scale(${s}) translate(${x}%, ${y}%)`
139
+ })
140
+
141
+ const sourceImageSize = ref()
142
+ function handleImageSize(size) {
143
+ console.log("SIZE EVENT", size)
144
+ sourceImageSize.value = size
145
+ }
146
+ const sourceAspectRatio = computed(() => sourceImageSize.value?.width / sourceImageSize.value?.height)
147
+
148
+ const minScale = computed(() => {
149
+ if(!sourceImageSize.value) return 0.01
150
+ const imageRatio = sourceAspectRatio.value
151
+ const requiredRatio = aspectRatio.value
152
+ if(fill.value) {
153
+ return (imageRatio > requiredRatio)
154
+ ? (imageRatio / requiredRatio)
155
+ : 1
156
+ } else {
157
+ return (imageRatio > requiredRatio)
158
+ ? 1
159
+ : imageRatio / requiredRatio
160
+ }
161
+ })
162
+ const maxScale = ref(5)
163
+
164
+ function updatePosition(x, y, scale) {
165
+ if(scale > maxScale.value) scale = maxScale.value
166
+ if(scale < minScale.value) scale = minScale.value
167
+ const ratio = aspectRatio.value / sourceAspectRatio.value
168
+ let xMin, xMax, yMin, yMax
169
+ if(fill.value) {
170
+ xMin = -1 + (1 / scale)
171
+ xMax = 1 - (1 / scale)
172
+ yMin = -1 + (1 / scale / ratio)
173
+ yMax = 1 - (1 / scale / ratio)
174
+ } else {
175
+ xMin = - 1
176
+ xMax = 1
177
+ yMin = - 1
178
+ yMax = 1
179
+ }
180
+ if(x < xMin) x = xMin
181
+ if(x > xMax) x = xMax
182
+ if(y < yMin) y = yMin
183
+ if(y > yMax) y = yMax
184
+ position.x = x
185
+ position.y = y
186
+ position.scale = scale
187
+ }
188
+
189
+ watch(() => [minScale.value, maxScale.value], () => updatePosition(position.x, position.y, position.scale))
190
+
191
+ const ready = computed(() => !!sourceImageSize.value)
192
+ console.log("R", ready.value)
193
+ if(ready.value) onMounted(() => emit('update:ready', true))
194
+ watch(() => ready.value, r => {
195
+ console.log("RR", ready.value)
196
+ emit('update:ready', r)
197
+ })
198
+
199
+ function preProcessTouch(ev, id) {
200
+ const contentPosition = getElementPositionInWindow(cropArea.value)
201
+ const contentSize = rectSize.value
202
+ const destX = (ev.clientX - contentPosition.x ) / contentSize.x * 2 - 1
203
+ const destY = (ev.clientY - contentPosition.y ) / contentSize.y * 2 - 1
204
+ const ratio = aspectRatio.value / sourceAspectRatio.value
205
+ const srcX = destX / position.scale
206
+ const srcY = destY / position.scale / ratio
207
+ //console.log(destX, destY, "R", ratio, "S", srcX, srcY)
208
+ return {
209
+ id,
210
+ x: srcX,
211
+ y: srcY,
212
+ dx: destX,
213
+ dy: destY
214
+ }
215
+ }
216
+
217
+ const dragStart = ref()
218
+
219
+ function updateTouches(newTouches) {
220
+ if(!sourceImage.value) return
221
+ const ratio = aspectRatio.value / sourceAspectRatio.value
222
+ const newCenter = newTouches.reduce(
223
+ (a, b) => ({
224
+ x: a.x + b.x / newTouches.length,
225
+ y: a.y + b.y / newTouches.length,
226
+ dx: a.dx + b.dx / newTouches.length,
227
+ dy: a.dy + b.dy / newTouches.length
228
+ }), { x: 0, y: 0, dx:0, dy:0 })
229
+ const newSize = newTouches.length > 1
230
+ ? newTouches.map(t => {
231
+ let x = t.dx - newCenter.dx, y = (t.dy - newCenter.dy)*ratio
232
+ return Math.sqrt(x*x+y*y)
233
+ }).reduce((a,b) => a + b / newTouches.length, 0)
234
+ : 1
235
+ if(newTouches.length == (dragStart.value && dragStart.value.touchCount || 0)) {
236
+ if(!newTouches.length) return
237
+ //console.log("newSize", newSize, "size", this.dragStart.size, "scale", this.dragStart.size * newSize)
238
+ updatePosition(
239
+ dragStart.value.x - newCenter.x,
240
+ dragStart.value.y - newCenter.y,
241
+ dragStart.value.size * newSize
242
+ )
243
+ } else {
244
+ if(newTouches.length) {
245
+ dragStart.value = {
246
+ x: position.x + newCenter.x,
247
+ y: position.y + newCenter.y,
248
+ size: position.scale / newSize,
249
+ touchCount: newTouches.length
250
+ }
251
+ } else {
252
+ dragStart.value = null
253
+ }
254
+ }
255
+ }
256
+
257
+ function handleEditorMouseDown(ev) {
258
+ ev.preventDefault()
259
+ ev.stopPropagation()
260
+ if(dragArea.value && cropArea.value) updateTouches([ preProcessTouch(ev, 'mouse') ])
261
+ }
262
+ function handleEditorMouseUp(ev) {
263
+ ev.preventDefault()
264
+ ev.stopPropagation()
265
+ updateTouches([ ])
266
+ }
267
+ function handleEditorMouseOut(ev) {
268
+ ev.preventDefault()
269
+ ev.stopPropagation()
270
+ updateTouches([ ])
271
+ }
272
+ function handleEditorMouseMove(ev) {
273
+ ev.preventDefault()
274
+ ev.stopPropagation()
275
+ if(dragArea.value && cropArea.value && dragStart.value) updateTouches([ preProcessTouch(ev, 'mouse') ])
276
+ }
277
+ function handleEditorTouchStart(ev) {
278
+ ev.preventDefault()
279
+ ev.stopPropagation()
280
+ if(dragArea.value && cropArea.value) updateTouches(
281
+ Array.prototype.slice.call(ev.targetTouches).map(t => preProcessTouch(t, t.identifier))
282
+ )
283
+ }
284
+ function handleEditorTouchEnd(ev) {
285
+ ev.preventDefault()
286
+ ev.stopPropagation()
287
+ if(dragArea.value && cropArea.value) updateTouches(
288
+ Array.prototype.slice.call(ev.targetTouches).map(t => preProcessTouch(t, t.identifier))
289
+ )
290
+ }
291
+ function handleEditorTouchCancel(ev) {
292
+ ev.preventDefault()
293
+ ev.stopPropagation()
294
+ if(dragArea.value && cropArea.value) updateTouches(
295
+ Array.prototype.slice.call(ev.targetTouches).map(t => preProcessTouch(t, t.identifier))
296
+ )
297
+ }
298
+ function handleEditorTouchMove(ev) {
299
+ ev.preventDefault()
300
+ ev.stopPropagation()
301
+ if($refs.editor && $refs.content) updateTouches(
302
+ Array.prototype.slice.call(ev.targetTouches).map(t => preProcessTouch(t, t.identifier))
303
+ )
304
+ }
305
+ function handleEditorWheel(ev) {
306
+ ev.preventDefault()
307
+ ev.stopPropagation()
308
+ const rate = 0.2
309
+ if(ev.deltaY > 0) {
310
+ updatePosition(position.x, position.y, position.scale / (1 + rate))
311
+ } else if(ev.deltaY < 0) {
312
+ updatePosition(position.x, position.y, position.scale * (1 + rate))
313
+ }
314
+ }
315
+
316
+
317
+ async function getSourceImageUrl() {
318
+ if(sourceUpload.value) {
319
+ await sourceUpload.value.prepare()
320
+ if(sourceUpload.value.url) {
321
+ return sourceUpload.value.url
322
+ }
323
+ await sourceUpload.value.upload()
324
+ if(sourceUpload.value.url) {
325
+ return sourceUpload.value.url
326
+ }
327
+ }
328
+ if(!sourceImageSize.value) throw new Error("crop not ready")
329
+ return `/api/image/image/${sourceImage.value}`
330
+ }
331
+
332
+ async function getSourceImageCanvas() {
333
+ if(sourceUpload.value?.canvas) return sourceUpload.value.canvas
334
+ const image = await loadImage(await getSourceImageUrl())
335
+ const canvas = imageToCanvas(image)
336
+ return canvas
337
+ }
338
+
339
+ async function crop() {
340
+ const ratio = aspectRatio.value / sourceAspectRatio.value
341
+ const xMin = Math.round(((position.x - 1/position.scale) / 2 + 0.5) * sourceImageSize.value.width)
342
+ const xMax = Math.round(((position.x + 1/position.scale) / 2 + 0.5) * sourceImageSize.value.width)
343
+ console.log("X", xMin, xMax)
344
+ const yMin = Math.round(((position.y - 1/position.scale/ratio) / 2 + 0.5) * sourceImageSize.value.height)
345
+ const yMax = Math.round(((position.y + 1/position.scale/ratio) / 2 + 0.5) * sourceImageSize.value.height)
346
+ console.log("Y", yMin, yMax)
347
+ const width = xMax - xMin
348
+ const height = yMax - yMin
349
+
350
+ const crop = {
351
+ x: position.x,
352
+ y: position.y,
353
+ zoom: position.scale,
354
+ orientation: 0,
355
+ originalImage: sourceImage.value || sourceUpload.value.id
356
+ }
357
+
358
+ const srcXMin = Math.max(0, xMin)
359
+ const srcYMin = Math.max(0, yMin)
360
+ const srcXMax = Math.min(sourceImageSize.value.width, xMax)
361
+ const srcYMax = Math.max(sourceImageSize.value.height, yMax)
362
+
363
+ const canvas = await getSourceImageCanvas()
364
+ const context = canvas.getContext('2d')
365
+ let imageData = context.getImageData(srcXMin, srcYMin, srcXMax - srcXMin, srcYMax - srcYMin)
366
+ canvas.width = width
367
+ canvas.height = height
368
+ context.clearRect(0, 0, canvas.width, canvas.height)
369
+ context.putImageData(imageData, srcXMin - xMin, srcYMin - yMin)
370
+
371
+
372
+ return { crop, canvas }
373
+ }
374
+
375
+ defineExpose({
376
+ crop
377
+ })
378
+ </script>
379
+
380
+ <style scoped>
381
+
382
+ </style>
@@ -0,0 +1,211 @@
1
+ <template>
2
+ <div v-if="state == 'edit'">
3
+ <ImageCrop v-if="sourceImage || sourceUpload"
4
+ :crop="cropData"
5
+ :aspectRatio="aspectRatio"
6
+ :sourceImage="sourceImage"
7
+ :fill="props.fill"
8
+ :sourceUpload="sourceUpload"
9
+ :type="type"
10
+ v-model:ready="cropReady"
11
+ ref="imageCrop" />
12
+ <div class="flex p-4">
13
+ <div class="flex-grow-1 flex">
14
+ <Button type="button" label="Add Image" icon="pi pi-plus" class="p-button-primary"
15
+ @click="() => state = 'upload' "/>
16
+ </div>
17
+ <div class="flex">
18
+ <Button type="button" label="Save Image" icon="pi pi-save" class="p-button-warning ml-2"
19
+ :disabled="!cropReady" @click="saveImage" />
20
+ <Button type="button" label="Remove Image" icon="pi pi-trash" class="p-button-danger ml-2"
21
+ @click="removeImage"/>
22
+ </div>
23
+ </div>
24
+ </div>
25
+ <div v-else>
26
+ <DropZone class="w-full relative p-6 md:p-8 lg:p-8" :accept="acceptList" @input="handleFile">
27
+ <div class="w-auto border-dashed border-primary-500 flex align-items-center justify-content-center"
28
+ :style="`aspect-ratio: ${aspectRatio}`">
29
+ <p class="text-primary text-xl">Drop image here!</p>
30
+ </div>
31
+ </DropZone>
32
+ <div class="flex p-4">
33
+ <div class="flex-grow-1 flex">
34
+ <FileInput :accept="acceptList" @input="handleFile" class="block">
35
+ <Button type="button" label="Upload Image" icon="pi pi-upload" class="p-button-primary" />
36
+ </FileInput>
37
+ </div>
38
+ </div>
39
+ </div>
40
+ </template>
41
+
42
+ <script setup>
43
+ import { computed, ref, watch, inject, getCurrentInstance } from 'vue'
44
+ import { useResizeObserver } from '@vueuse/core'
45
+ import { getElementPositionInWindow, getElementPositionInElement } from "./dom.js"
46
+
47
+ import { path, fetch } from '@live-change/vue3-ssr'
48
+
49
+ import { FileInput, DropZone } from '@live-change/upload-frontend'
50
+ import { uploadImage } from "./imageUploads.js"
51
+ import Button from "primevue/button"
52
+
53
+ import ImageCrop from "./ImageCrop.vue"
54
+
55
+ import { useToast } from 'primevue/usetoast'
56
+ const toast = useToast()
57
+ import { useConfirm } from 'primevue/useconfirm'
58
+ const confirm = useConfirm()
59
+
60
+ const props = defineProps({
61
+ modelValue: { // image
62
+ type: String,
63
+ default: null
64
+ },
65
+ sourceImage: {
66
+ type: String,
67
+ default: null
68
+ },
69
+ uploadedFile: {
70
+ type: (typeof window != "undefined") ? Blob : undefined,
71
+ default: null
72
+ },
73
+ purpose: {
74
+ type: String,
75
+ default: "unknown"
76
+ },
77
+ width: {
78
+ type: Number,
79
+ default: 256
80
+ },
81
+ height: {
82
+ type: Number,
83
+ default: 256
84
+ },
85
+ type: {
86
+ type: String,
87
+ default: 'rect' // or 'circle'
88
+ },
89
+ fill: {
90
+ type: Boolean,
91
+ default: true
92
+ },
93
+ saveButton: {
94
+ type: Boolean
95
+ }
96
+ })
97
+
98
+ const workingZone = inject('workingZone')
99
+ const loadingZone = inject('loadingZone')
100
+
101
+ const aspectRatio = computed(() => props.width/props.height)
102
+ const acceptList = 'image/jpeg, image/png, image/webp, .jpg, .png, .jpeg, .webp'
103
+
104
+ const state = ref(props.modelValue || props.sourceImage ? 'edit' : 'upload')
105
+
106
+ const sourceImage = ref(props.sourceImage)
107
+ const sourceUpload = ref()
108
+
109
+ const upload = ref()
110
+
111
+ const imageType = ref()
112
+ const imageName = ref()
113
+ const cropData = ref({ x: 0, y: 0, zoom: 1, orientation: 0 })
114
+
115
+ const imageCrop = ref()
116
+ const cropReady = ref(false)
117
+
118
+ const appContext = getCurrentInstance().appContext
119
+
120
+ const emit = defineEmits(['update:modelValue', 'close'])
121
+
122
+ async function removeImage(event) {
123
+ confirm.require({
124
+ target: event.currentTarget,
125
+ message: `Do you want to delete image?`,
126
+ icon: 'pi pi-info-circle',
127
+ acceptClass: 'p-button-danger',
128
+ accept: async () => {
129
+ sourceImage.value = null
130
+ state.value = 'upload'
131
+ emit('update:modelValue', null)
132
+ emit('close')
133
+ toast.add({ severity:'info', summary: 'Image Deleted', life: 1500 })
134
+ },
135
+ reject: () => {
136
+ toast.add({ severity:'error', summary: 'Rejected', detail: 'You have rejected', life: 3000 })
137
+ }
138
+ })
139
+ }
140
+
141
+ async function saveImage() {
142
+ await workingZone.addPromise('crop image and upload', (async () => {
143
+ if(!imageCrop.value) throw new Error("crop not available")
144
+ if(!cropReady.value) throw new Error("crop not ready")
145
+ console.log("IMAGE CROP", imageCrop.value)
146
+ const { crop, canvas } = await imageCrop.value.crop()
147
+ console.log("CROP RESULT", crop, canvas)
148
+
149
+ upload.value = uploadImage(props.purpose, { canvas },
150
+ { preparedPreview: true, appContext, generateId : true, crop, })
151
+ console.log("START PREPARE!")
152
+ emit('update:modelValue', upload.value.id)
153
+ if(sourceUpload.value) await sourceUpload.value.upload()
154
+ await upload.value.upload()
155
+ emit('close')
156
+ })())
157
+ }
158
+
159
+ async function handleFile(file) {
160
+ imageName.value = file.name
161
+ imageType.value = file.type
162
+ await workingZone.addPromise("upload source image", (async () => {
163
+ sourceUpload.value = uploadImage(props.purpose, { file },
164
+ { preparedPreview: true, appContext, generateId : true, saveCanvas: true })
165
+ console.log("START PREPARE!")
166
+ await sourceUpload.value.prepare()
167
+ cropReady.value = false
168
+ sourceImage.value = sourceUpload.value.id
169
+ state.value = 'edit'
170
+ })())
171
+ console.log("START UPLOAD!")
172
+ await sourceUpload.value.upload()
173
+ console.log("SOURCE UPLOADED", sourceUpload.value)
174
+ }
175
+ if(props.uploadedFile) handleFile(props.uploadedFile)
176
+
177
+ if(props.modelValue) { // Existing image
178
+ loadingZone.addPromise("load exsting image metadata", (async () => {
179
+ const imageData = await fetch(
180
+ path().image.image({ image: props.modelValue }).with(
181
+ image => path().image.image({ image: image.crop.originalImage }).bind('originalImage')
182
+ )
183
+ )
184
+ const originalImage = imageData.value.originalImage
185
+ console.log("IM DATA", imageData, originalImage)
186
+ imageName.value = originalImage.name
187
+ const splitName = originalImage.name.split('.')
188
+ const extension = splitName[splitName.length - 1].toLowerCase()
189
+ switch(extension) {
190
+ case 'jpg':
191
+ case 'jpeg':
192
+ imageType.value = 'image/jpeg'
193
+ break
194
+ case 'webp':
195
+ imageType.value = 'image/webp'
196
+ break
197
+ default:
198
+ case 'png':
199
+ imageType.value = 'image/png'
200
+ break
201
+ }
202
+ cropData.value = imageData.value.crop
203
+ sourceImage.value = originalImage.id
204
+ console.log("IMAGE", imageName.value, imageType.value)
205
+ })())
206
+ }
207
+ </script>
208
+
209
+ <style scoped>
210
+
211
+ </style>