refactor: adopt shared utility libraries

- Use VueUse for object URLs, timers, and clipboard state

- Replace custom collection and equality helpers with es-toolkit

- Use ofetch and safeDestr at remote API and JSON boundaries
This commit is contained in:
Danila Poyarkov 2026-07-10 22:42:24 +03:00
parent f525c0f4fb
commit b1e629e95f
16 changed files with 102 additions and 98 deletions

View file

@ -142,6 +142,7 @@
"acorn": "^8.16.0",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"destr": "^2.0.5",
"diff": "^8.0.3",
"es-toolkit": "^1.46.1",
"expr-eval": "^2.0.2",
@ -150,6 +151,7 @@
"fzstd": "^0.1.1",
"jspdf": "^4.2.1",
"nanoevents": "^9.1.0",
"ofetch": "^1.5.1",
"opentype.js": "^1.3.4",
"sucrase": "^3.35.1",
"svg2pdf.js": "^2.7.0",

View file

@ -174,6 +174,7 @@
"acorn": "^8.16.0",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"destr": "^2.0.5",
"diff": "^8.0.3",
"es-toolkit": "^1.46.1",
"expr-eval": "^2.0.2",
@ -182,6 +183,7 @@
"fzstd": "^0.1.1",
"jspdf": "^4.2.1",
"nanoevents": "^9.1.0",
"ofetch": "^1.5.1",
"opentype.js": "^1.3.4",
"sucrase": "^3.35.1",
"svg2pdf.js": "^2.7.0",

View file

@ -1,8 +1,8 @@
import type { Paragraph } from 'canvaskit-wasm'
import { isEqual } from 'es-toolkit/predicate'
import type { SceneNode, StyleRun } from '@open-pencil/scene-graph'
import { copyStyleRuns } from '@open-pencil/scene-graph/copy'
import type { JsonObject } from '@open-pencil/scene-graph/primitives'
export type TextEditSizeSnapshot = Partial<Pick<SceneNode, 'width' | 'height'>>
@ -92,22 +92,5 @@ function fillsEqual(
b: NonNullable<StyleRun['style']['fills']>
) {
if (a.length !== b.length) return false
return a.every((fill, index) => deepEqual(fill, b[index]))
}
function deepEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true
if (typeof a !== typeof b) return false
if (a === null || b === null) return false
if (typeof a !== 'object' || typeof b !== 'object') return false
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
return a.every((item, index) => deepEqual(item, b[index]))
}
const aRecord = a as JsonObject
const bRecord = b as JsonObject
const aKeys = Object.keys(aRecord)
const bKeys = Object.keys(bRecord)
if (aKeys.length !== bKeys.length) return false
return aKeys.every((key) => Object.hasOwn(bRecord, key) && deepEqual(aRecord[key], bRecord[key]))
return a.every((fill, index) => isEqual(fill, b[index]))
}

View file

@ -1,20 +1,26 @@
import { ofetch } from 'ofetch'
import type { IconifyResponse, IconSearchResult } from './types'
const ICONIFY_API = 'https://api.iconify.design'
const FETCH_TIMEOUT_MS = 10_000
function fetchWithTimeout(url: string): Promise<Response> {
return fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
}
const iconifyApi = ofetch.create({
baseURL: ICONIFY_API,
retry: 0,
timeout: FETCH_TIMEOUT_MS
})
export async function fetchIconifyCollection(
prefix: string,
iconNames: string[]
): Promise<IconifyResponse> {
const url = `${ICONIFY_API}/${prefix}.json?icons=${iconNames.map(encodeURIComponent).join(',')}`
const response = await fetchWithTimeout(url)
const response = await iconifyApi.raw<IconifyResponse>(`/${prefix}.json`, {
ignoreResponseError: true,
query: { icons: iconNames.join(',') }
})
if (!response.ok) throw new Error(`Iconify API error: ${response.status} for prefix "${prefix}"`)
return (await response.json()) as IconifyResponse
return response._data as IconifyResponse
}
export async function searchIconify(
@ -24,18 +30,17 @@ export async function searchIconify(
prefix?: string
}
): Promise<IconSearchResult> {
const params = new URLSearchParams({ query })
if (options?.limit) params.set('limit', String(options.limit))
if (options?.prefix) params.set('prefix', options.prefix)
const response = await fetchWithTimeout(`${ICONIFY_API}/search?${params}`)
const response = await iconifyApi.raw<IconSearchResult>('/search', {
ignoreResponseError: true,
query: { query, limit: options?.limit, prefix: options?.prefix }
})
if (!response.ok) throw new Error(`Iconify search error: ${response.status}`)
const data = await response.json()
const icons: string[] = data.icons ?? []
const data = response._data
const limit = options?.limit ?? 5
return {
icons: icons.slice(0, limit),
total: data.total ?? 0,
collections: data.collections ?? {}
icons: data?.icons.slice(0, limit) ?? [],
total: data?.total ?? 0,
collections: data?.collections ?? {}
}
}

View file

@ -1,4 +1,5 @@
import { orderBy, sortBy } from 'es-toolkit/array'
import { meanBy } from 'es-toolkit/math'
import { defineTool } from '#core/tools/schema'
@ -88,8 +89,8 @@ export const analyzeClusters = defineTool({
[...signatureMap.entries()]
.filter(([, nodes]) => nodes.length >= minCount)
.map(([signature, nodes]) => {
const avgWidth = nodes.reduce((sum, node) => sum + node.width, 0) / nodes.length
const avgHeight = nodes.reduce((sum, node) => sum + node.height, 0) / nodes.length
const avgWidth = meanBy(nodes, (node) => node.width)
const avgHeight = meanBy(nodes, (node) => node.height)
const widths = nodes.map((node) => node.width)
const heights = nodes.map((node) => node.height)
const widthRange = Math.max(...widths) - Math.min(...widths)

View file

@ -1,4 +1,5 @@
import { orderBy } from 'es-toolkit/array'
import { sumBy } from 'es-toolkit/math'
import type { Color } from '@open-pencil/scene-graph/primitives'
@ -99,7 +100,7 @@ export const analyzeColors = defineTool({
if (cluster.length > 1) {
clusters.push({
colors: cluster.map((c) => c.hex),
totalCount: cluster.reduce((sum, c) => sum + c.count, 0),
totalCount: sumBy(cluster, (color) => color.count),
suggestedHex: color.hex
})
}

View file

@ -1,3 +1,4 @@
import { safeDestr } from 'destr'
import { createTwoFilesPatch } from 'diff'
import type { SceneNode } from '@open-pencil/scene-graph'
@ -193,7 +194,7 @@ export const diffShow = defineTool({
let newProps: Record<string, unknown>
try {
newProps = JSON.parse(args.props)
newProps = safeDestr<Record<string, unknown>>(args.props)
} catch {
return { error: 'Invalid JSON in props' }
}

View file

@ -1,3 +1,5 @@
import { safeDestr } from 'destr'
import { normalizeVectorNetwork, validateVectorNetwork } from '@open-pencil/scene-graph'
import type { VectorNetwork } from '@open-pencil/scene-graph'
@ -26,7 +28,7 @@ export const createVector = defineTool({
if (args.path) {
let parsed: VectorNetwork
try {
parsed = JSON.parse(args.path) as VectorNetwork
parsed = safeDestr<VectorNetwork>(args.path)
} catch {
return { error: 'Invalid JSON in path parameter' }
}

View file

@ -1,4 +1,5 @@
import { wcagLuminance } from 'culori'
import { sumBy } from 'es-toolkit/math'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { Color } from '@open-pencil/scene-graph/primitives'
@ -70,7 +71,7 @@ function checkAlignmentIssues(ctx: LayoutContext): void {
return Math.abs(dim - (isRow ? children[0].width : children[0].height)) < 2
})
if (allSameSize && node.primaryAxisAlign === 'MIN' && node.itemSpacing === 0) {
const total = children.reduce((s, c) => s + (isRow ? c.width : c.height), 0)
const total = sumBy(children, (child) => (isRow ? child.width : child.height))
const pad = isRow ? node.paddingLeft + node.paddingRight : node.paddingTop + node.paddingBottom
if (total < ((isRow ? node.width : node.height) - pad) * 0.7) {
issues.push({

View file

@ -1,3 +1,5 @@
import { ofetch } from 'ofetch'
export interface StockPhotoResult {
url: string
width: number
@ -76,10 +78,17 @@ const pexelsProvider: StockPhotoProvider = {
name: 'pexels',
async search(query, { perPage, orientation, targetDim }) {
if (!pexelsApiKey) throw new Error('Pexels API key not configured')
const url = `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=${perPage}&orientation=${orientation}`
const resp = await fetch(url, { headers: { Authorization: pexelsApiKey } })
if (!resp.ok) throw new Error(`Pexels ${resp.status}`)
const data = (await resp.json()) as { photos: PexelsPhoto[] }
const response = await ofetch.raw<{ photos: PexelsPhoto[] }>(
'https://api.pexels.com/v1/search',
{
headers: { Authorization: pexelsApiKey },
ignoreResponseError: true,
query: { query, per_page: perPage, orientation },
retry: 0
}
)
if (!response.ok) throw new Error(`Pexels ${response.status}`)
const data = response._data as { photos: PexelsPhoto[] }
return data.photos.map((photo) => ({
url: pickPexelsSize(photo.src, targetDim),
width: photo.width,
@ -120,15 +129,20 @@ const unsplashProvider: StockPhotoProvider = {
async search(query, { perPage, orientation }) {
if (!unsplashAccessKey) throw new Error('Unsplash access key not configured')
const orient = orientation === 'square' ? 'squarish' : orientation
const url = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(query)}&per_page=${perPage}&orientation=${orient}`
const resp = await fetch(url, {
headers: {
Authorization: `Client-ID ${unsplashAccessKey}`,
'Accept-Version': 'v1'
const response = await ofetch.raw<{ results: UnsplashPhoto[] }>(
'https://api.unsplash.com/search/photos',
{
headers: {
Authorization: `Client-ID ${unsplashAccessKey}`,
'Accept-Version': 'v1'
},
ignoreResponseError: true,
query: { query, per_page: perPage, orientation: orient },
retry: 0
}
})
if (!resp.ok) throw new Error(`Unsplash ${resp.status}`)
const data = (await resp.json()) as { results: UnsplashPhoto[] }
)
if (!response.ok) throw new Error(`Unsplash ${response.status}`)
const data = response._data as { results: UnsplashPhoto[] }
return data.results.map((photo) => ({
url: pickUnsplashSize(photo.urls, 1080),
width: photo.width,

View file

@ -1,9 +1,11 @@
import { safeDestr } from 'destr'
import type { PhotoRequest } from './apply'
export function parsePhotoRequests(value: unknown): PhotoRequest[] | { error: string } {
let parsed: unknown
try {
parsed = JSON.parse(String(value))
parsed = safeDestr(String(value))
} catch {
return { error: 'Invalid JSON in requests' }
}

View file

@ -1,3 +1,5 @@
import { safeDestr } from 'destr'
import type { FigmaNodeProxy } from '#core/figma-api'
import { defineTool } from '#core/tools/schema'
@ -103,7 +105,7 @@ export const batchUpdate = defineTool({
execute: (figma, { operations }) => {
let ops: BatchOp[]
try {
ops = JSON.parse(String(operations))
ops = safeDestr<BatchOp[]>(String(operations))
} catch {
return { error: 'Invalid JSON in operations' }
}

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onScopeDispose, ref, watch } from 'vue'
import { useObjectUrl } from '@vueuse/core'
import { computed, ref, shallowRef, watch } from 'vue'
import {
DialogClose,
DialogContent,
@ -38,7 +39,8 @@ const { panels, commands } = useI18n()
const query = ref('')
const detailsOpen = ref(false)
const selectedAssetId = ref<string | null>(null)
const previewUrl = ref<string | null>(null)
const previewBlob = shallowRef<Blob | null>(null)
const previewUrl = useObjectUrl(previewBlob)
const previewLoading = ref(false)
let previewRequestId = 0
const insertButton = useButtonUI({ tone: 'ghost', size: 'iconSm' })
@ -98,23 +100,21 @@ const selectedAsset = computed(
)
const selectedPreviewNodeId = computed(() => selectedAsset.value?.componentId ?? null)
function revokePreview() {
if (!previewUrl.value) return
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = null
function clearPreview() {
previewBlob.value = null
}
async function updatePreview() {
const requestId = ++previewRequestId
const nodeId = selectedPreviewNodeId.value
if (!detailsOpen.value || !nodeId) {
revokePreview()
clearPreview()
return
}
const node = editor.getNode(nodeId)
if (!node) {
revokePreview()
clearPreview()
return
}
@ -124,8 +124,7 @@ async function updatePreview() {
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' }))
previewBlob.value = data ? new Blob([data], { type: 'image/png' }) : null
} finally {
if (requestId === previewRequestId) previewLoading.value = false
}
@ -135,8 +134,6 @@ watch([detailsOpen, selectedPreviewNodeId, () => editor.state.sceneVersion], upd
flush: 'post'
})
onScopeDispose(revokePreview)
function openDetails(asset: LocalAsset) {
selectedAssetId.value = asset.id
detailsOpen.value = true

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
import { refAutoReset } from '@vueuse/core'
import { refAutoReset, useClipboard } from '@vueuse/core'
import { computed, markRaw, nextTick, ref, watch } from 'vue'
import { getAcpDebugText, clearAcpDebugLog, hasAcpDebugEntries } from '@/app/ai/acp/transport'
@ -23,6 +23,7 @@ import type { JsonObject } from '@open-pencil/scene-graph/primitives'
const IS_DEV = import.meta.env.DEV
const { isConfigured, ensureChat, resetChat } = useAIChat()
const { copy } = useClipboard()
const { dialogs } = useI18n()
const chat = ref<Chat<UIMessage> | null>(null)
@ -112,7 +113,7 @@ async function handleCopyDebug() {
async function handleCopyAcpLog() {
const text = getAcpDebugText()
if (!text) return
await navigator.clipboard.writeText(text)
await copy(text)
acpLogCopied.value = true
}

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, computed, watch, onScopeDispose } from 'vue'
import { useObjectUrl } from '@vueuse/core'
import { computed, ref, shallowRef, watch } from 'vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import ExportScaleInput from '@/components/properties/ExportScaleInput.vue'
@ -36,7 +37,8 @@ const FORMAT_OPTIONS: { value: ExportFormatId; label: string }[] = [
{ value: 'pdf', label: 'PDF' }
]
const previewUrl = ref<string | null>(null)
const previewBlob = shallowRef<Blob | null>(null)
const previewUrl = useObjectUrl(previewBlob)
const showPreview = ref(false)
const exporting = ref(false)
@ -76,8 +78,7 @@ async function updatePreview() {
: editorStore.graph.getChildren(editorStore.state.currentPageId).map((n) => n.id)
if (ids.length === 0) {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = null
previewBlob.value = null
return
}
@ -88,11 +89,7 @@ async function updatePreview() {
}
const scale = maxW > 0 ? Math.min(PREVIEW_WIDTH / maxW, 2) : 1
const data = await editorStore.renderExportImage(ids, scale, 'PNG')
if (data) {
const prev = previewUrl.value
previewUrl.value = URL.createObjectURL(new Blob([data], { type: 'image/png' }))
if (prev) URL.revokeObjectURL(prev)
}
previewBlob.value = data ? new Blob([data], { type: 'image/png' }) : null
}
const previewKey = computed(
@ -106,10 +103,6 @@ const previewKey = computed(
watch(() => showPreview.value, updatePreview, { flush: 'post' })
watch(previewKey, updatePreview, { flush: 'post' })
onScopeDispose(() => {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
})
</script>
<template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useEventListener, useTimeoutFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { useTooltipUI } from '@/components/ui/tooltip'
@ -26,7 +26,6 @@ const triggerRef = ref<HTMLElement>()
const contentRef = ref<HTMLElement>()
const open = ref(false)
const position = ref({ x: 0, y: 0 })
let openTimer: ReturnType<typeof setTimeout> | undefined
const canOpen = computed(() => Boolean(label) && !disabled)
const contentStyle = computed(() => ({
@ -34,11 +33,14 @@ const contentStyle = computed(() => ({
top: `${position.value.y}px`
}))
function clearOpenTimer() {
if (!openTimer) return
clearTimeout(openTimer)
openTimer = undefined
}
const { start: startOpenTimer, stop: stopOpenTimer } = useTimeoutFn(
() => {
open.value = true
void nextTick(refreshPosition)
},
TOOLTIP_OPEN_DELAY_MS,
{ immediate: false }
)
function anchorElement() {
const root = triggerRef.value
@ -91,15 +93,12 @@ function refreshPosition() {
function show() {
if (!canOpen.value) return
clearOpenTimer()
openTimer = setTimeout(() => {
open.value = true
void nextTick(refreshPosition)
}, TOOLTIP_OPEN_DELAY_MS)
stopOpenTimer()
startOpenTimer()
}
function hide() {
clearOpenTimer()
stopOpenTimer()
open.value = false
}
@ -140,8 +139,6 @@ useEventListener(document, 'click', hide, { capture: true })
watch(canOpen, (value) => {
if (!value) hide()
})
onBeforeUnmount(hide)
</script>
<template>