openpencil/src/components/AssetsPanel.vue
2026-05-19 15:10:32 +03:00

412 lines
14 KiB
Vue

<script setup lang="ts">
import { computed, onScopeDispose, ref, watch } from 'vue'
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle
} from 'reka-ui'
import type { SceneNode } from '@open-pencil/core/scene-graph'
import { useI18n } from '@open-pencil/vue'
import { nodeIcon } from '@/app/editor/icons'
import { useEditorStore } from '@/app/editor/active-store'
import { openExternalLink } from '@/app/shell/ui'
import AppInput from '@/components/ui/AppInput.vue'
import { useButtonUI } from '@/components/ui/button'
import { useDialogUI } from '@/components/ui/dialog'
import Tip from '@/components/ui/Tip.vue'
type LocalAsset = {
id: string
name: string
node: SceneNode
componentId: string | null
variants: Array<{ name: string; values: string[] }>
variantCount: number
hasConflicts: boolean
sourceLibraryKey: string | null
description: string
docsUrl: string | null
}
const editor = useEditorStore()
const { panels, commands } = useI18n()
const query = ref('')
const detailsOpen = ref(false)
const selectedAssetId = ref<string | null>(null)
const previewUrl = ref<string | null>(null)
const previewLoading = ref(false)
let previewRequestId = 0
const insertButton = useButtonUI({ tone: 'ghost', size: 'iconSm' })
const primaryButton = useButtonUI({ tone: 'accent', size: 'md' })
const dialog = useDialogUI({ content: 'flex w-[720px] max-w-[92vw] flex-col overflow-hidden' })
function componentSetVariantInfo(componentSetId: string) {
return [...editor.collectVariantOptions(componentSetId)].map(([name, values]) => ({
name,
values: [...values].sort((a, b) => a.localeCompare(b))
}))
}
const graphNodes = computed(() => ({
sceneVersion: editor.state.sceneVersion,
nodes: [...editor.graph.nodes.values()]
}))
const assets = computed<LocalAsset[]>(() => {
return graphNodes.value.nodes
.filter((node) => node.type === 'COMPONENT' || node.type === 'COMPONENT_SET')
.filter((node) => {
if (node.type === 'COMPONENT_SET') return true
const parent = node.parentId ? editor.graph.getNode(node.parentId) : null
return parent?.type !== 'COMPONENT_SET'
})
.map((node) => {
const defaultVariant =
node.type === 'COMPONENT_SET' ? editor.getDefaultVariantForComponentSet(node.id) : node
const conflicts =
node.type === 'COMPONENT_SET' ? editor.getComponentSetVariantConflicts(node.id) : []
const variants = node.type === 'COMPONENT_SET' ? componentSetVariantInfo(node.id) : []
return {
id: node.id,
name: node.name,
node,
componentId: defaultVariant?.id ?? null,
variants,
variantCount: node.type === 'COMPONENT_SET' ? node.childIds.length : 0,
hasConflicts: conflicts.length > 0,
sourceLibraryKey: node.sourceLibraryKey,
description: node.symbolDescription,
docsUrl: node.symbolLinks[0]?.uri ?? null
}
})
.sort((a, b) => a.name.localeCompare(b.name))
})
const filteredAssets = computed(() => {
const normalized = query.value.trim().toLowerCase()
if (!normalized) return assets.value
return assets.value.filter((asset) => asset.name.toLowerCase().includes(normalized))
})
const selectedAsset = computed(
() => assets.value.find((asset) => asset.id === selectedAssetId.value) ?? null
)
const selectedPreviewNodeId = computed(() => selectedAsset.value?.componentId ?? null)
function revokePreview() {
if (!previewUrl.value) return
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = null
}
async function updatePreview() {
const requestId = ++previewRequestId
const nodeId = selectedPreviewNodeId.value
if (!detailsOpen.value || !nodeId) {
revokePreview()
return
}
const node = editor.getNode(nodeId)
if (!node) {
revokePreview()
return
}
previewLoading.value = true
try {
const maxSize = Math.max(node.width, node.height, 1)
const scale = Math.min(176 / maxSize, 2)
const data = await editor.renderExportImage([nodeId], scale, 'PNG')
if (requestId !== previewRequestId) return
revokePreview()
if (data) previewUrl.value = URL.createObjectURL(new Blob([data], { type: 'image/png' }))
} finally {
if (requestId === previewRequestId) previewLoading.value = false
}
}
watch([detailsOpen, selectedPreviewNodeId, () => editor.state.sceneVersion], updatePreview, {
flush: 'post'
})
onScopeDispose(revokePreview)
function openDetails(asset: LocalAsset) {
selectedAssetId.value = asset.id
detailsOpen.value = true
}
function insertionPoint(component: SceneNode, parentId: string) {
const canvasCenter = editor.viewportCanvasCenter()
const center = editor.screenToCanvas(canvasCenter.x, canvasCenter.y)
const parentOffset =
parentId === editor.state.currentPageId
? { x: 0, y: 0 }
: editor.graph.getAbsolutePosition(parentId)
return {
x: center.x - parentOffset.x - component.width / 2,
y: center.y - parentOffset.y - component.height / 2
}
}
function insertAsset(asset: LocalAsset) {
if (!asset.componentId) return
const component = editor.graph.getNode(asset.componentId)
if (!component) return
const parentId = editor.state.enteredContainerId ?? editor.state.currentPageId
const point = insertionPoint(component, parentId)
editor.createInstanceFromComponent(asset.componentId, point.x, point.y, parentId)
editor.requestRender()
}
function insertSelectedAsset() {
if (!selectedAsset.value) return
insertAsset(selectedAsset.value)
detailsOpen.value = false
}
</script>
<template>
<section data-test-id="assets-panel" class="flex min-h-0 flex-1 flex-col overflow-hidden">
<header class="shrink-0 px-3 py-2 text-[11px] tracking-wider text-muted uppercase">
{{ panels.assets }}
</header>
<div class="shrink-0 px-2 pb-2">
<AppInput
v-model="query"
type="search"
test-id="assets-search"
size="sm"
:placeholder="panels.searchLocalComponents"
/>
</div>
<div class="scrollbar-thin flex-1 overflow-y-auto px-1 pb-2">
<button
v-for="asset in filteredAssets"
:key="asset.id"
data-test-id="asset-item"
:data-asset-id="asset.id"
class="group/asset flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-surface hover:bg-hover"
@click="openDetails(asset)"
@dblclick="insertAsset(asset)"
>
<component
:is="nodeIcon(asset.node)"
class="size-3.5 shrink-0 text-component"
aria-hidden="true"
/>
<span class="min-w-0 flex-1">
<span class="flex min-w-0 items-center gap-1.5">
<span data-test-id="asset-name" class="truncate">{{ asset.name }}</span>
<span
v-if="asset.sourceLibraryKey"
data-test-id="asset-library-badge"
class="shrink-0 rounded bg-component/15 px-1 py-px text-[9px] font-medium text-component uppercase"
>
{{ panels.assetLibraryBadge }}
</span>
</span>
<span
v-if="asset.variants.length > 0"
data-test-id="asset-variant-summary"
class="mt-0.5 block truncate text-[10px] text-muted"
>
{{
panels.assetVariantSummary({
count: asset.variantCount,
names: asset.variants.map((variant) => variant.name).join(', ')
})
}}
</span>
<span
v-if="asset.description"
data-test-id="asset-description"
class="mt-0.5 block truncate text-[10px] text-muted"
>
{{ asset.description }}
</span>
<span
v-if="asset.hasConflicts"
data-test-id="asset-variant-conflict"
class="mt-0.5 block truncate text-[10px] text-[var(--color-warning-text)]"
>
{{ panels.duplicateVariantValues }}
</span>
</span>
<Tip v-if="asset.docsUrl" :label="panels.openDocumentation">
<span
:class="insertButton.base"
data-test-id="asset-docs"
@pointerdown.stop
@dblclick.stop
@click.stop="asset.docsUrl ? openExternalLink(asset.docsUrl) : undefined"
>
<icon-lucide-book-open class="size-3" />
</span>
</Tip>
<Tip :label="commands.createInstance">
<span
:class="insertButton.base"
data-test-id="asset-insert"
@pointerdown.stop
@click.stop="insertAsset(asset)"
>
<icon-lucide-plus class="size-3" />
</span>
</Tip>
</button>
<div
v-if="filteredAssets.length === 0"
data-test-id="assets-empty"
class="px-3 py-6 text-center text-xs text-muted"
>
{{ panels.noLocalComponents }}
</div>
</div>
<DialogRoot v-model:open="detailsOpen">
<DialogPortal>
<DialogOverlay :class="dialog.overlay" />
<DialogContent
v-if="selectedAsset"
data-test-id="asset-details-dialog"
:class="dialog.content"
>
<div class="flex items-center justify-between border-b border-border px-4 py-3">
<div class="flex min-w-0 items-center gap-2">
<component
:is="nodeIcon(selectedAsset.node)"
class="size-4 shrink-0 text-component"
/>
<div class="min-w-0">
<DialogTitle :class="dialog.title" class="truncate">{{
selectedAsset.name
}}</DialogTitle>
<p class="mt-0.5 text-[11px] text-muted">
{{
selectedAsset.node.type === 'COMPONENT_SET'
? panels.componentSet
: panels.component
}}
<span v-if="selectedAsset.variantCount > 0">
· {{ selectedAsset.variantCount }} variants</span
>
</p>
</div>
</div>
<DialogClose
data-test-id="asset-details-close"
class="flex size-7 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted hover:bg-hover hover:text-surface"
>
<icon-lucide-x class="size-4" />
</DialogClose>
</div>
<div class="grid min-h-0 grid-cols-[260px_1fr] gap-0">
<div class="border-r border-border p-4">
<div
data-test-id="asset-details-preview"
class="flex h-36 items-center justify-center overflow-hidden rounded-lg border border-border bg-canvas/60"
>
<img
v-if="previewUrl"
data-test-id="asset-details-preview-image"
:src="previewUrl"
:alt="`${selectedAsset.name} preview`"
class="max-h-[120px] max-w-[210px] object-contain"
/>
<div v-else class="text-center">
<icon-lucide-loader-2
v-if="previewLoading"
class="mx-auto size-5 animate-spin text-muted"
/>
<component
v-else
:is="nodeIcon(selectedAsset.node)"
class="mx-auto size-8 text-component"
/>
<p class="mt-2 max-w-44 truncate text-xs font-medium text-surface">
{{ selectedAsset.name }}
</p>
</div>
</div>
<button
data-test-id="asset-details-insert"
:class="primaryButton.base"
class="mt-3 w-full"
@click="insertSelectedAsset"
>
{{ panels.insertInstance }}
</button>
</div>
<div class="min-w-0 p-4">
<section v-if="selectedAsset.description" class="mb-4">
<h3 class="text-[11px] font-medium tracking-wider text-muted uppercase">
{{ panels.description }}
</h3>
<p
data-test-id="asset-details-description"
class="mt-1 text-xs leading-5 text-surface"
>
{{ selectedAsset.description }}
</p>
</section>
<section v-if="selectedAsset.sourceLibraryKey" class="mb-4">
<h3 class="text-[11px] font-medium tracking-wider text-muted uppercase">
{{ panels.assetLibraryBadge }}
</h3>
<p data-test-id="asset-details-library" class="mt-1 break-all text-xs text-muted">
{{ selectedAsset.sourceLibraryKey }}
</p>
</section>
<section v-if="selectedAsset.docsUrl" class="mb-4">
<h3 class="text-[11px] font-medium tracking-wider text-muted uppercase">
{{ panels.documentation }}
</h3>
<button
data-test-id="asset-details-docs"
class="mt-1 inline-flex items-center gap-1 rounded px-1 py-0.5 text-xs text-component hover:bg-component/10"
@click="
selectedAsset.docsUrl ? openExternalLink(selectedAsset.docsUrl) : undefined
"
>
<icon-lucide-book-open class="size-3" />
{{ panels.openDocs }}
</button>
</section>
<section v-if="selectedAsset.variants.length > 0">
<h3 class="text-[11px] font-medium tracking-wider text-muted uppercase">
{{ panels.properties }}
</h3>
<div class="mt-2 flex flex-col gap-2">
<div
v-for="variant in selectedAsset.variants"
:key="variant.name"
data-test-id="asset-details-property"
class="rounded border border-border bg-input/40 px-2 py-1.5"
>
<div class="text-xs font-medium text-surface">{{ variant.name }}</div>
<div class="mt-1 text-[11px] text-muted">{{ variant.values.join(', ') }}</div>
</div>
</div>
</section>
</div>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</section>
</template>