Phase 6: FillPicker, GradientEditor, ExportControls headless primitives

New SDK components:
- FillPickerRoot — fill type category (solid/gradient/image), swatch
  background, category switching. Events via emit, not callback props.
- GradientEditorRoot — stop state, subtype, add/remove/drag/select stops,
  active color, bar background. All stop mutations + color updates as
  action slot props.
- ExportControlsRoot — export settings list, scale/format options,
  selected node name.

Fixed callback props passed as :on-xxx across all SDK components:
- ScrubInputRoot: :on-keydown → :keydown
- FillPickerRoot: removed :on-update (consumers use @update on the
  component itself)
- GradientEditorRoot: :on-active-color-update → :update-active-color,
  :on-stop-drag → :drag-stop
This commit is contained in:
Danila Poyarkov 2026-03-17 10:16:34 +03:00
parent 72876f8a3e
commit 579492649d
8 changed files with 271 additions and 1 deletions

View file

@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useEditor } from '../context'
import type { ExportFormat } from '@open-pencil/core'
interface ExportSetting {
scale: number
format: ExportFormat
}
const SCALES = [0.5, 0.75, 1, 1.5, 2, 3, 4] as const
const FORMATS: ExportFormat[] = ['PNG', 'JPG', 'WEBP', 'SVG']
const editor = useEditor()
const settings = ref<ExportSetting[]>([{ scale: 1, format: 'PNG' }])
const selectedIds = computed(() => {
void editor.state.sceneVersion
return [...editor.state.selectedIds]
})
const nodeName = computed(() => {
void editor.state.sceneVersion
const ids = editor.state.selectedIds
if (ids.size === 1) {
const id = [...ids][0]
return editor.graph.getNode(id)?.name ?? 'Export'
}
return `${ids.size} layers`
})
function addSetting() {
const last = settings.value[settings.value.length - 1]
const nextScale = SCALES.find((s) => s > (last?.scale ?? 1)) ?? 2
settings.value.push({ scale: nextScale, format: last?.format ?? 'PNG' })
}
function removeSetting(index: number) {
settings.value.splice(index, 1)
}
function updateScale(index: number, scale: number) {
settings.value[index] = { ...settings.value[index], scale }
}
function updateFormat(index: number, format: ExportFormat) {
settings.value[index] = { ...settings.value[index], format }
}
</script>
<template>
<slot
:settings="settings"
:selected-ids="selectedIds"
:node-name="nodeName"
:scales="SCALES"
:formats="FORMATS"
:add-setting="addSetting"
:remove-setting="removeSetting"
:update-scale="updateScale"
:update-format="updateFormat"
/>
</template>

View file

@ -0,0 +1 @@
export { default as ExportControlsRoot } from './ExportControlsRoot.vue'

View file

@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed } from 'vue'
import { colorToCSS } from '@open-pencil/core'
import type { Fill, GradientStop } from '@open-pencil/core'
type FillCategory = 'SOLID' | 'GRADIENT' | 'IMAGE'
const FILL_CATEGORY: Record<string, FillCategory> = {
SOLID: 'SOLID',
GRADIENT_LINEAR: 'GRADIENT',
GRADIENT_RADIAL: 'GRADIENT',
GRADIENT_ANGULAR: 'GRADIENT',
GRADIENT_DIAMOND: 'GRADIENT',
IMAGE: 'IMAGE'
}
const { fill } = defineProps<{ fill: Fill }>()
const emit = defineEmits<{ update: [fill: Fill] }>()
const category = computed(() => FILL_CATEGORY[fill.type] ?? 'SOLID')
function toSolid() {
if (category.value === 'SOLID') return
const color = fill.gradientStops?.[0]?.color ?? fill.color
emit('update', { ...fill, type: 'SOLID', color: { ...color } })
}
function toGradient() {
if (category.value === 'GRADIENT') return
const stops: GradientStop[] = 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: 'GRADIENT_LINEAR',
gradientStops: stops,
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 0, m12: 0.5 }
})
}
function toImage() {
if (category.value === 'IMAGE') return
emit('update', { ...fill, type: 'IMAGE' })
}
function gradientCSS(stops: GradientStop[]): string {
return stops.map((s) => `${colorToCSS(s.color)} ${s.position * 100}%`).join(', ')
}
const swatchBg = computed(() => {
if (category.value === 'GRADIENT' && fill.gradientStops?.length)
return `linear-gradient(to right, ${gradientCSS(fill.gradientStops)})`
return colorToCSS(fill.color)
})
</script>
<template>
<slot
:fill="fill"
:category="category"
:swatch-bg="swatchBg"
:to-solid="toSolid"
:to-gradient="toGradient"
:to-image="toImage"
/>
</template>

View file

@ -0,0 +1 @@
export { default as FillPickerRoot } from './FillPickerRoot.vue'

View file

@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { colorToCSS, parseColor } from '@open-pencil/core'
import type { Fill, GradientStop, GradientTransform } from '@open-pencil/core'
type GradientSubtype =
| 'GRADIENT_LINEAR'
| 'GRADIENT_RADIAL'
| 'GRADIENT_ANGULAR'
| 'GRADIENT_DIAMOND'
const SUBTYPES: { value: GradientSubtype; label: string }[] = [
{ value: 'GRADIENT_LINEAR', label: 'Linear' },
{ value: 'GRADIENT_RADIAL', label: 'Radial' },
{ value: 'GRADIENT_ANGULAR', label: 'Angular' },
{ value: 'GRADIENT_DIAMOND', label: 'Diamond' }
]
const DEFAULT_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 stops = computed(() => fill.gradientStops ?? [])
const subtype = computed(() => fill.type as GradientSubtype)
const activeColor = computed(() => {
const s = stops.value
if (!s.length) return fill.color
return s[Math.min(activeStopIndex.value, s.length - 1)].color
})
const barBackground = computed(() =>
stops.value.length
? `linear-gradient(to right, ${stops.value.map((s) => `${colorToCSS(s.color)} ${s.position * 100}%`).join(', ')})`
: ''
)
function emitStops(newStops: GradientStop[]) {
emit('update', { ...fill, gradientStops: newStops })
}
function setSubtype(type: GradientSubtype) {
if (type === fill.type) return
emit('update', { ...fill, type, gradientTransform: DEFAULT_TRANSFORMS[type] })
}
function selectStop(index: number) {
activeStopIndex.value = index
}
function addStop() {
const s = [...stops.value]
const pos = s.length >= 2 ? (s[s.length - 2].position + s[s.length - 1].position) / 2 : 0.5
s.push({ color: { ...activeColor.value }, position: pos })
s.sort((a, b) => a.position - b.position)
activeStopIndex.value = s.findIndex((stop) => stop.position === pos)
emitStops(s)
}
function removeStop(index: number) {
if (stops.value.length <= 2) return
emitStops(stops.value.filter((_, i) => i !== index))
activeStopIndex.value = Math.min(activeStopIndex.value, stops.value.length - 2)
}
function updateStopPosition(index: number, position: number) {
const s = [...stops.value]
s[index] = { ...s[index], position: Math.max(0, Math.min(1, position / 100)) }
emitStops(s)
}
function updateStopColor(index: number, hex: string) {
const color = parseColor(hex.startsWith('#') ? hex : `#${hex}`)
if (!color) return
const s = [...stops.value]
s[index] = { ...s[index], color: { ...color, a: s[index].color.a } }
emitStops(s)
}
function updateStopOpacity(index: number, opacity: number) {
const s = [...stops.value]
s[index] = { ...s[index], color: { ...s[index].color, a: Math.max(0, Math.min(1, opacity / 100)) } }
emitStops(s)
}
function onActiveColorUpdate(color: { r: number; g: number; b: number; a: number }) {
const s = [...stops.value]
const idx = Math.min(activeStopIndex.value, s.length - 1)
s[idx] = { ...s[idx], color }
emitStops(s)
}
function onStopDrag(index: number, position: number) {
const s = [...stops.value]
s[index] = { ...s[index], position }
emitStops(s)
}
</script>
<template>
<slot
:stops="stops"
:subtype="subtype"
:subtypes="SUBTYPES"
:active-stop-index="activeStopIndex"
:active-color="activeColor"
:bar-background="barBackground"
:set-subtype="setSubtype"
:select-stop="selectStop"
:add-stop="addStop"
:remove-stop="removeStop"
:update-stop-position="updateStopPosition"
:update-stop-color="updateStopColor"
:update-stop-opacity="updateStopOpacity"
:update-active-color="onActiveColorUpdate"
:drag-stop="onStopDrag"
/>
</template>

View file

@ -0,0 +1 @@
export { default as GradientEditorRoot } from './GradientEditorRoot.vue'

View file

@ -120,7 +120,7 @@ provideScrubInput(ctx)
:start-scrub="startScrub"
:start-edit="startEdit"
:commit-edit="commitEdit"
:on-keydown="onKeydown"
:keydown="onKeydown"
:placeholder="props.placeholder"
/>
</template>

View file

@ -36,6 +36,10 @@ export { PositionControlsRoot } from './PositionControls'
export { LayerTreeRoot, LayerTreeItem, useLayerTree } from './LayerTree'
export { EditorMenuRoot } from './EditorMenu'
export { FillPickerRoot } from './FillPicker'
export { GradientEditorRoot } from './GradientEditor'
export { ExportControlsRoot } from './ExportControls'
export type { LayerTreeContext, LayerNode } from './LayerTree'
export type { CanvasContext } from './Canvas'