Merge branch 'reka-vueuse-refactor'

This commit is contained in:
Danila Poyarkov 2026-03-07 14:46:40 +03:00
commit 97baef474d
22 changed files with 578 additions and 433 deletions

View file

@ -29,6 +29,8 @@
"motion-v": "^2.0.0",
"prismjs": "^1.30.0",
"reka-ui": "^2.9.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.2.1",
"trystero": "^0.22.0",
"valibot": "^1.2.0",
@ -2166,6 +2168,10 @@
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
"tailwind-variants": ["tailwind-variants@3.2.2", "", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge"] }, "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg=="],
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],

View file

@ -54,6 +54,8 @@
"motion-v": "^2.0.0",
"prismjs": "^1.30.0",
"reka-ui": "^2.9.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.2.1",
"trystero": "^0.22.0",
"valibot": "^1.2.0",

View file

@ -16,31 +16,32 @@ import {
import IconChevronRight from '~icons/lucide/chevron-right'
import { ref, nextTick } from 'vue'
import { computed, ref } from 'vue'
import { useInlineRename } from '@/composables/use-inline-rename'
import { menuContent, menuItem, menuSeparator } from '@/components/ui/menu'
import { IS_TAURI } from '@/constants'
import { openFileDialog } from '@/composables/use-menu'
import { useEditorStore } from '@/stores/editor'
const store = useEditorStore()
const editingName = ref(false)
const DOCUMENT_NAME_ID = 'document-name'
const rename = useInlineRename<'document-name'>((_id, name) => {
store.state.documentName = name
})
const editingName = computed(() => rename.editingId.value === DOCUMENT_NAME_ID)
function setNameInputRef(el: HTMLInputElement | null) {
if (el) void rename.focusInput(el)
}
function startRename() {
editingName.value = true
nextTick(() => {
const input = document.querySelector<HTMLInputElement>('[data-doc-name-edit]')
input?.focus()
input?.select()
})
rename.start(DOCUMENT_NAME_ID, store.state.documentName)
}
function commitRename(input: HTMLInputElement) {
const value = input.value.trim()
if (value) {
store.state.documentName = value
}
editingName.value = false
rename.commit(DOCUMENT_NAME_ID, input)
}
const isMac = navigator.platform.includes('Mac')
@ -177,13 +178,13 @@ 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="(el) => setNameInputRef(el as HTMLInputElement | null)"
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"
@blur="commitRename($event.target as HTMLInputElement)"
@keydown.enter="($event.target as HTMLInputElement).blur()"
@keydown.escape="editingName = false"
@keydown="rename.onKeydown"
/>
<span
v-else
@ -215,28 +216,22 @@ const topMenus = [
<MenubarContent
:side-offset="4"
align="start"
class="min-w-52 rounded-lg border border-border bg-panel p-1 shadow-lg"
:class="menuContent({ class: 'min-w-52' })"
>
<template v-for="(item, i) in menu.items" :key="i">
<MenubarSeparator v-if="item.separator" class="mx-1 my-1 h-px bg-border" />
<MenubarSeparator v-if="item.separator" :class="menuSeparator()" />
<MenubarSub v-else-if="item.sub">
<MenubarSubTrigger
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs text-surface outline-none select-none hover:bg-hover"
>
<MenubarSubTrigger :class="menuItem()">
<span class="flex-1">{{ item.label }}</span>
<IconChevronRight class="size-3 text-muted" />
</MenubarSubTrigger>
<MenubarPortal>
<MenubarSubContent
:side-offset="4"
class="min-w-44 rounded-lg border border-border bg-panel p-1 shadow-lg"
>
<MenubarSubContent :side-offset="4" :class="menuContent({ class: 'min-w-44' })">
<template v-for="(sub, j) in item.sub" :key="j">
<MenubarSeparator v-if="sub.separator" class="mx-1 my-1 h-px bg-border" />
<MenubarSeparator v-if="sub.separator" :class="menuSeparator()" />
<MenubarItem
v-else
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none select-none"
:class="sub.disabled ? 'text-muted/50' : 'text-surface hover:bg-hover'"
:class="menuItem()"
:disabled="sub.disabled"
@select="sub.action?.()"
>
@ -252,7 +247,7 @@ const topMenus = [
<MenubarCheckboxItem
v-else-if="item.onCheckedChange"
:model-value="item.checked"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs text-surface outline-none select-none hover:bg-hover"
:class="menuItem()"
@update:model-value="item.onCheckedChange?.($event as boolean)"
>
<span class="flex-1">{{ item.label }}</span>
@ -262,8 +257,7 @@ const topMenus = [
</MenubarCheckboxItem>
<MenubarItem
v-else
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none select-none"
:class="item.disabled ? 'text-muted/50' : 'text-surface hover:bg-hover'"
:class="menuItem()"
:disabled="item.disabled"
@select="item.action?.()"
>

View file

@ -11,6 +11,8 @@ import {
SelectViewport
} from 'reka-ui'
import { selectContent, selectItem, selectTrigger } from '@/components/ui/select'
defineProps<{
options: { value: T; label: string }[]
placeholder?: string
@ -23,7 +25,7 @@ const modelValue = defineModel<T>({ required: true })
<SelectRoot v-model="modelValue">
<SelectTrigger
data-test-id="app-select-trigger"
class="flex min-w-0 flex-1 cursor-pointer items-center justify-between rounded border border-border bg-input px-1.5 py-1 text-xs text-surface outline-none hover:bg-hover"
:class="selectTrigger({ class: 'min-w-0 flex-1 rounded px-1.5 py-1 text-xs' })"
>
<SelectValue :placeholder="placeholder" />
<icon-lucide-chevron-down class="ml-1 size-3 shrink-0 text-muted" />
@ -32,14 +34,14 @@ const modelValue = defineModel<T>({ required: true })
<SelectContent
position="popper"
:side-offset="2"
class="z-50 max-h-56 min-w-[var(--reka-select-trigger-width)] overflow-hidden rounded-md border border-border bg-panel shadow-lg"
:class="selectContent({ class: 'max-h-56 min-w-[var(--reka-select-trigger-width)]' })"
>
<SelectViewport class="p-0.5">
<SelectItem
v-for="opt in options"
:key="String(opt.value)"
:value="opt.value"
class="relative flex cursor-pointer items-center rounded py-1.5 pl-6 pr-2 text-xs text-surface outline-none data-[highlighted]:bg-hover"
:class="selectItem({ class: 'rounded py-1.5 pl-6 pr-2 text-xs' })"
>
<SelectItemIndicator class="absolute left-1.5 inline-flex items-center justify-center">
<icon-lucide-check class="size-3 text-accent" />

View file

@ -12,6 +12,8 @@ import {
useFilter
} from 'reka-ui'
import { selectContent, selectItem, selectTrigger } from '@/components/ui/select'
import { panelSurface } from '@/components/ui/surface'
import { listFamilies } from '@/engine/fonts'
const modelValue = defineModel<string>({ required: true })
@ -51,7 +53,7 @@ function onSelect(val: string) {
<PopoverAnchor>
<button
data-test-id="font-picker-trigger"
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"
:class="selectTrigger({ class: 'w-full rounded px-2 py-1 text-xs' })"
@click="open = !open"
>
<span class="truncate">{{ modelValue }}</span>
@ -61,7 +63,13 @@ function onSelect(val: string) {
<PopoverContent
:side-offset="2"
align="start"
class="z-50 flex min-w-56 w-[var(--reka-popper-anchor-width)] flex-col overflow-hidden rounded-md border border-border bg-panel shadow-lg"
:class="
panelSurface({
radius: 'md',
padding: 'none',
class: 'z-50 flex min-w-56 w-[var(--reka-popper-anchor-width)] flex-col overflow-hidden'
})
"
@open-auto-focus.prevent
>
<ListboxRoot :model-value="modelValue" @update:model-value="onSelect">
@ -89,7 +97,7 @@ function onSelect(val: string) {
<ListboxItem
:value="option"
data-test-id="font-picker-item"
class="flex w-full cursor-pointer items-center gap-2 px-2 py-2 text-sm text-surface outline-none data-[highlighted]:bg-hover"
:class="selectItem({ class: 'w-full gap-2 px-2 py-2 text-sm' })"
:style="{ fontFamily: `'${option}', sans-serif` }"
>
<icon-lucide-check v-if="option === modelValue" class="size-3 shrink-0 text-accent" />

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,31 @@ const snap = computed({
}
})
function getDrawerTab(): DrawerTab {
if (store.state.activeRibbonTab === 'code') return 'code'
if (store.state.activeRibbonTab === 'ai') return 'ai'
return store.state.panelMode === 'design' ? 'design' : 'layers'
}
function setDrawerTab(tab: DrawerTab) {
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 (getDrawerTab() === tab && isOpen.value) {
snap.value = 'closed'
targetHeight.value = snapHeight('closed')
return
}
store.state.activeRibbonTab = tab
if (panelMode) store.state.panelMode = panelMode
setDrawerTab(tab)
if (!isOpen.value) {
snap.value = 'half'
targetHeight.value = snapHeight('half')
@ -74,10 +85,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 +118,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="getDrawerTab()" 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

@ -18,6 +18,7 @@ import IconImageDown from '~icons/lucide/image-down'
import IconSave from '~icons/lucide/save'
import IconZoomIn from '~icons/lucide/zoom-in'
import { menuContent, menuItem } from '@/components/ui/menu'
import { openFileDialog } from '@/composables/use-menu'
import { useEditorStore } from '@/stores/editor'
import { colorToCSS } from '@open-pencil/core'
@ -216,12 +217,18 @@ const onlineCount = computed(() => props.collabPeers.length + 1)
:side-offset="8"
side="bottom"
align="end"
class="z-50 w-48 rounded-xl border border-border bg-panel p-1.5 shadow-xl"
:class="menuContent({ class: 'w-48 rounded-xl p-1.5 shadow-xl' })"
>
<DropdownMenuItem
v-for="item in menuItems"
:key="item.label"
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-none bg-transparent px-2.5 py-2 text-xs text-surface outline-none select-none active:bg-hover data-[highlighted]:bg-hover"
:class="
menuItem({
justify: 'start',
class:
'w-full gap-2.5 rounded-lg border-none bg-transparent px-2.5 py-2 active:bg-hover'
})
"
@click="item.action()"
>
<component :is="item.icon" class="size-4 text-muted" />

View file

@ -12,6 +12,7 @@ import {
import { selectionToJSX, renderNodesToSVG } from '@open-pencil/core'
import { useEditorStore } from '@/stores/editor'
import { menuContent, menuItem, menuSeparator } from '@/components/ui/menu'
import { toast } from '@/composables/use-toast'
const store = useEditorStore()
@ -99,12 +100,12 @@ function copyAsJSX() {
toast.show('Copied as JSX')
}
const itemClass =
'flex w-full cursor-pointer select-none items-center justify-between gap-6 rounded px-2 py-1.5 text-xs text-surface outline-none data-[highlighted]:bg-hover data-[disabled]:cursor-default data-[disabled]:text-muted'
const componentItemClass =
'flex w-full cursor-pointer select-none items-center justify-between gap-6 rounded px-2 py-1.5 text-xs text-[#9747ff] outline-none data-[highlighted]:bg-[#9747ff]/12 data-[disabled]:cursor-default data-[disabled]:text-[#9747ff]/40'
const menuClass =
'z-50 min-w-56 rounded-lg border border-border bg-panel p-1 shadow-[0_8px_30px_rgb(0_0_0/0.4)] animate-in fade-in zoom-in-95'
const itemClass = menuItem()
const componentItemClass = menuItem({ tone: 'component' })
const menuClass = menuContent({
class: 'min-w-56 shadow-[0_8px_30px_rgb(0_0_0/0.4)] animate-in fade-in zoom-in-95'
})
const separatorClass = menuSeparator({ class: 'my-1' })
</script>
<template>
@ -154,7 +155,7 @@ const menuClass =
<span class="text-[11px] text-muted">⌫</span>
</ContextMenuItem>
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuSeparator :class="separatorClass" />
<ContextMenuSub v-if="otherPages.length > 0 && hasSelection">
<ContextMenuSubTrigger data-test-id="context-move-to-page" :class="itemClass">
@ -194,7 +195,7 @@ const menuClass =
<span class="text-[11px] text-muted">[</span>
</ContextMenuItem>
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuSeparator :class="separatorClass" />
<ContextMenuItem
data-test-id="context-group"
@ -224,7 +225,7 @@ const menuClass =
<span class="text-[11px] text-muted">⇧A</span>
</ContextMenuItem>
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuSeparator :class="separatorClass" />
<ContextMenuItem
data-test-id="context-create-component"
@ -271,7 +272,7 @@ const menuClass =
</ContextMenuItem>
<template v-if="hasSelection">
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuSeparator :class="separatorClass" />
<ContextMenuItem
data-test-id="context-toggle-visibility"
@ -290,7 +291,7 @@ const menuClass =
<span class="text-[11px] text-muted">⇧⌘L</span>
</ContextMenuItem>
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuSeparator :class="separatorClass" />
<ContextMenuSub>
<ContextMenuSubTrigger data-test-id="context-copy-paste-as" :class="itemClass">

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

@ -24,6 +24,7 @@ import IconGroup from '~icons/lucide/group'
import IconUngroup from '~icons/lucide/ungroup'
import IconLock from '~icons/lucide/lock'
import { menuContent, menuItem } from '@/components/ui/menu'
import { ACTION_TOAST_DURATION } from '@/constants'
import { TOOLS, useEditorStore } from '@/stores/editor'
import { toolIcons } from '@/utils/tools'
@ -174,17 +175,16 @@ function goNext() {
side="top"
:side-offset="8"
align="start"
class="min-w-32 rounded-lg border border-border bg-panel p-1 shadow-lg"
:class="menuContent({ class: 'min-w-32' })"
>
<DropdownMenuItem
v-for="sub in tool.flyout"
:key="sub"
:data-test-id="`toolbar-flyout-item-${sub.toLowerCase()}`"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none transition-colors"
:class="
store.state.activeTool === sub
? 'bg-accent text-white'
: 'text-surface hover:bg-hover'
menuItem({
class: store.state.activeTool === sub ? 'bg-accent text-white' : undefined
})
"
@select="store.setTool(sub)"
>
@ -291,17 +291,16 @@ function goNext() {
side="top"
:side-offset="8"
align="start"
class="min-w-32 rounded-lg border border-border bg-panel p-1 shadow-lg"
:class="menuContent({ class: 'min-w-32' })"
>
<DropdownMenuItem
v-for="sub in tool.flyout"
:key="sub"
:data-test-id="`mobile-toolbar-flyout-item-${sub.toLowerCase()}`"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none transition-colors"
:class="
store.state.activeTool === sub
? 'bg-accent text-white'
: 'text-surface hover:bg-hover'
menuItem({
class: store.state.activeTool === sub ? 'bg-accent text-white' : undefined
})
"
@select="store.setTool(sub)"
>

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

@ -15,6 +15,8 @@ import {
} from 'reka-ui'
import { computed, ref } from 'vue'
import { uiButton } from '@/components/ui/button'
import { selectContent, selectItem, selectTrigger } from '@/components/ui/select'
import { MODELS, useAIChat } from '@/composables/use-chat'
const { modelId } = useAIChat()
@ -52,7 +54,12 @@ function handleSubmit(e: Event) {
<SelectRoot v-model="modelId">
<SelectTrigger
data-test-id="chat-model-selector"
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-muted hover:bg-hover hover:text-surface"
:class="
selectTrigger({
class:
'gap-1 rounded border-none bg-transparent px-1.5 py-0.5 text-[10px] text-muted'
})
"
>
<icon-lucide-bot class="size-3" />
{{ selectedModelName }}
@ -63,14 +70,16 @@ function handleSubmit(e: Event) {
position="popper"
side="top"
:side-offset="4"
class="z-50 max-h-60 overflow-y-auto rounded-lg border border-border bg-panel p-1 shadow-lg"
:class="
selectContent({ radius: 'lg', padding: 'md', class: 'max-h-60 overflow-y-auto' })
"
>
<SelectViewport>
<SelectItem
v-for="model in MODELS"
:key="model.id"
:value="model.id"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[11px] text-surface outline-none data-[highlighted]:bg-hover"
:class="selectItem({ class: 'gap-2 rounded px-2 py-1.5 text-[11px]' })"
>
<SelectItemText class="flex-1">{{ model.name }}</SelectItemText>
<span
@ -101,7 +110,14 @@ function handleSubmit(e: Event) {
<button
type="button"
data-test-id="chat-stop-button"
class="shrink-0 rounded border border-border px-2 py-1.5 text-xs text-muted hover:bg-hover"
:class="
uiButton({
tone: 'ghost',
shape: 'rounded',
size: 'sm',
class: 'shrink-0 border border-border px-2 py-1.5'
})
"
@click="emit('stop')"
>
<icon-lucide-square class="size-3" />
@ -122,7 +138,14 @@ function handleSubmit(e: Event) {
<button
type="submit"
data-test-id="chat-send-button"
class="shrink-0 rounded bg-accent px-2.5 py-1.5 text-xs font-medium text-white hover:bg-accent/90"
:class="
uiButton({
tone: 'accent',
shape: 'rounded',
size: 'sm',
class: 'shrink-0 px-2.5 py-1.5 font-medium'
})
"
:disabled="!input.trim()"
>
<icon-lucide-send class="size-3" />

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,168 +186,129 @@ 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
data-test-id="layout-direction-horizontal"
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
data-test-id="layout-direction-vertical"
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
:class="
node.layoutMode === 'VERTICAL'
? 'border-accent bg-accent/10 text-accent'
: 'border-border text-muted hover:bg-hover hover:text-surface'
"
title="Vertical layout"
@click="store.setLayoutMode(node.id, 'VERTICAL')"
>
<icon-lucide-arrow-down class="size-3.5" />
</button>
<button
data-test-id="layout-direction-wrap"
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'
? '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')"
>
<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-wrap-text 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 data-test-id="layout-alignment-grid" 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

View file

@ -2,6 +2,7 @@
import { computed, ref } from 'vue'
import AppSelect from '@/components/AppSelect.vue'
import { menuContent, menuItem } from '@/components/ui/menu'
import ColorInput from '@/components/ColorInput.vue'
import ScrubInput from '@/components/ScrubInput.vue'
import { useNodeProps } from '@/composables/use-node-props'
@ -317,12 +318,12 @@ const borderWeights = computed(() => {
<DropdownMenuContent
:side-offset="4"
align="end"
class="z-50 min-w-[140px] rounded-md border border-border bg-panel p-0.5 shadow-lg"
:class="menuContent({ class: 'min-w-[140px] rounded-md p-0.5' })"
>
<DropdownMenuItem
v-for="opt in SIDE_OPTIONS"
:key="opt.value"
class="relative flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-surface outline-none data-[highlighted]:bg-hover"
:class="menuItem({ justify: 'start', class: 'relative px-2' })"
@click="selectSide(opt.value)"
>
<icon-lucide-check

View file

@ -0,0 +1,54 @@
import { twMerge } from 'tailwind-merge'
import { tv } from 'tailwind-variants'
const button = tv({
base: 'inline-flex items-center justify-center transition-colors select-none',
variants: {
tone: {
ghost: 'bg-transparent text-muted hover:bg-hover hover:text-surface',
accent: 'bg-accent text-white hover:bg-accent/90',
panel: 'bg-panel/70 text-surface backdrop-blur-xl',
panelAccent: 'bg-panel/70 text-accent border-accent/20 backdrop-blur-xl'
},
shape: {
square: 'rounded',
rounded: 'rounded-md',
pill: 'rounded-full'
},
size: {
sm: 'h-7 px-2 text-xs',
md: 'h-8 px-3 text-xs',
icon: 'size-8',
iconSm: 'size-7'
},
bordered: {
true: 'border border-white/10',
false: ''
}
},
compoundVariants: [
{ tone: 'accent', size: 'icon', class: 'px-0' },
{ tone: 'ghost', size: 'icon', class: 'px-0' },
{ tone: 'panel', size: 'icon', class: 'shadow-md' },
{ tone: 'panel', size: 'iconSm', class: 'shadow-sm' },
{ tone: 'panelAccent', size: 'icon', class: 'shadow-md' },
{ tone: 'panel', bordered: true, class: 'border border-white/10' },
{ tone: 'panelAccent', bordered: true, class: 'border' }
],
defaultVariants: {
tone: 'ghost',
shape: 'rounded',
size: 'sm',
bordered: false
}
})
export function uiButton(options?: {
tone?: 'ghost' | 'accent' | 'panel' | 'panelAccent'
shape?: 'square' | 'rounded' | 'pill'
size?: 'sm' | 'md' | 'icon' | 'iconSm'
bordered?: boolean
class?: string
}) {
return twMerge(button(options), options?.class)
}

45
src/components/ui/menu.ts Normal file
View file

@ -0,0 +1,45 @@
import { twMerge } from 'tailwind-merge'
import { tv } from 'tailwind-variants'
const content = tv({
base: 'z-50 rounded-lg border border-border bg-panel p-1 shadow-lg'
})
const item = tv({
base: 'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none select-none data-[highlighted]:bg-hover data-[disabled]:cursor-default data-[disabled]:text-muted/50',
variants: {
tone: {
default: 'text-surface',
component:
'text-[#9747ff] data-[highlighted]:bg-[#9747ff]/12 data-[disabled]:text-[#9747ff]/40'
},
justify: {
between: 'justify-between gap-6',
start: 'justify-start'
}
},
defaultVariants: {
tone: 'default',
justify: 'between'
}
})
const separator = tv({
base: 'mx-1 my-1 h-px bg-border'
})
export function menuContent(options?: { class?: string }) {
return twMerge(content(), options?.class)
}
export function menuItem(options?: {
tone?: 'default' | 'component'
justify?: 'between' | 'start'
class?: string
}) {
return twMerge(item(options), options?.class)
}
export function menuSeparator(options?: { class?: string }) {
return twMerge(separator(), options?.class)
}

View file

@ -0,0 +1,51 @@
import { twMerge } from 'tailwind-merge'
import { tv } from 'tailwind-variants'
const trigger = tv({
base: 'flex items-center justify-between border border-border bg-input text-surface outline-none hover:bg-hover'
})
const content = tv({
base: 'z-50 overflow-hidden border border-border bg-panel',
variants: {
radius: {
md: 'rounded-md',
lg: 'rounded-lg'
},
elevation: {
lg: 'shadow-lg',
xl: 'shadow-xl'
},
padding: {
none: '',
sm: 'p-0.5',
md: 'p-1'
}
},
defaultVariants: {
radius: 'md',
elevation: 'lg',
padding: 'sm'
}
})
const item = tv({
base: 'relative flex cursor-pointer items-center text-surface outline-none data-[highlighted]:bg-hover'
})
export function selectTrigger(options?: { class?: string }) {
return twMerge(trigger(), options?.class)
}
export function selectContent(options?: {
radius?: 'md' | 'lg'
elevation?: 'lg' | 'xl'
padding?: 'none' | 'sm' | 'md'
class?: string
}) {
return twMerge(content(options), options?.class)
}
export function selectItem(options?: { class?: string }) {
return twMerge(item(), options?.class)
}

View file

@ -0,0 +1,39 @@
import { twMerge } from 'tailwind-merge'
import { tv } from 'tailwind-variants'
const surface = tv({
base: 'border border-border bg-panel',
variants: {
elevation: {
md: 'shadow-md',
lg: 'shadow-lg',
xl: 'shadow-xl',
overlay: 'shadow-[0_8px_30px_rgb(0_0_0/0.4)]'
},
radius: {
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl'
},
padding: {
none: '',
sm: 'p-1',
md: 'p-2',
lg: 'p-3'
}
},
defaultVariants: {
elevation: 'lg',
radius: 'lg',
padding: 'sm'
}
})
export function panelSurface(options?: {
elevation?: 'md' | 'lg' | 'xl' | 'overlay'
radius?: 'md' | 'lg' | 'xl'
padding?: 'none' | 'sm' | 'md' | 'lg'
class?: string
}) {
return twMerge(surface(options), options?.class)
}

View file

@ -1,4 +1,4 @@
import { useRafFn, useResizeObserver } from '@vueuse/core'
import { useBreakpoints, useRafFn, useResizeObserver } from '@vueuse/core'
import { onMounted, onUnmounted, type Ref } from 'vue'
import { getCanvasKit, getGpuBackend } from '@open-pencil/core'
@ -128,12 +128,12 @@ 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 breakpoints = useBreakpoints({ mobile: 768 })
const isMobile = breakpoints.smaller('mobile')
function showRulers() {
return !noRulersParam && !isMobile.value
}
function renderNow() {
if (!renderer || destroyed) return
@ -143,7 +143,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

@ -1,20 +1,21 @@
import { nextTick, ref } from 'vue'
export function useInlineRename(onCommit: (id: string, newName: string) => void) {
const editingId = ref<string | null>(null)
export function useInlineRename<T extends string>(onCommit: (id: T, newName: string) => void) {
const editingId = ref<T | null>(null)
let originalName = ''
function start(id: string, currentName: string, selector: string) {
function start(id: T, currentName: string) {
editingId.value = id
originalName = currentName
nextTick(() => {
const input = document.querySelector<HTMLInputElement>(selector)
input?.focus()
input?.select()
})
}
function commit(id: string, input: HTMLInputElement) {
async function focusInput(input: HTMLInputElement | null) {
await nextTick()
input?.focus()
input?.select()
}
function commit(id: T, input: HTMLInputElement) {
if (editingId.value !== id) return
const value = input.value.trim()
if (value && value !== originalName) {
@ -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

@ -42,7 +42,7 @@ export async function openFileDialog() {
input.accept = '.fig'
input.addEventListener('change', () => {
const file = input.files?.[0]
if (file) openFileInNewTab(file)
if (file) void openFileInNewTab(file)
})
input.click()
}

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')
})