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:
Danila Poyarkov 2026-02-28 09:00:25 +03:00
parent e3c80fc790
commit 7e9cd46e0b
9 changed files with 667 additions and 460 deletions

11
components.d.ts vendored
View file

@ -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']
}
}

View file

@ -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">

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 }
}