refactor(core): use es-toolkit utilities

This commit is contained in:
Danila Poyarkov 2026-05-18 19:10:46 +03:00
parent f3649550f2
commit 955aa4d1e9
19 changed files with 115 additions and 93 deletions

View file

@ -292,6 +292,7 @@ Self-review checklist:
- Files should stay under ~600 lines — split by domain when they grow (see `packages/core/src/tools/` for the pattern)
- `structuredClone` for deep copies, never shallow spread when mutating nested objects
- Don't hand-roll what a dependency already does. Check existing deps first (`package.json`, `packages/*/package.json`). If none covers it, find a quality library instead of inlining an implementation — e.g. use `diff` for unified diffs, not a custom line-by-line loop; use `culori` for color math, not manual RGB parsing
- `es-toolkit` is available in core for small, focused utility helpers when it clearly improves readability. Prefer subpath imports such as `es-toolkit/object`, `es-toolkit/array`, and `es-toolkit/predicate`; good fits include `omit` / `pick` for object key selection, `uniq` for dedupe, and `isNotNil` for typed nullish filtering. Do not replace clear native JavaScript just for consistency, and avoid `es-toolkit/compat` unless deliberately migrating lodash-compatible behavior.
- Check Reka UI for existing components (Dialog, Popover, DropdownMenu, Select, Tooltip, Toast, etc.) before building custom ones — especially dropdowns, popovers, and modals

View file

@ -121,6 +121,7 @@
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"diff": "^8.0.3",
"es-toolkit": "^1.46.1",
"expr-eval": "^2.0.2",
"fflate": "^0.8.2",
"fontoxpath": "^3.34.0",
@ -1525,6 +1526,8 @@
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
"es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="],
"esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],

View file

@ -319,6 +319,7 @@
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"diff": "^8.0.3",
"es-toolkit": "^1.46.1",
"expr-eval": "^2.0.2",
"fflate": "^0.8.2",
"fontoxpath": "^3.34.0",

View file

@ -1,4 +1,5 @@
import type { CanvasKit, FontWeight, Paragraph, TypefaceFontProvider } from 'canvaskit-wasm'
import { uniq } from 'es-toolkit/array'
import { getCanvasKit } from '#core/canvaskit'
import { resolveRGBAForPreview } from '#core/color/management'
@ -133,7 +134,7 @@ function resolveParagraphFontFamilies(
if (primary !== DEFAULT_FONT_FAMILY) families.push(DEFAULT_FONT_FAMILY)
families.push(...arabicFallbacks, ...cjkFallbacks)
const resolved = [...new Set(families)]
const resolved = uniq(families)
fontFamilyCache.set(key, resolved)
if (fontFamilyCache.size > FONT_FAMILY_CACHE_LIMIT) {
const oldestKey = fontFamilyCache.keys().next().value

View file

@ -1,9 +1,11 @@
import { isNotNil } from 'es-toolkit/predicate'
import type { EditorContext } from '#core/editor/types'
import { computeBounds } from '#core/geometry'
export function createClipboardPlacementActions(ctx: EditorContext) {
function centerNodesAt(nodeIds: string[], cx: number, cy: number) {
const items = nodeIds.map((id) => ctx.graph.getNode(id)).filter((node) => node != null)
const items = nodeIds.map((id) => ctx.graph.getNode(id)).filter(isNotNil)
const bounds = computeBounds(items)
if (bounds.width === 0 && bounds.height === 0 && items.length === 0) return
const dx = cx - (bounds.x + bounds.width / 2)

View file

@ -1,3 +1,5 @@
import { omit } from 'es-toolkit/object'
import type { EditorContext } from '#core/editor/types'
import { randomHex } from '#core/random'
import { buildVariantName, parseVariantName } from '#core/scene-graph/variant-name'
@ -77,9 +79,7 @@ export function createVariantActions(ctx: EditorContext) {
for (const childId of node.childIds) {
const child = ctx.graph.getNode(childId)
if (!child) continue
const values = Object.fromEntries(
Object.entries(child.componentPropertyValues).filter(([key]) => key !== def.name)
)
const values = omit(child.componentPropertyValues, [def.name])
ctx.graph.updateNode(childId, { componentPropertyValues: values })
}
ctx.undo.push({
@ -95,9 +95,7 @@ export function createVariantActions(ctx: EditorContext) {
for (const cid of n.childIds) {
const c = ctx.graph.getNode(cid)
if (!c) continue
const v = Object.fromEntries(
Object.entries(c.componentPropertyValues).filter(([key]) => key !== def.name)
)
const v = omit(c.componentPropertyValues, [def.name])
ctx.graph.updateNode(cid, { componentPropertyValues: v })
}
}
@ -128,9 +126,7 @@ export function createVariantActions(ctx: EditorContext) {
if (!child) continue
const values = { ...child.componentPropertyValues }
if (prevName in values) {
const nextValues = Object.fromEntries(
Object.entries(values).filter(([key]) => key !== prevName)
)
const nextValues: Record<string, string> = omit(values, [prevName])
nextValues[newName] = values[prevName]
ctx.graph.updateNode(childId, { componentPropertyValues: nextValues })
}

View file

@ -1,3 +1,5 @@
import { pick } from 'es-toolkit/object'
import { computeLayout } from '#core/layout'
import type { LayoutMode, SceneNode } from '#core/scene-graph'
@ -58,7 +60,7 @@ function captureLayoutState(node: SceneNode): Partial<SceneNode> {
}
function pickState(node: SceneNode, keys: (keyof SceneNode)[]): Partial<SceneNode> {
return Object.fromEntries(keys.map((key) => [key, node[key]])) as Partial<SceneNode>
return pick(node, keys) as Partial<SceneNode>
}
function layoutModeUpdates(

View file

@ -1,3 +1,5 @@
import { pick } from 'es-toolkit/object'
import type { SceneNode } from '#core/scene-graph'
import { createLayoutModeActions } from './layout-mode'
@ -18,9 +20,7 @@ export function createNodeActions(ctx: EditorContext) {
function updateNodeWithUndo(id: string, changes: Partial<SceneNode>, label = 'Update') {
const node = ctx.graph.getNode(id)
if (!node) return
const previous = Object.fromEntries(
(Object.keys(changes) as (keyof SceneNode)[]).map((key) => [key, node[key]])
) as Partial<SceneNode>
const previous = pick(node, Object.keys(changes) as (keyof SceneNode)[]) as Partial<SceneNode>
ctx.graph.updateNode(id, changes)
ctx.runLayoutForNode(id)
ctx.undo.push({

View file

@ -1,3 +1,5 @@
import { pick } from 'es-toolkit/object'
import type { SceneNode } from '#core/scene-graph'
import type { UndoEntry } from '#core/scene-graph/undo'
import type { Rect, Vector } from '#core/types'
@ -9,6 +11,7 @@ import {
snapshotPage as createPageSnapshot,
type PageSnapshot
} from './history/snapshot'
import type { EditorContext } from './types'
export function createUndoActions(ctx: EditorContext) {
@ -110,9 +113,7 @@ export function createUndoActions(ctx: EditorContext) {
function commitNodeUpdate(nodeId: string, previous: Partial<SceneNode>, label = 'Update') {
const node = ctx.graph.getNode(nodeId)
if (!node) return
const current = Object.fromEntries(
(Object.keys(previous) as (keyof SceneNode)[]).map((key) => [key, node[key]])
) as Partial<SceneNode>
const current = pick(node, Object.keys(previous) as (keyof SceneNode)[]) as Partial<SceneNode>
ctx.undo.push({
label,
forward: () => {

View file

@ -1,3 +1,5 @@
import { isNotNil } from 'es-toolkit/predicate'
import { BLACK } from '#core/constants'
import type { NodeChange, VariableDataValuesEntry, Color, GUID } from '#core/kiwi/binary/codec'
import { populateAndApplyOverrides } from '#core/kiwi/instance-overrides'
@ -424,7 +426,7 @@ export function importNodeChanges(
}
const activeRootIds =
options.populate === 'first-page'
? [firstPageId, ...componentPageIds].filter(Boolean)
? [firstPageId, ...componentPageIds].filter(isNotNil)
: undefined
populateAndApplyOverrides(

View file

@ -1,3 +1,5 @@
import { orderBy, sortBy } from 'es-toolkit/array'
import { colorToHex, colorDistance as colorDist } from '#core/color'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import type { Color } from '#core/types'
@ -27,7 +29,7 @@ interface ColorCluster {
function clusterColors(colors: ColorInfo[], threshold: number): ColorCluster[] {
const clusters: ColorCluster[] = []
const used = new Set<string>()
const sorted = [...colors].sort((a, b) => b.count - a.count)
const sorted = orderBy(colors, ['count'], ['desc'])
for (const color of sorted) {
if (used.has(color.hex)) continue
@ -50,7 +52,7 @@ function clusterColors(colors: ColorInfo[], threshold: number): ColorCluster[] {
if (cluster.colors.length > 1) clusters.push(cluster)
}
return clusters.sort((a, b) => b.colors.length - a.colors.length)
return orderBy(clusters, [(cluster) => cluster.colors.length], ['desc'])
}
function collectColors(graph: SceneGraph): { colors: ColorInfo[]; totalNodes: number } {
@ -118,7 +120,7 @@ export const analyzeColorsCommand: RpcCommand<AnalyzeColorsArgs, AnalyzeColorsRe
args.threshold ?? 15
)
: []
return { colors: colors.sort((a, b) => b.count - a.count), totalNodes, clusters }
return { colors: orderBy(colors, ['count'], ['desc']), totalNodes, clusters }
}
}
@ -166,7 +168,7 @@ export const analyzeTypographyCommand: RpcCommand<AnalyzeTypographyArgs, Analyze
}
}
return { styles: [...styleMap.values()].sort((a, b) => b.count - a.count), totalTextNodes }
return { styles: orderBy([...styleMap.values()], ['count'], ['desc']), totalTextNodes }
}
}
@ -210,9 +212,11 @@ export const analyzeSpacingCommand: RpcCommand<void, AnalyzeSpacingResult> = {
}
const toValues = (map: Map<number, number>) =>
[...map.entries()]
.map(([value, count]) => ({ value, count }))
.sort((a, b) => b.count - a.count)
orderBy(
[...map.entries()].map(([value, count]) => ({ value, count })),
['count'],
['desc']
)
return { gaps: toValues(gapMap), paddings: toValues(paddingMap), totalNodes }
}
@ -250,9 +254,8 @@ function buildSignature(graph: SceneGraph, node: SceneNode): string {
if (!child) continue
childTypes.set(child.type, (childTypes.get(child.type) ?? 0) + 1)
}
const childPart = [...childTypes.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([t, c]) => `${t}:${c}`)
const childPart = sortBy([...childTypes.entries()], [([type]) => type])
.map(([type, count]) => `${type}:${count}`)
.join(',')
const w = Math.round(node.width / 10) * 10
const h = Math.round(node.height / 10) * 10
@ -287,11 +290,13 @@ export const analyzeClustersCommand: RpcCommand<AnalyzeClustersArgs, AnalyzeClus
sigMap.set(sig, arr)
}
const clusters = [...sigMap.entries()]
.filter(([, nodes]) => nodes.length >= minCount)
.map(([signature, nodes]) => ({ signature, nodes }))
.sort((a, b) => b.nodes.length - a.nodes.length)
.slice(0, limit)
const clusters = orderBy(
[...sigMap.entries()]
.filter(([, nodes]) => nodes.length >= minCount)
.map(([signature, nodes]) => ({ signature, nodes })),
[(cluster) => cluster.nodes.length],
['desc']
).slice(0, limit)
return { clusters, totalNodes }
}

View file

@ -1,3 +1,5 @@
import { omit, omitBy } from 'es-toolkit/object'
import { BLACK } from '#core/constants'
import type { Color } from '#core/types'
@ -21,9 +23,10 @@ export function removeVariable(graph: SceneGraph, id: string): void {
collection.variableIds = collection.variableIds.filter((vid) => vid !== id)
}
for (const node of graph.nodes.values()) {
node.boundVariables = Object.fromEntries(
Object.entries(node.boundVariables).filter(([, varId]) => varId !== id)
)
node.boundVariables = omitBy(node.boundVariables, (varId) => varId === id) as Record<
string,
string
>
}
}
@ -140,11 +143,7 @@ export function removeMode(graph: SceneGraph, collectionId: string, modeId: stri
}
for (const varId of collection.variableIds) {
const variable = graph.variables.get(varId)
if (variable) {
variable.valuesByMode = Object.fromEntries(
Object.entries(variable.valuesByMode).filter(([id]) => id !== modeId)
)
}
if (variable) variable.valuesByMode = omit(variable.valuesByMode, [modeId])
}
if (graph.activeMode.get(collectionId) === modeId) {
graph.activeMode.set(collectionId, collection.defaultModeId)
@ -236,9 +235,5 @@ export function bindVariable(
export function unbindVariable(graph: SceneGraph, nodeId: string, field: string): void {
const node = graph.nodes.get(nodeId)
if (node) {
node.boundVariables = Object.fromEntries(
Object.entries(node.boundVariables).filter(([key]) => key !== field)
)
}
if (node) node.boundVariables = omit(node.boundVariables, [field])
}

View file

@ -1,4 +1,5 @@
import type { CanvasKit, TypefaceFontProvider } from 'canvaskit-wasm'
import { uniq } from 'es-toolkit/array'
import { DEFAULT_FONT_FAMILY, IS_BROWSER, GOOGLE_FONTS_API_KEY } from '#core/constants'
import type { SceneGraph } from '#core/scene-graph'
@ -204,7 +205,7 @@ export class FontManager {
async listFamilies(): Promise<string[]> {
const fonts = this.localFonts ?? (await this.requestLocalFontAccess())
return [...new Set(fonts.map((f) => f.family))].sort()
return uniq(fonts.map((f) => f.family)).sort()
}
async fetchBundledFont(url: string): Promise<ArrayBuffer | null> {

View file

@ -1,3 +1,5 @@
import { omit } from 'es-toolkit/object'
import type { CharacterStyleOverride, StyleRun, TextDecoration } from '#core/scene-graph'
export function getStyleAt(runs: StyleRun[], index: number): CharacterStyleOverride {
@ -51,11 +53,7 @@ export function removeStyleFromRange(
if (chars[i]) {
const current = chars[i]
if (!current) continue
const copy = Object.fromEntries(
Object.entries(current).filter(
([key]) => !keys.includes(key as keyof CharacterStyleOverride)
)
) as CharacterStyleOverride
const copy = omit(current, keys) as CharacterStyleOverride
chars[i] = Object.keys(copy).length > 0 ? copy : null
}
}

View file

@ -1,3 +1,5 @@
import { orderBy, sortBy } from 'es-toolkit/array'
import { defineTool } from '#core/tools/schema'
interface SizedItem {
@ -64,8 +66,7 @@ export const analyzeClusters = defineTool({
const width = Math.round(raw.width / 10) * 10
const height = Math.round(raw.height / 10) * 10
const childSignature = [...childTypes.entries()]
.sort(([a], [b]) => a.localeCompare(b))
const childSignature = sortBy([...childTypes.entries()], [([type]) => type])
.map(([type, count]) => `${type}:${count}`)
.join(',')
const signature = `${raw.type}:${width}x${height}|${childSignature}`
@ -83,9 +84,10 @@ export const analyzeClusters = defineTool({
return false
})
const clusters = [...signatureMap.entries()]
.filter(([, nodes]) => nodes.length >= minCount)
.map(([signature, nodes]) => {
const clusters = orderBy(
[...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 widths = nodes.map((node) => node.width)
@ -94,19 +96,20 @@ export const analyzeClusters = defineTool({
const heightRange = Math.max(...heights) - Math.min(...heights)
const confidence = calcClusterConfidence(nodes)
return {
signature,
count: nodes.length,
avgWidth: Math.round(avgWidth),
avgHeight: Math.round(avgHeight),
widthRange: Math.round(widthRange),
heightRange: Math.round(heightRange),
confidence,
examples: nodes.slice(0, 3).map((node) => ({ id: node.id, name: node.name }))
}
})
.sort((a, b) => b.count - a.count)
.slice(0, limit)
return {
signature,
count: nodes.length,
avgWidth: Math.round(avgWidth),
avgHeight: Math.round(avgHeight),
widthRange: Math.round(widthRange),
heightRange: Math.round(heightRange),
confidence,
examples: nodes.slice(0, 3).map((node) => ({ id: node.id, name: node.name }))
}
}),
['count'],
['desc']
).slice(0, limit)
return { totalNodes, clusters }
}

View file

@ -1,3 +1,5 @@
import { orderBy } from 'es-toolkit/array'
import { colorDistance, colorToHex } from '#core/color'
import { defineTool } from '#core/tools/schema'
import type { Color } from '#core/types'
@ -67,7 +69,7 @@ export const analyzeColors = defineTool({
return false
})
const colors = [...colorMap.values()].sort((a, b) => b.count - a.count).slice(0, limit)
const colors = orderBy([...colorMap.values()], ['count'], ['desc']).slice(0, limit)
const result: Record<string, unknown> = {
totalNodes,
@ -76,9 +78,11 @@ export const analyzeColors = defineTool({
}
if (args.show_similar) {
const hardcoded = [...colorMap.values()]
.filter((c) => !c.variableName)
.sort((a, b) => b.count - a.count)
const hardcoded = orderBy(
[...colorMap.values()].filter((c) => !c.variableName),
['count'],
['desc']
)
const used = new Set<string>()
const clusters: { colors: string[]; totalCount: number; suggestedHex: string }[] = []
@ -102,7 +106,7 @@ export const analyzeColors = defineTool({
}
}
result.similarClusters = clusters.sort((a, b) => b.colors.length - a.colors.length)
result.similarClusters = orderBy(clusters, [(cluster) => cluster.colors.length], ['desc'])
}
return result

View file

@ -1,3 +1,5 @@
import { orderBy } from 'es-toolkit/array'
import { defineTool } from '#core/tools/schema'
export const analyzeSpacing = defineTool({
@ -36,13 +38,13 @@ export const analyzeSpacing = defineTool({
return false
})
const gaps = [...gapMap.entries()]
.sort((a, b) => b[1] - a[1])
.map(([value, count]) => ({ value, count, onGrid: value % gridSize === 0 }))
const gaps = orderBy([...gapMap.entries()], [(entry) => entry[1]], ['desc']).map(
([value, count]) => ({ value, count, onGrid: value % gridSize === 0 })
)
const paddings = [...paddingMap.entries()]
.sort((a, b) => b[1] - a[1])
.map(([value, count]) => ({ value, count, onGrid: value % gridSize === 0 }))
const paddings = orderBy([...paddingMap.entries()], [(entry) => entry[1]], ['desc']).map(
([value, count]) => ({ value, count, onGrid: value % gridSize === 0 })
)
const offGridGaps = gaps.filter((gap) => !gap.onGrid)
const offGridPaddings = paddings.filter((padding) => !padding.onGrid)

View file

@ -1,3 +1,5 @@
import { orderBy, sortBy } from 'es-toolkit/array'
import { defineTool } from '#core/tools/schema'
export const analyzeTypography = defineTool({
@ -43,16 +45,16 @@ export const analyzeTypography = defineTool({
return false
})
const styles = [...styleMap.values()].sort((a, b) => b.count - a.count)
const styles = orderBy([...styleMap.values()], ['count'], ['desc'])
if (args.group_by === 'family') {
const byFamily = new Map<string, number>()
for (const s of styles) byFamily.set(s.family, (byFamily.get(s.family) ?? 0) + s.count)
return {
totalTextNodes,
groups: [...byFamily.entries()]
.sort((a, b) => b[1] - a[1])
.map(([family, count]) => ({ family, count }))
groups: orderBy([...byFamily.entries()], [(entry) => entry[1]], ['desc']).map(
([family, count]) => ({ family, count })
)
}
}
@ -61,9 +63,10 @@ export const analyzeTypography = defineTool({
for (const s of styles) bySize.set(s.size, (bySize.get(s.size) ?? 0) + s.count)
return {
totalTextNodes,
groups: [...bySize.entries()]
.sort((a, b) => a[0] - b[0])
.map(([size, count]) => ({ size, count }))
groups: sortBy([...bySize.entries()], [(entry) => entry[0]]).map(([size, count]) => ({
size,
count
}))
}
}
@ -72,9 +75,9 @@ export const analyzeTypography = defineTool({
for (const s of styles) byWeight.set(s.weight, (byWeight.get(s.weight) ?? 0) + s.count)
return {
totalTextNodes,
groups: [...byWeight.entries()]
.sort((a, b) => b[1] - a[1])
.map(([weight, count]) => ({ weight, count }))
groups: orderBy([...byWeight.entries()], [(entry) => entry[1]], ['desc']).map(
([weight, count]) => ({ weight, count })
)
}
}

View file

@ -1,3 +1,5 @@
import { uniq } from 'es-toolkit/array'
import { defineTool } from '#core/tools/schema'
export const listFonts = defineTool({
@ -43,7 +45,7 @@ export const listAvailableFonts = defineTool({
},
execute: async (figma, args) => {
const fonts = await figma.listAvailableFontsAsync()
let families = Array.from(new Set(fonts.map((font) => font.fontName.family)))
let families = uniq(fonts.map((font) => font.fontName.family))
if (args.family) {
const q = args.family.toLowerCase()
families = families.filter((family) => family.toLowerCase().includes(q))