Refactor UI to use Reka & VueUse primitives

This commit is contained in:
Danila Poyarkov 2026-03-06 18:28:40 +03:00
parent df8e303498
commit a334a7f63c
10 changed files with 276 additions and 400 deletions

View file

@ -6,6 +6,9 @@
* and MCP (JSON Schema) are generated from these definitions.
*/
import { parseColor } from '../color'
import { DEFAULT_SHADOW_COLOR } from '../constants'
import type { FigmaAPI, FigmaNodeProxy } from '../figma-api'
export type ParamType = 'string' | 'number' | 'boolean' | 'color' | 'string[]'

View file

@ -16,7 +16,7 @@ import {
import IconChevronRight from '~icons/lucide/chevron-right'
import { ref, nextTick } from 'vue'
import { ref } from 'vue'
import { IS_TAURI } from '@/constants'
import { openFileDialog } from '@/composables/use-menu'
@ -25,14 +25,13 @@ import { useEditorStore } from '@/stores/editor'
const store = useEditorStore()
const editingName = ref(false)
const nameInputRef = ref<HTMLInputElement | null>(null)
function startRename() {
async function startRename() {
editingName.value = true
nextTick(() => {
const input = document.querySelector<HTMLInputElement>('[data-doc-name-edit]')
input?.focus()
input?.select()
})
await nextTick()
nameInputRef.value?.focus()
nameInputRef.value?.select()
}
function commitRename(input: HTMLInputElement) {
@ -177,7 +176,7 @@ const topMenus = [
<img data-test-id="app-logo" src="/favicon-32.png" class="size-4" alt="OpenPencil" />
<input
v-if="editingName"
data-doc-name-edit
ref="nameInputRef"
data-test-id="app-document-name-input"
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0.5 text-xs text-surface outline-none"
:value="store.state.documentName"

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { useElementSize, useWindowSize } from '@vueuse/core'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { motion } from 'motion-v'
import type { PanInfo } from 'motion-v'
import { computed, ref } from 'vue'
@ -20,6 +21,7 @@ import {
import { useEditorStore } from '@/stores/editor'
type Snap = 'closed' | 'half' | 'full'
type DrawerTab = 'layers' | 'design' | 'code' | 'ai'
const store = useEditorStore()
@ -35,22 +37,32 @@ const snap = computed({
}
})
const activeDrawerTab = computed<DrawerTab>({
get: () => {
if (store.state.activeRibbonTab === 'code') return 'code'
if (store.state.activeRibbonTab === 'ai') return 'ai'
return store.state.panelMode === 'design' ? 'design' : 'layers'
},
set: (tab) => {
if (tab === 'code' || tab === 'ai') {
store.state.activeRibbonTab = tab
return
}
store.state.activeRibbonTab = 'panels'
store.state.panelMode = tab
}
})
const isOpen = computed(() => snap.value !== 'closed')
const isPanelActive = computed(() => isOpen.value && store.state.activeRibbonTab === 'panels')
function selectTab(tab: 'panels' | 'code' | 'ai', panelMode?: 'layers' | 'design') {
const isSameTab =
store.state.activeRibbonTab === tab && (!panelMode || store.state.panelMode === panelMode)
if (isSameTab && isOpen.value) {
function toggleTab(tab: DrawerTab) {
if (activeDrawerTab.value === tab && isOpen.value) {
snap.value = 'closed'
targetHeight.value = snapHeight('closed')
return
}
store.state.activeRibbonTab = tab
if (panelMode) store.state.panelMode = panelMode
activeDrawerTab.value = tab
if (!isOpen.value) {
snap.value = 'half'
targetHeight.value = snapHeight('half')
@ -74,10 +86,6 @@ function onPan(_e: PointerEvent, info: PanInfo) {
const maxHeight = snapHeight('full')
const raw = snapHeight(snap.value) - info.offset.y
targetHeight.value = Math.max(headerH.value, Math.min(maxHeight, raw))
if (info.offset.y < 0 && !store.state.activeRibbonTab) {
store.state.activeRibbonTab = 'panels'
}
}
function onPanEnd(_e: PointerEvent, info: PanInfo) {
@ -111,109 +119,82 @@ const drawerTransition = {
@pan="onPan"
@panEnd="onPanEnd"
>
<nav
ref="headerRef"
aria-label="Mobile panel navigation"
class="flex shrink-0 flex-col"
role="tablist"
>
<div class="flex w-full justify-center pt-2">
<div class="h-1 w-8 rounded-full bg-muted/40" />
</div>
<div class="flex w-full items-center px-2 py-2">
<div
role="tab"
data-test-id="mobile-ribbon-layers"
:aria-selected="isPanelActive && store.state.panelMode === 'layers'"
tabindex="0"
class="flex h-full cursor-pointer items-center justify-center gap-1.5 px-4 text-xs outline-none transition-colors select-none"
:class="
isPanelActive && store.state.panelMode === 'layers' ? 'text-accent' : 'text-muted'
"
@click="selectTab('panels', 'layers')"
>
<icon-lucide-layers class="size-4" />
<TabsRoot :model-value="activeDrawerTab" class="flex min-h-0 flex-1 flex-col">
<nav ref="headerRef" aria-label="Mobile panel navigation" class="flex shrink-0 flex-col">
<div class="flex w-full justify-center pt-2">
<div class="h-1 w-8 rounded-full bg-muted/40" />
</div>
<TabsList class="flex w-full items-center px-2 py-2">
<TabsTrigger
data-test-id="mobile-ribbon-layers"
value="layers"
class="flex h-full cursor-pointer items-center justify-center gap-1.5 px-4 text-xs outline-none transition-colors select-none data-[state=active]:text-accent"
@click="toggleTab('layers')"
>
<icon-lucide-layers class="size-4" />
</TabsTrigger>
<div
role="tab"
data-test-id="mobile-ribbon-design"
:aria-selected="isPanelActive && store.state.panelMode === 'design'"
tabindex="0"
class="flex h-full cursor-pointer items-center justify-center gap-1.5 px-4 text-xs outline-none transition-colors select-none"
:class="
isPanelActive && store.state.panelMode === 'design' ? 'text-accent' : 'text-muted'
"
@click="selectTab('panels', 'design')"
>
<icon-lucide-sliders-horizontal class="size-4" />
</div>
<TabsTrigger
data-test-id="mobile-ribbon-design"
value="design"
class="flex h-full cursor-pointer items-center justify-center gap-1.5 px-4 text-xs outline-none transition-colors select-none data-[state=active]:text-accent"
@click="toggleTab('design')"
>
<icon-lucide-sliders-horizontal class="size-4" />
</TabsTrigger>
<div class="flex-1" />
<div class="flex-1" />
<div
role="tab"
data-test-id="mobile-ribbon-code"
:aria-selected="isOpen && store.state.activeRibbonTab === 'code'"
tabindex="0"
class="flex h-full cursor-pointer items-center justify-center px-3 outline-none transition-colors select-none"
:class="isOpen && store.state.activeRibbonTab === 'code' ? 'text-accent' : 'text-muted'"
@click="selectTab('code')"
>
<icon-lucide-code class="size-4" />
</div>
<TabsTrigger
data-test-id="mobile-ribbon-code"
value="code"
class="flex h-full cursor-pointer items-center justify-center px-3 outline-none transition-colors select-none data-[state=active]:text-accent"
@click="toggleTab('code')"
>
<icon-lucide-code class="size-4" />
</TabsTrigger>
<div
role="tab"
data-test-id="mobile-ribbon-ai"
:aria-selected="isOpen && store.state.activeRibbonTab === 'ai'"
tabindex="0"
class="flex h-full cursor-pointer items-center justify-center px-3 outline-none transition-colors select-none"
:class="isOpen && store.state.activeRibbonTab === 'ai' ? 'text-accent' : 'text-muted'"
@click="selectTab('ai')"
>
<icon-lucide-sparkles class="size-4" />
</div>
<TabsTrigger
data-test-id="mobile-ribbon-ai"
value="ai"
class="flex h-full cursor-pointer items-center justify-center px-3 outline-none transition-colors select-none data-[state=active]:text-accent"
@click="toggleTab('ai')"
>
<icon-lucide-sparkles class="size-4" />
</TabsTrigger>
</TabsList>
</nav>
<div data-test-id="mobile-drawer-content" class="min-h-0 flex-1 overflow-y-auto">
<TabsContent value="layers" class="mt-0 h-full data-[state=inactive]:hidden">
<div data-test-id="mobile-drawer-layers" class="flex h-full flex-col">
<PagesPanel />
<div class="border-t border-border" />
<header class="shrink-0 px-3 py-2 text-[11px] uppercase tracking-wider text-muted">
Layers
</header>
<LayerTree class="min-h-0 flex-1" />
</div>
</TabsContent>
<TabsContent value="design" class="mt-0 h-full data-[state=inactive]:hidden">
<div data-test-id="mobile-drawer-design" class="flex h-full flex-col">
<DesignPanel />
</div>
</TabsContent>
<TabsContent value="code" class="mt-0 h-full data-[state=inactive]:hidden">
<div data-test-id="mobile-drawer-code" class="flex h-full flex-col">
<CodePanel />
</div>
</TabsContent>
<TabsContent value="ai" class="mt-0 h-full data-[state=inactive]:hidden">
<div data-test-id="mobile-drawer-ai" class="flex h-full flex-col">
<ChatPanel />
</div>
</TabsContent>
</div>
</nav>
<div data-test-id="mobile-drawer-content" class="min-h-0 flex-1 overflow-y-auto">
<div
v-show="store.state.activeRibbonTab === 'panels' && store.state.panelMode === 'layers'"
data-test-id="mobile-drawer-layers"
class="flex h-full flex-col"
>
<PagesPanel />
<div class="border-t border-border" />
<header class="shrink-0 px-3 py-2 text-[11px] uppercase tracking-wider text-muted">
Layers
</header>
<LayerTree class="min-h-0 flex-1" />
</div>
<div
v-show="store.state.activeRibbonTab === 'panels' && store.state.panelMode === 'design'"
data-test-id="mobile-drawer-design"
class="flex h-full flex-col"
>
<DesignPanel />
</div>
<div
v-show="store.state.activeRibbonTab === 'code'"
data-test-id="mobile-drawer-code"
class="flex h-full flex-col"
>
<CodePanel />
</div>
<div
v-show="store.state.activeRibbonTab === 'ai'"
data-test-id="mobile-drawer-ai"
class="flex h-full flex-col"
>
<ChatPanel />
</div>
</div>
</TabsRoot>
</motion.div>
</template>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useInlineRename } from '@/composables/use-inline-rename'
import { useEditorStore } from '@/stores/editor'
@ -7,6 +7,7 @@ import { useEditorStore } from '@/stores/editor'
const store = useEditorStore()
const DIVIDER_RE = /^[-–—*\s]+$/
const pageInputRefs = new Map<string, HTMLInputElement>()
function isDivider(page: { name: string; childIds: string[] }) {
return page.childIds.length === 0 && DIVIDER_RE.test(page.name)
@ -18,9 +19,21 @@ const pages = computed(() => {
})
const rename = useInlineRename((id, name) => store.renamePage(id, name))
const activeRenameId = ref<string | null>(null)
function setPageInputRef(pageId: string, el: HTMLInputElement | null) {
if (el) pageInputRefs.set(pageId, el)
else pageInputRefs.delete(pageId)
if (el && activeRenameId.value === pageId) {
activeRenameId.value = null
void rename.focusInput(el)
}
}
function startRename(pg: { id: string; name: string }) {
rename.start(pg.id, pg.name, '[data-page-edit]')
rename.start(pg.id, pg.name)
activeRenameId.value = pg.id
}
</script>
@ -43,7 +56,7 @@ function startRename(pg: { id: string; name: string }) {
<div v-for="pg in pages" :key="pg.id">
<input
v-if="rename.editingId.value === pg.id"
data-page-edit
:ref="(el) => setPageInputRef(pg.id, el as HTMLInputElement | null)"
data-test-id="pages-item-input"
class="w-full rounded border border-accent bg-input px-2 py-1 text-xs text-surface outline-none"
:value="pg.name"

View file

@ -43,14 +43,25 @@ watch(collections, (cols) => {
})
const editingCollectionId = ref<string | null>(null)
const collectionInputRefs = new Map<string, HTMLInputElement>()
const pendingCollectionFocusId = ref<string | null>(null)
function setCollectionInputRef(id: string, el: HTMLInputElement | null) {
if (el) collectionInputRefs.set(id, el)
else collectionInputRefs.delete(id)
if (el && pendingCollectionFocusId.value === id) {
pendingCollectionFocusId.value = null
void nextTick(() => {
el.focus()
el.select()
})
}
}
function startRenameCollection(id: string) {
editingCollectionId.value = id
nextTick(() => {
const input = document.querySelector<HTMLInputElement>('[data-collection-edit]')
input?.focus()
input?.select()
})
pendingCollectionFocusId.value = id
}
function commitRenameCollection(id: string, input: HTMLInputElement) {
@ -412,7 +423,7 @@ const table = useVueTable({
<template v-for="col in collections" :key="col.id">
<input
v-if="editingCollectionId === col.id"
data-collection-edit
:ref="(el) => setCollectionInputRef(col.id, el as HTMLInputElement | null)"
class="w-24 rounded border border-accent bg-input px-2 py-0.5 text-xs text-surface outline-none"
:value="col.name"
@blur="commitRenameCollection(col.id, $event.target as HTMLInputElement)"

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onClickOutside } from '@vueuse/core'
import AppSelect from '@/components/AppSelect.vue'
import ScrubInput from '@/components/ScrubInput.vue'
import { useNodeProps } from '@/composables/use-node-props'
@ -10,17 +10,6 @@ import type { SceneNode, LayoutSizing, LayoutAlign, LayoutCounterAlign } from '@
const { store, node, 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.value.parentId) return false
@ -28,7 +17,7 @@ const isInAutoLayout = computed(() => {
return parent ? parent.layoutMode !== 'NONE' : false
})
const widthSizing = computed(() => {
const widthSizing = computed<LayoutSizing>(() => {
if (node.value.layoutMode !== 'NONE') {
return node.value.layoutMode === 'HORIZONTAL'
? node.value.primaryAxisSizing
@ -38,7 +27,7 @@ const widthSizing = computed(() => {
return 'FIXED'
})
const heightSizing = computed(() => {
const heightSizing = computed<LayoutSizing>(() => {
if (node.value.layoutMode !== 'NONE') {
return node.value.layoutMode === 'VERTICAL'
? node.value.primaryAxisSizing
@ -55,7 +44,6 @@ function setWidthSizing(sizing: LayoutSizing) {
} else if (isInAutoLayout.value) {
updateProp('layoutGrow', sizing === 'FILL' ? 1 : 0)
}
widthSizingOpen.value = false
}
function setHeightSizing(sizing: LayoutSizing) {
@ -65,13 +53,6 @@ function setHeightSizing(sizing: LayoutSizing) {
} 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() {
@ -104,6 +85,24 @@ function commitUniformPadding(_value: number, previous: number) {
)
}
const widthSizingOptions = computed(() => {
const options: { value: LayoutSizing; label: string }[] = [
{ value: 'FIXED', label: `Fixed width (${Math.round(node.value.width)})` }
]
if (node.value.layoutMode !== 'NONE') options.push({ value: 'HUG', label: 'Hug contents' })
if (isInAutoLayout.value) options.push({ value: 'FILL', label: 'Fill container' })
return options
})
const heightSizingOptions = computed(() => {
const options: { value: LayoutSizing; label: string }[] = [
{ value: 'FIXED', label: `Fixed height (${Math.round(node.value.height)})` }
]
if (node.value.layoutMode !== 'NONE') options.push({ value: 'HUG', label: 'Hug contents' })
if (isInAutoLayout.value) options.push({ value: 'FILL', label: 'Fill container' })
return options
})
const ALIGN_GRID: Array<{ primary: LayoutAlign; counter: LayoutCounterAlign }> = [
{ primary: 'MIN', counter: 'MIN' },
{ primary: 'CENTER', counter: 'MIN' },
@ -129,8 +128,7 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
<div v-if="node" data-test-id="layout-section" 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">
<div class="flex min-w-0 flex-1 items-center gap-1">
<ScrubInput
icon="W"
:model-value="Math.round(node.width)"
@ -138,47 +136,15 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
@update:model-value="updateProp('width', $event)"
@commit="(v: number, p: number) => commitProp('width', v, p)"
/>
<button
<AppSelect
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"
data-test-id="layout-width-sizing-dropdown"
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>
:model-value="widthSizing"
:options="widthSizingOptions"
@update:model-value="setWidthSizing"
/>
</div>
<!-- Height -->
<div ref="heightDimRef" class="relative flex min-w-0 flex-1 items-center gap-1">
<div class="flex min-w-0 flex-1 items-center gap-1">
<ScrubInput
icon="H"
:model-value="Math.round(node.height)"
@ -186,49 +152,16 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
@update:model-value="updateProp('height', $event)"
@commit="(v: number, p: number) => commitProp('height', v, p)"
/>
<button
<AppSelect
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"
data-test-id="layout-height-sizing-dropdown"
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>
:model-value="heightSizing"
:options="heightSizingOptions"
@update:model-value="setHeightSizing"
/>
</div>
</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>
@ -253,183 +186,106 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
</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'
"
data-test-id="layout-direction-vertical"
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'
? 'border-accent bg-accent/10 text-accent'
: 'border-border text-muted hover:bg-hover hover:text-surface'
"
data-test-id="layout-direction-horizontal"
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>
<icon-lucide-arrow-right class="size-3.5" />
</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'
node.layoutMode === 'VERTICAL'
? 'border-accent bg-accent/10 text-accent'
: 'border-border text-muted hover:bg-hover hover:text-surface'
"
data-test-id="layout-direction-wrap"
title="Wrap"
@click="updateProp('layoutWrap', node.layoutWrap === 'WRAP' ? 'NO_WRAP' : 'WRAP')"
@click="store.setLayoutMode(node.id, 'VERTICAL')"
>
<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>
<icon-lucide-arrow-down class="size-3.5" />
</button>
</div>
<!-- Alignment grid + Gap -->
<div class="mt-1.5 flex items-center gap-2">
<div
data-test-id="layout-alignment-grid"
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>
<div class="mt-2 grid grid-cols-2 gap-1.5">
<ScrubInput
:model-value="node.itemSpacing"
icon="Gap"
:model-value="Math.round(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"
:value="node[side]"
min="0"
@change="updateProp(side, +($event.target as HTMLInputElement).value)"
/>
</div>
</div>
</template>
<template v-else>
<template v-if="hasUniformPadding() && !showIndividualPadding">
<ScrubInput
:model-value="node.paddingTop"
icon="Pad"
:model-value="Math.round(node.paddingTop)"
:min="0"
@update:model-value="setUniformPadding($event)"
@update:model-value="setUniformPadding"
@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"
class="rounded border border-border bg-transparent px-2 py-1 text-left text-xs text-muted hover:bg-hover hover:text-surface"
@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>
{{ showIndividualPadding ? 'Uniform padding' : 'Per-side padding' }}
</button>
</div>
<div v-if="showIndividualPadding || !hasUniformPadding()" class="mt-1.5 grid grid-cols-2 gap-1.5">
<ScrubInput
icon="Top"
:model-value="Math.round(node.paddingTop)"
:min="0"
@update:model-value="updateProp('paddingTop', $event)"
@commit="(v: number, p: number) => commitProp('paddingTop', v, p)"
/>
<ScrubInput
icon="Right"
:model-value="Math.round(node.paddingRight)"
:min="0"
@update:model-value="updateProp('paddingRight', $event)"
@commit="(v: number, p: number) => commitProp('paddingRight', v, p)"
/>
<ScrubInput
icon="Bottom"
:model-value="Math.round(node.paddingBottom)"
:min="0"
@update:model-value="updateProp('paddingBottom', $event)"
@commit="(v: number, p: number) => commitProp('paddingBottom', v, p)"
/>
<ScrubInput
icon="Left"
:model-value="Math.round(node.paddingLeft)"
:min="0"
@update:model-value="updateProp('paddingLeft', $event)"
@commit="(v: number, p: number) => commitProp('paddingLeft', v, p)"
/>
</div>
<div class="mt-2">
<label class="mb-1 block text-[11px] text-muted">Alignment</label>
<div class="grid grid-cols-3 gap-1">
<button
v-for="cell in ALIGN_GRID"
:key="`${cell.primary}-${cell.counter}`"
class="flex aspect-square cursor-pointer items-center justify-center rounded border text-[11px]"
:class="
node.primaryAxisAlign === cell.primary && node.counterAxisAlign === cell.counter
? 'border-accent bg-accent/10 text-accent'
: 'border-border text-muted hover:bg-hover hover:text-surface'
"
@click="setAlignment(cell.primary, cell.counter)"
>
<span class="size-1.5 rounded-full bg-current" />
</button>
</div>
</div>
</template>
</div>
<!-- Clip content (for all frames) -->
<div v-if="node.type === 'FRAME'" class="border-b border-border px-3 py-2">
<label class="flex cursor-pointer items-center gap-2 text-xs text-surface">
<input
type="checkbox"
class="accent-accent"
:checked="node.clipsContent"
@change="
store.updateNodeWithUndo(
node.id,
{ clipsContent: !node.clipsContent },
'Toggle clip content'
)
"
/>
Clip content
</label>
</div>
</template>

View file

@ -1,4 +1,4 @@
import { useRafFn, useResizeObserver } from '@vueuse/core'
import { useMediaQuery, useRafFn, useResizeObserver } from '@vueuse/core'
import { onMounted, onUnmounted, type Ref } from 'vue'
import { getCanvasKit, getGpuBackend } from '@/engine/canvaskit'
@ -128,12 +128,11 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, store: Edito
const params = new URLSearchParams(window.location.search)
const noRulersParam = params.has('no-rulers')
const mobileQuery = matchMedia('(max-width: 767px)')
let showRulers = !noRulersParam && !mobileQuery.matches
mobileQuery.addEventListener('change', (e) => {
showRulers = !noRulersParam && !e.matches
dirty = true
})
const isMobile = useMediaQuery('(max-width: 767px)')
function showRulers() {
return !noRulersParam && !isMobile.value
}
function renderNow() {
if (!renderer || destroyed) return
@ -143,7 +142,7 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, store: Edito
renderer.zoom = store.state.zoom
renderer.viewportWidth = canvasRef.value?.clientWidth ?? 0
renderer.viewportHeight = canvasRef.value?.clientHeight ?? 0
renderer.showRulers = showRulers
renderer.showRulers = showRulers()
renderer.pageColor = store.state.pageColor
renderer.pageId = store.state.currentPageId
renderer.render(

View file

@ -4,14 +4,15 @@ export function useInlineRename(onCommit: (id: string, newName: string) => void)
const editingId = ref<string | null>(null)
let originalName = ''
function start(id: string, currentName: string, selector: string) {
function start(id: string, currentName: string) {
editingId.value = id
originalName = currentName
nextTick(() => {
const input = document.querySelector<HTMLInputElement>(selector)
input?.focus()
input?.select()
})
}
async function focusInput(input: HTMLInputElement | null) {
await nextTick()
input?.focus()
input?.select()
}
function commit(id: string, input: HTMLInputElement) {
@ -23,11 +24,20 @@ export function useInlineRename(onCommit: (id: string, newName: string) => void)
editingId.value = null
}
function cancel() {
editingId.value = null
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === 'Escape') {
if (e.key === 'Enter') {
;(e.target as HTMLInputElement).blur()
return
}
if (e.key === 'Escape') {
cancel()
}
}
return { editingId, start, commit, onKeydown }
return { editingId, start, focusInput, commit, cancel, onKeydown }
}

View file

@ -1,4 +1,5 @@
import { onUnmounted } from 'vue'
import { useFileDialog } from '@vueuse/core'
import { IS_TAURI } from '@/constants'
import { useEditorStore } from '@/stores/editor'
@ -37,14 +38,12 @@ export async function openFileDialog() {
}
}
const input = document.createElement('input')
input.type = 'file'
input.accept = '.fig'
input.addEventListener('change', () => {
const file = input.files?.[0]
if (file) openFileInNewTab(file)
const { open, onChange } = useFileDialog({ accept: '.fig', multiple: false })
onChange((files) => {
const file = files?.[0]
if (file) void openFileInNewTab(file)
})
input.click()
open()
}
const store = useEditorStore()

View file

@ -1,3 +1,4 @@
import { useEventListener } from '@vueuse/core'
import { ref } from 'vue'
export type ToastVariant = 'default' | 'error'
@ -12,6 +13,7 @@ const TOAST_DURATION = 3000
const toasts = ref<Toast[]>([])
let nextId = 0
let errorHandlersInitialized = false
function show(message: string, variant: ToastVariant = 'default') {
toasts.value.push({ id: ++nextId, message, variant })
@ -22,10 +24,13 @@ function remove(id: number) {
}
function setupGlobalErrorHandler() {
window.addEventListener('error', (e) => {
if (errorHandlersInitialized) return
errorHandlersInitialized = true
useEventListener(window, 'error', (e) => {
show(e.message || 'An unexpected error occurred', 'error')
})
window.addEventListener('unhandledrejection', (e) => {
useEventListener(window, 'unhandledrejection', (e) => {
const msg = e.reason instanceof Error ? e.reason.message : String(e.reason)
show(msg || 'An unexpected error occurred', 'error')
})