Refactor UI to use Reka & VueUse primitives
This commit is contained in:
parent
df8e303498
commit
a334a7f63c
|
|
@ -6,6 +6,9 @@
|
|||
* and MCP (JSON Schema) are generated from these definitions.
|
||||
*/
|
||||
|
||||
import { parseColor } from '../color'
|
||||
import { DEFAULT_SHADOW_COLOR } from '../constants'
|
||||
|
||||
import type { FigmaAPI, FigmaNodeProxy } from '../figma-api'
|
||||
|
||||
export type ParamType = 'string' | 'number' | 'boolean' | 'color' | 'string[]'
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
|
||||
import IconChevronRight from '~icons/lucide/chevron-right'
|
||||
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { IS_TAURI } from '@/constants'
|
||||
import { openFileDialog } from '@/composables/use-menu'
|
||||
|
|
@ -25,14 +25,13 @@ import { useEditorStore } from '@/stores/editor'
|
|||
const store = useEditorStore()
|
||||
|
||||
const editingName = ref(false)
|
||||
const nameInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function startRename() {
|
||||
async function startRename() {
|
||||
editingName.value = true
|
||||
nextTick(() => {
|
||||
const input = document.querySelector<HTMLInputElement>('[data-doc-name-edit]')
|
||||
input?.focus()
|
||||
input?.select()
|
||||
})
|
||||
await nextTick()
|
||||
nameInputRef.value?.focus()
|
||||
nameInputRef.value?.select()
|
||||
}
|
||||
|
||||
function commitRename(input: HTMLInputElement) {
|
||||
|
|
@ -177,7 +176,7 @@ const topMenus = [
|
|||
<img data-test-id="app-logo" src="/favicon-32.png" class="size-4" alt="OpenPencil" />
|
||||
<input
|
||||
v-if="editingName"
|
||||
data-doc-name-edit
|
||||
ref="nameInputRef"
|
||||
data-test-id="app-document-name-input"
|
||||
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0.5 text-xs text-surface outline-none"
|
||||
:value="store.state.documentName"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
import { motion } from 'motion-v'
|
||||
import type { PanInfo } from 'motion-v'
|
||||
import { computed, ref } from 'vue'
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
import { useEditorStore } from '@/stores/editor'
|
||||
|
||||
type Snap = 'closed' | 'half' | 'full'
|
||||
type DrawerTab = 'layers' | 'design' | 'code' | 'ai'
|
||||
|
||||
const store = useEditorStore()
|
||||
|
||||
|
|
@ -35,22 +37,32 @@ const snap = computed({
|
|||
}
|
||||
})
|
||||
|
||||
const activeDrawerTab = computed<DrawerTab>({
|
||||
get: () => {
|
||||
if (store.state.activeRibbonTab === 'code') return 'code'
|
||||
if (store.state.activeRibbonTab === 'ai') return 'ai'
|
||||
return store.state.panelMode === 'design' ? 'design' : 'layers'
|
||||
},
|
||||
set: (tab) => {
|
||||
if (tab === 'code' || tab === 'ai') {
|
||||
store.state.activeRibbonTab = tab
|
||||
return
|
||||
}
|
||||
store.state.activeRibbonTab = 'panels'
|
||||
store.state.panelMode = tab
|
||||
}
|
||||
})
|
||||
|
||||
const isOpen = computed(() => snap.value !== 'closed')
|
||||
|
||||
const isPanelActive = computed(() => isOpen.value && store.state.activeRibbonTab === 'panels')
|
||||
|
||||
function selectTab(tab: 'panels' | 'code' | 'ai', panelMode?: 'layers' | 'design') {
|
||||
const isSameTab =
|
||||
store.state.activeRibbonTab === tab && (!panelMode || store.state.panelMode === panelMode)
|
||||
|
||||
if (isSameTab && isOpen.value) {
|
||||
function toggleTab(tab: DrawerTab) {
|
||||
if (activeDrawerTab.value === tab && isOpen.value) {
|
||||
snap.value = 'closed'
|
||||
targetHeight.value = snapHeight('closed')
|
||||
return
|
||||
}
|
||||
|
||||
store.state.activeRibbonTab = tab
|
||||
if (panelMode) store.state.panelMode = panelMode
|
||||
activeDrawerTab.value = tab
|
||||
if (!isOpen.value) {
|
||||
snap.value = 'half'
|
||||
targetHeight.value = snapHeight('half')
|
||||
|
|
@ -74,10 +86,6 @@ function onPan(_e: PointerEvent, info: PanInfo) {
|
|||
const maxHeight = snapHeight('full')
|
||||
const raw = snapHeight(snap.value) - info.offset.y
|
||||
targetHeight.value = Math.max(headerH.value, Math.min(maxHeight, raw))
|
||||
|
||||
if (info.offset.y < 0 && !store.state.activeRibbonTab) {
|
||||
store.state.activeRibbonTab = 'panels'
|
||||
}
|
||||
}
|
||||
|
||||
function onPanEnd(_e: PointerEvent, info: PanInfo) {
|
||||
|
|
@ -111,109 +119,82 @@ const drawerTransition = {
|
|||
@pan="onPan"
|
||||
@panEnd="onPanEnd"
|
||||
>
|
||||
<nav
|
||||
ref="headerRef"
|
||||
aria-label="Mobile panel navigation"
|
||||
class="flex shrink-0 flex-col"
|
||||
role="tablist"
|
||||
>
|
||||
<div class="flex w-full justify-center pt-2">
|
||||
<div class="h-1 w-8 rounded-full bg-muted/40" />
|
||||
</div>
|
||||
<div class="flex w-full items-center px-2 py-2">
|
||||
<div
|
||||
role="tab"
|
||||
data-test-id="mobile-ribbon-layers"
|
||||
:aria-selected="isPanelActive && store.state.panelMode === 'layers'"
|
||||
tabindex="0"
|
||||
class="flex h-full cursor-pointer items-center justify-center gap-1.5 px-4 text-xs outline-none transition-colors select-none"
|
||||
:class="
|
||||
isPanelActive && store.state.panelMode === 'layers' ? 'text-accent' : 'text-muted'
|
||||
"
|
||||
@click="selectTab('panels', 'layers')"
|
||||
>
|
||||
<icon-lucide-layers class="size-4" />
|
||||
<TabsRoot :model-value="activeDrawerTab" class="flex min-h-0 flex-1 flex-col">
|
||||
<nav ref="headerRef" aria-label="Mobile panel navigation" class="flex shrink-0 flex-col">
|
||||
<div class="flex w-full justify-center pt-2">
|
||||
<div class="h-1 w-8 rounded-full bg-muted/40" />
|
||||
</div>
|
||||
<TabsList class="flex w-full items-center px-2 py-2">
|
||||
<TabsTrigger
|
||||
data-test-id="mobile-ribbon-layers"
|
||||
value="layers"
|
||||
class="flex h-full cursor-pointer items-center justify-center gap-1.5 px-4 text-xs outline-none transition-colors select-none data-[state=active]:text-accent"
|
||||
@click="toggleTab('layers')"
|
||||
>
|
||||
<icon-lucide-layers class="size-4" />
|
||||
</TabsTrigger>
|
||||
|
||||
<div
|
||||
role="tab"
|
||||
data-test-id="mobile-ribbon-design"
|
||||
:aria-selected="isPanelActive && store.state.panelMode === 'design'"
|
||||
tabindex="0"
|
||||
class="flex h-full cursor-pointer items-center justify-center gap-1.5 px-4 text-xs outline-none transition-colors select-none"
|
||||
:class="
|
||||
isPanelActive && store.state.panelMode === 'design' ? 'text-accent' : 'text-muted'
|
||||
"
|
||||
@click="selectTab('panels', 'design')"
|
||||
>
|
||||
<icon-lucide-sliders-horizontal class="size-4" />
|
||||
</div>
|
||||
<TabsTrigger
|
||||
data-test-id="mobile-ribbon-design"
|
||||
value="design"
|
||||
class="flex h-full cursor-pointer items-center justify-center gap-1.5 px-4 text-xs outline-none transition-colors select-none data-[state=active]:text-accent"
|
||||
@click="toggleTab('design')"
|
||||
>
|
||||
<icon-lucide-sliders-horizontal class="size-4" />
|
||||
</TabsTrigger>
|
||||
|
||||
<div class="flex-1" />
|
||||
<div class="flex-1" />
|
||||
|
||||
<div
|
||||
role="tab"
|
||||
data-test-id="mobile-ribbon-code"
|
||||
:aria-selected="isOpen && store.state.activeRibbonTab === 'code'"
|
||||
tabindex="0"
|
||||
class="flex h-full cursor-pointer items-center justify-center px-3 outline-none transition-colors select-none"
|
||||
:class="isOpen && store.state.activeRibbonTab === 'code' ? 'text-accent' : 'text-muted'"
|
||||
@click="selectTab('code')"
|
||||
>
|
||||
<icon-lucide-code class="size-4" />
|
||||
</div>
|
||||
<TabsTrigger
|
||||
data-test-id="mobile-ribbon-code"
|
||||
value="code"
|
||||
class="flex h-full cursor-pointer items-center justify-center px-3 outline-none transition-colors select-none data-[state=active]:text-accent"
|
||||
@click="toggleTab('code')"
|
||||
>
|
||||
<icon-lucide-code class="size-4" />
|
||||
</TabsTrigger>
|
||||
|
||||
<div
|
||||
role="tab"
|
||||
data-test-id="mobile-ribbon-ai"
|
||||
:aria-selected="isOpen && store.state.activeRibbonTab === 'ai'"
|
||||
tabindex="0"
|
||||
class="flex h-full cursor-pointer items-center justify-center px-3 outline-none transition-colors select-none"
|
||||
:class="isOpen && store.state.activeRibbonTab === 'ai' ? 'text-accent' : 'text-muted'"
|
||||
@click="selectTab('ai')"
|
||||
>
|
||||
<icon-lucide-sparkles class="size-4" />
|
||||
</div>
|
||||
<TabsTrigger
|
||||
data-test-id="mobile-ribbon-ai"
|
||||
value="ai"
|
||||
class="flex h-full cursor-pointer items-center justify-center px-3 outline-none transition-colors select-none data-[state=active]:text-accent"
|
||||
@click="toggleTab('ai')"
|
||||
>
|
||||
<icon-lucide-sparkles class="size-4" />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</nav>
|
||||
|
||||
<div data-test-id="mobile-drawer-content" class="min-h-0 flex-1 overflow-y-auto">
|
||||
<TabsContent value="layers" class="mt-0 h-full data-[state=inactive]:hidden">
|
||||
<div data-test-id="mobile-drawer-layers" class="flex h-full flex-col">
|
||||
<PagesPanel />
|
||||
<div class="border-t border-border" />
|
||||
<header class="shrink-0 px-3 py-2 text-[11px] uppercase tracking-wider text-muted">
|
||||
Layers
|
||||
</header>
|
||||
<LayerTree class="min-h-0 flex-1" />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="design" class="mt-0 h-full data-[state=inactive]:hidden">
|
||||
<div data-test-id="mobile-drawer-design" class="flex h-full flex-col">
|
||||
<DesignPanel />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="code" class="mt-0 h-full data-[state=inactive]:hidden">
|
||||
<div data-test-id="mobile-drawer-code" class="flex h-full flex-col">
|
||||
<CodePanel />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ai" class="mt-0 h-full data-[state=inactive]:hidden">
|
||||
<div data-test-id="mobile-drawer-ai" class="flex h-full flex-col">
|
||||
<ChatPanel />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div data-test-id="mobile-drawer-content" class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div
|
||||
v-show="store.state.activeRibbonTab === 'panels' && store.state.panelMode === 'layers'"
|
||||
data-test-id="mobile-drawer-layers"
|
||||
class="flex h-full flex-col"
|
||||
>
|
||||
<PagesPanel />
|
||||
<div class="border-t border-border" />
|
||||
<header class="shrink-0 px-3 py-2 text-[11px] uppercase tracking-wider text-muted">
|
||||
Layers
|
||||
</header>
|
||||
<LayerTree class="min-h-0 flex-1" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="store.state.activeRibbonTab === 'panels' && store.state.panelMode === 'design'"
|
||||
data-test-id="mobile-drawer-design"
|
||||
class="flex h-full flex-col"
|
||||
>
|
||||
<DesignPanel />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="store.state.activeRibbonTab === 'code'"
|
||||
data-test-id="mobile-drawer-code"
|
||||
class="flex h-full flex-col"
|
||||
>
|
||||
<CodePanel />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="store.state.activeRibbonTab === 'ai'"
|
||||
data-test-id="mobile-drawer-ai"
|
||||
class="flex h-full flex-col"
|
||||
>
|
||||
<ChatPanel />
|
||||
</div>
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</motion.div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
import AppSelect from '@/components/AppSelect.vue'
|
||||
import ScrubInput from '@/components/ScrubInput.vue'
|
||||
import { useNodeProps } from '@/composables/use-node-props'
|
||||
|
||||
|
|
@ -10,17 +10,6 @@ import type { SceneNode, LayoutSizing, LayoutAlign, LayoutCounterAlign } from '@
|
|||
const { store, node, updateProp, commitProp } = useNodeProps()
|
||||
|
||||
const showIndividualPadding = ref(false)
|
||||
const widthSizingOpen = ref(false)
|
||||
const heightSizingOpen = ref(false)
|
||||
const widthDimRef = ref<HTMLElement | null>(null)
|
||||
const heightDimRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onClickOutside(widthDimRef, () => {
|
||||
widthSizingOpen.value = false
|
||||
})
|
||||
onClickOutside(heightDimRef, () => {
|
||||
heightSizingOpen.value = false
|
||||
})
|
||||
|
||||
const isInAutoLayout = computed(() => {
|
||||
if (!node.value.parentId) return false
|
||||
|
|
@ -28,7 +17,7 @@ const isInAutoLayout = computed(() => {
|
|||
return parent ? parent.layoutMode !== 'NONE' : false
|
||||
})
|
||||
|
||||
const widthSizing = computed(() => {
|
||||
const widthSizing = computed<LayoutSizing>(() => {
|
||||
if (node.value.layoutMode !== 'NONE') {
|
||||
return node.value.layoutMode === 'HORIZONTAL'
|
||||
? node.value.primaryAxisSizing
|
||||
|
|
@ -38,7 +27,7 @@ const widthSizing = computed(() => {
|
|||
return 'FIXED'
|
||||
})
|
||||
|
||||
const heightSizing = computed(() => {
|
||||
const heightSizing = computed<LayoutSizing>(() => {
|
||||
if (node.value.layoutMode !== 'NONE') {
|
||||
return node.value.layoutMode === 'VERTICAL'
|
||||
? node.value.primaryAxisSizing
|
||||
|
|
@ -55,7 +44,6 @@ function setWidthSizing(sizing: LayoutSizing) {
|
|||
} else if (isInAutoLayout.value) {
|
||||
updateProp('layoutGrow', sizing === 'FILL' ? 1 : 0)
|
||||
}
|
||||
widthSizingOpen.value = false
|
||||
}
|
||||
|
||||
function setHeightSizing(sizing: LayoutSizing) {
|
||||
|
|
@ -65,13 +53,6 @@ function setHeightSizing(sizing: LayoutSizing) {
|
|||
} else if (isInAutoLayout.value) {
|
||||
updateProp('layoutAlignSelf', sizing === 'FILL' ? 'STRETCH' : 'AUTO')
|
||||
}
|
||||
heightSizingOpen.value = false
|
||||
}
|
||||
|
||||
function sizingLabel(s: string) {
|
||||
if (s === 'HUG') return 'Hug'
|
||||
if (s === 'FILL') return 'Fill'
|
||||
return 'Fixed'
|
||||
}
|
||||
|
||||
function hasUniformPadding() {
|
||||
|
|
@ -104,6 +85,24 @@ function commitUniformPadding(_value: number, previous: number) {
|
|||
)
|
||||
}
|
||||
|
||||
const widthSizingOptions = computed(() => {
|
||||
const options: { value: LayoutSizing; label: string }[] = [
|
||||
{ value: 'FIXED', label: `Fixed width (${Math.round(node.value.width)})` }
|
||||
]
|
||||
if (node.value.layoutMode !== 'NONE') options.push({ value: 'HUG', label: 'Hug contents' })
|
||||
if (isInAutoLayout.value) options.push({ value: 'FILL', label: 'Fill container' })
|
||||
return options
|
||||
})
|
||||
|
||||
const heightSizingOptions = computed(() => {
|
||||
const options: { value: LayoutSizing; label: string }[] = [
|
||||
{ value: 'FIXED', label: `Fixed height (${Math.round(node.value.height)})` }
|
||||
]
|
||||
if (node.value.layoutMode !== 'NONE') options.push({ value: 'HUG', label: 'Hug contents' })
|
||||
if (isInAutoLayout.value) options.push({ value: 'FILL', label: 'Fill container' })
|
||||
return options
|
||||
})
|
||||
|
||||
const ALIGN_GRID: Array<{ primary: LayoutAlign; counter: LayoutCounterAlign }> = [
|
||||
{ primary: 'MIN', counter: 'MIN' },
|
||||
{ primary: 'CENTER', counter: 'MIN' },
|
||||
|
|
@ -129,8 +128,7 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
|
|||
<div v-if="node" data-test-id="layout-section" class="border-b border-border px-3 py-2">
|
||||
<label class="mb-1.5 block text-[11px] text-muted">Layout</label>
|
||||
<div class="flex gap-1.5">
|
||||
<!-- Width -->
|
||||
<div ref="widthDimRef" class="relative flex min-w-0 flex-1 items-center gap-1">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<ScrubInput
|
||||
icon="W"
|
||||
:model-value="Math.round(node.width)"
|
||||
|
|
@ -138,47 +136,15 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
|
|||
@update:model-value="updateProp('width', $event)"
|
||||
@commit="(v: number, p: number) => commitProp('width', v, p)"
|
||||
/>
|
||||
<button
|
||||
<AppSelect
|
||||
v-if="node.layoutMode !== 'NONE' || isInAutoLayout"
|
||||
class="cursor-pointer whitespace-nowrap rounded border-none bg-transparent px-1 py-px text-[10px] text-muted hover:bg-hover hover:text-surface"
|
||||
@click="widthSizingOpen = !widthSizingOpen"
|
||||
>
|
||||
{{ sizingLabel(widthSizing) }}
|
||||
</button>
|
||||
<div
|
||||
v-if="widthSizingOpen"
|
||||
data-test-id="layout-width-sizing-dropdown"
|
||||
class="absolute top-full left-0 right-0 z-10 min-w-40 rounded-md border border-border bg-panel p-1 shadow-lg"
|
||||
>
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover"
|
||||
:class="widthSizing === 'FIXED' ? 'text-accent' : 'text-surface'"
|
||||
@click="setWidthSizing('FIXED')"
|
||||
>
|
||||
<span class="w-4 text-center text-[11px] opacity-70">↔</span>Fixed width ({{
|
||||
Math.round(node.width)
|
||||
}})
|
||||
</button>
|
||||
<button
|
||||
v-if="node.layoutMode !== 'NONE'"
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover"
|
||||
:class="widthSizing === 'HUG' ? 'text-accent' : 'text-surface'"
|
||||
@click="setWidthSizing('HUG')"
|
||||
>
|
||||
<span class="w-4 text-center text-[11px] opacity-70">↤↦</span>Hug contents
|
||||
</button>
|
||||
<button
|
||||
v-if="isInAutoLayout"
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover"
|
||||
:class="widthSizing === 'FILL' ? 'text-accent' : 'text-surface'"
|
||||
@click="setWidthSizing('FILL')"
|
||||
>
|
||||
<span class="w-4 text-center text-[11px] opacity-70">⟷</span>Fill container
|
||||
</button>
|
||||
</div>
|
||||
:model-value="widthSizing"
|
||||
:options="widthSizingOptions"
|
||||
@update:model-value="setWidthSizing"
|
||||
/>
|
||||
</div>
|
||||
<!-- Height -->
|
||||
<div ref="heightDimRef" class="relative flex min-w-0 flex-1 items-center gap-1">
|
||||
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<ScrubInput
|
||||
icon="H"
|
||||
:model-value="Math.round(node.height)"
|
||||
|
|
@ -186,49 +152,16 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
|
|||
@update:model-value="updateProp('height', $event)"
|
||||
@commit="(v: number, p: number) => commitProp('height', v, p)"
|
||||
/>
|
||||
<button
|
||||
<AppSelect
|
||||
v-if="node.layoutMode !== 'NONE' || isInAutoLayout"
|
||||
class="cursor-pointer whitespace-nowrap rounded border-none bg-transparent px-1 py-px text-[10px] text-muted hover:bg-hover hover:text-surface"
|
||||
@click="heightSizingOpen = !heightSizingOpen"
|
||||
>
|
||||
{{ sizingLabel(heightSizing) }}
|
||||
</button>
|
||||
<div
|
||||
v-if="heightSizingOpen"
|
||||
data-test-id="layout-height-sizing-dropdown"
|
||||
class="absolute top-full left-0 right-0 z-10 min-w-40 rounded-md border border-border bg-panel p-1 shadow-lg"
|
||||
>
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover"
|
||||
:class="heightSizing === 'FIXED' ? 'text-accent' : 'text-surface'"
|
||||
@click="setHeightSizing('FIXED')"
|
||||
>
|
||||
<span class="w-4 text-center text-[11px] opacity-70">↕</span>Fixed height ({{
|
||||
Math.round(node.height)
|
||||
}})
|
||||
</button>
|
||||
<button
|
||||
v-if="node.layoutMode !== 'NONE'"
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover"
|
||||
:class="heightSizing === 'HUG' ? 'text-accent' : 'text-surface'"
|
||||
@click="setHeightSizing('HUG')"
|
||||
>
|
||||
<span class="w-4 text-center text-[11px] opacity-70">↤↦</span>Hug contents
|
||||
</button>
|
||||
<button
|
||||
v-if="isInAutoLayout"
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-xs hover:bg-hover"
|
||||
:class="heightSizing === 'FILL' ? 'text-accent' : 'text-surface'"
|
||||
@click="setHeightSizing('FILL')"
|
||||
>
|
||||
<span class="w-4 text-center text-[11px] opacity-70">⟷</span>Fill container
|
||||
</button>
|
||||
</div>
|
||||
:model-value="heightSizing"
|
||||
:options="heightSizingOptions"
|
||||
@update:model-value="setHeightSizing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto Layout -->
|
||||
<div v-if="node.type === 'FRAME'" class="border-b border-border px-3 py-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="mb-1.5 block text-[11px] text-muted">Auto layout</label>
|
||||
|
|
@ -253,183 +186,106 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
|
|||
</div>
|
||||
|
||||
<template v-if="node.layoutMode !== 'NONE'">
|
||||
<!-- Direction -->
|
||||
<div class="mt-1.5 flex gap-0.5">
|
||||
<button
|
||||
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
|
||||
:class="
|
||||
node.layoutMode === 'VERTICAL'
|
||||
? 'border-accent bg-accent text-white'
|
||||
: 'border-border bg-input text-muted hover:bg-hover hover:text-surface'
|
||||
"
|
||||
data-test-id="layout-direction-vertical"
|
||||
title="Vertical layout"
|
||||
@click="store.setLayoutMode(node.id, 'VERTICAL')"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16">
|
||||
<rect x="3" y="2" width="10" height="3" rx="0.5" fill="currentColor" />
|
||||
<rect x="3" y="6.5" width="10" height="3" rx="0.5" fill="currentColor" />
|
||||
<rect x="3" y="11" width="10" height="3" rx="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
|
||||
:class="
|
||||
node.layoutMode === 'HORIZONTAL'
|
||||
? 'border-accent bg-accent text-white'
|
||||
: 'border-border bg-input text-muted hover:bg-hover hover:text-surface'
|
||||
? 'border-accent bg-accent/10 text-accent'
|
||||
: 'border-border text-muted hover:bg-hover hover:text-surface'
|
||||
"
|
||||
data-test-id="layout-direction-horizontal"
|
||||
title="Horizontal layout"
|
||||
@click="store.setLayoutMode(node.id, 'HORIZONTAL')"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16">
|
||||
<rect x="2" y="3" width="3" height="10" rx="0.5" fill="currentColor" />
|
||||
<rect x="6.5" y="3" width="3" height="10" rx="0.5" fill="currentColor" />
|
||||
<rect x="11" y="3" width="3" height="10" rx="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
<icon-lucide-arrow-right class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
|
||||
:class="
|
||||
node.layoutWrap === 'WRAP'
|
||||
? 'border-accent bg-accent text-white'
|
||||
: 'border-border bg-input text-muted hover:bg-hover hover:text-surface'
|
||||
node.layoutMode === 'VERTICAL'
|
||||
? 'border-accent bg-accent/10 text-accent'
|
||||
: 'border-border text-muted hover:bg-hover hover:text-surface'
|
||||
"
|
||||
data-test-id="layout-direction-wrap"
|
||||
title="Wrap"
|
||||
@click="updateProp('layoutWrap', node.layoutWrap === 'WRAP' ? 'NO_WRAP' : 'WRAP')"
|
||||
@click="store.setLayoutMode(node.id, 'VERTICAL')"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16">
|
||||
<rect x="2" y="2" width="5" height="5" rx="0.5" fill="currentColor" />
|
||||
<rect x="9" y="2" width="5" height="5" rx="0.5" fill="currentColor" />
|
||||
<rect x="2" y="9" width="5" height="5" rx="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
<icon-lucide-arrow-down class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Alignment grid + Gap -->
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<div
|
||||
data-test-id="layout-alignment-grid"
|
||||
class="grid grid-cols-3 gap-0.5 rounded border border-border bg-input p-1"
|
||||
>
|
||||
<button
|
||||
v-for="(a, i) in ALIGN_GRID"
|
||||
:key="i"
|
||||
class="flex size-3.5 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent p-0 hover:bg-hover"
|
||||
@click="setAlignment(a.primary, a.counter)"
|
||||
>
|
||||
<span
|
||||
class="rounded-full"
|
||||
:class="
|
||||
node.primaryAxisAlign === a.primary && node.counterAxisAlign === a.counter
|
||||
? 'size-1.5 bg-accent'
|
||||
: 'size-1 bg-muted opacity-40'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 grid grid-cols-2 gap-1.5">
|
||||
<ScrubInput
|
||||
:model-value="node.itemSpacing"
|
||||
icon="Gap"
|
||||
:model-value="Math.round(node.itemSpacing)"
|
||||
:min="0"
|
||||
@update:model-value="updateProp('itemSpacing', $event)"
|
||||
@commit="(v: number, p: number) => commitProp('itemSpacing', v, p)"
|
||||
>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14">
|
||||
<rect x="0" y="1" width="4" height="12" rx="0.5" fill="currentColor" opacity="0.4" />
|
||||
<rect x="5" y="5" width="4" height="4" rx="0.5" fill="currentColor" />
|
||||
<rect x="10" y="1" width="4" height="12" rx="0.5" fill="currentColor" opacity="0.4" />
|
||||
</svg>
|
||||
</template>
|
||||
</ScrubInput>
|
||||
</div>
|
||||
/>
|
||||
|
||||
<!-- Padding -->
|
||||
<div class="mt-1.5 flex items-start gap-1">
|
||||
<template v-if="showIndividualPadding || !hasUniformPadding()">
|
||||
<div class="grid flex-1 grid-cols-2 gap-0.5">
|
||||
<div
|
||||
v-for="side in [
|
||||
'paddingTop',
|
||||
'paddingRight',
|
||||
'paddingBottom',
|
||||
'paddingLeft'
|
||||
] as const"
|
||||
:key="side"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
class="w-full rounded border border-border bg-input px-1 py-0.5 text-center text-[11px] text-surface"
|
||||
:value="node[side]"
|
||||
min="0"
|
||||
@change="updateProp(side, +($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="hasUniformPadding() && !showIndividualPadding">
|
||||
<ScrubInput
|
||||
:model-value="node.paddingTop"
|
||||
icon="Pad"
|
||||
:model-value="Math.round(node.paddingTop)"
|
||||
:min="0"
|
||||
@update:model-value="setUniformPadding($event)"
|
||||
@update:model-value="setUniformPadding"
|
||||
@commit="commitUniformPadding"
|
||||
>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14">
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="14"
|
||||
height="14"
|
||||
rx="2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<rect x="3" y="3" width="8" height="8" rx="1" fill="currentColor" opacity="0.3" />
|
||||
</svg>
|
||||
</template>
|
||||
</ScrubInput>
|
||||
/>
|
||||
</template>
|
||||
<button
|
||||
class="flex shrink-0 cursor-pointer items-center justify-center rounded border p-1"
|
||||
:class="
|
||||
showIndividualPadding || !hasUniformPadding()
|
||||
? 'border-accent bg-accent text-white'
|
||||
: 'border-border text-muted hover:bg-hover hover:text-surface'
|
||||
"
|
||||
title="Individual padding"
|
||||
class="rounded border border-border bg-transparent px-2 py-1 text-left text-xs text-muted hover:bg-hover hover:text-surface"
|
||||
@click="showIndividualPadding = !showIndividualPadding"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14">
|
||||
<rect x="0" y="0" width="14" height="4" rx="1" fill="currentColor" opacity="0.6" />
|
||||
<rect x="10" y="0" width="4" height="14" rx="1" fill="currentColor" opacity="0.6" />
|
||||
<rect x="0" y="10" width="14" height="4" rx="1" fill="currentColor" opacity="0.6" />
|
||||
<rect x="0" y="0" width="4" height="14" rx="1" fill="currentColor" opacity="0.6" />
|
||||
</svg>
|
||||
{{ showIndividualPadding ? 'Uniform padding' : 'Per-side padding' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showIndividualPadding || !hasUniformPadding()" class="mt-1.5 grid grid-cols-2 gap-1.5">
|
||||
<ScrubInput
|
||||
icon="Top"
|
||||
:model-value="Math.round(node.paddingTop)"
|
||||
:min="0"
|
||||
@update:model-value="updateProp('paddingTop', $event)"
|
||||
@commit="(v: number, p: number) => commitProp('paddingTop', v, p)"
|
||||
/>
|
||||
<ScrubInput
|
||||
icon="Right"
|
||||
:model-value="Math.round(node.paddingRight)"
|
||||
:min="0"
|
||||
@update:model-value="updateProp('paddingRight', $event)"
|
||||
@commit="(v: number, p: number) => commitProp('paddingRight', v, p)"
|
||||
/>
|
||||
<ScrubInput
|
||||
icon="Bottom"
|
||||
:model-value="Math.round(node.paddingBottom)"
|
||||
:min="0"
|
||||
@update:model-value="updateProp('paddingBottom', $event)"
|
||||
@commit="(v: number, p: number) => commitProp('paddingBottom', v, p)"
|
||||
/>
|
||||
<ScrubInput
|
||||
icon="Left"
|
||||
:model-value="Math.round(node.paddingLeft)"
|
||||
:min="0"
|
||||
@update:model-value="updateProp('paddingLeft', $event)"
|
||||
@commit="(v: number, p: number) => commitProp('paddingLeft', v, p)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<label class="mb-1 block text-[11px] text-muted">Alignment</label>
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
<button
|
||||
v-for="cell in ALIGN_GRID"
|
||||
:key="`${cell.primary}-${cell.counter}`"
|
||||
class="flex aspect-square cursor-pointer items-center justify-center rounded border text-[11px]"
|
||||
:class="
|
||||
node.primaryAxisAlign === cell.primary && node.counterAxisAlign === cell.counter
|
||||
? 'border-accent bg-accent/10 text-accent'
|
||||
: 'border-border text-muted hover:bg-hover hover:text-surface'
|
||||
"
|
||||
@click="setAlignment(cell.primary, cell.counter)"
|
||||
>
|
||||
<span class="size-1.5 rounded-full bg-current" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Clip content (for all frames) -->
|
||||
<div v-if="node.type === 'FRAME'" class="border-b border-border px-3 py-2">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-xs text-surface">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-accent"
|
||||
:checked="node.clipsContent"
|
||||
@change="
|
||||
store.updateNodeWithUndo(
|
||||
node.id,
|
||||
{ clipsContent: !node.clipsContent },
|
||||
'Toggle clip content'
|
||||
)
|
||||
"
|
||||
/>
|
||||
Clip content
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useRafFn, useResizeObserver } from '@vueuse/core'
|
||||
import { useMediaQuery, useRafFn, useResizeObserver } from '@vueuse/core'
|
||||
import { onMounted, onUnmounted, type Ref } from 'vue'
|
||||
|
||||
import { getCanvasKit, getGpuBackend } from '@/engine/canvaskit'
|
||||
|
|
@ -128,12 +128,11 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, store: Edito
|
|||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const noRulersParam = params.has('no-rulers')
|
||||
const mobileQuery = matchMedia('(max-width: 767px)')
|
||||
let showRulers = !noRulersParam && !mobileQuery.matches
|
||||
mobileQuery.addEventListener('change', (e) => {
|
||||
showRulers = !noRulersParam && !e.matches
|
||||
dirty = true
|
||||
})
|
||||
const isMobile = useMediaQuery('(max-width: 767px)')
|
||||
|
||||
function showRulers() {
|
||||
return !noRulersParam && !isMobile.value
|
||||
}
|
||||
|
||||
function renderNow() {
|
||||
if (!renderer || destroyed) return
|
||||
|
|
@ -143,7 +142,7 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, store: Edito
|
|||
renderer.zoom = store.state.zoom
|
||||
renderer.viewportWidth = canvasRef.value?.clientWidth ?? 0
|
||||
renderer.viewportHeight = canvasRef.value?.clientHeight ?? 0
|
||||
renderer.showRulers = showRulers
|
||||
renderer.showRulers = showRulers()
|
||||
renderer.pageColor = store.state.pageColor
|
||||
renderer.pageId = store.state.currentPageId
|
||||
renderer.render(
|
||||
|
|
|
|||
|
|
@ -4,14 +4,15 @@ export function useInlineRename(onCommit: (id: string, newName: string) => void)
|
|||
const editingId = ref<string | null>(null)
|
||||
let originalName = ''
|
||||
|
||||
function start(id: string, currentName: string, selector: string) {
|
||||
function start(id: string, currentName: string) {
|
||||
editingId.value = id
|
||||
originalName = currentName
|
||||
nextTick(() => {
|
||||
const input = document.querySelector<HTMLInputElement>(selector)
|
||||
input?.focus()
|
||||
input?.select()
|
||||
})
|
||||
}
|
||||
|
||||
async function focusInput(input: HTMLInputElement | null) {
|
||||
await nextTick()
|
||||
input?.focus()
|
||||
input?.select()
|
||||
}
|
||||
|
||||
function commit(id: string, input: HTMLInputElement) {
|
||||
|
|
@ -23,11 +24,20 @@ export function useInlineRename(onCommit: (id: string, newName: string) => void)
|
|||
editingId.value = null
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === 'Escape') {
|
||||
if (e.key === 'Enter') {
|
||||
;(e.target as HTMLInputElement).blur()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
return { editingId, start, commit, onKeydown }
|
||||
return { editingId, start, focusInput, commit, cancel, onKeydown }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { onUnmounted } from 'vue'
|
||||
import { useFileDialog } from '@vueuse/core'
|
||||
|
||||
import { IS_TAURI } from '@/constants'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
|
|
@ -37,14 +38,12 @@ export async function openFileDialog() {
|
|||
}
|
||||
}
|
||||
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.fig'
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files?.[0]
|
||||
if (file) openFileInNewTab(file)
|
||||
const { open, onChange } = useFileDialog({ accept: '.fig', multiple: false })
|
||||
onChange((files) => {
|
||||
const file = files?.[0]
|
||||
if (file) void openFileInNewTab(file)
|
||||
})
|
||||
input.click()
|
||||
open()
|
||||
}
|
||||
|
||||
const store = useEditorStore()
|
||||
|
|
|
|||
|
|
@ -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