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:
parent
72876f8a3e
commit
579492649d
66
packages/vue/src/ExportControls/ExportControlsRoot.vue
Normal file
66
packages/vue/src/ExportControls/ExportControlsRoot.vue
Normal 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>
|
||||
1
packages/vue/src/ExportControls/index.ts
Normal file
1
packages/vue/src/ExportControls/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as ExportControlsRoot } from './ExportControlsRoot.vue'
|
||||
71
packages/vue/src/FillPicker/FillPickerRoot.vue
Normal file
71
packages/vue/src/FillPicker/FillPickerRoot.vue
Normal 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>
|
||||
1
packages/vue/src/FillPicker/index.ts
Normal file
1
packages/vue/src/FillPicker/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as FillPickerRoot } from './FillPickerRoot.vue'
|
||||
126
packages/vue/src/GradientEditor/GradientEditorRoot.vue
Normal file
126
packages/vue/src/GradientEditor/GradientEditorRoot.vue
Normal 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>
|
||||
1
packages/vue/src/GradientEditor/index.ts
Normal file
1
packages/vue/src/GradientEditor/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as GradientEditorRoot } from './GradientEditorRoot.vue'
|
||||
|
|
@ -120,7 +120,7 @@ provideScrubInput(ctx)
|
|||
:start-scrub="startScrub"
|
||||
:start-edit="startEdit"
|
||||
:commit-edit="commitEdit"
|
||||
:on-keydown="onKeydown"
|
||||
:keydown="onKeydown"
|
||||
:placeholder="props.placeholder"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue