Wire editor actions, move kiwi-serialize, clean dead code

- VariablesDialog: replace 7 manual undo.push + graph mutation + requestRender
  patterns with editor.renameCollection/addVariable/removeVariable etc.
- PositionSection: replace inline alignment/flip/rotate geometry with
  editor.alignNodes/flipNodes/rotateNodes
- Move kiwi-serialize.ts into kiwi/ where it belongs
- Remove dead createPropertyChange export from undo.ts
- Un-export internal queryFonts/FontInfo from fonts.ts
- Deduplicate font weight maps: single FONT_WEIGHT_NAMES in fonts.ts,
  figma-api-proxy.ts imports from there
This commit is contained in:
Danila Poyarkov 2026-03-16 22:30:56 +03:00
parent 4b49d69601
commit 30f8a3fda6
10 changed files with 57 additions and 280 deletions

View file

@ -7,7 +7,7 @@ import {
buildFigKiwi,
parseFigKiwiChunks,
decompressFigKiwiDataAsync
} from './kiwi-serialize'
} from './kiwi/kiwi-serialize'
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/codec'
import { decodeBinarySchema, compileSchema, ByteBuffer } from './kiwi/kiwi-schema'
import { nodeChangeToProps, sortChildren } from './kiwi/kiwi-convert'

View file

@ -1,5 +1,5 @@
import { zipSync, type Zippable } from 'fflate'
import { buildFigKiwi } from './kiwi-serialize'
import { buildFigKiwi } from './kiwi/kiwi-serialize'
interface CompressMessage {
schemaDeflated: Uint8Array

View file

@ -1,7 +1,7 @@
import { zipSync, deflateSync, type Zippable } from 'fflate'
import { CANVAS_BG_COLOR, IS_BROWSER, IS_TAURI } from './constants'
import { sceneNodeToKiwi, fractionalPosition, buildFigKiwi, buildFontDigestMap, safeColor } from './kiwi-serialize'
import { sceneNodeToKiwi, fractionalPosition, buildFigKiwi, buildFontDigestMap, safeColor } from './kiwi/kiwi-serialize'
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/codec'
import { stringToGuid } from './kiwi/kiwi-convert'
import { renderThumbnail } from './render-image'

View file

@ -9,6 +9,7 @@ import type {
LayoutMode,
} from './scene-graph'
import { normalizeColor } from './color'
import { FONT_WEIGHT_NAMES } from './fonts'
import type { Rect } from './types'
import { copyFills, copyStrokes, copyEffects } from './copy'
@ -17,42 +18,24 @@ const MIXED = Symbol('mixed')
export type FigmaFontName = { family: string; style: string }
export function weightToStyleName(weight: number, italic: boolean): string {
const names: Record<number, string> = {
100: 'Thin',
200: 'Extra Light',
300: 'Light',
400: 'Regular',
500: 'Medium',
600: 'Semi Bold',
700: 'Bold',
800: 'Extra Bold',
900: 'Black'
}
const base = names[weight] ?? 'Regular'
const base = FONT_WEIGHT_NAMES[weight] ?? 'Regular'
return italic ? `${base} Italic` : base
}
const STYLE_NAME_TO_WEIGHT: Record<string, number> = Object.fromEntries([
...Object.entries(FONT_WEIGHT_NAMES).map(([w, name]) => [name.toLowerCase(), Number(w)]),
['ultra light', 200],
['', 400],
['demi bold', 600],
['ultra bold', 800],
['heavy', 900]
])
export function styleNameToWeight(style: string): { weight: number; italic: boolean } {
const lower = style.toLowerCase()
const italic = lower.includes('italic')
const clean = lower.replace(/italic/i, '').trim()
const map: Record<string, number> = {
thin: 100,
'extra light': 200,
'ultra light': 200,
light: 300,
regular: 400,
'': 400,
medium: 500,
'semi bold': 600,
'demi bold': 600,
bold: 700,
'extra bold': 800,
'ultra bold': 800,
black: 900,
heavy: 900
}
return { weight: map[clean] ?? 400, italic }
return { weight: STYLE_NAME_TO_WEIGHT[clean] ?? 400, italic }
}
export const INTERNAL_ID = Symbol('id')

View file

@ -29,7 +29,7 @@ export function getFontProvider(): TypefaceFontProvider | null {
return fontProvider
}
export async function queryFonts(): Promise<FontInfo[]> {
async function queryFonts(): Promise<FontInfo[]> {
if (!IS_BROWSER || !window.queryLocalFonts) return []
try {
const fonts = await window.queryLocalFonts()
@ -353,17 +353,20 @@ export function setCJKFallbackFamily(family: string): void {
cjkFallbackFamily = family
}
export function weightToStyle(weight: number, italic = false): string {
let label = 'Regular'
if (weight <= 100) label = 'Thin'
else if (weight <= 200) label = 'ExtraLight'
else if (weight <= 300) label = 'Light'
else if (weight <= 400) label = 'Regular'
else if (weight <= 500) label = 'Medium'
else if (weight <= 600) label = 'SemiBold'
else if (weight <= 700) label = 'Bold'
else if (weight <= 800) label = 'ExtraBold'
else if (weight >= 900) label = 'Black'
if (italic) label += ' Italic'
return label
export const FONT_WEIGHT_NAMES: Record<number, string> = {
100: 'Thin',
200: 'Extra Light',
300: 'Light',
400: 'Regular',
500: 'Medium',
600: 'Semi Bold',
700: 'Bold',
800: 'Extra Bold',
900: 'Black'
}
export function weightToStyle(weight: number, italic = false): string {
const rounded = Math.round(weight / 100) * 100
const label = (FONT_WEIGHT_NAMES[rounded] ?? 'Regular').replace(/ /g, '')
return italic ? `${label} Italic` : label
}

View file

@ -97,6 +97,7 @@ export { computeLayout, computeAllLayouts, setTextMeasurer } from './layout'
export type { TextMeasurer } from './layout'
export { getCanvasKit, getGpuBackend, type CanvasKitOptions, type GpuBackend } from './canvaskit'
export {
FONT_WEIGHT_NAMES,
collectFontKeys,
loadFont,
listFamilies,
@ -171,7 +172,7 @@ export {
sceneNodeToKiwi,
fractionalPosition,
mapToFigmaType
} from './kiwi-serialize'
} from './kiwi/kiwi-serialize'
export {
createElement,

View file

@ -2,13 +2,13 @@ export const FIG_KIWI_VERSION = 106
import { deflateSync, inflateSync } from 'fflate'
import { weightToStyle, getLoadedFontData } from './fonts'
import { encodeVectorNetworkBlob } from './vector'
import { stringToGuid, VARIABLE_BINDING_FIELDS } from './kiwi/kiwi-convert'
import { weightToStyle, getLoadedFontData } from '../fonts'
import { encodeVectorNetworkBlob } from '../vector'
import { stringToGuid, VARIABLE_BINDING_FIELDS } from './kiwi-convert'
import type { NodeChange, Paint, VariableConsumptionEntry } from './kiwi/codec'
import type { SceneGraph, SceneNode, CharacterStyleOverride } from './scene-graph'
import type { Color, GUID } from './types'
import type { NodeChange, Paint, VariableConsumptionEntry } from './codec'
import type { SceneGraph, SceneNode, CharacterStyleOverride } from '../scene-graph'
import type { Color, GUID } from '../types'
const fontDigestCache = new Map<string, Uint8Array>()

View file

@ -1,5 +1,3 @@
import type { SceneNode } from './scene-graph'
export interface UndoEntry {
label: string
forward: () => void
@ -92,18 +90,4 @@ export class UndoManager {
}
}
export function createPropertyChange(
node: SceneNode,
changes: Partial<SceneNode>,
label: string
): UndoEntry {
const previous = Object.fromEntries(
(Object.keys(changes) as (keyof SceneNode)[]).map((key) => [key, node[key]])
) as Partial<SceneNode>
return {
label,
forward: () => Object.assign(node, changes),
inverse: () => Object.assign(node, previous)
}
}

View file

@ -69,20 +69,7 @@ function commitRenameCollection(id: string, input: HTMLInputElement) {
const value = input.value.trim()
const collection = store.graph.variableCollections.get(id)
if (collection && value && value !== collection.name) {
const oldName = collection.name
store.undo.push({
label: 'Rename collection',
forward: () => {
store.graph.variableCollections.set(id, { ...collection, name: value })
store.requestRender()
},
inverse: () => {
store.graph.variableCollections.set(id, { ...collection, name: oldName })
store.requestRender()
}
})
store.graph.variableCollections.set(id, { ...collection, name: value })
store.requestRender()
store.renameCollection(id, value)
}
editingCollectionId.value = null
}
@ -119,42 +106,15 @@ function shortName(variable: Variable): string {
function commitNameEdit(variable: Variable, newName: string) {
if (newName && newName !== variable.name) {
const oldName = variable.name
store.undo.push({
label: 'Rename variable',
forward: () => {
variable.name = newName
store.requestRender()
},
inverse: () => {
variable.name = oldName
store.requestRender()
}
})
variable.name = newName
store.requestRender()
store.renameVariable(variable.id, newName)
}
}
function updateColorValue(variable: Variable, modeId: string, color: Color) {
const oldValue = structuredClone(variable.valuesByMode[modeId])
store.undo.push({
label: 'Change variable value',
forward: () => {
variable.valuesByMode[modeId] = color
store.requestRender()
},
inverse: () => {
variable.valuesByMode[modeId] = oldValue
store.requestRender()
}
})
variable.valuesByMode[modeId] = color
store.requestRender()
store.updateVariableValue(variable.id, modeId, color)
}
function commitValueEdit(variable: Variable, modeId: string, newValue: string) {
const oldValue = structuredClone(variable.valuesByMode[modeId])
let parsed: VariableValue
if (variable.type === 'COLOR') {
parsed = parseColor(newValue.startsWith('#') ? newValue : `#${newValue}`)
@ -167,19 +127,7 @@ function commitValueEdit(variable: Variable, modeId: string, newValue: string) {
} else {
parsed = newValue
}
store.undo.push({
label: 'Change variable value',
forward: () => {
variable.valuesByMode[modeId] = parsed
store.requestRender()
},
inverse: () => {
variable.valuesByMode[modeId] = oldValue
store.requestRender()
}
})
variable.valuesByMode[modeId] = parsed
store.requestRender()
store.updateVariableValue(variable.id, modeId, parsed)
}
function addVariable() {
@ -201,19 +149,7 @@ function addVariable() {
description: '',
hiddenFromPublishing: false
}
store.undo.push({
label: 'Create variable',
forward: () => {
store.graph.addVariable(variable)
store.requestRender()
},
inverse: () => {
store.graph.removeVariable(id)
store.requestRender()
}
})
store.graph.addVariable(variable)
store.requestRender()
store.addVariable(variable)
}
function addCollection() {
@ -225,42 +161,12 @@ function addCollection() {
defaultModeId: 'default',
variableIds: []
}
const prevTab = activeTab.value
store.undo.push({
label: 'Create collection',
forward: () => {
store.graph.addCollection(collection)
activeTab.value = id
store.requestRender()
},
inverse: () => {
store.graph.removeCollection(id)
activeTab.value = prevTab
store.requestRender()
}
})
store.graph.addCollection(collection)
store.addCollection(collection)
activeTab.value = id
store.requestRender()
}
function removeVariable(id: string) {
const variable = store.graph.variables.get(id)
if (!variable) return
const snapshot = structuredClone(variable)
store.undo.push({
label: 'Delete variable',
forward: () => {
store.graph.removeVariable(id)
store.requestRender()
},
inverse: () => {
store.graph.addVariable(snapshot)
store.requestRender()
}
})
store.graph.removeVariable(id)
store.requestRender()
store.removeVariable(id)
}
const columns = computed<ColumnDef<Variable>[]>(() => {

View file

@ -19,107 +19,7 @@ const hValue = multiProp('height')
const rotationValue = computed(() =>
isMulti.value ? multiProp('rotation').value : Math.round(node.value?.rotation ?? 0)
)
type HAlign = 'left' | 'center' | 'right'
type VAlign = 'top' | 'center' | 'bottom'
function alignHorizontal(align: HAlign) {
const selected = nodes.value
if (selected.length === 0) return
let minX: number, maxX: number
if (selected.length === 1) {
const parent = store.graph.getNode(selected[0].parentId ?? '')
if (!parent) return
minX = 0
maxX = parent.width
} else {
minX = Infinity
maxX = -Infinity
for (const n of selected) {
const abs = store.graph.getAbsolutePosition(n.id)
minX = Math.min(minX, abs.x)
maxX = Math.max(maxX, abs.x + n.width)
}
}
for (const n of selected) {
let targetX: number
if (selected.length === 1) {
if (align === 'left') targetX = minX
else if (align === 'right') targetX = maxX - n.width
else targetX = (minX + maxX) / 2 - n.width / 2
store.updateNode(n.id, { x: targetX })
} else {
const abs = store.graph.getAbsolutePosition(n.id)
if (align === 'left') targetX = minX
else if (align === 'right') targetX = maxX - n.width
else targetX = (minX + maxX) / 2 - n.width / 2
store.updateNode(n.id, { x: n.x + (targetX - abs.x) })
}
}
store.requestRender()
}
function alignVertical(align: VAlign) {
const selected = nodes.value
if (selected.length === 0) return
let minY: number, maxY: number
if (selected.length === 1) {
const parent = store.graph.getNode(selected[0].parentId ?? '')
if (!parent) return
minY = 0
maxY = parent.height
} else {
minY = Infinity
maxY = -Infinity
for (const n of selected) {
const abs = store.graph.getAbsolutePosition(n.id)
minY = Math.min(minY, abs.y)
maxY = Math.max(maxY, abs.y + n.height)
}
}
for (const n of selected) {
let targetY: number
if (selected.length === 1) {
if (align === 'top') targetY = minY
else if (align === 'bottom') targetY = maxY - n.height
else targetY = (minY + maxY) / 2 - n.height / 2
store.updateNode(n.id, { y: targetY })
} else {
const abs = store.graph.getAbsolutePosition(n.id)
if (align === 'top') targetY = minY
else if (align === 'bottom') targetY = maxY - n.height
else targetY = (minY + maxY) / 2 - n.height / 2
store.updateNode(n.id, { y: n.y + (targetY - abs.y) })
}
}
store.requestRender()
}
function flipHorizontal() {
for (const n of nodes.value) {
store.updateNodeWithUndo(n.id, { flipX: !n.flipX }, 'Flip horizontal')
}
store.requestRender()
}
function flipVertical() {
for (const n of nodes.value) {
store.updateNodeWithUndo(n.id, { flipY: !n.flipY }, 'Flip vertical')
}
}
function rotate90() {
for (const n of nodes.value) {
store.updateNodeWithUndo(n.id, { rotation: (n.rotation + 90) % 360 }, 'Rotate 90°')
}
store.requestRender()
}
const ids = computed(() => nodes.value.map((n) => n.id))
</script>
<template>
@ -133,7 +33,7 @@ function rotate90() {
class="flex size-7 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-align-left"
title="Align left"
@click="alignHorizontal('left')"
@click="store.alignNodes(ids, 'horizontal', 'min')"
>
<icon-lucide-align-horizontal-justify-start class="size-3.5" />
</button>
@ -141,7 +41,7 @@ function rotate90() {
class="flex size-7 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-align-center-h"
title="Align center horizontally"
@click="alignHorizontal('center')"
@click="store.alignNodes(ids, 'horizontal', 'center')"
>
<icon-lucide-align-horizontal-justify-center class="size-3.5" />
</button>
@ -149,7 +49,7 @@ function rotate90() {
class="flex size-7 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-align-right"
title="Align right"
@click="alignHorizontal('right')"
@click="store.alignNodes(ids, 'horizontal', 'max')"
>
<icon-lucide-align-horizontal-justify-end class="size-3.5" />
</button>
@ -159,7 +59,7 @@ function rotate90() {
class="flex size-7 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-align-top"
title="Align top"
@click="alignVertical('top')"
@click="store.alignNodes(ids, 'vertical', 'min')"
>
<icon-lucide-align-vertical-justify-start class="size-3.5" />
</button>
@ -167,7 +67,7 @@ function rotate90() {
class="flex size-7 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-align-center-v"
title="Align center vertically"
@click="alignVertical('center')"
@click="store.alignNodes(ids, 'vertical', 'center')"
>
<icon-lucide-align-vertical-justify-center class="size-3.5" />
</button>
@ -175,7 +75,7 @@ function rotate90() {
class="flex size-7 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-align-bottom"
title="Align bottom"
@click="alignVertical('bottom')"
@click="store.alignNodes(ids, 'vertical', 'max')"
>
<icon-lucide-align-vertical-justify-end class="size-3.5" />
</button>
@ -235,7 +135,7 @@ function rotate90() {
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-flip-horizontal"
title="Flip horizontal"
@click="flipHorizontal"
@click="store.flipNodes(ids, 'horizontal')"
>
<icon-lucide-flip-horizontal class="size-3.5" />
</button>
@ -243,7 +143,7 @@ function rotate90() {
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-flip-vertical"
title="Flip vertical"
@click="flipVertical"
@click="store.flipNodes(ids, 'vertical')"
>
<icon-lucide-flip-vertical class="size-3.5" />
</button>
@ -251,7 +151,7 @@ function rotate90() {
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
data-test-id="position-rotate-90"
title="Rotate 90°"
@click="rotate90"
@click="store.rotateNodes(ids, 90)"
>
<icon-lucide-rotate-cw class="size-3.5" />
</button>