FillPicker.vue (504 → 121 lines): dumb tab switcher, no gradient internals. Delegates entirely to sub-components. New GradientEditor.vue (227 lines): owns gradient stops, stop bar with pointer drag, subtype select, and HsvColorArea for active stop. Uses AppSelect instead of hand-rolled Reka UI Select. New ImageFillPicker.vue (79 lines): image preview, file picker, scale mode select via AppSelect. Each component owns its full concern — no data leaking between them. Replaced inline SVGs with Lucide icons, use twMerge for tab classes.
80 lines
2.3 KiB
Vue
80 lines
2.3 KiB
Vue
<script setup lang="ts">
|
|
import { computed, shallowRef, watch } from 'vue'
|
|
import { useFileDialog, useObjectUrl } from '@vueuse/core'
|
|
|
|
import AppSelect from './AppSelect.vue'
|
|
import { useEditorStore } from '@/stores/editor'
|
|
|
|
import type { Fill, ImageScaleMode } from '@open-pencil/core'
|
|
|
|
const IMAGE_SCALE_MODES: { value: ImageScaleMode; label: string }[] = [
|
|
{ value: 'FILL', label: 'Fill' },
|
|
{ value: 'FIT', label: 'Fit' },
|
|
{ value: 'CROP', label: 'Crop' },
|
|
{ value: 'TILE', label: 'Tile' }
|
|
]
|
|
|
|
const { fill } = defineProps<{ fill: Fill }>()
|
|
const emit = defineEmits<{ update: [fill: Fill] }>()
|
|
const store = useEditorStore()
|
|
|
|
const imageBlob = shallowRef<Blob | null>(null)
|
|
const imagePreviewUrl = useObjectUrl(imageBlob)
|
|
|
|
watch(
|
|
() => fill.imageHash,
|
|
(hash) => {
|
|
if (!hash) {
|
|
imageBlob.value = null
|
|
return
|
|
}
|
|
const data = store.graph.images.get(hash)
|
|
imageBlob.value = data ? new Blob([data]) : null
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
const { open: pickImage, onChange: onFileChange } = useFileDialog({
|
|
accept: 'image/png,image/jpeg,image/webp',
|
|
multiple: false
|
|
})
|
|
|
|
onFileChange(async (files) => {
|
|
const file = files?.[0]
|
|
if (!file) return
|
|
const bytes = new Uint8Array(await file.arrayBuffer())
|
|
const hash = store.storeImage(bytes)
|
|
emit('update', {
|
|
...fill,
|
|
type: 'IMAGE',
|
|
imageHash: hash,
|
|
imageScaleMode: fill.imageScaleMode ?? 'FILL'
|
|
})
|
|
})
|
|
|
|
const scaleMode = computed({
|
|
get: () => fill.imageScaleMode ?? ('FILL' as ImageScaleMode),
|
|
set: (mode: ImageScaleMode) => emit('update', { ...fill, imageScaleMode: mode })
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="space-y-2">
|
|
<div
|
|
v-if="imagePreviewUrl"
|
|
class="flex h-24 items-center justify-center overflow-hidden rounded border border-border"
|
|
>
|
|
<img :src="imagePreviewUrl" class="max-h-full max-w-full object-contain" />
|
|
</div>
|
|
<button
|
|
class="flex h-7 w-full cursor-pointer items-center justify-center gap-1 rounded border border-border bg-input text-xs text-surface hover:bg-hover"
|
|
data-test-id="fill-picker-choose-image"
|
|
@click="() => pickImage()"
|
|
>
|
|
<icon-lucide-image class="size-3" />
|
|
{{ fill.imageHash ? 'Replace' : 'Choose image' }}
|
|
</button>
|
|
<AppSelect v-model="scaleMode" :options="IMAGE_SCALE_MODES" />
|
|
</div>
|
|
</template>
|