From b1e629e95fc0e5b2eb6195b4655f167834f5c66b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 10 Jul 2026 22:42:24 +0300 Subject: [PATCH] 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 --- bun.lock | 2 + packages/core/package.json | 2 + packages/core/src/editor/text/session.ts | 21 +--------- packages/core/src/icons/api.ts | 37 ++++++++++-------- packages/core/src/tools/analyze/clusters.ts | 5 ++- packages/core/src/tools/analyze/colors.ts | 3 +- packages/core/src/tools/analyze/diff.ts | 3 +- packages/core/src/tools/create/vector.ts | 4 +- .../core/src/tools/describe/layout-issues.ts | 3 +- .../core/src/tools/stock-photo/providers.ts | 38 +++++++++++++------ .../core/src/tools/stock-photo/requests.ts | 4 +- packages/core/src/tools/structure/batch.ts | 4 +- src/components/AssetsPanel.vue | 21 +++++----- src/components/ChatPanel.vue | 5 ++- src/components/properties/ExportSection.vue | 19 +++------- src/components/ui/Tip.vue | 29 +++++++------- 16 files changed, 102 insertions(+), 98 deletions(-) diff --git a/bun.lock b/bun.lock index f65d67ecf..4541f951d 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/packages/core/package.json b/packages/core/package.json index c1c7d9495..71f486676 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/editor/text/session.ts b/packages/core/src/editor/text/session.ts index e15a7cb7f..4e4b96b82 100644 --- a/packages/core/src/editor/text/session.ts +++ b/packages/core/src/editor/text/session.ts @@ -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> @@ -92,22 +92,5 @@ function fillsEqual( b: NonNullable ) { 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])) } diff --git a/packages/core/src/icons/api.ts b/packages/core/src/icons/api.ts index b358920af..a508d0221 100644 --- a/packages/core/src/icons/api.ts +++ b/packages/core/src/icons/api.ts @@ -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 { - 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 { - const url = `${ICONIFY_API}/${prefix}.json?icons=${iconNames.map(encodeURIComponent).join(',')}` - const response = await fetchWithTimeout(url) + const response = await iconifyApi.raw(`/${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 { - 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('/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 ?? {} } } diff --git a/packages/core/src/tools/analyze/clusters.ts b/packages/core/src/tools/analyze/clusters.ts index 9ccf6a705..0ed784de3 100644 --- a/packages/core/src/tools/analyze/clusters.ts +++ b/packages/core/src/tools/analyze/clusters.ts @@ -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) diff --git a/packages/core/src/tools/analyze/colors.ts b/packages/core/src/tools/analyze/colors.ts index db6cf80a9..e00acc81e 100644 --- a/packages/core/src/tools/analyze/colors.ts +++ b/packages/core/src/tools/analyze/colors.ts @@ -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 }) } diff --git a/packages/core/src/tools/analyze/diff.ts b/packages/core/src/tools/analyze/diff.ts index 4db862eb9..f4a318fb5 100644 --- a/packages/core/src/tools/analyze/diff.ts +++ b/packages/core/src/tools/analyze/diff.ts @@ -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 try { - newProps = JSON.parse(args.props) + newProps = safeDestr>(args.props) } catch { return { error: 'Invalid JSON in props' } } diff --git a/packages/core/src/tools/create/vector.ts b/packages/core/src/tools/create/vector.ts index d486e95bc..143dc45e7 100644 --- a/packages/core/src/tools/create/vector.ts +++ b/packages/core/src/tools/create/vector.ts @@ -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(args.path) } catch { return { error: 'Invalid JSON in path parameter' } } diff --git a/packages/core/src/tools/describe/layout-issues.ts b/packages/core/src/tools/describe/layout-issues.ts index fcffb1ee6..b30809582 100644 --- a/packages/core/src/tools/describe/layout-issues.ts +++ b/packages/core/src/tools/describe/layout-issues.ts @@ -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({ diff --git a/packages/core/src/tools/stock-photo/providers.ts b/packages/core/src/tools/stock-photo/providers.ts index f12db2a4c..1ab156225 100644 --- a/packages/core/src/tools/stock-photo/providers.ts +++ b/packages/core/src/tools/stock-photo/providers.ts @@ -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, diff --git a/packages/core/src/tools/stock-photo/requests.ts b/packages/core/src/tools/stock-photo/requests.ts index ca6763732..919740839 100644 --- a/packages/core/src/tools/stock-photo/requests.ts +++ b/packages/core/src/tools/stock-photo/requests.ts @@ -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' } } diff --git a/packages/core/src/tools/structure/batch.ts b/packages/core/src/tools/structure/batch.ts index e85a86aa9..ee0069e4a 100644 --- a/packages/core/src/tools/structure/batch.ts +++ b/packages/core/src/tools/structure/batch.ts @@ -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(String(operations)) } catch { return { error: 'Invalid JSON in operations' } } diff --git a/src/components/AssetsPanel.vue b/src/components/AssetsPanel.vue index 740e24bb4..22a417733 100644 --- a/src/components/AssetsPanel.vue +++ b/src/components/AssetsPanel.vue @@ -1,5 +1,6 @@