openpencil/src/components/FillPicker.vue
Danila Poyarkov 3b32b4d80c
Perf: 5.4× faster file open, worker-based parsing, instance index (#96)
* Perf: cache label collection, offload .fig compression to worker

Label cache: collect sections/components once per scene change,
filter by viewport on each frame. Eliminates full tree walk during
pan/zoom (~17ms/frame → <1ms on large files).

Export worker: move fflate compression off the main thread to prevent
frame drops during save (451ms+ → non-blocking).

* Perf: worker-based .fig parsing, instance index, non-blocking font loading

- Offload .fig parsing (unzip + Kiwi decode) to a Web Worker
- Add instance index (componentId → Set<nodeId>) for O(1) getInstances()
- Defer graph event subscription during file open to skip redundant syncs
- Make font loading non-blocking — render immediately, load fonts in background
- Copy image buffers before worker transfer to prevent detached ArrayBuffer crash
- Show toast on font load failure and file open errors
- Cache failed Google Fonts families to avoid repeated network requests
- Fix missing ref import in FillPicker
- Yield to UI between parse and layout for responsive loading spinner
2026-03-12 14:49:28 +03:00

505 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, ref, shallowRef, watch } from 'vue'
import { useFileDialog, useObjectUrl } from '@vueuse/core'
import {
PopoverRoot,
PopoverTrigger,
PopoverPortal,
PopoverContent,
SelectRoot,
SelectTrigger,
SelectValue,
SelectPortal,
SelectContent,
SelectItem,
SelectItemText,
SelectItemIndicator,
SelectViewport
} from 'reka-ui'
import HsvColorArea from './HsvColorArea.vue'
import ScrubInput from './ScrubInput.vue'
import { useEditorStore } from '@/stores/editor'
import { colorToCSS, colorToHexRaw, parseColor } from '@open-pencil/core'
import type {
Color,
Fill,
GradientStop,
GradientTransform,
ImageScaleMode
} from '@open-pencil/core'
type FillCategory = 'SOLID' | 'GRADIENT' | 'IMAGE'
type GradientSubtype =
| 'GRADIENT_LINEAR'
| 'GRADIENT_RADIAL'
| 'GRADIENT_ANGULAR'
| 'GRADIENT_DIAMOND'
const GRADIENT_SUBTYPES: { type: GradientSubtype; label: string }[] = [
{ type: 'GRADIENT_LINEAR', label: 'Linear' },
{ type: 'GRADIENT_RADIAL', label: 'Radial' },
{ type: 'GRADIENT_ANGULAR', label: 'Angular' },
{ type: 'GRADIENT_DIAMOND', label: 'Diamond' }
]
const DEFAULT_GRADIENT_TRANSFORMS: Record<GradientSubtype, GradientTransform> = {
GRADIENT_LINEAR: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 0, m12: 0.5 },
GRADIENT_RADIAL: { m00: 0.5, m01: 0, m02: 0.5, m10: 0, m11: 0.5, m12: 0.5 },
GRADIENT_ANGULAR: { m00: 0.5, m01: 0, m02: 0.5, m10: 0, m11: 0.5, m12: 0.5 },
GRADIENT_DIAMOND: { m00: 0.5, m01: 0, m02: 0.5, m10: 0, m11: 0.5, m12: 0.5 }
}
const { fill } = defineProps<{
fill: Fill
}>()
const emit = defineEmits<{
update: [fill: Fill]
}>()
const activeStopIndex = ref(0)
const fillCategory = computed<FillCategory>(() => {
if (fill.type.startsWith('GRADIENT')) return 'GRADIENT'
if (fill.type === 'IMAGE') return 'IMAGE'
return 'SOLID'
})
const isGradient = computed(() => fillCategory.value === 'GRADIENT')
const gradientSubtype = computed(() =>
isGradient.value ? (fill.type as GradientSubtype) : 'GRADIENT_LINEAR'
)
const activeColor = computed(() => {
if (isGradient.value && fill.gradientStops?.length) {
const idx = Math.min(activeStopIndex.value, fill.gradientStops.length - 1)
return fill.gradientStops[idx].color
}
return fill.color
})
function onColorUpdate(color: Color) {
if (isGradient.value && fill.gradientStops?.length) {
const stops = [...fill.gradientStops]
const idx = Math.min(activeStopIndex.value, stops.length - 1)
stops[idx] = { ...stops[idx], color }
emit('update', { ...fill, gradientStops: stops })
} else {
emit('update', { ...fill, color })
}
}
function setCategory(cat: FillCategory) {
if (cat === fillCategory.value) return
if (cat === 'SOLID') {
const color = fill.gradientStops?.length ? { ...fill.gradientStops[0].color } : fill.color
emit('update', { ...fill, type: 'SOLID', color })
} else if (cat === 'GRADIENT') {
const type: GradientSubtype = 'GRADIENT_LINEAR'
const stops = fill.gradientStops?.length
? fill.gradientStops
: [
{ color: { ...fill.color }, position: 0 },
{ color: { r: 1, g: 1, b: 1, a: 1 }, position: 1 }
]
emit('update', {
...fill,
type,
gradientStops: stops,
gradientTransform: DEFAULT_GRADIENT_TRANSFORMS[type]
})
activeStopIndex.value = 0
} else {
emit('update', { ...fill, type: 'IMAGE' })
}
}
function setGradientSubtype(type: string) {
const subtype = type as GradientSubtype
if (subtype === fill.type) return
emit('update', {
...fill,
type: subtype,
gradientTransform: DEFAULT_GRADIENT_TRANSFORMS[subtype]
})
}
function selectStop(index: number) {
activeStopIndex.value = index
}
function addStop() {
if (!fill.gradientStops) return
const stops = [...fill.gradientStops]
const newPos =
stops.length >= 2
? (stops[stops.length - 2].position + stops[stops.length - 1].position) / 2
: 0.5
stops.push({ color: { ...activeColor.value }, position: newPos })
stops.sort((a, b) => a.position - b.position)
const newIndex = stops.findIndex((s) => s.position === newPos)
activeStopIndex.value = newIndex
emit('update', { ...fill, gradientStops: stops })
}
function removeStop(index: number) {
if (!fill.gradientStops || fill.gradientStops.length <= 2) return
const stops = fill.gradientStops.filter((_, i) => i !== index)
activeStopIndex.value = Math.min(activeStopIndex.value, stops.length - 1)
emit('update', { ...fill, gradientStops: stops })
}
function updateStopPosition(index: number, value: string) {
if (!fill.gradientStops) return
const pos = Math.max(0, Math.min(100, Number(value))) / 100
const stops = [...fill.gradientStops]
stops[index] = { ...stops[index], position: pos }
emit('update', { ...fill, gradientStops: stops })
}
function updateStopColor(index: number, hex: string) {
if (!fill.gradientStops) return
const color = parseColor(hex.startsWith('#') ? hex : `#${hex}`)
if (!color) return
const stops = [...fill.gradientStops]
stops[index] = { ...stops[index], color: { ...color, a: stops[index].color.a } }
emit('update', { ...fill, gradientStops: stops })
}
function updateStopOpacity(index: number, value: string) {
if (!fill.gradientStops) return
const a = Math.max(0, Math.min(100, Number(value))) / 100
const stops = [...fill.gradientStops]
stops[index] = { ...stops[index], color: { ...stops[index].color, a } }
emit('update', { ...fill, gradientStops: stops })
}
function gradientStops(stops: GradientStop[]): string {
return stops.map((s) => `${colorToCSS(s.color)} ${s.position * 100}%`).join(', ')
}
const swatchBackground = computed(() => {
if (isGradient.value && fill.gradientStops?.length) {
return `linear-gradient(to right, ${gradientStops(fill.gradientStops)})`
}
return colorToCSS(fill.color)
})
const gradientBarBackground = computed(() => {
if (!fill.gradientStops?.length) return ''
return `linear-gradient(to right, ${gradientStops(fill.gradientStops)})`
})
const gradientStopBarRef = ref<HTMLDivElement | null>(null)
const draggingStopIndex = ref<number | null>(null)
function onStopPointerDown(index: number, e: PointerEvent) {
selectStop(index)
draggingStopIndex.value = index
const el = gradientStopBarRef.value
if (el) el.setPointerCapture(e.pointerId)
}
function onStopBarPointerMove(e: PointerEvent) {
const el = gradientStopBarRef.value
if (!el || draggingStopIndex.value === null || !el.hasPointerCapture(e.pointerId)) return
const rect = el.getBoundingClientRect()
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const stops = [...(fill.gradientStops ?? [])]
stops[draggingStopIndex.value] = { ...stops[draggingStopIndex.value], position: pos }
emit('update', { ...fill, gradientStops: stops })
}
function onStopBarPointerUp() {
draggingStopIndex.value = null
}
function stopSwatchColor(stop: GradientStop) {
return colorToCSS(stop.color)
}
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 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'
})
})
function setScaleMode(mode: string) {
emit('update', { ...fill, imageScaleMode: mode as ImageScaleMode })
}
</script>
<template>
<PopoverRoot>
<PopoverTrigger as-child>
<button
data-test-id="fill-picker-swatch"
class="size-5 shrink-0 cursor-pointer rounded border border-border p-0"
:style="{ background: swatchBackground }"
/>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
class="z-[100] w-60 rounded-lg border border-border bg-panel p-2 shadow-xl"
:side-offset="4"
side="left"
>
<!-- Fill category tabs: Solid | Gradient | Image -->
<div class="mb-2 flex items-center gap-0.5">
<button
class="flex size-6 cursor-pointer items-center justify-center rounded border-none p-0 text-muted transition-colors hover:bg-hover hover:text-surface"
:class="{ 'bg-hover text-surface': fillCategory === 'SOLID' }"
data-test-id="fill-picker-tab-solid"
title="Solid"
@click="setCategory('SOLID')"
>
<svg class="size-3.5" viewBox="0 0 16 16">
<rect x="2" y="2" width="12" height="12" rx="2" fill="currentColor" />
</svg>
</button>
<button
class="flex size-6 cursor-pointer items-center justify-center rounded border-none p-0 text-muted transition-colors hover:bg-hover hover:text-surface"
:class="{ 'bg-hover text-surface': fillCategory === 'GRADIENT' }"
data-test-id="fill-picker-tab-gradient"
title="Gradient"
@click="setCategory('GRADIENT')"
>
<svg class="size-3.5" viewBox="0 0 16 16">
<defs>
<linearGradient id="gl">
<stop offset="0" stop-color="currentColor" />
<stop offset="1" stop-color="currentColor" stop-opacity="0" />
</linearGradient>
</defs>
<rect x="2" y="2" width="12" height="12" rx="2" fill="url(#gl)" />
</svg>
</button>
<button
class="flex size-6 cursor-pointer items-center justify-center rounded border-none p-0 text-muted transition-colors hover:bg-hover hover:text-surface"
:class="{ 'bg-hover text-surface': fillCategory === 'IMAGE' }"
data-test-id="fill-picker-tab-image"
title="Image"
@click="setCategory('IMAGE')"
>
<icon-lucide-image class="size-3.5" />
</button>
</div>
<!-- Gradient subtype dropdown -->
<div v-if="isGradient" class="mb-2">
<SelectRoot :model-value="gradientSubtype" @update:model-value="setGradientSubtype">
<SelectTrigger
class="flex h-7 w-28 cursor-pointer items-center justify-between rounded border border-border bg-input px-2 text-xs text-surface"
>
<SelectValue />
<icon-lucide-chevron-down class="size-3 text-muted" />
</SelectTrigger>
<SelectPortal>
<SelectContent
class="z-[200] min-w-[112px] rounded-md border border-border bg-panel py-1 shadow-xl"
position="popper"
side="bottom"
:side-offset="4"
:align="'start'"
>
<SelectViewport>
<SelectItem
v-for="sub in GRADIENT_SUBTYPES"
:key="sub.type"
:value="sub.type"
class="relative flex cursor-pointer items-center rounded py-1 pr-2 pl-6 text-xs text-surface outline-none data-[highlighted]:bg-accent data-[highlighted]:text-white"
>
<SelectItemIndicator class="absolute left-1.5">
<icon-lucide-check class="size-3" />
</SelectItemIndicator>
<SelectItemText>{{ sub.label }}</SelectItemText>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</div>
<!-- Gradient stop bar -->
<div
v-if="isGradient && fill.gradientStops?.length"
ref="gradientStopBarRef"
data-test-id="fill-picker-gradient-bar"
class="relative mb-2 h-6 rounded"
:style="{ background: gradientBarBackground }"
@pointermove="onStopBarPointerMove"
@pointerup="onStopBarPointerUp"
>
<div
v-for="(stop, idx) in fill.gradientStops"
:key="idx"
class="absolute top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-sm border-2 shadow-sm"
:class="idx === activeStopIndex ? 'border-white' : 'border-white/60'"
:style="{
left: `${stop.position * 100}%`,
background: stopSwatchColor(stop)
}"
@pointerdown.stop="onStopPointerDown(idx, $event)"
/>
</div>
<!-- Gradient stops list -->
<div v-if="isGradient && fill.gradientStops?.length" class="mb-2">
<div class="mb-1 flex items-center justify-between">
<span class="text-[11px] text-muted">Stops</span>
<button
class="flex size-4 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted hover:text-surface"
data-test-id="fill-picker-add-stop"
title="Add stop"
@click="addStop"
>
<icon-lucide-plus class="size-3" />
</button>
</div>
<div
v-for="(stop, idx) in fill.gradientStops"
:key="idx"
class="flex items-center gap-1 py-0.5"
:class="{ 'rounded bg-hover/50': idx === activeStopIndex }"
@click="selectStop(idx)"
>
<ScrubInput
class="w-11"
suffix="%"
:model-value="Math.round(stop.position * 100)"
:min="0"
:max="100"
@update:model-value="updateStopPosition(idx, String($event))"
@click.stop
/>
<button
class="size-4 shrink-0 cursor-pointer rounded border border-border p-0"
:style="{ background: stopSwatchColor(stop) }"
@click.stop="selectStop(idx)"
/>
<input
class="min-w-0 flex-1 rounded border border-border bg-input px-1 py-0.5 font-mono text-[11px] text-surface"
:value="colorToHexRaw(stop.color)"
maxlength="6"
@change="updateStopColor(idx, ($event.target as HTMLInputElement).value)"
@click.stop
/>
<ScrubInput
class="w-9"
suffix="%"
:model-value="Math.round(stop.color.a * 100)"
:min="0"
:max="100"
@update:model-value="updateStopOpacity(idx, String($event))"
@click.stop
/>
<button
v-if="(fill.gradientStops?.length ?? 0) > 2"
class="flex size-4 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted hover:text-surface"
@click.stop="removeStop(idx)"
>
</button>
</div>
</div>
<!-- Image fill -->
<div v-if="fill.type === 'IMAGE'" 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>
<SelectRoot
:model-value="fill.imageScaleMode ?? 'FILL'"
@update:model-value="setScaleMode"
>
<SelectTrigger
class="flex h-7 w-full cursor-pointer items-center justify-between rounded border border-border bg-input px-2 text-xs text-surface"
>
<SelectValue />
<icon-lucide-chevron-down class="size-3 text-muted" />
</SelectTrigger>
<SelectPortal>
<SelectContent
class="z-[200] min-w-[112px] rounded-md border border-border bg-panel py-1 shadow-xl"
position="popper"
side="bottom"
:side-offset="4"
:align="'start'"
>
<SelectViewport>
<SelectItem
v-for="mode in IMAGE_SCALE_MODES"
:key="mode.value"
:value="mode.value"
class="relative flex cursor-pointer items-center rounded py-1 pr-2 pl-6 text-xs text-surface outline-none data-[highlighted]:bg-accent data-[highlighted]:text-white"
>
<SelectItemIndicator class="absolute left-1.5">
<icon-lucide-check class="size-3" />
</SelectItemIndicator>
<SelectItemText>{{ mode.label }}</SelectItemText>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</div>
<!-- HSV color area (solid mode, or editing active gradient stop) -->
<HsvColorArea v-if="fill.type !== 'IMAGE'" :color="activeColor" @update="onColorUpdate" />
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>