Merge branch 'reka-vueuse-refactor'
This commit is contained in:
commit
97baef474d
6
bun.lock
6
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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?.()"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
54
src/components/ui/button.ts
Normal file
54
src/components/ui/button.ts
Normal 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
45
src/components/ui/menu.ts
Normal 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)
|
||||
}
|
||||
51
src/components/ui/select.ts
Normal file
51
src/components/ui/select.ts
Normal 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)
|
||||
}
|
||||
39
src/components/ui/surface.ts
Normal file
39
src/components/ui/surface.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue