Split PropertiesPanel into sections, add Typography panel
- PropertiesPanel.vue: 513 → 67 lines (composition only) - Extracted: PositionSection, LayoutSection, AppearanceSection, FillSection, StrokeSection, TypographySection - Shared useNodeProps() composable for update/commit logic - Typography panel: font family picker with search (queryLocalFonts), weight select, font size, line height, letter spacing, text alignment - Font picker shows each family in its own typeface - Loads selected font into both CanvasKit and browser on pick
This commit is contained in:
parent
e3c80fc790
commit
7e9cd46e0b
11
components.d.ts
vendored
11
components.d.ts
vendored
|
|
@ -11,12 +11,23 @@ export {}
|
|||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AppearanceSection: typeof import('./src/components/properties/AppearanceSection.vue')['default']
|
||||
ColorPicker: typeof import('./src/components/ColorPicker.vue')['default']
|
||||
EditorCanvas: typeof import('./src/components/EditorCanvas.vue')['default']
|
||||
FillSection: typeof import('./src/components/properties/FillSection.vue')['default']
|
||||
IconLucideALargeSmall: typeof import('~icons/lucide/a-large-small')['default']
|
||||
IconLucideBaseline: typeof import('~icons/lucide/baseline')['default']
|
||||
IconLucideCheck: typeof import('~icons/lucide/check')['default']
|
||||
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
|
||||
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
||||
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
LayersPanel: typeof import('./src/components/LayersPanel.vue')['default']
|
||||
LayoutSection: typeof import('./src/components/properties/LayoutSection.vue')['default']
|
||||
PositionSection: typeof import('./src/components/properties/PositionSection.vue')['default']
|
||||
PropertiesPanel: typeof import('./src/components/PropertiesPanel.vue')['default']
|
||||
ScrubInput: typeof import('./src/components/ScrubInput.vue')['default']
|
||||
StrokeSection: typeof import('./src/components/properties/StrokeSection.vue')['default']
|
||||
Toolbar: typeof import('./src/components/Toolbar.vue')['default']
|
||||
TypographySection: typeof import('./src/components/properties/TypographySection.vue')['default']
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,217 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ColorPicker from './ColorPicker.vue'
|
||||
import ScrubInput from './ScrubInput.vue'
|
||||
import { useEditorStore } from '../stores/editor'
|
||||
|
||||
import { DEFAULT_SHAPE_FILL } from '../constants'
|
||||
|
||||
import type { Color, Fill, Stroke, LayoutSizing, LayoutAlign, LayoutCounterAlign, SceneNode } from '../engine/scene-graph'
|
||||
import AppearanceSection from './properties/AppearanceSection.vue'
|
||||
import FillSection from './properties/FillSection.vue'
|
||||
import LayoutSection from './properties/LayoutSection.vue'
|
||||
import PositionSection from './properties/PositionSection.vue'
|
||||
import StrokeSection from './properties/StrokeSection.vue'
|
||||
import TypographySection from './properties/TypographySection.vue'
|
||||
|
||||
const store = useEditorStore()
|
||||
|
||||
const node = computed(() => store.selectedNode.value)
|
||||
const multiCount = computed(() => store.selectedNodes.value.length)
|
||||
const showIndividualPadding = ref(false)
|
||||
const widthSizingOpen = ref(false)
|
||||
const heightSizingOpen = ref(false)
|
||||
const widthDimRef = ref<HTMLElement | null>(null)
|
||||
const heightDimRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onClickOutside(widthDimRef, () => { widthSizingOpen.value = false })
|
||||
onClickOutside(heightDimRef, () => { heightSizingOpen.value = false })
|
||||
|
||||
const isInAutoLayout = computed(() => {
|
||||
const n = node.value
|
||||
if (!n?.parentId) return false
|
||||
const parent = store.graph.getNode(n.parentId)
|
||||
return parent ? parent.layoutMode !== 'NONE' : false
|
||||
})
|
||||
|
||||
const widthSizing = computed(() => {
|
||||
const n = node.value
|
||||
if (!n) return 'FIXED'
|
||||
if (n.layoutMode !== 'NONE') {
|
||||
return n.layoutMode === 'HORIZONTAL' ? n.primaryAxisSizing : n.counterAxisSizing
|
||||
}
|
||||
if (isInAutoLayout.value && n.layoutGrow > 0) return 'FILL'
|
||||
return 'FIXED'
|
||||
})
|
||||
|
||||
const heightSizing = computed(() => {
|
||||
const n = node.value
|
||||
if (!n) return 'FIXED'
|
||||
if (n.layoutMode !== 'NONE') {
|
||||
return n.layoutMode === 'VERTICAL' ? n.primaryAxisSizing : n.counterAxisSizing
|
||||
}
|
||||
if (isInAutoLayout.value && n.layoutAlignSelf === 'STRETCH') return 'FILL'
|
||||
return 'FIXED'
|
||||
})
|
||||
|
||||
function setWidthSizing(sizing: LayoutSizing) {
|
||||
const n = node.value
|
||||
if (!n) return
|
||||
if (n.layoutMode !== 'NONE') {
|
||||
if (n.layoutMode === 'HORIZONTAL') updateProp('primaryAxisSizing', sizing)
|
||||
else updateProp('counterAxisSizing', sizing)
|
||||
} else if (isInAutoLayout.value) {
|
||||
updateProp('layoutGrow', sizing === 'FILL' ? 1 : 0)
|
||||
}
|
||||
widthSizingOpen.value = false
|
||||
}
|
||||
|
||||
function setHeightSizing(sizing: LayoutSizing) {
|
||||
const n = node.value
|
||||
if (!n) return
|
||||
if (n.layoutMode !== 'NONE') {
|
||||
if (n.layoutMode === 'VERTICAL') updateProp('primaryAxisSizing', sizing)
|
||||
else updateProp('counterAxisSizing', sizing)
|
||||
} else if (isInAutoLayout.value) {
|
||||
updateProp('layoutAlignSelf', sizing === 'FILL' ? 'STRETCH' : 'AUTO')
|
||||
}
|
||||
heightSizingOpen.value = false
|
||||
}
|
||||
|
||||
function sizingLabel(s: string) {
|
||||
if (s === 'HUG') return 'Hug'
|
||||
if (s === 'FILL') return 'Fill'
|
||||
return 'Fixed'
|
||||
}
|
||||
|
||||
function hasUniformPadding() {
|
||||
const n = node.value
|
||||
if (!n) return true
|
||||
return n.paddingTop === n.paddingRight &&
|
||||
n.paddingRight === n.paddingBottom &&
|
||||
n.paddingBottom === n.paddingLeft
|
||||
}
|
||||
|
||||
function setUniformPadding(v: number) {
|
||||
if (!node.value) return
|
||||
store.updateNode(node.value.id, {
|
||||
paddingTop: v,
|
||||
paddingRight: v,
|
||||
paddingBottom: v,
|
||||
paddingLeft: v
|
||||
})
|
||||
}
|
||||
|
||||
function commitUniformPadding(_value: number, previous: number) {
|
||||
if (!node.value) return
|
||||
store.commitNodeUpdate(node.value.id, {
|
||||
paddingTop: previous,
|
||||
paddingRight: previous,
|
||||
paddingBottom: previous,
|
||||
paddingLeft: previous
|
||||
} as unknown as Partial<SceneNode>, 'Change padding')
|
||||
}
|
||||
|
||||
const ALIGN_GRID: Array<{ primary: LayoutAlign; counter: LayoutCounterAlign }> = [
|
||||
{ primary: 'MIN', counter: 'MIN' },
|
||||
{ primary: 'CENTER', counter: 'MIN' },
|
||||
{ primary: 'MAX', counter: 'MIN' },
|
||||
{ primary: 'MIN', counter: 'CENTER' },
|
||||
{ primary: 'CENTER', counter: 'CENTER' },
|
||||
{ primary: 'MAX', counter: 'CENTER' },
|
||||
{ primary: 'MIN', counter: 'MAX' },
|
||||
{ primary: 'CENTER', counter: 'MAX' },
|
||||
{ primary: 'MAX', counter: 'MAX' }
|
||||
]
|
||||
|
||||
function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
|
||||
if (!node.value) return
|
||||
store.updateNodeWithUndo(node.value.id, {
|
||||
primaryAxisAlign: primary,
|
||||
counterAxisAlign: counter
|
||||
}, 'Change alignment')
|
||||
}
|
||||
|
||||
function updateProp(key: string, value: number | string) {
|
||||
if (multiCount.value > 1) {
|
||||
for (const n of store.selectedNodes.value) {
|
||||
store.updateNode(n.id, { [key]: value })
|
||||
}
|
||||
} else if (node.value) {
|
||||
store.updateNode(node.value.id, { [key]: value })
|
||||
}
|
||||
}
|
||||
|
||||
function commitProp(key: string, _value: number | string, previous: number | string) {
|
||||
if (multiCount.value > 1) {
|
||||
for (const n of store.selectedNodes.value) {
|
||||
store.commitNodeUpdate(n.id, { [key]: previous } as Partial<SceneNode>, `Change ${key}`)
|
||||
}
|
||||
} else if (node.value) {
|
||||
store.commitNodeUpdate(node.value.id, { [key]: previous } as Partial<SceneNode>, `Change ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
function updateFillColor(index: number, color: Color) {
|
||||
if (!node.value) return
|
||||
const fills = [...node.value.fills]
|
||||
fills[index] = { ...fills[index], color }
|
||||
store.updateNodeWithUndo(node.value.id, { fills }, "Change fill")
|
||||
}
|
||||
|
||||
function addFill() {
|
||||
if (!node.value) return
|
||||
store.updateNodeWithUndo(node.value.id, { fills: [...node.value.fills, { ...DEFAULT_SHAPE_FILL }] }, "Add fill")
|
||||
}
|
||||
|
||||
function removeFill(index: number) {
|
||||
if (!node.value) return
|
||||
const fills = node.value.fills.filter((_, i) => i !== index)
|
||||
store.updateNodeWithUndo(node.value.id, { fills }, "Change fill")
|
||||
}
|
||||
|
||||
function toggleFillVisibility(index: number) {
|
||||
if (!node.value) return
|
||||
const fills = [...node.value.fills]
|
||||
fills[index] = { ...fills[index], visible: !fills[index].visible }
|
||||
store.updateNodeWithUndo(node.value.id, { fills }, "Change fill")
|
||||
}
|
||||
|
||||
function addStroke() {
|
||||
if (!node.value) return
|
||||
const stroke: Stroke = {
|
||||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||
weight: 1,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
align: 'CENTER'
|
||||
}
|
||||
store.updateNodeWithUndo(node.value.id, { strokes: [...node.value.strokes, stroke] }, "Add stroke")
|
||||
}
|
||||
|
||||
function updateStrokeColor(index: number, color: Color) {
|
||||
if (!node.value) return
|
||||
const strokes = [...node.value.strokes]
|
||||
strokes[index] = { ...strokes[index], color }
|
||||
store.updateNodeWithUndo(node.value.id, { strokes }, "Change stroke")
|
||||
}
|
||||
|
||||
function updateStrokeWeight(index: number, weight: number) {
|
||||
if (!node.value) return
|
||||
const strokes = [...node.value.strokes]
|
||||
strokes[index] = { ...strokes[index], weight }
|
||||
store.updateNodeWithUndo(node.value.id, { strokes }, "Change stroke")
|
||||
}
|
||||
|
||||
function removeStroke(index: number) {
|
||||
if (!node.value) return
|
||||
const strokes = node.value.strokes.filter((_, i) => i !== index)
|
||||
store.updateNodeWithUndo(node.value.id, { strokes }, "Change stroke")
|
||||
}
|
||||
|
||||
function colorHex(c: Color) {
|
||||
const hex = (v: number) =>
|
||||
Math.round(v * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
return `#${hex(c.r)}${hex(c.g)}${hex(c.b)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -231,20 +33,7 @@ function colorHex(c: Color) {
|
|||
<span class="text-[11px] text-muted">Mixed</span>
|
||||
<span class="text-xs font-semibold">{{ multiCount }} layers</span>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
<label class="mb-1.5 block text-[11px] text-muted">Appearance</label>
|
||||
<div class="flex gap-1.5">
|
||||
<ScrubInput
|
||||
icon="⊘"
|
||||
suffix="%"
|
||||
:model-value="Math.round((store.selectedNodes.value[0]?.opacity ?? 1) * 100)"
|
||||
:min="0"
|
||||
:max="100"
|
||||
@update:model-value="updateProp('opacity', $event / 100)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AppearanceSection :opacity="store.selectedNodes.value[0]?.opacity ?? 1" />
|
||||
</div>
|
||||
|
||||
<!-- Single selection -->
|
||||
|
|
@ -255,247 +44,12 @@ function colorHex(c: Color) {
|
|||
<span class="text-xs font-semibold">{{ node.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Position -->
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
<label class="mb-1.5 block text-[11px] text-muted">Position</label>
|
||||
<div class="flex gap-1.5">
|
||||
<ScrubInput icon="X" :model-value="Math.round(node.x)" @update:model-value="updateProp('x', $event)" @commit="(v: number, p: number) => commitProp('x', v, p)" />
|
||||
<ScrubInput icon="Y" :model-value="Math.round(node.y)" @update:model-value="updateProp('y', $event)" @commit="(v: number, p: number) => commitProp('y', v, p)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rotation -->
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
<div class="flex gap-1.5">
|
||||
<ScrubInput icon="R" suffix="°" :model-value="Math.round(node.rotation)" :min="-360" :max="360" @update:model-value="updateProp('rotation', $event)" @commit="(v: number, p: number) => commitProp('rotation', v, p)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layout / Dimensions -->
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
<label class="mb-1.5 block text-[11px] text-muted">Layout</label>
|
||||
<div class="flex gap-1.5">
|
||||
<!-- Width -->
|
||||
<div ref="widthDimRef" class="relative flex min-w-0 flex-1 items-center gap-1">
|
||||
<ScrubInput icon="W" :model-value="Math.round(node.width)" :min="0" @update:model-value="updateProp('width', $event)" @commit="(v: number, p: number) => commitProp('width', v, p)" />
|
||||
<button
|
||||
v-if="node.layoutMode !== 'NONE' || isInAutoLayout"
|
||||
class="cursor-pointer whitespace-nowrap rounded border-none bg-transparent px-1 py-px text-[10px] text-muted hover:bg-hover hover:text-surface"
|
||||
@click="widthSizingOpen = !widthSizingOpen"
|
||||
>{{ sizingLabel(widthSizing) }}</button>
|
||||
<div v-if="widthSizingOpen" class="absolute top-full left-0 right-0 z-10 min-w-40 rounded-md border border-border bg-panel p-1 shadow-lg">
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover"
|
||||
:class="widthSizing === 'FIXED' ? 'text-accent' : 'text-surface'"
|
||||
@click="setWidthSizing('FIXED')"
|
||||
><span class="w-4 text-center text-[11px] opacity-70">↔</span>Fixed width ({{ Math.round(node.width) }})</button>
|
||||
<button
|
||||
v-if="node.layoutMode !== 'NONE'"
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover"
|
||||
:class="widthSizing === 'HUG' ? 'text-accent' : 'text-surface'"
|
||||
@click="setWidthSizing('HUG')"
|
||||
><span class="w-4 text-center text-[11px] opacity-70">↤↦</span>Hug contents</button>
|
||||
<button
|
||||
v-if="isInAutoLayout"
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover"
|
||||
:class="widthSizing === 'FILL' ? 'text-accent' : 'text-surface'"
|
||||
@click="setWidthSizing('FILL')"
|
||||
><span class="w-4 text-center text-[11px] opacity-70">⟷</span>Fill container</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Height -->
|
||||
<div ref="heightDimRef" class="relative flex min-w-0 flex-1 items-center gap-1">
|
||||
<ScrubInput icon="H" :model-value="Math.round(node.height)" :min="0" @update:model-value="updateProp('height', $event)" @commit="(v: number, p: number) => commitProp('height', v, p)" />
|
||||
<button
|
||||
v-if="node.layoutMode !== 'NONE' || isInAutoLayout"
|
||||
class="cursor-pointer whitespace-nowrap rounded border-none bg-transparent px-1 py-px text-[10px] text-muted hover:bg-hover hover:text-surface"
|
||||
@click="heightSizingOpen = !heightSizingOpen"
|
||||
>{{ sizingLabel(heightSizing) }}</button>
|
||||
<div v-if="heightSizingOpen" class="absolute top-full left-0 right-0 z-10 min-w-40 rounded-md border border-border bg-panel p-1 shadow-lg">
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover"
|
||||
:class="heightSizing === 'FIXED' ? 'text-accent' : 'text-surface'"
|
||||
@click="setHeightSizing('FIXED')"
|
||||
><span class="w-4 text-center text-[11px] opacity-70">↕</span>Fixed height ({{ Math.round(node.height) }})</button>
|
||||
<button
|
||||
v-if="node.layoutMode !== 'NONE'"
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover"
|
||||
:class="heightSizing === 'HUG' ? 'text-accent' : 'text-surface'"
|
||||
@click="setHeightSizing('HUG')"
|
||||
><span class="w-4 text-center text-[11px] opacity-70">↤↦</span>Hug contents</button>
|
||||
<button
|
||||
v-if="isInAutoLayout"
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover"
|
||||
:class="heightSizing === 'FILL' ? 'text-accent' : 'text-surface'"
|
||||
@click="setHeightSizing('FILL')"
|
||||
><span class="w-4 text-center text-[11px] opacity-70">⟷</span>Fill container</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Corner radius -->
|
||||
<div class="mt-1.5 flex gap-1.5">
|
||||
<ScrubInput icon="↻" :model-value="node.cornerRadius" :min="0" @update:model-value="updateProp('cornerRadius', $event)" @commit="(v: number, p: number) => commitProp('cornerRadius', v, p)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto Layout -->
|
||||
<div v-if="node.type === 'FRAME'" class="border-b border-border px-3 py-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="mb-1.5 block text-[11px] text-muted">Auto layout</label>
|
||||
<button
|
||||
v-if="node.layoutMode === 'NONE'"
|
||||
class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface"
|
||||
title="Add auto layout (Shift+A)"
|
||||
@click="store.setLayoutMode(node.id, 'VERTICAL')"
|
||||
>+</button>
|
||||
<button
|
||||
v-else
|
||||
class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface"
|
||||
title="Remove auto layout"
|
||||
@click="store.setLayoutMode(node.id, 'NONE')"
|
||||
>−</button>
|
||||
</div>
|
||||
|
||||
<template v-if="node.layoutMode !== 'NONE'">
|
||||
<!-- Direction -->
|
||||
<div class="mt-1.5 flex gap-0.5">
|
||||
<button
|
||||
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
|
||||
:class="node.layoutMode === 'VERTICAL'
|
||||
? 'border-accent bg-accent text-white'
|
||||
: 'border-border bg-input text-muted hover:bg-hover hover:text-surface'"
|
||||
title="Vertical layout"
|
||||
@click="store.setLayoutMode(node.id, 'VERTICAL')"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="3" y="2" width="10" height="3" rx="0.5" fill="currentColor" /><rect x="3" y="6.5" width="10" height="3" rx="0.5" fill="currentColor" /><rect x="3" y="11" width="10" height="3" rx="0.5" fill="currentColor" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
|
||||
:class="node.layoutMode === 'HORIZONTAL'
|
||||
? 'border-accent bg-accent text-white'
|
||||
: 'border-border bg-input text-muted hover:bg-hover hover:text-surface'"
|
||||
title="Horizontal layout"
|
||||
@click="store.setLayoutMode(node.id, 'HORIZONTAL')"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="2" y="3" width="3" height="10" rx="0.5" fill="currentColor" /><rect x="6.5" y="3" width="3" height="10" rx="0.5" fill="currentColor" /><rect x="11" y="3" width="3" height="10" rx="0.5" fill="currentColor" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
|
||||
:class="node.layoutWrap === 'WRAP'
|
||||
? 'border-accent bg-accent text-white'
|
||||
: 'border-border bg-input text-muted hover:bg-hover hover:text-surface'"
|
||||
title="Wrap"
|
||||
@click="updateProp('layoutWrap', node.layoutWrap === 'WRAP' ? 'NO_WRAP' : 'WRAP')"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="2" y="2" width="5" height="5" rx="0.5" fill="currentColor" /><rect x="9" y="2" width="5" height="5" rx="0.5" fill="currentColor" /><rect x="2" y="9" width="5" height="5" rx="0.5" fill="currentColor" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Alignment grid + Gap -->
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<div class="grid grid-cols-3 gap-0.5 rounded border border-border bg-input p-1">
|
||||
<button
|
||||
v-for="(a, i) in ALIGN_GRID"
|
||||
:key="i"
|
||||
class="flex size-3.5 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent p-0 hover:bg-hover"
|
||||
@click="setAlignment(a.primary, a.counter)"
|
||||
>
|
||||
<span
|
||||
class="rounded-full"
|
||||
:class="node.primaryAxisAlign === a.primary && node.counterAxisAlign === a.counter
|
||||
? 'size-1.5 bg-accent'
|
||||
: 'size-1 bg-muted opacity-40'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<ScrubInput :model-value="node.itemSpacing" :min="0" @update:model-value="updateProp('itemSpacing', $event)" @commit="(v: number, p: number) => commitProp('itemSpacing', v, p)">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14"><rect x="0" y="1" width="4" height="12" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="5" y="5" width="4" height="4" rx="0.5" fill="currentColor" /><rect x="10" y="1" width="4" height="12" rx="0.5" fill="currentColor" opacity="0.4" /></svg>
|
||||
</template>
|
||||
</ScrubInput>
|
||||
</div>
|
||||
|
||||
<!-- Padding -->
|
||||
<div class="mt-1.5 flex items-start gap-1">
|
||||
<template v-if="showIndividualPadding || !hasUniformPadding()">
|
||||
<div class="grid flex-1 grid-cols-2 gap-0.5">
|
||||
<div v-for="side in ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'] as const" :key="side">
|
||||
<input
|
||||
type="number"
|
||||
class="w-full rounded border border-border bg-input px-1 py-0.5 text-center text-[11px] text-surface [&::-webkit-inner-spin-button]:hidden"
|
||||
:value="node[side]"
|
||||
min="0"
|
||||
@change="updateProp(side, +($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ScrubInput :model-value="node.paddingTop" :min="0" @update:model-value="setUniformPadding($event)" @commit="commitUniformPadding">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14"><rect x="0" y="0" width="14" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1" /><rect x="3" y="3" width="8" height="8" rx="1" fill="currentColor" opacity="0.3" /></svg>
|
||||
</template>
|
||||
</ScrubInput>
|
||||
</template>
|
||||
<button
|
||||
class="flex shrink-0 cursor-pointer items-center justify-center rounded border p-1"
|
||||
:class="showIndividualPadding || !hasUniformPadding()
|
||||
? 'border-accent bg-accent text-white'
|
||||
: 'border-border text-muted hover:bg-hover hover:text-surface'"
|
||||
title="Individual padding"
|
||||
@click="showIndividualPadding = !showIndividualPadding"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14"><rect x="0" y="0" width="14" height="4" rx="1" fill="currentColor" opacity="0.6" /><rect x="10" y="0" width="4" height="14" rx="1" fill="currentColor" opacity="0.6" /><rect x="0" y="10" width="14" height="4" rx="1" fill="currentColor" opacity="0.6" /><rect x="0" y="0" width="4" height="14" rx="1" fill="currentColor" opacity="0.6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Appearance -->
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
<label class="mb-1.5 block text-[11px] text-muted">Appearance</label>
|
||||
<div class="flex gap-1.5">
|
||||
<ScrubInput icon="⊘" suffix="%" :model-value="Math.round(node.opacity * 100)" :min="0" :max="100" @update:model-value="updateProp('opacity', $event / 100)" @commit="(v: number, p: number) => commitProp('opacity', v / 100, p / 100)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fill -->
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="mb-1.5 block text-[11px] text-muted">Fill</label>
|
||||
<button class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface" @click="addFill">+</button>
|
||||
</div>
|
||||
<div v-for="(fill, i) in node.fills" :key="i" class="group flex items-center gap-1.5 py-0.5">
|
||||
<button
|
||||
class="w-4 cursor-pointer border-none bg-transparent p-0 text-center text-xs text-muted"
|
||||
:class="{ 'opacity-40': !fill.visible }"
|
||||
@click="toggleFillVisibility(i)"
|
||||
>{{ fill.visible ? '◉' : '○' }}</button>
|
||||
<ColorPicker :color="fill.color" @update="updateFillColor(i, $event)" />
|
||||
<span class="flex-1 font-mono text-xs">{{ colorHex(fill.color) }}</span>
|
||||
<button class="cursor-pointer border-none bg-transparent px-0.5 text-sm leading-none text-muted opacity-0 transition-opacity group-hover:opacity-100 hover:text-surface" @click="removeFill(i)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stroke -->
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="mb-1.5 block text-[11px] text-muted">Stroke</label>
|
||||
<button class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface" @click="addStroke">+</button>
|
||||
</div>
|
||||
<div v-for="(stroke, i) in node.strokes" :key="i" class="group flex items-center gap-1.5 py-0.5">
|
||||
<ColorPicker :color="stroke.color" @update="updateStrokeColor(i, $event)" />
|
||||
<span class="flex-1 font-mono text-xs">{{ colorHex(stroke.color) }}</span>
|
||||
<input
|
||||
type="number"
|
||||
class="w-9 rounded border border-border bg-input px-1 py-0.5 text-center text-[11px] text-surface [&::-webkit-inner-spin-button]:hidden"
|
||||
:value="stroke.weight"
|
||||
min="0"
|
||||
@change="updateStrokeWeight(i, +($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<button class="cursor-pointer border-none bg-transparent px-0.5 text-sm leading-none text-muted opacity-0 transition-opacity group-hover:opacity-100 hover:text-surface" @click="removeStroke(i)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<PositionSection :node="node" />
|
||||
<LayoutSection :node="node" />
|
||||
<AppearanceSection :opacity="node.opacity" />
|
||||
<TypographySection v-if="node.type === 'TEXT'" :node="node" />
|
||||
<FillSection :node-id="node.id" :fills="node.fills" />
|
||||
<StrokeSection :node-id="node.id" :strokes="node.strokes" />
|
||||
|
||||
<!-- Effects -->
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
|
|
|
|||
25
src/components/properties/AppearanceSection.vue
Normal file
25
src/components/properties/AppearanceSection.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import ScrubInput from '../ScrubInput.vue'
|
||||
import { useNodeProps } from '../../composables/use-node-props'
|
||||
|
||||
const { updateProp, commitProp } = useNodeProps()
|
||||
|
||||
defineProps<{ opacity: number }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
<label class="mb-1.5 block text-[11px] text-muted">Appearance</label>
|
||||
<div class="flex gap-1.5">
|
||||
<ScrubInput
|
||||
icon="⊘"
|
||||
suffix="%"
|
||||
:model-value="Math.round(opacity * 100)"
|
||||
:min="0"
|
||||
:max="100"
|
||||
@update:model-value="updateProp('opacity', $event / 100)"
|
||||
@commit="(v: number, p: number) => commitProp('opacity', v / 100, p / 100)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
54
src/components/properties/FillSection.vue
Normal file
54
src/components/properties/FillSection.vue
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<script setup lang="ts">
|
||||
import ColorPicker from '../ColorPicker.vue'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import { DEFAULT_SHAPE_FILL } from '../../constants'
|
||||
|
||||
import type { Color, Fill } from '../../engine/scene-graph'
|
||||
|
||||
const props = defineProps<{ nodeId: string; fills: Fill[] }>()
|
||||
const store = useEditorStore()
|
||||
|
||||
function colorHex(c: Color) {
|
||||
const hex = (v: number) => Math.round(v * 255).toString(16).padStart(2, '0')
|
||||
return `#${hex(c.r)}${hex(c.g)}${hex(c.b)}`
|
||||
}
|
||||
|
||||
function updateColor(index: number, color: Color) {
|
||||
const fills = [...props.fills]
|
||||
fills[index] = { ...fills[index], color }
|
||||
store.updateNodeWithUndo(props.nodeId, { fills }, 'Change fill')
|
||||
}
|
||||
|
||||
function add() {
|
||||
store.updateNodeWithUndo(props.nodeId, { fills: [...props.fills, { ...DEFAULT_SHAPE_FILL }] }, 'Add fill')
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
store.updateNodeWithUndo(props.nodeId, { fills: props.fills.filter((_, i) => i !== index) }, 'Change fill')
|
||||
}
|
||||
|
||||
function toggleVisibility(index: number) {
|
||||
const fills = [...props.fills]
|
||||
fills[index] = { ...fills[index], visible: !fills[index].visible }
|
||||
store.updateNodeWithUndo(props.nodeId, { fills }, 'Change fill')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="mb-1.5 block text-[11px] text-muted">Fill</label>
|
||||
<button class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface" @click="add">+</button>
|
||||
</div>
|
||||
<div v-for="(fill, i) in fills" :key="i" class="group flex items-center gap-1.5 py-0.5">
|
||||
<button
|
||||
class="w-4 cursor-pointer border-none bg-transparent p-0 text-center text-xs text-muted"
|
||||
:class="{ 'opacity-40': !fill.visible }"
|
||||
@click="toggleVisibility(i)"
|
||||
>{{ fill.visible ? '◉' : '○' }}</button>
|
||||
<ColorPicker :color="fill.color" @update="updateColor(i, $event)" />
|
||||
<span class="flex-1 font-mono text-xs">{{ colorHex(fill.color) }}</span>
|
||||
<button class="cursor-pointer border-none bg-transparent px-0.5 text-sm leading-none text-muted opacity-0 transition-opacity group-hover:opacity-100 hover:text-surface" @click="remove(i)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
256
src/components/properties/LayoutSection.vue
Normal file
256
src/components/properties/LayoutSection.vue
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
import ScrubInput from '../ScrubInput.vue'
|
||||
import { useNodeProps } from '../../composables/use-node-props'
|
||||
|
||||
import type { SceneNode, LayoutSizing, LayoutAlign, LayoutCounterAlign } from '../../engine/scene-graph'
|
||||
|
||||
const { node } = defineProps<{ node: SceneNode }>()
|
||||
const { store, updateProp, commitProp } = useNodeProps()
|
||||
|
||||
const showIndividualPadding = ref(false)
|
||||
const widthSizingOpen = ref(false)
|
||||
const heightSizingOpen = ref(false)
|
||||
const widthDimRef = ref<HTMLElement | null>(null)
|
||||
const heightDimRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onClickOutside(widthDimRef, () => { widthSizingOpen.value = false })
|
||||
onClickOutside(heightDimRef, () => { heightSizingOpen.value = false })
|
||||
|
||||
const isInAutoLayout = computed(() => {
|
||||
if (!node.parentId) return false
|
||||
const parent = store.graph.getNode(node.parentId)
|
||||
return parent ? parent.layoutMode !== 'NONE' : false
|
||||
})
|
||||
|
||||
const widthSizing = computed(() => {
|
||||
if (node.layoutMode !== 'NONE') {
|
||||
return node.layoutMode === 'HORIZONTAL' ? node.primaryAxisSizing : node.counterAxisSizing
|
||||
}
|
||||
if (isInAutoLayout.value && node.layoutGrow > 0) return 'FILL'
|
||||
return 'FIXED'
|
||||
})
|
||||
|
||||
const heightSizing = computed(() => {
|
||||
if (node.layoutMode !== 'NONE') {
|
||||
return node.layoutMode === 'VERTICAL' ? node.primaryAxisSizing : node.counterAxisSizing
|
||||
}
|
||||
if (isInAutoLayout.value && node.layoutAlignSelf === 'STRETCH') return 'FILL'
|
||||
return 'FIXED'
|
||||
})
|
||||
|
||||
function setWidthSizing(sizing: LayoutSizing) {
|
||||
if (node.layoutMode !== 'NONE') {
|
||||
if (node.layoutMode === 'HORIZONTAL') updateProp('primaryAxisSizing', sizing)
|
||||
else updateProp('counterAxisSizing', sizing)
|
||||
} else if (isInAutoLayout.value) {
|
||||
updateProp('layoutGrow', sizing === 'FILL' ? 1 : 0)
|
||||
}
|
||||
widthSizingOpen.value = false
|
||||
}
|
||||
|
||||
function setHeightSizing(sizing: LayoutSizing) {
|
||||
if (node.layoutMode !== 'NONE') {
|
||||
if (node.layoutMode === 'VERTICAL') updateProp('primaryAxisSizing', sizing)
|
||||
else updateProp('counterAxisSizing', sizing)
|
||||
} else if (isInAutoLayout.value) {
|
||||
updateProp('layoutAlignSelf', sizing === 'FILL' ? 'STRETCH' : 'AUTO')
|
||||
}
|
||||
heightSizingOpen.value = false
|
||||
}
|
||||
|
||||
function sizingLabel(s: string) {
|
||||
if (s === 'HUG') return 'Hug'
|
||||
if (s === 'FILL') return 'Fill'
|
||||
return 'Fixed'
|
||||
}
|
||||
|
||||
function hasUniformPadding() {
|
||||
return node.paddingTop === node.paddingRight &&
|
||||
node.paddingRight === node.paddingBottom &&
|
||||
node.paddingBottom === node.paddingLeft
|
||||
}
|
||||
|
||||
function setUniformPadding(v: number) {
|
||||
store.updateNode(node.id, { paddingTop: v, paddingRight: v, paddingBottom: v, paddingLeft: v })
|
||||
}
|
||||
|
||||
function commitUniformPadding(_value: number, previous: number) {
|
||||
store.commitNodeUpdate(node.id, {
|
||||
paddingTop: previous, paddingRight: previous, paddingBottom: previous, paddingLeft: previous
|
||||
} as unknown as Partial<SceneNode>, 'Change padding')
|
||||
}
|
||||
|
||||
const ALIGN_GRID: Array<{ primary: LayoutAlign; counter: LayoutCounterAlign }> = [
|
||||
{ primary: 'MIN', counter: 'MIN' },
|
||||
{ primary: 'CENTER', counter: 'MIN' },
|
||||
{ primary: 'MAX', counter: 'MIN' },
|
||||
{ primary: 'MIN', counter: 'CENTER' },
|
||||
{ primary: 'CENTER', counter: 'CENTER' },
|
||||
{ primary: 'MAX', counter: 'CENTER' },
|
||||
{ primary: 'MIN', counter: 'MAX' },
|
||||
{ primary: 'CENTER', counter: 'MAX' },
|
||||
{ primary: 'MAX', counter: 'MAX' }
|
||||
]
|
||||
|
||||
function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
|
||||
store.updateNodeWithUndo(node.id, { primaryAxisAlign: primary, counterAxisAlign: counter }, 'Change alignment')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
<label class="mb-1.5 block text-[11px] text-muted">Layout</label>
|
||||
<div class="flex gap-1.5">
|
||||
<!-- Width -->
|
||||
<div ref="widthDimRef" class="relative flex min-w-0 flex-1 items-center gap-1">
|
||||
<ScrubInput icon="W" :model-value="Math.round(node.width)" :min="0" @update:model-value="updateProp('width', $event)" @commit="(v: number, p: number) => commitProp('width', v, p)" />
|
||||
<button
|
||||
v-if="node.layoutMode !== 'NONE' || isInAutoLayout"
|
||||
class="cursor-pointer whitespace-nowrap rounded border-none bg-transparent px-1 py-px text-[10px] text-muted hover:bg-hover hover:text-surface"
|
||||
@click="widthSizingOpen = !widthSizingOpen"
|
||||
>{{ sizingLabel(widthSizing) }}</button>
|
||||
<div v-if="widthSizingOpen" class="absolute top-full left-0 right-0 z-10 min-w-40 rounded-md border border-border bg-panel p-1 shadow-lg">
|
||||
<button class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover" :class="widthSizing === 'FIXED' ? 'text-accent' : 'text-surface'" @click="setWidthSizing('FIXED')">
|
||||
<span class="w-4 text-center text-[11px] opacity-70">↔</span>Fixed width ({{ Math.round(node.width) }})
|
||||
</button>
|
||||
<button v-if="node.layoutMode !== 'NONE'" class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover" :class="widthSizing === 'HUG' ? 'text-accent' : 'text-surface'" @click="setWidthSizing('HUG')">
|
||||
<span class="w-4 text-center text-[11px] opacity-70">↤↦</span>Hug contents
|
||||
</button>
|
||||
<button v-if="isInAutoLayout" class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover" :class="widthSizing === 'FILL' ? 'text-accent' : 'text-surface'" @click="setWidthSizing('FILL')">
|
||||
<span class="w-4 text-center text-[11px] opacity-70">⟷</span>Fill container
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Height -->
|
||||
<div ref="heightDimRef" class="relative flex min-w-0 flex-1 items-center gap-1">
|
||||
<ScrubInput icon="H" :model-value="Math.round(node.height)" :min="0" @update:model-value="updateProp('height', $event)" @commit="(v: number, p: number) => commitProp('height', v, p)" />
|
||||
<button
|
||||
v-if="node.layoutMode !== 'NONE' || isInAutoLayout"
|
||||
class="cursor-pointer whitespace-nowrap rounded border-none bg-transparent px-1 py-px text-[10px] text-muted hover:bg-hover hover:text-surface"
|
||||
@click="heightSizingOpen = !heightSizingOpen"
|
||||
>{{ sizingLabel(heightSizing) }}</button>
|
||||
<div v-if="heightSizingOpen" class="absolute top-full left-0 right-0 z-10 min-w-40 rounded-md border border-border bg-panel p-1 shadow-lg">
|
||||
<button class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover" :class="heightSizing === 'FIXED' ? 'text-accent' : 'text-surface'" @click="setHeightSizing('FIXED')">
|
||||
<span class="w-4 text-center text-[11px] opacity-70">↕</span>Fixed height ({{ Math.round(node.height) }})
|
||||
</button>
|
||||
<button v-if="node.layoutMode !== 'NONE'" class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover" :class="heightSizing === 'HUG' ? 'text-accent' : 'text-surface'" @click="setHeightSizing('HUG')">
|
||||
<span class="w-4 text-center text-[11px] opacity-70">↤↦</span>Hug contents
|
||||
</button>
|
||||
<button v-if="isInAutoLayout" class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover" :class="heightSizing === 'FILL' ? 'text-accent' : 'text-surface'" @click="setHeightSizing('FILL')">
|
||||
<span class="w-4 text-center text-[11px] opacity-70">⟷</span>Fill container
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Corner radius -->
|
||||
<div class="mt-1.5 flex gap-1.5">
|
||||
<ScrubInput icon="↻" :model-value="node.cornerRadius" :min="0" @update:model-value="updateProp('cornerRadius', $event)" @commit="(v: number, p: number) => commitProp('cornerRadius', v, p)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto Layout -->
|
||||
<div v-if="node.type === 'FRAME'" class="border-b border-border px-3 py-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="mb-1.5 block text-[11px] text-muted">Auto layout</label>
|
||||
<button
|
||||
v-if="node.layoutMode === 'NONE'"
|
||||
class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface"
|
||||
title="Add auto layout (Shift+A)"
|
||||
@click="store.setLayoutMode(node.id, 'VERTICAL')"
|
||||
>+</button>
|
||||
<button
|
||||
v-else
|
||||
class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface"
|
||||
title="Remove auto layout"
|
||||
@click="store.setLayoutMode(node.id, 'NONE')"
|
||||
>−</button>
|
||||
</div>
|
||||
|
||||
<template v-if="node.layoutMode !== 'NONE'">
|
||||
<!-- Direction -->
|
||||
<div class="mt-1.5 flex gap-0.5">
|
||||
<button
|
||||
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
|
||||
:class="node.layoutMode === 'VERTICAL' ? 'border-accent bg-accent text-white' : 'border-border bg-input text-muted hover:bg-hover hover:text-surface'"
|
||||
title="Vertical layout"
|
||||
@click="store.setLayoutMode(node.id, 'VERTICAL')"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="3" y="2" width="10" height="3" rx="0.5" fill="currentColor" /><rect x="3" y="6.5" width="10" height="3" rx="0.5" fill="currentColor" /><rect x="3" y="11" width="10" height="3" rx="0.5" fill="currentColor" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
|
||||
:class="node.layoutMode === 'HORIZONTAL' ? 'border-accent bg-accent text-white' : 'border-border bg-input text-muted hover:bg-hover hover:text-surface'"
|
||||
title="Horizontal layout"
|
||||
@click="store.setLayoutMode(node.id, 'HORIZONTAL')"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="2" y="3" width="3" height="10" rx="0.5" fill="currentColor" /><rect x="6.5" y="3" width="3" height="10" rx="0.5" fill="currentColor" /><rect x="11" y="3" width="3" height="10" rx="0.5" fill="currentColor" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
|
||||
:class="node.layoutWrap === 'WRAP' ? 'border-accent bg-accent text-white' : 'border-border bg-input text-muted hover:bg-hover hover:text-surface'"
|
||||
title="Wrap"
|
||||
@click="updateProp('layoutWrap', node.layoutWrap === 'WRAP' ? 'NO_WRAP' : 'WRAP')"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="2" y="2" width="5" height="5" rx="0.5" fill="currentColor" /><rect x="9" y="2" width="5" height="5" rx="0.5" fill="currentColor" /><rect x="2" y="9" width="5" height="5" rx="0.5" fill="currentColor" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Alignment grid + Gap -->
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<div class="grid grid-cols-3 gap-0.5 rounded border border-border bg-input p-1">
|
||||
<button
|
||||
v-for="(a, i) in ALIGN_GRID"
|
||||
:key="i"
|
||||
class="flex size-3.5 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent p-0 hover:bg-hover"
|
||||
@click="setAlignment(a.primary, a.counter)"
|
||||
>
|
||||
<span
|
||||
class="rounded-full"
|
||||
:class="node.primaryAxisAlign === a.primary && node.counterAxisAlign === a.counter ? 'size-1.5 bg-accent' : 'size-1 bg-muted opacity-40'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<ScrubInput :model-value="node.itemSpacing" :min="0" @update:model-value="updateProp('itemSpacing', $event)" @commit="(v: number, p: number) => commitProp('itemSpacing', v, p)">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14"><rect x="0" y="1" width="4" height="12" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="5" y="5" width="4" height="4" rx="0.5" fill="currentColor" /><rect x="10" y="1" width="4" height="12" rx="0.5" fill="currentColor" opacity="0.4" /></svg>
|
||||
</template>
|
||||
</ScrubInput>
|
||||
</div>
|
||||
|
||||
<!-- Padding -->
|
||||
<div class="mt-1.5 flex items-start gap-1">
|
||||
<template v-if="showIndividualPadding || !hasUniformPadding()">
|
||||
<div class="grid flex-1 grid-cols-2 gap-0.5">
|
||||
<div v-for="side in ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'] as const" :key="side">
|
||||
<input
|
||||
type="number"
|
||||
class="w-full rounded border border-border bg-input px-1 py-0.5 text-center text-[11px] text-surface [&::-webkit-inner-spin-button]:hidden"
|
||||
:value="node[side]"
|
||||
min="0"
|
||||
@change="updateProp(side, +($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ScrubInput :model-value="node.paddingTop" :min="0" @update:model-value="setUniformPadding($event)" @commit="commitUniformPadding">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14"><rect x="0" y="0" width="14" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1" /><rect x="3" y="3" width="8" height="8" rx="1" fill="currentColor" opacity="0.3" /></svg>
|
||||
</template>
|
||||
</ScrubInput>
|
||||
</template>
|
||||
<button
|
||||
class="flex shrink-0 cursor-pointer items-center justify-center rounded border p-1"
|
||||
:class="showIndividualPadding || !hasUniformPadding() ? 'border-accent bg-accent text-white' : 'border-border text-muted hover:bg-hover hover:text-surface'"
|
||||
title="Individual padding"
|
||||
@click="showIndividualPadding = !showIndividualPadding"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14"><rect x="0" y="0" width="14" height="4" rx="1" fill="currentColor" opacity="0.6" /><rect x="10" y="0" width="4" height="14" rx="1" fill="currentColor" opacity="0.6" /><rect x="0" y="10" width="14" height="4" rx="1" fill="currentColor" opacity="0.6" /><rect x="0" y="0" width="4" height="14" rx="1" fill="currentColor" opacity="0.6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
22
src/components/properties/PositionSection.vue
Normal file
22
src/components/properties/PositionSection.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import ScrubInput from '../ScrubInput.vue'
|
||||
import { useNodeProps } from '../../composables/use-node-props'
|
||||
|
||||
import type { SceneNode } from '../../engine/scene-graph'
|
||||
|
||||
const { node } = defineProps<{ node: SceneNode }>()
|
||||
const { updateProp, commitProp } = useNodeProps()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
<label class="mb-1.5 block text-[11px] text-muted">Position</label>
|
||||
<div class="flex gap-1.5">
|
||||
<ScrubInput icon="X" :model-value="Math.round(node.x)" @update:model-value="updateProp('x', $event)" @commit="(v: number, p: number) => commitProp('x', v, p)" />
|
||||
<ScrubInput icon="Y" :model-value="Math.round(node.y)" @update:model-value="updateProp('y', $event)" @commit="(v: number, p: number) => commitProp('y', v, p)" />
|
||||
</div>
|
||||
<div class="mt-1.5 flex gap-1.5">
|
||||
<ScrubInput icon="R" suffix="°" :model-value="Math.round(node.rotation)" :min="-360" :max="360" @update:model-value="updateProp('rotation', $event)" @commit="(v: number, p: number) => commitProp('rotation', v, p)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
56
src/components/properties/StrokeSection.vue
Normal file
56
src/components/properties/StrokeSection.vue
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<script setup lang="ts">
|
||||
import ColorPicker from '../ColorPicker.vue'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
|
||||
import type { Color, Stroke } from '../../engine/scene-graph'
|
||||
|
||||
const props = defineProps<{ nodeId: string; strokes: Stroke[] }>()
|
||||
const store = useEditorStore()
|
||||
|
||||
function colorHex(c: Color) {
|
||||
const hex = (v: number) => Math.round(v * 255).toString(16).padStart(2, '0')
|
||||
return `#${hex(c.r)}${hex(c.g)}${hex(c.b)}`
|
||||
}
|
||||
|
||||
function updateColor(index: number, color: Color) {
|
||||
const strokes = [...props.strokes]
|
||||
strokes[index] = { ...strokes[index], color }
|
||||
store.updateNodeWithUndo(props.nodeId, { strokes }, 'Change stroke')
|
||||
}
|
||||
|
||||
function updateWeight(index: number, weight: number) {
|
||||
const strokes = [...props.strokes]
|
||||
strokes[index] = { ...strokes[index], weight }
|
||||
store.updateNodeWithUndo(props.nodeId, { strokes }, 'Change stroke')
|
||||
}
|
||||
|
||||
function add() {
|
||||
const stroke: Stroke = { color: { r: 0, g: 0, b: 0, a: 1 }, weight: 1, opacity: 1, visible: true, align: 'CENTER' }
|
||||
store.updateNodeWithUndo(props.nodeId, { strokes: [...props.strokes, stroke] }, 'Add stroke')
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
store.updateNodeWithUndo(props.nodeId, { strokes: props.strokes.filter((_, i) => i !== index) }, 'Change stroke')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="mb-1.5 block text-[11px] text-muted">Stroke</label>
|
||||
<button class="cursor-pointer rounded border-none bg-transparent px-1 text-base leading-none text-muted hover:bg-hover hover:text-surface" @click="add">+</button>
|
||||
</div>
|
||||
<div v-for="(stroke, i) in strokes" :key="i" class="group flex items-center gap-1.5 py-0.5">
|
||||
<ColorPicker :color="stroke.color" @update="updateColor(i, $event)" />
|
||||
<span class="flex-1 font-mono text-xs">{{ colorHex(stroke.color) }}</span>
|
||||
<input
|
||||
type="number"
|
||||
class="w-9 rounded border border-border bg-input px-1 py-0.5 text-center text-[11px] text-surface [&::-webkit-inner-spin-button]:hidden"
|
||||
:value="stroke.weight"
|
||||
min="0"
|
||||
@change="updateWeight(i, +($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<button class="cursor-pointer border-none bg-transparent px-0.5 text-sm leading-none text-muted opacity-0 transition-opacity group-hover:opacity-100 hover:text-surface" @click="remove(i)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
196
src/components/properties/TypographySection.vue
Normal file
196
src/components/properties/TypographySection.vue
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
import ScrubInput from '../ScrubInput.vue'
|
||||
import { useNodeProps } from '../../composables/use-node-props'
|
||||
import { listFamilies, loadFont } from '../../engine/fonts'
|
||||
|
||||
import type { SceneNode } from '../../engine/scene-graph'
|
||||
|
||||
const { node } = defineProps<{ node: SceneNode }>()
|
||||
const { store, updateProp, commitProp } = useNodeProps()
|
||||
|
||||
const fontPickerOpen = ref(false)
|
||||
const fontSearch = ref('')
|
||||
const fontFamilies = ref<string[]>([])
|
||||
const fontPickerRef = ref<HTMLElement | null>(null)
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
onClickOutside(fontPickerRef, () => { fontPickerOpen.value = false })
|
||||
|
||||
const filteredFamilies = computed(() => {
|
||||
const q = fontSearch.value.toLowerCase()
|
||||
if (!q) return fontFamilies.value
|
||||
return fontFamilies.value.filter((f) => f.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
const WEIGHTS = [
|
||||
{ value: 100, label: 'Thin' },
|
||||
{ value: 200, label: 'ExtraLight' },
|
||||
{ value: 300, label: 'Light' },
|
||||
{ value: 400, label: 'Regular' },
|
||||
{ value: 500, label: 'Medium' },
|
||||
{ value: 600, label: 'SemiBold' },
|
||||
{ value: 700, label: 'Bold' },
|
||||
{ value: 800, label: 'ExtraBold' },
|
||||
{ value: 900, label: 'Black' }
|
||||
]
|
||||
|
||||
const currentWeightLabel = computed(() =>
|
||||
WEIGHTS.find((w) => w.value === node.fontWeight)?.label ?? 'Regular'
|
||||
)
|
||||
|
||||
const ALIGN_OPTIONS = [
|
||||
{ value: 'LEFT' as const, icon: 'icon-lucide-align-left' },
|
||||
{ value: 'CENTER' as const, icon: 'icon-lucide-align-center' },
|
||||
{ value: 'RIGHT' as const, icon: 'icon-lucide-align-right' }
|
||||
]
|
||||
|
||||
async function openFontPicker() {
|
||||
fontPickerOpen.value = true
|
||||
fontSearch.value = node.fontFamily
|
||||
if (fontFamilies.value.length === 0) {
|
||||
fontFamilies.value = await listFamilies()
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
searchInputRef.value?.focus()
|
||||
searchInputRef.value?.select()
|
||||
})
|
||||
}
|
||||
|
||||
async function selectFamily(family: string) {
|
||||
await loadFont(family, currentWeightLabel.value)
|
||||
store.updateNodeWithUndo(node.id, { fontFamily: family }, 'Change font')
|
||||
store.requestRender()
|
||||
fontPickerOpen.value = false
|
||||
}
|
||||
|
||||
async function selectWeight(weight: number) {
|
||||
const label = WEIGHTS.find((w) => w.value === weight)?.label ?? 'Regular'
|
||||
await loadFont(node.fontFamily, label)
|
||||
store.updateNodeWithUndo(node.id, { fontWeight: weight }, 'Change font weight')
|
||||
store.requestRender()
|
||||
}
|
||||
|
||||
function setAlign(align: 'LEFT' | 'CENTER' | 'RIGHT') {
|
||||
store.updateNodeWithUndo(node.id, { textAlignHorizontal: align }, 'Change text alignment')
|
||||
store.requestRender()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFont(node.fontFamily, currentWeightLabel.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
<label class="mb-1.5 block text-[11px] text-muted">Typography</label>
|
||||
|
||||
<!-- Font family picker -->
|
||||
<div ref="fontPickerRef" class="relative mb-1.5">
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center justify-between rounded border border-border bg-input px-2 py-1 text-xs text-surface hover:bg-hover"
|
||||
@click="openFontPicker"
|
||||
>
|
||||
<span class="truncate">{{ node.fontFamily }}</span>
|
||||
<icon-lucide-chevron-down class="size-3 shrink-0 text-muted" />
|
||||
</button>
|
||||
|
||||
<!-- Font picker dropdown -->
|
||||
<div
|
||||
v-if="fontPickerOpen"
|
||||
class="absolute top-full right-0 left-0 z-20 mt-0.5 flex max-h-64 flex-col rounded-md border border-border bg-panel shadow-lg"
|
||||
>
|
||||
<div class="flex items-center gap-1 border-b border-border px-2 py-1">
|
||||
<icon-lucide-search class="size-3 shrink-0 text-muted" />
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="fontSearch"
|
||||
class="min-w-0 flex-1 border-none bg-transparent text-xs text-surface outline-none placeholder:text-muted"
|
||||
placeholder="Search fonts…"
|
||||
@keydown.escape="fontPickerOpen = false"
|
||||
@keydown.enter="filteredFamilies.length && selectFamily(filteredFamilies[0])"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<button
|
||||
v-for="family in filteredFamilies"
|
||||
:key="family"
|
||||
class="flex w-full cursor-pointer items-center gap-2 border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover"
|
||||
:class="family === node.fontFamily ? 'text-accent' : 'text-surface'"
|
||||
:style="{ fontFamily: family }"
|
||||
@click="selectFamily(family)"
|
||||
>
|
||||
<icon-lucide-check v-if="family === node.fontFamily" class="size-3 shrink-0" />
|
||||
<span v-else class="size-3 shrink-0" />
|
||||
<span class="truncate">{{ family }}</span>
|
||||
</button>
|
||||
<div v-if="filteredFamilies.length === 0" class="px-2 py-3 text-center text-xs text-muted">
|
||||
No fonts found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weight + Size -->
|
||||
<div class="mb-1.5 flex gap-1.5">
|
||||
<select
|
||||
class="min-w-0 flex-1 cursor-pointer rounded border border-border bg-input px-1.5 py-1 text-xs text-surface"
|
||||
:value="node.fontWeight"
|
||||
@change="selectWeight(+($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="w in WEIGHTS" :key="w.value" :value="w.value">{{ w.label }}</option>
|
||||
</select>
|
||||
<ScrubInput
|
||||
class="flex-1"
|
||||
:model-value="node.fontSize"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
@update:model-value="updateProp('fontSize', $event)"
|
||||
@commit="(v: number, p: number) => commitProp('fontSize', v, p)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Line height + Letter spacing -->
|
||||
<div class="mb-1.5 flex gap-1.5">
|
||||
<ScrubInput
|
||||
class="flex-1"
|
||||
:model-value="node.lineHeight ?? Math.round((node.fontSize || 14) * 1.2)"
|
||||
:min="0"
|
||||
@update:model-value="updateProp('lineHeight', $event)"
|
||||
@commit="(v: number, p: number) => commitProp('lineHeight', v, p)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-baseline class="size-3" />
|
||||
</template>
|
||||
</ScrubInput>
|
||||
<ScrubInput
|
||||
class="flex-1"
|
||||
suffix="%"
|
||||
:model-value="node.letterSpacing"
|
||||
@update:model-value="updateProp('letterSpacing', $event)"
|
||||
@commit="(v: number, p: number) => commitProp('letterSpacing', v, p)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-a-large-small class="size-3" />
|
||||
</template>
|
||||
</ScrubInput>
|
||||
</div>
|
||||
|
||||
<!-- Text alignment -->
|
||||
<div class="flex gap-0.5">
|
||||
<button
|
||||
v-for="opt in ALIGN_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
|
||||
:class="node.textAlignHorizontal === opt.value
|
||||
? 'border-accent bg-accent text-white'
|
||||
: 'border-border bg-input text-muted hover:bg-hover hover:text-surface'"
|
||||
@click="setAlign(opt.value)"
|
||||
>
|
||||
<component :is="opt.icon" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
33
src/composables/use-node-props.ts
Normal file
33
src/composables/use-node-props.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useEditorStore } from '../stores/editor'
|
||||
|
||||
import type { SceneNode } from '../engine/scene-graph'
|
||||
|
||||
export function useNodeProps() {
|
||||
const store = useEditorStore()
|
||||
|
||||
function updateProp(key: string, value: number | string) {
|
||||
if (store.selectedNodes.value.length > 1) {
|
||||
for (const n of store.selectedNodes.value) {
|
||||
store.updateNode(n.id, { [key]: value })
|
||||
}
|
||||
} else {
|
||||
const node = store.selectedNode.value
|
||||
if (node) store.updateNode(node.id, { [key]: value })
|
||||
}
|
||||
}
|
||||
|
||||
function commitProp(key: string, _value: number | string, previous: number | string) {
|
||||
if (store.selectedNodes.value.length > 1) {
|
||||
for (const n of store.selectedNodes.value) {
|
||||
store.commitNodeUpdate(n.id, { [key]: previous } as Partial<SceneNode>, `Change ${key}`)
|
||||
}
|
||||
} else {
|
||||
const node = store.selectedNode.value
|
||||
if (node) {
|
||||
store.commitNodeUpdate(node.id, { [key]: previous } as Partial<SceneNode>, `Change ${key}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { store, updateProp, commitProp }
|
||||
}
|
||||
Loading…
Reference in a new issue