chore: format codebase

This commit is contained in:
Danila Poyarkov 2026-05-24 12:15:29 +03:00
parent a38c0ac7b5
commit abd795bd9a
135 changed files with 1119 additions and 616 deletions

View file

@ -22,6 +22,9 @@ jobs:
- run: bun install --frozen-lockfile
- name: Format check
run: bun run format:check
- run: bun run build
- uses: cloudflare/wrangler-action@v3

View file

@ -34,6 +34,9 @@ jobs:
- run: bun install --frozen-lockfile
- name: Format check
run: bun run format:check
- name: Lint & typecheck
run: bun run check

View file

@ -15,6 +15,7 @@
"lint": "bun run lint:structure && oxlint -c oxlint.json --type-aware --type-check src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/",
"lint:structure": "oxlint -c oxlint.json vite.config.ts vite/ src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ tests/ scripts/",
"format": "oxfmt --write .oxfmtrc.json vite.config.ts vite/ src/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/vue/src/ tests scripts/",
"format:check": "bun run format && status=$(git status --porcelain -uall) && test -z \"$status\" || (echo \"$status\" && exit 1)",
"check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:i18n && bun run check:packages && bun run check:arch && bun run test:dupes",
"check:i18n": "bun scripts/check-locales.ts",
"check:packages": "bun scripts/check-package-metadata.ts",

View file

@ -119,7 +119,9 @@ async function exportFromFile(format: string, args: ExportArgs) {
}
const formatId = format.toLowerCase()
let options: { format?: string; scale?: number; quality?: number; renderThumbnail?: boolean } | undefined
let options:
| { format?: string; scale?: number; quality?: number; renderThumbnail?: boolean }
| undefined
if (format === 'JSX') {
options = { format: args.style }
} else if (format === 'FIG') {

View file

@ -1,6 +1,7 @@
import type { Canvas } from 'canvaskit-wasm'
import { getAbsolutePosition, getWorldMatrix } from '#core/canvas/coordinate'
import type { SkiaRenderer, RenderOverlays } from '#core/canvas/renderer'
import {
LABEL_OFFSET_Y,
SIZE_PILL_PADDING_X,
@ -12,8 +13,6 @@ import {
import { rotatedCorners } from '#core/geometry'
import type { SceneNode, SceneGraph } from '#core/scene-graph'
import type { SkiaRenderer, RenderOverlays } from '#core/canvas/renderer'
import { ellipsizeLabelText } from './text'
function getOverlayRotation(node: SceneNode, overlays?: RenderOverlays): number {

View file

@ -1,5 +1,7 @@
import type { Canvas } from 'canvaskit-wasm'
import { drawNodeHighlightRect } from '#core/canvas/highlight-rect'
import type { SkiaRenderer } from '#core/canvas/renderer'
import {
AI_ACTIVE_COLOR,
AI_DONE_COLOR,
@ -8,9 +10,6 @@ import {
} from '#core/constants'
import type { SceneGraph } from '#core/scene-graph'
import { drawNodeHighlightRect } from '#core/canvas/highlight-rect'
import type { SkiaRenderer } from '#core/canvas/renderer'
export function drawAiOverlays(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph): void {
const now = performance.now()

View file

@ -70,7 +70,13 @@ function toScreenRect(r: SkiaRenderer, [x, y, width, height]: RectTuple) {
)
}
function drawStripedRect(r: SkiaRenderer, canvas: Canvas, rectTuple: RectTuple, color: Color, fill: Color) {
function drawStripedRect(
r: SkiaRenderer,
canvas: Canvas,
rectTuple: RectTuple,
color: Color,
fill: Color
) {
const [, , width, height] = rectTuple
if (width <= 0 || height <= 0) return
const rect = toScreenRect(r, rectTuple)
@ -209,13 +215,7 @@ function drawSpacingHover(
) {
const rects = gapRects(node, graph)
for (const rect of rects) {
drawStripedRect(
r,
canvas,
rect,
AUTO_LAYOUT_HOVER_MAGENTA,
AUTO_LAYOUT_HOVER_MAGENTA_FILL
)
drawStripedRect(r, canvas, rect, AUTO_LAYOUT_HOVER_MAGENTA, AUTO_LAYOUT_HOVER_MAGENTA_FILL)
}
if (!showValue || rects.length === 0) return
const [x, y, width, height] = rects[0]

View file

@ -1,12 +1,12 @@
import type { ImageFilter, MaskFilter, Canvas, Paint, Path } from 'canvaskit-wasm'
import * as AiOverlays from '#core/canvas/overlays/ai'
import * as Effects from '#core/canvas/effects'
import * as Fills from '#core/canvas/fills'
import * as Labels from '#core/canvas/labels/draw'
import * as NodeEditOverlay from '#core/canvas/node-edit-overlay'
import type { NodeEditOverlayState } from '#core/canvas/node-edit-overlay'
import * as Overlays from '#core/canvas/overlays'
import * as AiOverlays from '#core/canvas/overlays/ai'
import * as PenOverlay from '#core/canvas/pen-overlay'
import type { SkiaRenderer } from '#core/canvas/renderer'
import type { RenderOverlays } from '#core/canvas/renderer/types'
@ -95,7 +95,11 @@ const rendererMethods: ThisType<SkiaRenderer> = {
Overlays.drawLayoutInsertIndicator(this, canvas, indicator)
},
drawAutoLayoutHover(canvas: Canvas, graph: SceneGraph, hover?: RenderOverlays['autoLayoutHover']) {
drawAutoLayoutHover(
canvas: Canvas,
graph: SceneGraph,
hover?: RenderOverlays['autoLayoutHover']
) {
Overlays.drawAutoLayoutHover(this, canvas, graph, hover)
},

View file

@ -176,7 +176,11 @@ export function render(
canvas.scale(r.dpr, r.dpr)
p.beginPhase('render:scene')
if (layer === 'scene' && !hasVolatileOverlays && renderSceneBacking(r, canvas, graph, sceneVersion)) {
if (
layer === 'scene' &&
!hasVolatileOverlays &&
renderSceneBacking(r, canvas, graph, sceneVersion)
) {
p.setScenePictureMode('hit', 'backing')
} else {
canvas.translate(r.panX, r.panY)

View file

@ -45,10 +45,7 @@ export function updateSceneBackingPreviewState(r: SkiaRenderer, layer: RenderLay
if (layer !== 'scene') return
const previous = r.lastSceneViewport
const viewportChanged =
!previous ||
previous.panX !== r.panX ||
previous.panY !== r.panY ||
previous.zoom !== r.zoom
!previous || previous.panX !== r.panX || previous.panY !== r.panY || previous.zoom !== r.zoom
if (viewportChanged) {
const timestamp = now()
if (r.sceneBackingLastViewportEventAt > 0) {
@ -85,7 +82,12 @@ function backingScreenCoverageContainsViewport(r: SkiaRenderer): boolean {
const scale = r.zoom / backing.zoom
const x = r.panX - backing.panX * scale
const y = r.panY - backing.panY * scale
return x <= 0 && y <= 0 && x + backing.width * scale >= r.viewportWidth && y + backing.height * scale >= r.viewportHeight
return (
x <= 0 &&
y <= 0 &&
x + backing.width * scale >= r.viewportWidth &&
y + backing.height * scale >= r.viewportHeight
)
}
function backingWorldCoverageContainsLiveViewport(r: SkiaRenderer): boolean {
@ -129,12 +131,7 @@ function drawSceneBacking(
const backing = r.sceneBacking
if (
!backing ||
!backingCoverageContainsLiveViewport(
r,
sceneVersion,
allowStaleZoom,
positionPreviewVersion
)
!backingCoverageContainsLiveViewport(r, sceneVersion, allowStaleZoom, positionPreviewVersion)
) {
return false
}
@ -464,4 +461,3 @@ export function renderSceneBacking(
positionPreviewVersion
)
}

View file

@ -47,7 +47,12 @@ type SmoothCornerPathParams = {
arcSectionLength: number
}
function smoothCornerRadii(node: SceneNode, width: number, height: number, spread: number): Record<SmoothCornerKey, SmoothCorner> {
function smoothCornerRadii(
node: SceneNode,
width: number,
height: number,
spread: number
): Record<SmoothCornerKey, SmoothCorner> {
const radius = (value: number) => Math.max(0, value + spread)
const radii: Record<SmoothCornerKey, number> = node.independentCorners
? {
@ -63,7 +68,11 @@ function smoothCornerRadii(node: SceneNode, width: number, height: number, sprea
bottomLeft: radius(node.cornerRadius)
}
if (radii.topLeft === radii.topRight && radii.topRight === radii.bottomRight && radii.bottomRight === radii.bottomLeft) {
if (
radii.topLeft === radii.topRight &&
radii.topRight === radii.bottomRight &&
radii.bottomRight === radii.bottomLeft
) {
const budget = Math.min(width, height) / 2
const clampedRadius = Math.min(radii.topLeft, budget)
return {
@ -80,7 +89,10 @@ function smoothCornerRadii(node: SceneNode, width: number, height: number, sprea
bottomRight: -1,
bottomLeft: -1
}
const adjacentByCorner: Record<SmoothCornerKey, Array<{ corner: SmoothCornerKey; sideLength: number }>> = {
const adjacentByCorner: Record<
SmoothCornerKey,
Array<{ corner: SmoothCornerKey; sideLength: number }>
> = {
topLeft: [
{ corner: 'topRight', sideLength: width },
{ corner: 'bottomLeft', sideLength: height }
@ -99,7 +111,9 @@ function smoothCornerRadii(node: SceneNode, width: number, height: number, sprea
]
}
for (const corner of (Object.keys(radii) as SmoothCornerKey[]).sort((a, b) => radii[b] - radii[a])) {
for (const corner of (Object.keys(radii) as SmoothCornerKey[]).sort(
(a, b) => radii[b] - radii[a]
)) {
const cornerRadius = radii[corner]
const budget = Math.min(
...adjacentByCorner[corner].map((adjacent) => {
@ -154,34 +168,115 @@ function smoothCornerPathParams(corner: SmoothCorner, smoothing: number): Smooth
}
}
function drawTopRightSmoothCorner(path: Path, corner: SmoothCornerPathParams, x: number, y: number) {
function drawTopRightSmoothCorner(
path: Path,
corner: SmoothCornerPathParams,
x: number,
y: number
) {
if (corner.radius === 0) {
path.lineTo(x + corner.p, y)
return
}
path.cubicTo(x + corner.a, y, x + corner.a + corner.b, y, x + corner.a + corner.b + corner.c, y + corner.d)
path.arcToRotated(corner.radius, corner.radius, 0, true, false, x + corner.p - corner.d, y + corner.p - corner.a - corner.b - corner.c)
path.cubicTo(x + corner.p, y + corner.p - corner.a - corner.b, x + corner.p, y + corner.p - corner.a, x + corner.p, y + corner.p)
path.cubicTo(
x + corner.a,
y,
x + corner.a + corner.b,
y,
x + corner.a + corner.b + corner.c,
y + corner.d
)
path.arcToRotated(
corner.radius,
corner.radius,
0,
true,
false,
x + corner.p - corner.d,
y + corner.p - corner.a - corner.b - corner.c
)
path.cubicTo(
x + corner.p,
y + corner.p - corner.a - corner.b,
x + corner.p,
y + corner.p - corner.a,
x + corner.p,
y + corner.p
)
}
function drawBottomRightSmoothCorner(path: Path, corner: SmoothCornerPathParams, x: number, y: number) {
function drawBottomRightSmoothCorner(
path: Path,
corner: SmoothCornerPathParams,
x: number,
y: number
) {
if (corner.radius === 0) {
path.lineTo(x, y + corner.p)
return
}
path.cubicTo(x, y + corner.a, x, y + corner.a + corner.b, x - corner.d, y + corner.a + corner.b + corner.c)
path.arcToRotated(corner.radius, corner.radius, 0, true, false, x - corner.p + corner.a + corner.b + corner.c, y + corner.p - corner.d)
path.cubicTo(x - corner.p + corner.a + corner.b, y + corner.p, x - corner.p + corner.a, y + corner.p, x - corner.p, y + corner.p)
path.cubicTo(
x,
y + corner.a,
x,
y + corner.a + corner.b,
x - corner.d,
y + corner.a + corner.b + corner.c
)
path.arcToRotated(
corner.radius,
corner.radius,
0,
true,
false,
x - corner.p + corner.a + corner.b + corner.c,
y + corner.p - corner.d
)
path.cubicTo(
x - corner.p + corner.a + corner.b,
y + corner.p,
x - corner.p + corner.a,
y + corner.p,
x - corner.p,
y + corner.p
)
}
function drawBottomLeftSmoothCorner(path: Path, corner: SmoothCornerPathParams, x: number, y: number) {
function drawBottomLeftSmoothCorner(
path: Path,
corner: SmoothCornerPathParams,
x: number,
y: number
) {
if (corner.radius === 0) {
path.lineTo(x - corner.p, y)
return
}
path.cubicTo(x - corner.a, y, x - corner.a - corner.b, y, x - corner.a - corner.b - corner.c, y - corner.d)
path.arcToRotated(corner.radius, corner.radius, 0, true, false, x - corner.p + corner.d, y - corner.p + corner.a + corner.b + corner.c)
path.cubicTo(x - corner.p, y - corner.p + corner.a + corner.b, x - corner.p, y - corner.p + corner.a, x - corner.p, y - corner.p)
path.cubicTo(
x - corner.a,
y,
x - corner.a - corner.b,
y,
x - corner.a - corner.b - corner.c,
y - corner.d
)
path.arcToRotated(
corner.radius,
corner.radius,
0,
true,
false,
x - corner.p + corner.d,
y - corner.p + corner.a + corner.b + corner.c
)
path.cubicTo(
x - corner.p,
y - corner.p + corner.a + corner.b,
x - corner.p,
y - corner.p + corner.a,
x - corner.p,
y - corner.p
)
}
function drawTopLeftSmoothCorner(path: Path, corner: SmoothCornerPathParams, x: number, y: number) {
@ -189,9 +284,31 @@ function drawTopLeftSmoothCorner(path: Path, corner: SmoothCornerPathParams, x:
path.lineTo(x, y - corner.p)
return
}
path.cubicTo(x, y - corner.a, x, y - corner.a - corner.b, x + corner.d, y - corner.a - corner.b - corner.c)
path.arcToRotated(corner.radius, corner.radius, 0, true, false, x + corner.p - corner.a - corner.b - corner.c, y - corner.p + corner.d)
path.cubicTo(x + corner.p - corner.a - corner.b, y - corner.p, x + corner.p - corner.a, y - corner.p, x + corner.p, y - corner.p)
path.cubicTo(
x,
y - corner.a,
x,
y - corner.a - corner.b,
x + corner.d,
y - corner.a - corner.b - corner.c
)
path.arcToRotated(
corner.radius,
corner.radius,
0,
true,
false,
x + corner.p - corner.a - corner.b - corner.c,
y - corner.p + corner.d
)
path.cubicTo(
x + corner.p - corner.a - corner.b,
y - corner.p,
x + corner.p - corner.a,
y - corner.p,
x + corner.p,
y - corner.p
)
}
export function makeSmoothRRectPath(

View file

@ -205,7 +205,14 @@ export function drawRRectStrokeWithAlign(
stroke: Stroke
): void {
if (nodeHasSmoothCorners(node)) {
drawStrokeWithAlign(r, canvas, node, r.ck.LTRBRect(0, 0, node.width, node.height), true, stroke.align)
drawStrokeWithAlign(
r,
canvas,
node,
r.ck.LTRBRect(0, 0, node.width, node.height),
true,
stroke.align
)
return
}

View file

@ -24,11 +24,7 @@ export function derivedUnderlineRect(node: Pick<SceneNode, 'width'>, baselineY:
}
}
export function drawFigmaDerivedText(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode
): boolean {
export function drawFigmaDerivedText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): boolean {
if (!node.figmaDerivedTextGlyphs?.length) return false
let underlineBaselineY = 0

View file

@ -1,12 +1,17 @@
import type { Path } from 'canvaskit-wasm'
import type { SceneNode } from '#core/scene-graph'
import { textNodeToOutlineLayout } from '#core/text/outlines'
import type { OutlineCommand } from '#core/text/opentype'
import { textNodeToOutlineLayout } from '#core/text/outlines'
import type { SkiaRenderer } from './renderer'
function appendOutlineCommand(path: Path, command: OutlineCommand, xOffset: number, yOffset: number): void {
function appendOutlineCommand(
path: Path,
command: OutlineCommand,
xOffset: number,
yOffset: number
): void {
switch (command.type) {
case 'M':
path.moveTo((command.x ?? 0) + xOffset, (command.y ?? 0) + yOffset)

View file

@ -1,10 +1,10 @@
import { inflateSync, deflateSync } from 'fflate'
import { shapeTextForClipboard } from './canvas/text'
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/fig/codec'
import type { NodeChange as KiwiNodeChange } from './kiwi/fig/codec'
import { populateAndApplyOverrides } from './kiwi/fig/instance-overrides'
import type { InstanceNodeChange } from './kiwi/fig/instance-overrides'
import { decodeBinarySchema, compileSchema, ByteBuffer } from './kiwi/schema-runtime'
import { nodeChangeToProps, sortChildren } from './kiwi/fig/node-change/convert'
import {
sceneNodeToKiwi,
@ -15,9 +15,9 @@ import {
makeCanvasNodeChange,
buildFontDigestMap
} from './kiwi/fig/node-change/serialize'
import { decodeBinarySchema, compileSchema, ByteBuffer } from './kiwi/schema-runtime'
import { randomInt } from './random'
import type { SceneGraph, SceneNode } from './scene-graph'
import { shapeTextForClipboard } from './canvas/text'
import { buildDerivedTextDataV4 } from './text/derived-text/clipboard'
interface FigmaClipboardMeta {
@ -337,7 +337,10 @@ export async function buildFigmaClipboardHTML(
if (!source) return
change.textAutoResize = 'NONE'
change.textUserLayoutVersion = 5
change.lineHeight = { value: source.lineHeight ?? 100, units: source.lineHeight ? 'PIXELS' : 'PERCENT' }
change.lineHeight = {
value: source.lineHeight ?? 100,
units: source.lineHeight ? 'PIXELS' : 'PERCENT'
}
const shaped = await shapeTextForClipboard(source).catch(() => null)
change.derivedTextData = await buildDerivedTextDataV4(source, fontDigestMap, shaped, blobs)
})

View file

@ -395,10 +395,7 @@ function applyTextStyleOverrides(props: Record<string, unknown>, o: Partial<Scen
applyTextAlignmentOverrides(props, o)
}
function applyTextAlignmentOverrides(
props: Record<string, unknown>,
o: Partial<SceneNode>
): void {
function applyTextAlignmentOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
const textAlign = props.textAlign ?? props.textAlignHorizontal ?? props.textHorizontalAlignment
if (typeof textAlign === 'string') {
o.textAlignHorizontal = TEXT_ALIGN_ALIAS_MAP[textAlign.toLowerCase()] ?? 'LEFT'

View file

@ -10,11 +10,11 @@ import type { Vector } from '#core/types'
import { createClipboardCopyActions } from './clipboard/copy'
import { createClipboardExportActions } from './clipboard/export'
import { createClipboardFontActions } from './clipboard/fonts'
import { createClipboardImageActions } from './clipboard/images'
import { deleteIds, recreateSnapshots, restoreDeletedEntries } from './clipboard/history'
import { createClipboardImageActions } from './clipboard/images'
import { replaceTargetsWithCreated, selectedReplacementTargets } from './clipboard/paste-replace'
import { resolvePasteTarget } from './clipboard/paste-target'
import { createClipboardPlacementActions } from './clipboard/placement'
import { replaceTargetsWithCreated, selectedReplacementTargets } from './clipboard/paste-replace'
import { collectSubtrees, restoreSubtree, snapshotSubtree } from './clipboard/subtree-history'
import type { EditorContext } from './types'

View file

@ -1,15 +1,10 @@
import type { EditorContext } from '#core/editor/types'
import { computeAbsoluteBounds } from '#core/geometry'
import { computeAllLayouts } from '#core/layout'
import type { SceneNode } from '#core/scene-graph'
import type { EditorContext } from '#core/editor/types'
import { deleteIds, type DeletedEntry, recreateSnapshots, restoreDeletedEntries } from './history'
import { collectSubtrees, snapshotSubtree } from './subtree-history'
import {
deleteIds,
type DeletedEntry,
recreateSnapshots,
restoreDeletedEntries
} from './history'
type CenterNodesAt = (nodeIds: string[], cx: number, cy: number) => void

View file

@ -2,12 +2,12 @@ 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'
import type {
ComponentPropertyDefinition,
ComponentPropertyType,
SceneNode
} from '#core/scene-graph'
import { buildVariantName, parseVariantName } from '#core/scene-graph/variant-name'
export type VariantConflict = {
values: Record<string, string>

View file

@ -11,7 +11,8 @@ export function selectedNodesInSharedParent(ctx: EditorContext, selectedNodes: S
if (topLevel.length === 0 || topLevel.some((node) => node.locked)) return null
const parentId = topLevel[0].parentId ?? ctx.state.currentPageId
if (!topLevel.every((node) => (node.parentId ?? ctx.state.currentPageId) === parentId)) return null
if (!topLevel.every((node) => (node.parentId ?? ctx.state.currentPageId) === parentId))
return null
const parent = ctx.graph.getNode(parentId)
return parent ? { topLevel, parentId, parent } : null

View file

@ -11,7 +11,6 @@ import {
snapshotPage as createPageSnapshot,
type PageSnapshot
} from './history/snapshot'
import type { EditorContext } from './types'
export function createUndoActions(ctx: EditorContext) {

View file

@ -219,7 +219,11 @@ export class FigmaAPI implements NodeProxyHost {
return (node as BaseNode & { [INTERNAL_ID]: string })[INTERNAL_ID]
}
group(nodes: ReadonlyArray<FigmaNodeProxy>, parent: FigmaNodeProxy, index?: number): FigmaGroupNode
group(
nodes: ReadonlyArray<FigmaNodeProxy>,
parent: FigmaNodeProxy,
index?: number
): FigmaGroupNode
group(nodes: ReadonlyArray<BaseNode>, parent: BaseNode & ChildrenMixin, index?: number): GroupNode
group(
nodes: ReadonlyArray<BaseNode | FigmaNodeProxy>,
@ -424,8 +428,16 @@ export class FigmaAPI implements NodeProxyHost {
// --- Flatten ---
flatten(nodes: ReadonlyArray<FigmaNodeProxy>, parent?: FigmaNodeProxy, index?: number): FigmaVectorNode
flatten(nodes: ReadonlyArray<BaseNode>, parent?: BaseNode & ChildrenMixin, index?: number): VectorNode
flatten(
nodes: ReadonlyArray<FigmaNodeProxy>,
parent?: FigmaNodeProxy,
index?: number
): FigmaVectorNode
flatten(
nodes: ReadonlyArray<BaseNode>,
parent?: BaseNode & ChildrenMixin,
index?: number
): VectorNode
flatten(
nodes: ReadonlyArray<BaseNode | FigmaNodeProxy>,
parent?: (BaseNode & ChildrenMixin) | FigmaNodeProxy,

View file

@ -118,7 +118,9 @@ export function extractPaths(svgBody: string): IconPathInfo[] {
d,
fill: resolveAttr(attrValue(tag, 'fill'), groupAttrs.fill, 'currentColor'),
stroke: resolveAttr(attrValue(tag, 'stroke'), groupAttrs.stroke, null),
strokeWidth: Number.parseFloat(attrValue(tag, 'stroke-width') ?? groupAttrs.strokeWidth ?? '1'),
strokeWidth: Number.parseFloat(
attrValue(tag, 'stroke-width') ?? groupAttrs.strokeWidth ?? '1'
),
strokeCap: attrValue(tag, 'stroke-linecap') ?? groupAttrs.strokeCap ?? 'butt',
strokeJoin: attrValue(tag, 'stroke-linejoin') ?? groupAttrs.strokeJoin ?? 'miter',
fillRule: fillRuleAttr === 'evenodd' ? 'EVENODD' : 'NONZERO'

View file

@ -4,9 +4,9 @@ import { deflateSync } from 'fflate'
import type { SkiaRenderer } from '#core/canvas'
import { CANVAS_BG_COLOR, IS_BROWSER, IS_TAURI } from '#core/constants'
import { renderThumbnail } from '#core/io/formats/raster'
import { populateAllLazyFigImportRoots } from '#core/kiwi/fig/lazy-import'
import { initCodec, getCompiledSchema, getSchemaBytes } from '#core/kiwi/fig/codec'
import type { NodeChange } from '#core/kiwi/fig/codec'
import { populateAllLazyFigImportRoots } from '#core/kiwi/fig/lazy-import'
import { stringToGuid } from '#core/kiwi/fig/node-change/convert'
import { buildFigmaPaintVariableColorMap } from '#core/kiwi/fig/node-change/export-node'
import {
@ -91,11 +91,17 @@ async function renderFigThumbnail(
): Promise<Uint8Array> {
if (!pageId) return THUMBNAIL_1X1
if (ck && renderer) {
return renderThumbnail(ck, renderer, graph, pageId, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) ?? THUMBNAIL_1X1
return (
renderThumbnail(ck, renderer, graph, pageId, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) ??
THUMBNAIL_1X1
)
}
if (!renderHeadless || IS_BROWSER || IS_TAURI) return THUMBNAIL_1X1
const { headlessRenderThumbnail } = await import('#core/io/formats/raster')
return (await headlessRenderThumbnail(graph, pageId, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)) ?? THUMBNAIL_1X1
return (
(await headlessRenderThumbnail(graph, pageId, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)) ??
THUMBNAIL_1X1
)
}
function assignVariableGuids(

View file

@ -77,11 +77,7 @@ function applyPadding(style: Record<string, string>, node: SceneNode): void {
else style.padding = `${px(pt)} ${px(pr)} ${px(pb)} ${px(pl)}`
}
function applyLayoutStyle(
style: Record<string, string>,
node: SceneNode,
graph: SceneGraph
): void {
function applyLayoutStyle(style: Record<string, string>, node: SceneNode, graph: SceneGraph): void {
const ctx = getNodeContext(node, graph)
if (ctx.isGrid) {
@ -143,11 +139,9 @@ function applyAppearanceStyle(style: Record<string, string>, node: SceneNode): v
function applyTextStyle(style: Record<string, string>, node: SceneNode): void {
if (node.type !== 'TEXT') return
style.fontSize = px(node.fontSize)
if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY)
style.fontFamily = node.fontFamily
if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY) style.fontFamily = node.fontFamily
if (node.fontWeight !== 400) style.fontWeight = String(node.fontWeight)
if (node.textAlignHorizontal !== 'LEFT')
style.textAlign = node.textAlignHorizontal.toLowerCase()
if (node.textAlignHorizontal !== 'LEFT') style.textAlign = node.textAlignHorizontal.toLowerCase()
const textColor = solidFillColor(node.fills)
if (textColor) style.color = textColor
}

View file

@ -1,5 +1,5 @@
import { polygonVertices } from '#core/geometry'
import { nodeHasRadius } from '#core/canvas/shapes'
import { polygonVertices } from '#core/geometry'
import type { SceneNode, VectorNetwork, VectorSegment, VectorVertex } from '#core/scene-graph'
const CMD_CLOSE = 0

View file

@ -1,10 +1,10 @@
import { isNotNil } from 'es-toolkit/predicate'
import { BLACK } from '#core/constants'
import { setLazyFigImportContext } from '#core/kiwi/fig/lazy-import'
import type { NodeChange, VariableDataValuesEntry, Color, GUID } from '#core/kiwi/fig/codec'
import { populateAndApplyOverrides } from '#core/kiwi/fig/instance-overrides'
import type { InstanceNodeChange } from '#core/kiwi/fig/instance-overrides'
import { setLazyFigImportContext } from '#core/kiwi/fig/lazy-import'
import {
guidToString,
nodeChangeToProps,
@ -19,10 +19,14 @@ import type { VariableType, VariableValue } from '#core/scene-graph'
type AssetRef = { key: string; version?: string }
type AliasRef = { guid?: GUID; assetRef?: AssetRef }
function applyImportedCanvasMetadata(page: ReturnType<SceneGraph['addPage']>, canvasNc: NodeChange) {
function applyImportedCanvasMetadata(
page: ReturnType<SceneGraph['addPage']>,
canvasNc: NodeChange
) {
page.source.format = 'fig'
page.source.orderKey = canvasNc.parentIndex?.position ?? null
if (canvasNc.backgroundColor) page.source.fig.rawNodeFields.backgroundColor = structuredClone(canvasNc.backgroundColor)
if (canvasNc.backgroundColor)
page.source.fig.rawNodeFields.backgroundColor = structuredClone(canvasNc.backgroundColor)
page.source.fig.rawNodeFields.strokeJoin = canvasNc.strokeJoin
page.source.fig.rawNodeFields.strokeWeight = canvasNc.strokeWeight
if (canvasNc.pageType) page.source.fig.rawNodeFields.pageType = canvasNc.pageType
@ -476,7 +480,8 @@ export function importNodeChanges(
)
})
if (activeRootIds) rememberLazyFigImportContext(graph, changeMap, guidToNodeId, blobs, activeRootIds)
if (activeRootIds)
rememberLazyFigImportContext(graph, changeMap, guidToNodeId, blobs, activeRootIds)
setVariableColorResolver(null)

View file

@ -63,7 +63,8 @@ function applySwapProp(
val: ComponentPropValue,
modified?: Set<string>
): void {
const swapId = propTextCharacters(val) ?? (val.guidValue ? guidToString(val.guidValue) : undefined)
const swapId =
propTextCharacters(val) ?? (val.guidValue ? guidToString(val.guidValue) : undefined)
const newCompId = swapId ? ctx.guidToNodeId.get(swapId) : undefined
if (!newCompId) return
applyPatchAndMark(

View file

@ -1,10 +1,8 @@
import {
applyInstanceDirectAssignments,
applyOverrideAssignments
} from './assignments'
import { collectAssignmentsMap, collectPropRefsMap } from './maps'
import type { OverrideContext } from '#core/kiwi/fig/instance-overrides/types'
import { applyInstanceDirectAssignments, applyOverrideAssignments } from './assignments'
import { collectAssignmentsMap, collectPropRefsMap } from './maps'
/**
* Apply all component property assignments (visibility toggles, instance swaps).
*

View file

@ -1,11 +1,10 @@
import type { GUID } from '#core/kiwi/fig/codec'
import { guidToString } from '#core/kiwi/fig/node-change/convert'
import type {
ComponentPropAssignment,
ComponentPropValue,
OverrideContext
} from '#core/kiwi/fig/instance-overrides/types'
import { guidToString } from '#core/kiwi/fig/node-change/convert'
export function normalizePropName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]/g, '')
@ -42,7 +41,8 @@ function resolveAssignmentValue(
if (variableValue?.symbolIdValue?.guid) return { guidValue: variableValue.symbolIdValue.guid }
if (variableValue?.boolValue !== undefined) return { boolValue: variableValue.boolValue }
if (variableValue?.textValue !== undefined) return { textValue: variableValue.textValue }
if (variableValue?.textDataValue !== undefined) return { textDataValue: variableValue.textDataValue }
if (variableValue?.textDataValue !== undefined)
return { textDataValue: variableValue.textDataValue }
return resolveDefaults ? (ctx.propDefaults.get(key) ?? assignment.value) : assignment.value
}

View file

@ -1,5 +1,5 @@
import { resolveGeometryPaths } from '#core/kiwi/fig/node-change/convert'
import type { DerivedSymbolOverride } from '#core/kiwi/fig/instance-overrides/types'
import { resolveGeometryPaths } from '#core/kiwi/fig/node-change/convert'
import type { GeometryPath, SceneNode } from '#core/scene-graph'
function scaleGeometryBlobs(geom: GeometryPath[], sx: number, sy: number): GeometryPath[] {
@ -36,12 +36,20 @@ export function resolveDsdGeometry(
if (fg.length > 0) result.fillGeometry = fg
else if (d.size && target.fillGeometry.length > 0 && target.width > 0 && target.height > 0) {
result.fillGeometry = scaleGeometryBlobs(target.fillGeometry, d.size.x / target.width, d.size.y / target.height)
result.fillGeometry = scaleGeometryBlobs(
target.fillGeometry,
d.size.x / target.width,
d.size.y / target.height
)
}
if (sg.length > 0) result.strokeGeometry = sg
else if (d.size && target.strokeGeometry.length > 0 && target.width > 0 && target.height > 0) {
result.strokeGeometry = scaleGeometryBlobs(target.strokeGeometry, d.size.x / target.width, d.size.y / target.height)
result.strokeGeometry = scaleGeometryBlobs(
target.strokeGeometry,
d.size.x / target.width,
d.size.y / target.height
)
}
return result

View file

@ -1,6 +1,9 @@
import { applyOverridePatch } from '#core/kiwi/fig/instance-overrides/patches'
import { resolveOverrideTarget } from '#core/kiwi/fig/instance-overrides/resolve'
import type { DerivedSymbolOverride, OverrideContext } from '#core/kiwi/fig/instance-overrides/types'
import type {
DerivedSymbolOverride,
OverrideContext
} from '#core/kiwi/fig/instance-overrides/types'
import { buildDsdLayoutUpdates } from './layout'
import { propagateDsdChanges } from './propagate'
@ -45,7 +48,8 @@ function resolveDsdUpdates(ctx: OverrideContext): { modified: Set<string>; sizeS
const nodeId = ctx.guidToNodeId.get(ncId)
if (!nodeId || (ctx.activeNodeIds && !ctx.activeNodeIds.has(nodeId))) continue
for (const d of derived) applyDsdOverride(ctx, visibleSiblingCount, nodeId, d, modified, sizeSet)
for (const d of derived)
applyDsdOverride(ctx, visibleSiblingCount, nodeId, d, modified, sizeSet)
}
return { modified, sizeSet }

View file

@ -1,11 +1,18 @@
import type {
DerivedSymbolOverride,
OverrideContext
} from '#core/kiwi/fig/instance-overrides/types'
import { convertLetterSpacing, convertLineHeight } from '#core/kiwi/fig/node-change/convert'
import { convertFigmaDerivedTextGlyphs } from '#core/kiwi/fig/node-change/derived-text-glyphs'
import type { DerivedSymbolOverride, OverrideContext } from '#core/kiwi/fig/instance-overrides/types'
import type { SceneNode } from '#core/scene-graph'
import { resolveDsdGeometry } from './geometry'
function getVisibleSiblingCount(ctx: OverrideContext, cache: Map<string, number>, parentId: string): number {
function getVisibleSiblingCount(
ctx: OverrideContext,
cache: Map<string, number>,
parentId: string
): number {
const cached = cache.get(parentId)
if (cached !== undefined) return cached
const count = ctx.graph.getChildren(parentId).filter((child) => child.visible).length
@ -18,12 +25,21 @@ function resolveSizeOnlyPosition(
visibleSiblingCount: Map<string, number>,
node: SceneNode
): Pick<SceneNode, 'x' | 'y'> | null {
if (!node.parentId || getVisibleSiblingCount(ctx, visibleSiblingCount, node.parentId) !== 1 || !node.componentId) return null
if (
!node.parentId ||
getVisibleSiblingCount(ctx, visibleSiblingCount, node.parentId) !== 1 ||
!node.componentId
)
return null
const source = ctx.graph.getNode(node.componentId)
if (!source) return null
const sourceParent = source.parentId ? ctx.graph.getNode(source.parentId) : null
if (!sourceParent) return { x: source.x, y: source.y }
const withinParent = source.x >= 0 && source.y >= 0 && source.x + source.width <= sourceParent.width + 0.01 && source.y + source.height <= sourceParent.height + 0.01
const withinParent =
source.x >= 0 &&
source.y >= 0 &&
source.x + source.width <= sourceParent.width + 0.01 &&
source.y + source.height <= sourceParent.height + 0.01
return withinParent ? { x: source.x, y: source.y } : { x: 0, y: 0 }
}
@ -35,9 +51,13 @@ function buildDsdTextUpdates(
const updates: Partial<SceneNode> = {}
if (d.fontSize !== undefined) updates.fontSize = d.fontSize
if (d.lineHeight !== undefined) updates.lineHeight = convertLineHeight(d.lineHeight, d.fontSize)
if (d.letterSpacing !== undefined) updates.letterSpacing = convertLetterSpacing(d.letterSpacing, d.fontSize)
if (d.letterSpacing !== undefined)
updates.letterSpacing = convertLetterSpacing(d.letterSpacing, d.fontSize)
if (d.strokeWeight !== undefined && target.strokes.length > 0) {
updates.strokes = target.strokes.map((stroke) => ({ ...stroke, weight: d.strokeWeight as number }))
updates.strokes = target.strokes.map((stroke) => ({
...stroke,
weight: d.strokeWeight as number
}))
}
const figmaDerivedTextGlyphs = convertFigmaDerivedTextGlyphs(d.derivedTextData, blobs)
if (figmaDerivedTextGlyphs.length > 0) updates.figmaDerivedTextGlyphs = figmaDerivedTextGlyphs

View file

@ -17,8 +17,10 @@ function buildCloneUpdates(
if (source.x !== clone.x) updates.x = source.x
if (source.y !== clone.y) updates.y = source.y
if (!ctx.geometryOverrideNodes.has(cloneId)) {
if (source.fillGeometry !== clone.fillGeometry) updates.fillGeometry = copyGeometryPaths(source.fillGeometry)
if (source.strokeGeometry !== clone.strokeGeometry) updates.strokeGeometry = copyGeometryPaths(source.strokeGeometry)
if (source.fillGeometry !== clone.fillGeometry)
updates.fillGeometry = copyGeometryPaths(source.fillGeometry)
if (source.strokeGeometry !== clone.strokeGeometry)
updates.strokeGeometry = copyGeometryPaths(source.strokeGeometry)
}
if (source.text === clone.text && source.figmaDerivedTextGlyphs) {
updates.figmaDerivedTextGlyphs = structuredClone(source.figmaDerivedTextGlyphs)

View file

@ -14,10 +14,10 @@ import { guidToString } from '#core/kiwi/fig/node-change/convert'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import { copyFills, copyStyleRuns } from '#core/scene-graph/copy'
import { applyComponentProperties } from './component-props'
import { applyConstraintScaling } from './constraints'
import { applyDerivedSymbolData } from './derived-symbol-data'
import { populateInstances } from './populate'
import { applyComponentProperties } from './component-props'
import { preComputeRoots } from './resolve'
import { applySymbolOverrides } from './symbol/overrides'
import { propagateOverridesTransitively } from './sync'
@ -100,7 +100,11 @@ function propagateResolvedChildPlacementClones(graph: SceneGraph): void {
const sourceChild = graph.getNode(source.childIds[i])
const child = graph.getNode(node.childIds[i])
if (!sourceChild || !child) continue
if (sourceChild.overrideKey && child.overrideKey && sourceChild.overrideKey !== child.overrideKey) {
if (
sourceChild.overrideKey &&
child.overrideKey &&
sourceChild.overrideKey !== child.overrideKey
) {
continue
}
const updates: Partial<SceneNode> = {}

View file

@ -325,7 +325,10 @@ export function resolveOverrideTarget(
* Only renames when the current name matches the root component name (preserves
* user-given names). Clears the componentIdRoot cache after changing the tree.
*/
function collectStyledStrokeDescendants(ctx: OverrideContext, nodeId: string): SceneNode['strokes'][] {
function collectStyledStrokeDescendants(
ctx: OverrideContext,
nodeId: string
): SceneNode['strokes'][] {
const result: SceneNode['strokes'][] = []
const visit = (id: string) => {
const node = ctx.graph.getNode(id)
@ -337,7 +340,11 @@ function collectStyledStrokeDescendants(ctx: OverrideContext, nodeId: string): S
return result
}
function applyStrokeDescendants(ctx: OverrideContext, nodeId: string, strokes: SceneNode['strokes'][]): void {
function applyStrokeDescendants(
ctx: OverrideContext,
nodeId: string,
strokes: SceneNode['strokes'][]
): void {
let index = 0
const visit = (id: string) => {
const node = ctx.graph.getNode(id)

View file

@ -27,12 +27,24 @@ function assignDirectUpdate(
updates: Partial<SceneNode>
): void {
switch (key) {
case 'text': updates.text = source.text; break
case 'visible': updates.visible = source.visible; break
case 'opacity': updates.opacity = source.opacity; break
case 'locked': updates.locked = source.locked; break
case 'layoutGrow': updates.layoutGrow = source.layoutGrow; break
case 'textAutoResize': updates.textAutoResize = source.textAutoResize; break
case 'text':
updates.text = source.text
break
case 'visible':
updates.visible = source.visible
break
case 'opacity':
updates.opacity = source.opacity
break
case 'locked':
updates.locked = source.locked
break
case 'layoutGrow':
updates.layoutGrow = source.layoutGrow
break
case 'textAutoResize':
updates.textAutoResize = source.textAutoResize
break
}
}
@ -59,10 +71,18 @@ function assignCopiedUpdate(
updates: Partial<SceneNode>
): void {
switch (key) {
case 'fills': updates.fills = copyFills(source.fills); break
case 'strokes': updates.strokes = copyStrokes(source.strokes); break
case 'effects': updates.effects = copyEffects(source.effects); break
case 'styleRuns': updates.styleRuns = copyStyleRuns(source.styleRuns); break
case 'fills':
updates.fills = copyFills(source.fills)
break
case 'strokes':
updates.strokes = copyStrokes(source.strokes)
break
case 'effects':
updates.effects = copyEffects(source.effects)
break
case 'styleRuns':
updates.styleRuns = copyStyleRuns(source.styleRuns)
break
}
}

View file

@ -6,7 +6,8 @@ export function guidToString(guid: GUID): string {
export function stringToGuid(str: string): GUID {
const match = str.match(/^(?:VariableID:|VariableCollectionId:)?(\d+):(\d+)$/)
if (match) return { sessionID: Number.parseInt(match[1], 10), localID: Number.parseInt(match[2], 10) }
if (match)
return { sessionID: Number.parseInt(match[1], 10), localID: Number.parseInt(match[2], 10) }
const [session, local] = str.split(':')
return { sessionID: Number.parseInt(session, 10), localID: Number.parseInt(local, 10) }
}

View file

@ -1,5 +1,5 @@
import { getLazyFigImportContext, setLazyFigImportContext } from '#core/kiwi/fig/lazy-import'
import type { InstanceNodeChange } from '#core/kiwi/fig/instance-overrides'
import { getLazyFigImportContext, setLazyFigImportContext } from '#core/kiwi/fig/lazy-import'
import { SceneGraph } from '#core/scene-graph'
import type { SceneNode, Variable, VariableCollection, DocumentColorSpace } from '#core/scene-graph'

View file

@ -22,7 +22,10 @@ self.onmessage = (e: MessageEvent<ArrayBuffer | WorkerParseRequest>) => {
const graph = importNodeChanges(nodeChanges, blobs, new Map(images), request.options)
graph.figKiwiVersion = figKiwiVersion
const serialized = serializeSceneGraph(graph)
;(self as WorkerScope).postMessage({ graph: serialized }, serializedSceneGraphTransferList(serialized))
;(self as WorkerScope).postMessage(
{ graph: serialized },
serializedSceneGraphTransferList(serialized)
)
} catch (err) {
self.postMessage({ error: err instanceof Error ? err.message : String(err) })
}

View file

@ -3,4 +3,10 @@ export { ByteBuffer } from './bb'
export { compileSchema } from './js'
export { decodeBinarySchema, encodeBinarySchema } from './binary'
export { parseSchema } from './parser'
export { validateSchema, expectFieldNumber, expectEnumValue, findDefinition, findField } from './validate'
export {
validateSchema,
expectFieldNumber,
expectEnumValue,
findDefinition,
findField
} from './validate'

View file

@ -6,7 +6,9 @@ export function findDefinition(schema: Schema, name: string): Definition | null
}
export function findField(schema: Schema, definitionName: string, fieldName: string): Field | null {
return findDefinition(schema, definitionName)?.fields.find((field) => field.name === fieldName) ?? null
return (
findDefinition(schema, definitionName)?.fields.find((field) => field.name === fieldName) ?? null
)
}
export function expectFieldNumber(

View file

@ -36,8 +36,14 @@ function updateChildFromYoga(graph: SceneGraph, child: SceneNode, yogaChild: Yog
const derived = child.figmaDerivedLayout
graph.updateNode(child.id, {
x: child.type === 'INSTANCE' ? yogaChild.getComputedLeft() : (derived?.x ?? yogaChild.getComputedLeft()),
y: child.type === 'INSTANCE' ? yogaChild.getComputedTop() : (derived?.y ?? yogaChild.getComputedTop()),
x:
child.type === 'INSTANCE'
? yogaChild.getComputedLeft()
: (derived?.x ?? yogaChild.getComputedLeft()),
y:
child.type === 'INSTANCE'
? yogaChild.getComputedTop()
: (derived?.y ?? yogaChild.getComputedTop()),
width: derived?.width ?? yogaChild.getComputedWidth(),
height: derived?.height ?? yogaChild.getComputedHeight()
})

View file

@ -155,8 +155,16 @@ export class HudRenderer {
const pictureLabel = stats.scenePictureMode === 'record' ? 'record' : 'picture'
const pictureTime =
stats.scenePictureMode === 'record' ? stats.scenePictureRecordTime : stats.scenePictureDrawTime
canvas.drawText(`${pictureLabel}: ${pictureTime.toFixed(1)}ms`, col1, y, this.textPaint, this.hudFont)
stats.scenePictureMode === 'record'
? stats.scenePictureRecordTime
: stats.scenePictureDrawTime
canvas.drawText(
`${pictureLabel}: ${pictureTime.toFixed(1)}ms`,
col1,
y,
this.textPaint,
this.hudFont
)
canvas.drawText(`flush: ${stats.flushTime.toFixed(1)}ms`, col2, y, this.textPaint, this.hudFont)
if (visiblePhases.length > 0) {

View file

@ -325,8 +325,6 @@ export class SceneGraph {
'maxHeight'
])
runPreviewUpdates(fn: () => void): void {
this.previewMutationDepth++
try {

View file

@ -1,6 +1,5 @@
import { normalizeVectorNetwork } from './vector-network'
import type { SceneNode } from './types'
import { normalizeVectorNetwork } from './vector-network'
type PreviewGraph = {
nodes: Map<string, SceneNode>

View file

@ -1,10 +1,8 @@
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'
import type { NodeChange } from '#core/kiwi/fig/codec'
import type { SceneNode } from '#core/scene-graph'
import { encodePathCommandsBlob } from '#core/kiwi/fig/node-change/path-commands'
import type { SceneNode } from '#core/scene-graph'
import { normalizeFontFamily, weightToFigmaStyle, weightToStyle } from '#core/text/fonts'
import { type GlyphOutlineMetrics, getGlyphOutlineMetricsSync } from '#core/text/opentype'
@ -136,7 +134,8 @@ export async function buildDerivedTextDataV4(
const normalizedFamily = normalizeFontFamily(node.fontFamily)
const key = `${normalizedFamily}|${style}`
const lineHeightFallback = node.lineHeight ?? Math.ceil(node.fontSize * 1.2)
const glyphMetrics = getGlyphOutlineMetricsSync(node.fontFamily, style, node.text, node.fontSize) ?? []
const glyphMetrics =
getGlyphOutlineMetricsSync(node.fontFamily, style, node.text, node.fontSize) ?? []
const fallbackAdvance = node.text.length > 0 ? node.width / Math.max(node.text.length, 1) : 0
const textGlyphs = buildTextGlyphs(node.text, glyphMetrics, fallbackAdvance, node.fontSize)
@ -144,7 +143,10 @@ export async function buildDerivedTextDataV4(
const lineBreaks = computeLineBreaks(node, glyphMetrics, textGlyphs, fallbackAdvance, shaped)
const lineBreakSet = new Set(lineBreaks)
const shapedByChar = new Map<number, (typeof shaped extends null | undefined ? never : NonNullable<typeof shaped>)['glyphs'][number]>()
const shapedByChar = new Map<
number,
(typeof shaped extends null | undefined ? never : NonNullable<typeof shaped>)['glyphs'][number]
>()
if (shaped) {
for (const g of shaped.glyphs) shapedByChar.set(g.firstCharacter, g)
}
@ -207,7 +209,11 @@ export async function buildDerivedTextDataV4(
glyphs,
fontMetaData: [
{
key: { family: normalizedFamily, style: weightToFigmaStyle(node.fontWeight, node.italic), postscript: '' },
key: {
family: normalizedFamily,
style: weightToFigmaStyle(node.fontWeight, node.italic),
postscript: ''
},
fontLineHeight: 1.2,
fontDigest: digestMap.get(key),
fontStyle: node.italic ? 'ITALIC' : 'NORMAL',

View file

@ -2,7 +2,11 @@ import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'
import type { CharacterStyleOverride, SceneNode } from '#core/scene-graph'
import { fontManager, weightToStyle } from '#core/text/fonts'
import { fontHasGlyphSync, getGlyphOutlineMetricsSync, type OutlineCommand } from '#core/text/opentype'
import {
fontHasGlyphSync,
getGlyphOutlineMetricsSync,
type OutlineCommand
} from '#core/text/opentype'
export type TextOutlineUnsupportedReason =
| 'not-text'
@ -29,7 +33,12 @@ export interface TextOutlineLayout {
const COMPLEX_SCRIPT_PATTERN = /[\u0590-\u08ff\u0900-\u0dff\ufb1d-\ufdff\ufe70-\ufeff]/
type TextStyle = Required<Pick<CharacterStyleOverride, 'fontFamily' | 'fontSize' | 'fontWeight' | 'italic' | 'letterSpacing'>>
type TextStyle = Required<
Pick<
CharacterStyleOverride,
'fontFamily' | 'fontSize' | 'fontWeight' | 'italic' | 'letterSpacing'
>
>
function baseTextStyle(node: SceneNode): TextStyle {
return {
@ -66,7 +75,10 @@ function resolvedGlyphStyle(style: TextStyle, char: string): TextStyle | null {
if (fontHasGlyphSync(style.fontFamily, styleName(style), char)) return style
const family = fallbackFamilies().find((candidate) => {
const next = fallbackStyle(style, candidate)
return fontManager.loadedData(next.fontFamily, styleName(next)) && fontHasGlyphSync(next.fontFamily, styleName(next), char)
return (
fontManager.loadedData(next.fontFamily, styleName(next)) &&
fontHasGlyphSync(next.fontFamily, styleName(next), char)
)
})
return family ? fallbackStyle(style, family) : null
}
@ -130,7 +142,12 @@ function glyphAdvance(node: SceneNode, absoluteIndex: number): number | null {
const char = node.text[absoluteIndex]
const style = resolvedGlyphStyle(textStyleAt(node, absoluteIndex), char)
if (!style) return null
const metrics = getGlyphOutlineMetricsSync(style.fontFamily, styleName(style), char, style.fontSize)
const metrics = getGlyphOutlineMetricsSync(
style.fontFamily,
styleName(style),
char,
style.fontSize
)
const glyph = metrics?.[0]
return glyph ? glyph.advance + style.letterSpacing : null
}
@ -211,7 +228,12 @@ function verticalOffset(node: SceneNode, contentHeight: number): number {
}
}
function lineGlyphs(node: SceneNode, line: TextLine, baseline: number, xOffset: number): { glyphs: TextOutlineGlyph[]; width: number } | null {
function lineGlyphs(
node: SceneNode,
line: TextLine,
baseline: number,
xOffset: number
): { glyphs: TextOutlineGlyph[]; width: number } | null {
const glyphs: TextOutlineGlyph[] = []
let cursorX = xOffset
let index = 0
@ -229,7 +251,12 @@ function lineGlyphs(node: SceneNode, line: TextLine, baseline: number, xOffset:
}
const segment = line.text.slice(index, end)
const metrics = getGlyphOutlineMetricsSync(style.fontFamily, styleName(style), segment, style.fontSize)
const metrics = getGlyphOutlineMetricsSync(
style.fontFamily,
styleName(style),
segment,
style.fontSize
)
if (!metrics) return null
for (const glyph of metrics) {
@ -258,7 +285,10 @@ export function textNodeToOutlineLayout(node: SceneNode): TextOutlineLayout | nu
if (!measured) return null
const xOffset = lineOffsetX(node, measured.width)
maxWidth = Math.max(maxWidth, measured.width)
const placed = xOffset === 0 ? measured.glyphs : measured.glyphs.map((glyph) => ({ ...glyph, x: glyph.x + xOffset }))
const placed =
xOffset === 0
? measured.glyphs
: measured.glyphs.map((glyph) => ({ ...glyph, x: glyph.x + xOffset }))
glyphs.push(...placed)
}

View file

@ -88,13 +88,13 @@ 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 widths = nodes.map((node) => node.width)
const heights = nodes.map((node) => node.height)
const widthRange = Math.max(...widths) - Math.min(...widths)
const heightRange = Math.max(...heights) - Math.min(...heights)
const confidence = calcClusterConfidence(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)
const heights = nodes.map((node) => node.height)
const widthRange = Math.max(...widths) - Math.min(...widths)
const heightRange = Math.max(...heights) - Math.min(...heights)
const confidence = calcClusterConfidence(nodes)
return {
signature,

View file

@ -4,7 +4,6 @@ import { extractPaths } from '#core/icons/svg'
import type { IconPathInfo } from '#core/icons/types'
import { parseSVGPath } from '#core/io/formats/svg/parse-path'
import { defineTool } from '#core/tools/schema'
import type { Rect } from '#core/types'
function parseSvgViewBox(svg: string): Rect | null {

View file

@ -1,10 +1,10 @@
import type { CanvasKit, Path } from 'canvaskit-wasm'
import { addOpenSegmentsToPath } from './path-helpers'
import type { VectorNetwork } from '#core/scene-graph'
import type { Vector } from '#core/types'
import { addOpenSegmentsToPath } from './path-helpers'
export function fitCircleArc(
pts: Vector[]
): { cx: number; cy: number; r: number; startAngleDeg: number; sweepDeg: number } | null {

View file

@ -12,8 +12,6 @@ export {
import type { CanvasKit, Path } from 'canvaskit-wasm'
import { addOpenSegmentsToPath, addSegmentDirected } from './path-helpers'
import type {
HandleMirroring,
VectorNetwork,
@ -22,6 +20,8 @@ import type {
VectorVertex,
WindingRule
} from '#core/scene-graph'
import { addOpenSegmentsToPath, addSegmentDirected } from './path-helpers'
export { vectorNetworkToCenterlinePath, fitCircleArc, isClosedThinCrescent } from './centerline'
// --- vectorNetworkBlob binary format ---

View file

@ -4,7 +4,9 @@ import { serve } from '@hono/node-server'
import { startServer } from './server.js'
if (process.argv.includes('--help') || process.argv.includes('-h')) {
process.stdout.write(`openpencil-mcp-http\n\nStart the OpenPencil MCP HTTP and WebSocket server.\n\nOptions:\n --help, -h Show this help message\n`)
process.stdout.write(
`openpencil-mcp-http\n\nStart the OpenPencil MCP HTTP and WebSocket server.\n\nOptions:\n --help, -h Show this help message\n`
)
process.exit(0)
}

View file

@ -6,7 +6,9 @@ import { MCP_VERSION, registerTools } from './server.js'
import { createStdioRpcBridge } from './stdio-bridge.js'
if (process.argv.includes('--help') || process.argv.includes('-h')) {
process.stdout.write(`openpencil-mcp\n\nStart the OpenPencil MCP stdio bridge.\n\nOptions:\n --help, -h Show this help message\n`)
process.stdout.write(
`openpencil-mcp\n\nStart the OpenPencil MCP stdio bridge.\n\nOptions:\n --help, -h Show this help message\n`
)
process.exit(0)
}

View file

@ -69,12 +69,15 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption
? `Save the current document to disk. If path is provided, it must be inside ${resolvedRoot}.`
: 'Save the current document to disk. Uses the existing file path if available, otherwise prompts for a location.',
inputSchema: resolvedRoot
? z.object({ path: z.string().describe('Optional absolute path for the .fig file').optional() })
? z.object({
path: z.string().describe('Optional absolute path for the .fig file').optional()
})
: z.object({})
},
async (args: { path?: string }) => {
try {
const safePath = args.path && resolvedRoot ? resolveSafePath(args.path, resolvedRoot) : undefined
const safePath =
args.path && resolvedRoot ? resolveSafePath(args.path, resolvedRoot) : undefined
const result = await sendRpc({ command: 'save_file', args: { path: safePath } })
const res = result as { ok?: boolean; error?: string }
if (res.ok === false) return fail(new Error(res.error))

View file

@ -46,7 +46,18 @@ export function handleToolMouseDown({
}
if (tool === 'SELECT') {
handleSelectDown(event, cx, cy, sx, sy, editor, hitFns, tryStartRotation, handleTextEditClick, setDrag)
handleSelectDown(
event,
cx,
cy,
sx,
sy,
editor,
hitFns,
tryStartRotation,
handleTextEditClick,
setDrag
)
return
}

View file

@ -14,8 +14,8 @@ import { createCanvasPointer } from '#vue/canvas/pointer/use'
import { createTextEditInput } from '#vue/canvas/text-edit/input'
import { handleToolMouseDown } from '#vue/canvas/tool-input/use'
import { createCanvasTransformInput } from '#vue/canvas/transform-input/use'
import { createClickCounter } from '#vue/shared/input/click-count'
import { resolveAutoLayoutHover } from '#vue/shared/input/auto-layout-hover'
import { createClickCounter } from '#vue/shared/input/click-count'
import { handleDrawMove, handleDrawUp } from '#vue/shared/input/draw'
import { handleMoveMove, handleMoveUp } from '#vue/shared/input/move'
import { handleNodeEditMove } from '#vue/shared/input/node-edit'

View file

@ -252,7 +252,8 @@ export function createLayoutActions({
updateProp('primaryAxisSizing', 'HUG')
} else {
if (n.primaryAxisSizing === 'HUG') updateProp('primaryAxisSizing', 'FIXED')
if (isInAutoLayout.value) updateProp('layoutAlignSelf', sizing === 'FILL' ? 'STRETCH' : 'AUTO')
if (isInAutoLayout.value)
updateProp('layoutAlignSelf', sizing === 'FILL' ? 'STRETCH' : 'AUTO')
}
}

View file

@ -1,6 +1,5 @@
import type { Ref } from 'vue'
import { BLACK } from '@open-pencil/core/constants'
import {
getFillOkHCL,
getStrokeOkHCL,
@ -10,6 +9,7 @@ import {
setNodeStrokeOkHCL
} from '@open-pencil/core/color'
import type { OkHCLColor } from '@open-pencil/core/color'
import { BLACK } from '@open-pencil/core/constants'
import type { Editor } from '@open-pencil/core/editor'
import type { SceneNode } from '@open-pencil/core/scene-graph'
@ -30,17 +30,11 @@ export function getStrokeOkHCLColor(node: SceneNode | null, index: number): OkHC
}
function fallbackFillOkHCL(node: SceneNode, index: number) {
return (
getFillOkHCLColor(node, index) ??
rgbaToOkHCL(node.fills[index]?.color ?? BLACK)
)
return getFillOkHCLColor(node, index) ?? rgbaToOkHCL(node.fills[index]?.color ?? BLACK)
}
function fallbackStrokeOkHCL(node: SceneNode, index: number) {
return (
getStrokeOkHCLColor(node, index) ??
rgbaToOkHCL(node.strokes[index]?.color ?? BLACK)
)
return getStrokeOkHCLColor(node, index) ?? rgbaToOkHCL(node.strokes[index]?.color ?? BLACK)
}
export function createOkHCLActions(editor: Editor) {

View file

@ -21,7 +21,11 @@ function isNear(value: number, target: number, tolerance = AUTO_LAYOUT_HOVER_TIC
return Math.abs(value - target) <= tolerance
}
function resolvePaddingHover(node: SceneNode, localX: number, localY: number): AutoLayoutHover | null {
function resolvePaddingHover(
node: SceneNode,
localX: number,
localY: number
): AutoLayoutHover | null {
const centerX = node.width / 2
const centerY = node.height / 2
@ -40,7 +44,10 @@ function resolvePaddingHover(node: SceneNode, localX: number, localY: number): A
if (isNear(localX, centerX) && isNear(localY, tickY)) {
return { nodeId: node.id, kind: 'padding-value', side: 'bottom' }
}
if (localY >= node.height - Math.min(node.paddingBottom, AUTO_LAYOUT_HOVER_PADDING_REGION_TOLERANCE)) {
if (
localY >=
node.height - Math.min(node.paddingBottom, AUTO_LAYOUT_HOVER_PADDING_REGION_TOLERANCE)
) {
return { nodeId: node.id, kind: 'padding', side: 'bottom' }
}
}
@ -60,7 +67,10 @@ function resolvePaddingHover(node: SceneNode, localX: number, localY: number): A
if (isNear(localX, tickX) && isNear(localY, centerY)) {
return { nodeId: node.id, kind: 'padding-value', side: 'right' }
}
if (localX >= node.width - Math.min(node.paddingRight, AUTO_LAYOUT_HOVER_PADDING_REGION_TOLERANCE)) {
if (
localX >=
node.width - Math.min(node.paddingRight, AUTO_LAYOUT_HOVER_PADDING_REGION_TOLERANCE)
) {
return { nodeId: node.id, kind: 'padding', side: 'right' }
}
}
@ -124,7 +134,11 @@ function resolveChildrenHover(
return null
}
export function resolveAutoLayoutHover(cx: number, cy: number, editor: Editor): AutoLayoutHover | null {
export function resolveAutoLayoutHover(
cx: number,
cy: number,
editor: Editor
): AutoLayoutHover | null {
if (editor.state.selectedIds.size !== 1) return null
const nodeId = [...editor.state.selectedIds][0]
const node = editor.graph.getNode(nodeId)
@ -139,7 +153,6 @@ export function resolveAutoLayoutHover(cx: number, cy: number, editor: Editor):
return (
resolveSpacingHover(node, children, localX, localY) ??
resolvePaddingHover(node, localX, localY) ??
resolveChildrenHover(node, children, localX, localY) ??
{ nodeId: node.id, kind: 'frame' }
resolveChildrenHover(node, children, localX, localY) ?? { nodeId: node.id, kind: 'frame' }
)
}

View file

@ -64,7 +64,8 @@ export function createSelectionMoveDrag(
editor: Editor,
duplicate: boolean
): DragState {
if (duplicate && editor.state.selectedIds.size > 0) return duplicateAndDrag(cx, cy, sx, sy, editor).drag
if (duplicate && editor.state.selectedIds.size > 0)
return duplicateAndDrag(cx, cy, sx, sy, editor).drag
const originals = collectMoveOriginals(editor)

View file

@ -1,7 +1,7 @@
import type { Ref } from 'vue'
import { BLACK } from '@open-pencil/core/constants'
import { colorToHexRaw, parseColor } from '@open-pencil/core/color'
import { BLACK } from '@open-pencil/core/constants'
import type { Editor } from '@open-pencil/core/editor'
import { randomHex } from '@open-pencil/core/random'
import type {

View file

@ -31,7 +31,8 @@ function checkRuntimePath(packageName: string, field: string, value: string): vo
function walkExports(packageName: string, value: unknown, path: string[] = []): void {
if (typeof value === 'string') {
const key = path.at(-1)
if (key !== 'types' && key !== 'bun') checkRuntimePath(packageName, `exports.${path.join('.')}`, value)
if (key !== 'types' && key !== 'bun')
checkRuntimePath(packageName, `exports.${path.join('.')}`, value)
return
}
if (!value || typeof value !== 'object') return
@ -59,7 +60,10 @@ for (const packageDir of publicPackages) {
walkExports(pkg.name, pkg.exports)
if (pkg.publishConfig && ('exports' in pkg.publishConfig || 'main' in pkg.publishConfig || 'types' in pkg.publishConfig)) {
if (
pkg.publishConfig &&
('exports' in pkg.publishConfig || 'main' in pkg.publishConfig || 'types' in pkg.publishConfig)
) {
errors.push(`${pkg.name}: publishConfig must not rewrite runtime entrypoints`)
}
}

View file

@ -30,7 +30,10 @@ try {
const tarballs: string[] = []
for (const packageDir of packageDirs) {
const output = run(['bun', 'pm', 'pack', '--destination', tempDir, '--quiet'], join(rootDir, packageDir))
const output = run(
['bun', 'pm', 'pack', '--destination', tempDir, '--quiet'],
join(rootDir, packageDir)
)
const filename = output
.split('\n')
.map((line) => line.trim())

View file

@ -9,8 +9,8 @@ import type { StepBudget, ToolLogEntry } from '@open-pencil/core/tools'
import { makeFigmaFromStore } from '@/app/automation/bridge/figma-factory'
import { getActiveEditorStore } from '@/app/editor/active-store'
import { ensureGraphFonts } from '@/app/editor/fonts'
import type { EditorStore } from '@/app/editor/active-store'
import { ensureGraphFonts } from '@/app/editor/fonts'
export const MAX_AGENT_STEPS = 50

View file

@ -413,7 +413,9 @@ export function createEffectsSection(store: EditorStore) {
cornerRadius: 22,
cornerSmoothing: 0.85,
fills: [solid({ r: 0.58, g: 0.27, b: 0.95, a: 0.7 })],
effects: [{ ...dropShadow(0, 0, 28, 0, { r: 0.56, g: 0.33, b: 1, a: 0.72 }), blendMode: 'SCREEN' }]
effects: [
{ ...dropShadow(0, 0, 28, 0, { r: 0.56, g: 0.33, b: 1, a: 0.72 }), blendMode: 'SCREEN' }
]
})
const effectBlendText = store.createShape('TEXT', 184, 34, 88, 34, effectBlendCard)
graph.updateNode(effectBlendText, {

View file

@ -43,7 +43,9 @@ function copyAction(
): () => void {
switch (id) {
case 'copy-as-text':
return runAsync(() => actions.clipboardWrite(editor.copySelectionAsText(actions.ids()), 'text'))
return runAsync(() =>
actions.clipboardWrite(editor.copySelectionAsText(actions.ids()), 'text')
)
case 'copy-as-svg':
return runAsync(() => actions.clipboardWrite(editor.copySelectionAsSVG(actions.ids()), 'SVG'))
case 'copy-as-png':

View file

@ -6,13 +6,13 @@ import { editorCommandMetadata } from '@open-pencil/vue'
import type { EditorCommandId } from '@open-pencil/vue'
import { TOOL_SHORTCUTS } from '@/app/editor/session'
import { appMenuTinykeysShortcut } from '@/app/shell/menu/shortcut'
import { isEditing } from '@/app/shell/keyboard/focus'
import { bindSpaceHandTool } from '@/app/shell/keyboard/space-tool'
import type {
KeyboardShortcutOptions,
KeyboardShortcutRunOptions
} from '@/app/shell/keyboard/types'
import { appMenuTinykeysShortcut } from '@/app/shell/menu/shortcut'
type ShortcutAction = (options: KeyboardShortcutRunOptions) => void

View file

@ -10,7 +10,10 @@ type TextFormatUpdates = {
const store = useEditorStore()
export function alignSelected(axis: 'horizontal' | 'vertical', align: 'min' | 'center' | 'max'): void {
export function alignSelected(
axis: 'horizontal' | 'vertical',
align: 'min' | 'center' | 'max'
): void {
store.alignNodes([...store.state.selectedIds], axis, align)
}
@ -41,7 +44,9 @@ export function toggleSelectedTextUnderline(): void {
})
}
export function createSharedEditorMenuActions(setTheme: (theme: 'light' | 'dark' | 'auto') => void) {
export function createSharedEditorMenuActions(
setTheme: (theme: 'light' | 'dark' | 'auto') => void
) {
return {
'zoom-in': () => store.applyZoom(-100, window.innerWidth / 2, window.innerHeight / 2),
'zoom-out': () => store.applyZoom(100, window.innerWidth / 2, window.innerHeight / 2),

View file

@ -10,7 +10,11 @@ function isActionItem(entry: AppMenuEntry): entry is AppMenuActionItem {
function findShortcutInEntries(entries: readonly AppMenuEntry[], id: string): string | undefined {
for (const entry of entries) {
if (!isActionItem(entry)) continue
if (entry.id === id) return entry.shortcut ?? (entry.command ? editorCommandMetadata(entry.command).shortcut : undefined)
if (entry.id === id)
return (
entry.shortcut ??
(entry.command ? editorCommandMetadata(entry.command).shortcut : undefined)
)
const shortcut = entry.sub ? findShortcutInEntries(entry.sub, id) : undefined
if (shortcut) return shortcut
}

View file

@ -29,7 +29,13 @@ const ctx = useColorPickerPanelContext()
:min="0"
:max="0.4"
:step="0.001"
:display="{ value: toPercent(ctx.okhcl.okhcl.c), min: 0, max: 40, step: 1, parse: fromPercent }"
:display="{
value: toPercent(ctx.okhcl.okhcl.c),
min: 0,
max: 40,
step: 1,
parse: fromPercent
}"
:gradient-style="ctx.okhclSliderGradient?.okhclChroma ?? undefined"
:thumb-fill="colorToCSS(ctx.okhclSliderPreview?.okhclChroma ?? ctx.color)"
test-id="color-slider-okhcl-c"
@ -42,7 +48,13 @@ const ctx = useColorPickerPanelContext()
:min="0"
:max="1"
:step="0.001"
:display="{ value: toPercent(ctx.okhcl.okhcl.l), min: 0, max: 100, step: 1, parse: fromPercent }"
:display="{
value: toPercent(ctx.okhcl.okhcl.l),
min: 0,
max: 100,
step: 1,
parse: fromPercent
}"
:gradient-style="ctx.okhclSliderGradient?.okhclLightness ?? undefined"
:thumb-fill="colorToCSS(ctx.okhclSliderPreview?.okhclLightness ?? ctx.color)"
test-id="color-slider-okhcl-l"
@ -55,7 +67,13 @@ const ctx = useColorPickerPanelContext()
:min="0"
:max="1"
:step="0.001"
:display="{ value: toPercent(ctx.okhcl.okhcl.a ?? 1), min: 0, max: 100, step: 1, parse: fromPercent }"
:display="{
value: toPercent(ctx.okhcl.okhcl.a ?? 1),
min: 0,
max: 100,
step: 1,
parse: fromPercent
}"
checkerboard
:gradient-style="`background: linear-gradient(to right, transparent, ${colorToCSS(ctx.color)})`"
:thumb-fill="colorToCSS(ctx.color)"

View file

@ -3,12 +3,7 @@ import { useAttrs, watch } from 'vue'
import { templateRef } from '@vueuse/core'
import { TreeItem, ContextMenuRoot, ContextMenuTrigger, ContextMenuPortal } from 'reka-ui'
import {
LayerTreeRoot,
LayerTreeItem,
useI18n,
useInlineRename
} from '@open-pencil/vue'
import { LayerTreeRoot, LayerTreeItem, useI18n, useInlineRename } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import { nodeIcon, COMPONENT_TYPES } from '@/app/editor/icons'
import CanvasMenu from './CanvasMenu.vue'
@ -53,165 +48,165 @@ function onTreeSelect(e: CustomEvent, select: (additive: boolean) => void) {
<div v-bind="attrs" class="relative min-h-0 flex-1 overflow-hidden">
<ContextMenuTrigger as-child @contextmenu="onLayerRightClick">
<div data-test-id="layers-scroll" class="scrollbar-thin h-full overflow-y-auto px-1">
<template v-if="flattenItems">
<LayerTreeItem
v-for="item in flattenItems"
:key="item._id"
v-slot="{ node, isSelected, padLeft, actions }"
:node="item.value"
:level="item.level"
:has-children="item.hasChildren"
>
<TreeItem
v-slot="{ isExpanded }"
:value="item.value"
<template v-if="flattenItems">
<LayerTreeItem
v-for="item in flattenItems"
:key="item._id"
v-slot="{ node, isSelected, padLeft, actions }"
:node="item.value"
:level="item.level"
as-child
@select="(e: CustomEvent) => onTreeSelect(e, actions.select)"
@toggle="
(e: CustomEvent) => {
if (e.detail.originalEvent?.type === 'click') e.preventDefault()
}
"
:has-children="item.hasChildren"
>
<!-- Rename mode -->
<div
v-if="rename.editingId.value === node.id"
class="flex w-full items-center gap-1 py-1"
:style="{ paddingLeft: padLeft }"
<TreeItem
v-slot="{ isExpanded }"
:value="item.value"
:level="item.level"
as-child
@select="(e: CustomEvent) => onTreeSelect(e, actions.select)"
@toggle="
(e: CustomEvent) => {
if (e.detail.originalEvent?.type === 'click') e.preventDefault()
}
"
>
<span
v-if="item.hasChildren"
class="flex w-4 shrink-0 cursor-pointer items-center justify-center text-muted transition-transform hover:text-surface"
:class="isExpanded ? 'rotate-90' : 'rotate-0'"
@click.stop="actions.toggleExpand"
>
<icon-lucide-chevron-right class="size-3" />
</span>
<span v-else class="w-4 shrink-0" />
<component :is="nodeIcon(node)" class="size-3 shrink-0 opacity-70" />
<input
ref="renameInput"
data-layer-edit
data-test-id="layers-item-input"
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0 text-xs text-surface outline-none"
:value="node.name"
@blur="rename.commit(node.id, $event)"
@keydown.stop="rename.onKeydown"
/>
</div>
<!-- Normal row -->
<button
v-else
data-test-id="layers-item"
class="group/row relative flex w-full cursor-pointer items-center gap-1 rounded border-none py-1 pr-1 text-left text-xs"
:class="[
isSelected
? 'bg-accent text-white'
: 'bg-transparent text-surface hover:bg-hover',
draggingId === node.id ? 'opacity-30' : '',
instructionTargetId === node.id && instruction?.type === 'make-child'
? 'bg-accent/15 text-surface outline-2 outline-accent outline-offset-[-2px]'
: '',
!node.visible ? 'opacity-50' : ''
]"
:style="{ paddingLeft: padLeft }"
@dblclick="rename.start(node.id, node.name)"
>
<span
v-if="item.hasChildren"
class="flex w-4 shrink-0 cursor-pointer items-center justify-center text-muted transition-transform hover:text-surface"
:class="isExpanded ? 'rotate-90' : 'rotate-0'"
@click.stop="actions.toggleExpand"
>
<icon-lucide-chevron-right class="size-3" />
</span>
<span v-else class="w-4 shrink-0" />
<component
:is="nodeIcon(node)"
class="size-3 shrink-0"
:class="
COMPONENT_TYPES.has(node.type) ? 'text-component opacity-100' : 'opacity-70'
"
/>
<span class="min-w-0 flex-1 truncate">{{ node.name }}</span>
<span
class="flex shrink-0 items-center gap-0.5"
:class="
!node.locked && node.visible ? 'opacity-0 group-hover/row:opacity-100' : ''
"
>
<Tip :label="node.locked ? t.unlock : t.lock">
<span
class="flex size-4 items-center justify-center rounded hover:bg-white/15"
@pointerdown.stop
@click.stop="actions.toggleLock"
>
<icon-lucide-lock
v-if="node.locked"
class="size-3"
:class="isSelected ? 'text-white' : 'text-surface'"
/>
<icon-lucide-unlock
v-else
class="size-3 opacity-0 group-hover/row:opacity-100"
:class="isSelected ? 'text-white/80' : 'text-surface/70'"
/>
</span>
</Tip>
<Tip :label="node.visible ? t.hide : t.show">
<span
class="flex size-4 items-center justify-center rounded hover:bg-white/15"
@pointerdown.stop
@click.stop="actions.toggleVisibility"
>
<icon-lucide-eye-off
v-if="!node.visible"
class="size-3"
:class="isSelected ? 'text-white' : 'text-surface'"
/>
<icon-lucide-eye
v-else
class="size-3 opacity-0 group-hover/row:opacity-100"
:class="isSelected ? 'text-white/80' : 'text-surface/70'"
/>
</span>
</Tip>
</span>
<!-- Rename mode -->
<div
v-if="instructionTargetId === node.id && instruction?.type === 'make-child'"
class="pointer-events-none absolute inset-y-1 rounded border border-accent bg-accent/10"
:style="{
left: `${item.level * INDENT}px`,
right: '4px'
}"
/>
v-if="rename.editingId.value === node.id"
class="flex w-full items-center gap-1 py-1"
:style="{ paddingLeft: padLeft }"
>
<span
v-if="item.hasChildren"
class="flex w-4 shrink-0 cursor-pointer items-center justify-center text-muted transition-transform hover:text-surface"
:class="isExpanded ? 'rotate-90' : 'rotate-0'"
@click.stop="actions.toggleExpand"
>
<icon-lucide-chevron-right class="size-3" />
</span>
<span v-else class="w-4 shrink-0" />
<component :is="nodeIcon(node)" class="size-3 shrink-0 opacity-70" />
<input
ref="renameInput"
data-layer-edit
data-test-id="layers-item-input"
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0 text-xs text-surface outline-none"
:value="node.name"
@blur="rename.commit(node.id, $event)"
@keydown.stop="rename.onKeydown"
/>
</div>
<!-- DnD reorder indicator -->
<div
v-if="
instructionTargetId === node.id &&
instruction &&
instruction.type !== 'make-child'
"
class="pointer-events-none absolute h-0.5 bg-accent"
:class="{
'bottom-0': instruction.type === 'reorder-below',
'top-0': instruction.type === 'reorder-above'
}"
:style="{
left: `${(item.level - 1) * INDENT}px`,
width: `calc(100% - ${(item.level - 1) * INDENT}px)`
}"
/>
</button>
</TreeItem>
</LayerTreeItem>
</template>
<!-- Normal row -->
<button
v-else
data-test-id="layers-item"
class="group/row relative flex w-full cursor-pointer items-center gap-1 rounded border-none py-1 pr-1 text-left text-xs"
:class="[
isSelected
? 'bg-accent text-white'
: 'bg-transparent text-surface hover:bg-hover',
draggingId === node.id ? 'opacity-30' : '',
instructionTargetId === node.id && instruction?.type === 'make-child'
? 'bg-accent/15 text-surface outline-2 outline-accent outline-offset-[-2px]'
: '',
!node.visible ? 'opacity-50' : ''
]"
:style="{ paddingLeft: padLeft }"
@dblclick="rename.start(node.id, node.name)"
>
<span
v-if="item.hasChildren"
class="flex w-4 shrink-0 cursor-pointer items-center justify-center text-muted transition-transform hover:text-surface"
:class="isExpanded ? 'rotate-90' : 'rotate-0'"
@click.stop="actions.toggleExpand"
>
<icon-lucide-chevron-right class="size-3" />
</span>
<span v-else class="w-4 shrink-0" />
<component
:is="nodeIcon(node)"
class="size-3 shrink-0"
:class="
COMPONENT_TYPES.has(node.type) ? 'text-component opacity-100' : 'opacity-70'
"
/>
<span class="min-w-0 flex-1 truncate">{{ node.name }}</span>
<span
class="flex shrink-0 items-center gap-0.5"
:class="
!node.locked && node.visible ? 'opacity-0 group-hover/row:opacity-100' : ''
"
>
<Tip :label="node.locked ? t.unlock : t.lock">
<span
class="flex size-4 items-center justify-center rounded hover:bg-white/15"
@pointerdown.stop
@click.stop="actions.toggleLock"
>
<icon-lucide-lock
v-if="node.locked"
class="size-3"
:class="isSelected ? 'text-white' : 'text-surface'"
/>
<icon-lucide-unlock
v-else
class="size-3 opacity-0 group-hover/row:opacity-100"
:class="isSelected ? 'text-white/80' : 'text-surface/70'"
/>
</span>
</Tip>
<Tip :label="node.visible ? t.hide : t.show">
<span
class="flex size-4 items-center justify-center rounded hover:bg-white/15"
@pointerdown.stop
@click.stop="actions.toggleVisibility"
>
<icon-lucide-eye-off
v-if="!node.visible"
class="size-3"
:class="isSelected ? 'text-white' : 'text-surface'"
/>
<icon-lucide-eye
v-else
class="size-3 opacity-0 group-hover/row:opacity-100"
:class="isSelected ? 'text-white/80' : 'text-surface/70'"
/>
</span>
</Tip>
</span>
<div
v-if="instructionTargetId === node.id && instruction?.type === 'make-child'"
class="pointer-events-none absolute inset-y-1 rounded border border-accent bg-accent/10"
:style="{
left: `${item.level * INDENT}px`,
right: '4px'
}"
/>
<!-- DnD reorder indicator -->
<div
v-if="
instructionTargetId === node.id &&
instruction &&
instruction.type !== 'make-child'
"
class="pointer-events-none absolute h-0.5 bg-accent"
:class="{
'bottom-0': instruction.type === 'reorder-below',
'top-0': instruction.type === 'reorder-above'
}"
:style="{
left: `${(item.level - 1) * INDENT}px`,
width: `calc(100% - ${(item.level - 1) * INDENT}px)`
}"
/>
</button>
</TreeItem>
</LayerTreeItem>
</template>
</div>
</ContextMenuTrigger>
</div>

View file

@ -53,45 +53,48 @@ function handlePageDblClick(
</Tip>
</div>
<div class="min-h-0 flex-1 overflow-hidden">
<div data-test-id="pages-scroll" class="scrollbar-thin h-full overflow-x-hidden overflow-y-auto px-1 pb-1">
<div v-for="pg in pages" :key="pg.id">
<div
v-if="rename.editingId.value === pg.id"
class="flex w-full items-center gap-1.5 rounded px-2 py-1"
>
<icon-lucide-file class="size-3 shrink-0 opacity-70" />
<input
ref="pageInput"
data-test-id="pages-item-input"
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0 text-xs text-surface outline-none"
:value="pg.name"
@blur="rename.commit(pg.id, $event)"
@keydown.stop="rename.onKeydown"
/>
<div
data-test-id="pages-scroll"
class="scrollbar-thin h-full overflow-x-hidden overflow-y-auto px-1 pb-1"
>
<div v-for="pg in pages" :key="pg.id">
<div
v-if="rename.editingId.value === pg.id"
class="flex w-full items-center gap-1.5 rounded px-2 py-1"
>
<icon-lucide-file class="size-3 shrink-0 opacity-70" />
<input
ref="pageInput"
data-test-id="pages-item-input"
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0 text-xs text-surface outline-none"
:value="pg.name"
@blur="rename.commit(pg.id, $event)"
@keydown.stop="rename.onKeydown"
/>
</div>
<div
v-else-if="isDivider(pg)"
class="my-1 flex items-center px-2"
@dblclick="startRename(pg)"
>
<div class="h-px flex-1 bg-border" />
</div>
<button
v-else
data-test-id="pages-item"
class="flex w-full cursor-pointer items-center gap-1.5 rounded border-none px-2 py-1 text-left text-xs"
:class="
pg.id === currentPageId
? 'bg-hover text-surface'
: 'bg-transparent text-muted hover:bg-hover hover:text-surface'
"
@click="actions.switch(pg.id)"
@dblclick="handlePageDblClick(pg, actions.rename)"
>
<icon-lucide-file class="size-3 shrink-0" />
<span class="truncate">{{ pg.name }}</span>
</button>
</div>
<div
v-else-if="isDivider(pg)"
class="my-1 flex items-center px-2"
@dblclick="startRename(pg)"
>
<div class="h-px flex-1 bg-border" />
</div>
<button
v-else
data-test-id="pages-item"
class="flex w-full cursor-pointer items-center gap-1.5 rounded border-none px-2 py-1 text-left text-xs"
:class="
pg.id === currentPageId
? 'bg-hover text-surface'
: 'bg-transparent text-muted hover:bg-hover hover:text-surface'
"
@click="actions.switch(pg.id)"
@dblclick="handlePageDblClick(pg, actions.rename)"
>
<icon-lucide-file class="size-3 shrink-0" />
<span class="truncate">{{ pg.name }}</span>
</button>
</div>
</div>
</div>
</div>

View file

@ -8,12 +8,7 @@ interface ToolButtonProps extends RequiredTestIdProps {
mobile?: boolean
}
const {
icon,
active = false,
mobile = false,
testId
} = defineProps<ToolButtonProps>()
const { icon, active = false, mobile = false, testId } = defineProps<ToolButtonProps>()
const emit = defineEmits<{
click: []

View file

@ -27,12 +27,7 @@ interface AppSelectProps<TValue extends string | number> extends TestIdProps {
ui?: AppSelectUi
}
const {
options,
placeholder,
ui,
testId = 'app-select-trigger'
} = defineProps<AppSelectProps<T>>()
const { options, placeholder, ui, testId = 'app-select-trigger' } = defineProps<AppSelectProps<T>>()
const modelValue = defineModel<T>({ required: true })

View file

@ -1,5 +1,4 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { getSelectedNodes } from '#tests/helpers/store'
const editor = useEditorSetup()
@ -20,7 +19,6 @@ function getSelectedCount() {
})
}
test('copy + paste via store duplicates a shape', async () => {
await editor.canvas.drawRect(100, 100, 120, 80)
await editor.canvas.waitForRender()

View file

@ -91,5 +91,10 @@ test('selecting a frame shows Frame in JSX', async () => {
test('switching back to Design tab works', async () => {
await designTab().click()
await expect(editor.page.getByTestId('design-panel-single').or(editor.page.getByTestId('design-panel-empty')).first()).toBeVisible()
await expect(
editor.page
.getByTestId('design-panel-single')
.or(editor.page.getByTestId('design-panel-empty'))
.first()
).toBeVisible()
})

View file

@ -140,9 +140,7 @@ test('assets panel groups component sets and inserts the default variant', async
await expect(details.getByTestId('asset-details-description')).toContainText(
'Reusable button component'
)
await expect(details.getByTestId('asset-details-library')).toContainText(
'lk-test-library'
)
await expect(details.getByTestId('asset-details-library')).toContainText('lk-test-library')
await expect(details.getByTestId('asset-details-docs')).toBeVisible()
await expect(details.getByTestId('asset-details-property')).toContainText('Type')
await page.getByTestId('asset-details-close').click()
@ -233,7 +231,9 @@ test('assets insertion accounts for entered container coordinates', async ({ pag
const selected = selectedId ? store.graph.getNode(selectedId) : null
if (!selected) return null
const abs = store.graph.getAbsolutePosition(selected.id)
const center = store.screenToCanvas(...Object.values(store.viewportCanvasCenter()) as [number, number])
const center = store.screenToCanvas(
...(Object.values(store.viewportCanvasCenter()) as [number, number])
)
return {
parentId: selected.parentId,
centerX: abs.x + selected.width / 2,

View file

@ -227,7 +227,7 @@ test('outline stroke is disabled for fill-only shapes', async () => {
await editor.page.keyboard.press('Escape')
})
test('Copy/Paste as submenu exists', async () => {
test('Copy/Paste as submenu exists', async () => {
await rightClickShape(130, 130)
const submenuTrigger = contextItem('context-copy-paste-as')

View file

@ -2,7 +2,9 @@ import { expect, test } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
test('dragging selected nested instance content reorders its auto-layout item', async ({ page }) => {
test('dragging selected nested instance content reorders its auto-layout item', async ({
page
}) => {
await page.goto('/')
const canvas = new CanvasHelper(page)
await canvas.waitForInit()
@ -83,7 +85,9 @@ test('dragging selected nested instance content reorders its auto-layout item',
canvas.assertNoErrors()
})
test('auto-layout drag does not show an insert indicator before order changes', async ({ page }) => {
test('auto-layout drag does not show an insert indicator before order changes', async ({
page
}) => {
await page.goto('/')
const canvas = new CanvasHelper(page)
await canvas.waitForInit()

View file

@ -75,7 +75,9 @@ test('dragging a layer into a container expands it and shows the child', async (
await source.dragTo(target, { targetPosition: { x: 70, y: 12 } })
await canvas.waitForRender()
await expect(page.locator(`[data-node-id="${ids.rect}"]`).getByTestId('layers-item')).toBeVisible()
await expect(
page.locator(`[data-node-id="${ids.rect}"]`).getByTestId('layers-item')
).toBeVisible()
expect(await layerOrder(page, ids.frame)).toEqual([ids.rect])
canvas.assertNoErrors()
})

View file

@ -78,14 +78,16 @@ test('stroke sides toggle shows per-side weight inputs', async () => {
await expect(toggle).toBeVisible({ timeout: 5000 })
const sectionInputsBefore = await page
.getByTestId('stroke-section').getByTestId('scrub-input')
.getByTestId('stroke-section')
.getByTestId('scrub-input')
.count()
await toggle.click()
await canvas.waitForRender()
const sectionInputsAfter = await page
.getByTestId('stroke-section').getByTestId('scrub-input')
.getByTestId('stroke-section')
.getByTestId('scrub-input')
.count()
expect(sectionInputsAfter).toBeGreaterThan(sectionInputsBefore)
@ -93,7 +95,8 @@ test('stroke sides toggle shows per-side weight inputs', async () => {
await canvas.waitForRender()
const sectionInputsFinal = await page
.getByTestId('stroke-section').getByTestId('scrub-input')
.getByTestId('stroke-section')
.getByTestId('scrub-input')
.count()
expect(sectionInputsFinal).toBe(sectionInputsBefore)
})

View file

@ -148,7 +148,9 @@ test('blend modes', async () => {
width: 324,
height: 180,
cornerRadius: 20,
fills: [{ type: 'SOLID', color: { r: 0.95, g: 0.95, b: 0.97, a: 1 }, visible: true, opacity: 1 }]
fills: [
{ type: 'SOLID', color: { r: 0.95, g: 0.95, b: 0.97, a: 1 }, visible: true, opacity: 1 }
]
})
store.graph.createNode('RECTANGLE', pageId, {
name: 'Multiply Base',
@ -157,7 +159,9 @@ test('blend modes', async () => {
width: 96,
height: 96,
cornerRadius: 20,
fills: [{ type: 'SOLID', color: { r: 1, g: 0.23, b: 0.2, a: 0.95 }, visible: true, opacity: 1 }]
fills: [
{ type: 'SOLID', color: { r: 1, g: 0.23, b: 0.2, a: 0.95 }, visible: true, opacity: 1 }
]
})
store.graph.createNode('RECTANGLE', pageId, {
name: 'Multiply Layer',
@ -167,7 +171,9 @@ test('blend modes', async () => {
height: 96,
cornerRadius: 20,
blendMode: 'MULTIPLY',
fills: [{ type: 'SOLID', color: { r: 0.12, g: 0.45, b: 1, a: 0.92 }, visible: true, opacity: 1 }]
fills: [
{ type: 'SOLID', color: { r: 0.12, g: 0.45, b: 1, a: 0.92 }, visible: true, opacity: 1 }
]
})
store.graph.createNode('ELLIPSE', pageId, {
name: 'Screen Layer',
@ -176,7 +182,9 @@ test('blend modes', async () => {
width: 104,
height: 104,
blendMode: 'SCREEN',
fills: [{ type: 'SOLID', color: { r: 0.05, g: 0.75, b: 0.45, a: 0.8 }, visible: true, opacity: 1 }]
fills: [
{ type: 'SOLID', color: { r: 0.05, g: 0.75, b: 0.45, a: 0.8 }, visible: true, opacity: 1 }
]
})
store.graph.createNode('RECTANGLE', pageId, {
name: 'Overlay Multi Fill',
@ -215,7 +223,9 @@ test('alpha mask stack', async () => {
width: 324,
height: 180,
cornerRadius: 20,
fills: [{ type: 'SOLID', color: { r: 0.08, g: 0.1, b: 0.18, a: 1 }, visible: true, opacity: 1 }]
fills: [
{ type: 'SOLID', color: { r: 0.08, g: 0.1, b: 0.18, a: 1 }, visible: true, opacity: 1 }
]
})
store.graph.createNode('RECTANGLE', frame.id, {
name: 'Unmasked Baseline',
@ -224,7 +234,9 @@ test('alpha mask stack', async () => {
width: 58,
height: 132,
cornerRadius: 12,
fills: [{ type: 'SOLID', color: { r: 0.98, g: 0.75, b: 0.18, a: 1 }, visible: true, opacity: 1 }]
fills: [
{ type: 'SOLID', color: { r: 0.98, g: 0.75, b: 0.18, a: 1 }, visible: true, opacity: 1 }
]
})
store.graph.createNode('ELLIPSE', frame.id, {
name: 'Alpha Mask',
@ -243,7 +255,9 @@ test('alpha mask stack', async () => {
width: 220,
height: 38,
rotation: -12,
fills: [{ type: 'SOLID', color: { r: 0.08, g: 0.73, b: 0.73, a: 1 }, visible: true, opacity: 1 }]
fills: [
{ type: 'SOLID', color: { r: 0.08, g: 0.73, b: 0.73, a: 1 }, visible: true, opacity: 1 }
]
})
store.graph.createNode('RECTANGLE', frame.id, {
name: 'Masked Purple Stripe',
@ -252,7 +266,9 @@ test('alpha mask stack', async () => {
width: 222,
height: 42,
rotation: -12,
fills: [{ type: 'SOLID', color: { r: 0.58, g: 0.27, b: 0.95, a: 1 }, visible: true, opacity: 1 }]
fills: [
{ type: 'SOLID', color: { r: 0.58, g: 0.27, b: 0.95, a: 1 }, visible: true, opacity: 1 }
]
})
store.graph.createNode('RECTANGLE', frame.id, {
name: 'Masked Pink Stripe',
@ -282,7 +298,9 @@ test('smoothed corners with blended shadow', async () => {
width: 272,
height: 168,
cornerRadius: 20,
fills: [{ type: 'SOLID', color: { r: 0.08, g: 0.1, b: 0.18, a: 1 }, visible: true, opacity: 1 }]
fills: [
{ type: 'SOLID', color: { r: 0.08, g: 0.1, b: 0.18, a: 1 }, visible: true, opacity: 1 }
]
})
store.graph.createNode('RECTANGLE', pageId, {
name: 'Uniform Smooth Radius',
@ -292,7 +310,9 @@ test('smoothed corners with blended shadow', async () => {
height: 88,
cornerRadius: 28,
cornerSmoothing: 0.75,
fills: [{ type: 'SOLID', color: { r: 0.58, g: 0.27, b: 0.95, a: 1 }, visible: true, opacity: 1 }],
fills: [
{ type: 'SOLID', color: { r: 0.58, g: 0.27, b: 0.95, a: 1 }, visible: true, opacity: 1 }
],
effects: [
{
type: 'DROP_SHADOW',
@ -317,7 +337,9 @@ test('smoothed corners with blended shadow', async () => {
bottomRightRadius: 34,
bottomLeftRadius: 10,
cornerSmoothing: 1,
fills: [{ type: 'SOLID', color: { r: 0.08, g: 0.73, b: 0.73, a: 1 }, visible: true, opacity: 1 }],
fills: [
{ type: 'SOLID', color: { r: 0.08, g: 0.73, b: 0.73, a: 1 }, visible: true, opacity: 1 }
],
strokes: [
{
color: { r: 1, g: 1, b: 1, a: 0.8 },

View file

@ -1,5 +1,4 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert'
import { getPageChildren, getSelectedNode } from '#tests/helpers/store'
@ -11,9 +10,7 @@ test('ScrubInput drag changes X position', async () => {
const before = await getSelectedNode(editor.page)
const initialX = expectDefined(before, 'selected rectangle before drag').x
const xScrub = editor.page
.getByTestId('position-section').getByTestId('scrub-input')
.first()
const xScrub = editor.page.getByTestId('position-section').getByTestId('scrub-input').first()
await editor.canvas.dragScrubInput(xScrub, 50)
const after = await getSelectedNode(editor.page)

View file

@ -1,32 +1,40 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert'
import { getSelectedNode } from '#tests/helpers/store'
const editor = useEditorSetup()
test('fill visibility supports repeat click and undo redo', async () => {
await editor.canvas.drawRect(120, 120, 120, 80)
await editor.canvas.waitForRender()
const fillButton = editor.page.getByTestId('fill-visibility-0')
await expect(fillButton).toBeVisible()
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').fills[0]?.visible).toBe(true)
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').fills[0]?.visible).toBe(
true
)
await fillButton.click()
await editor.canvas.waitForRender()
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').fills[0]?.visible).toBe(false)
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').fills[0]?.visible).toBe(
false
)
await fillButton.click()
await editor.canvas.waitForRender()
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').fills[0]?.visible).toBe(true)
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').fills[0]?.visible).toBe(
true
)
await editor.canvas.undo()
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').fills[0]?.visible).toBe(false)
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').fills[0]?.visible).toBe(
false
)
await editor.canvas.redo()
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').fills[0]?.visible).toBe(true)
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').fills[0]?.visible).toBe(
true
)
})
test('stroke visibility supports repeat click and undo redo', async () => {
@ -35,21 +43,31 @@ test('stroke visibility supports repeat click and undo redo', async () => {
const strokeButton = editor.page.getByTestId('stroke-visibility-0')
await expect(strokeButton).toBeVisible()
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').strokes[0]?.visible).toBe(true)
expect(
expectDefined(await getSelectedNode(editor.page), 'selected node').strokes[0]?.visible
).toBe(true)
await strokeButton.click()
await editor.canvas.waitForRender()
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').strokes[0]?.visible).toBe(false)
expect(
expectDefined(await getSelectedNode(editor.page), 'selected node').strokes[0]?.visible
).toBe(false)
await strokeButton.click()
await editor.canvas.waitForRender()
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').strokes[0]?.visible).toBe(true)
expect(
expectDefined(await getSelectedNode(editor.page), 'selected node').strokes[0]?.visible
).toBe(true)
await editor.canvas.undo()
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').strokes[0]?.visible).toBe(false)
expect(
expectDefined(await getSelectedNode(editor.page), 'selected node').strokes[0]?.visible
).toBe(false)
await editor.canvas.redo()
expect(expectDefined(await getSelectedNode(editor.page), 'selected node').strokes[0]?.visible).toBe(true)
expect(
expectDefined(await getSelectedNode(editor.page), 'selected node').strokes[0]?.visible
).toBe(true)
})
test('appearance visibility supports repeat click and undo redo in one step', async () => {

View file

@ -1,11 +1,9 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert'
import { getSelectedNode } from '#tests/helpers/store'
const editor = useEditorSetup()
function getPageChildren() {
return editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()

View file

@ -1,5 +1,4 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { getPageChildren } from '#tests/helpers/store'
import { toolbarFlyoutItemTestId, toolbarFlyoutTestId } from '#tests/helpers/test-ids'

View file

@ -1,5 +1,4 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { variablesAddTestId } from '#tests/helpers/test-ids'
const editor = useEditorSetup()
@ -57,9 +56,9 @@ test('add variable menu creates non-color variable types', async () => {
await editor.page.getByTestId('variables-add-variable').click()
await editor.page.getByTestId(variablesAddTestId('STRING')).click()
await expect(
editor.page.getByTestId('variable-row').filter({ hasText: 'New text' })
).toHaveCount(1)
await expect(editor.page.getByTestId('variable-row').filter({ hasText: 'New text' })).toHaveCount(
1
)
await editor.page.getByTestId('variables-add-variable').click()
await editor.page.getByTestId(variablesAddTestId('BOOLEAN')).click()
@ -91,10 +90,7 @@ test('color swatch opens color picker', async () => {
await editor.page.getByTestId('variables-section-open').click()
await expect(editor.page.getByTestId('variables-dialog')).toBeVisible({ timeout: 3000 })
const swatch = editor.page
.getByTestId('variable-row')
.first()
.getByTestId('color-picker-swatch')
const swatch = editor.page.getByTestId('variable-row').first().getByTestId('color-picker-swatch')
await expect(swatch).toBeVisible({ timeout: 3000 })
await swatch.click()
await expect(editor.page.getByTestId('color-picker-popover')).toBeVisible({ timeout: 5000 })

View file

@ -16,7 +16,9 @@ function actionItems(entries: readonly AppMenuEntry[]): AppMenuEntry[] {
describe('APP_MENU_SCHEMA', () => {
test('does not duplicate shortcuts for command-backed entries', () => {
const duplicated = APP_MENU_SCHEMA.flatMap((group) =>
actionItems(group.items).filter((entry) => !('type' in entry) && entry.command && entry.shortcut)
actionItems(group.items).filter(
(entry) => !('type' in entry) && entry.command && entry.shortcut
)
)
expect(duplicated).toEqual([])

View file

@ -10,7 +10,9 @@ import {
import { expectDefined } from '#tests/helpers/assert'
function expectFigmaEditableTextDefaults(textNode: NonNullable<Awaited<ReturnType<typeof parseFigmaClipboard>>>['nodes'][number]) {
function expectFigmaEditableTextDefaults(
textNode: NonNullable<Awaited<ReturnType<typeof parseFigmaClipboard>>>['nodes'][number]
) {
expect(textNode.textUserLayoutVersion).toBe(5)
expect(textNode.textExplicitLayoutVersion).toBe(1)
expect(textNode.textBidiVersion).toBe(1)
@ -73,7 +75,9 @@ describe('buildFigmaClipboardHTML', () => {
expectFigmaEditableTextDefaults(textNode)
expect(textNode.derivedTextData?.glyphs).toBeDefined()
expect(textNode.derivedTextData?.baselines?.length).toBeGreaterThan(0)
expect(textNode.derivedTextData?.logicalIndexToCharacterOffsetMap?.length).toBe(text.text.length + 1)
expect(textNode.derivedTextData?.logicalIndexToCharacterOffsetMap?.length).toBe(
text.text.length + 1
)
expect(textNode.derivedTextData?.derivedLines).toEqual([{ directionality: 'LTR' }])
})

View file

@ -1,11 +1,15 @@
import { describe, expect, test } from 'bun:test'
import { buildOpenPencilClipboardHTML, FigmaAPI, parseOpenPencilClipboard, SceneGraph } from '@open-pencil/core'
import {
buildOpenPencilClipboardHTML,
FigmaAPI,
parseOpenPencilClipboard,
SceneGraph
} from '@open-pencil/core'
import type { SceneNode } from '@open-pencil/core'
import { expectDefined } from '#tests/helpers/assert'
import type { SceneNode } from '@open-pencil/core'
describe('clipboard roundtrip with images', () => {
function graphWithImageNode(): {
graph: SceneGraph
@ -159,4 +163,3 @@ describe('clipboard roundtrip with images', () => {
expect(clipboard.images.get(hash)).toEqual(bytes)
})
})

View file

@ -21,7 +21,10 @@ describe('editor scoped hit testing', () => {
})
editor.state.enteredContainerId = frame.id
editor.setCanvasKit({} as Parameters<typeof editor.setCanvasKit>[0], {} as Parameters<typeof editor.setCanvasKit>[1])
editor.setCanvasKit(
{} as Parameters<typeof editor.setCanvasKit>[0],
{} as Parameters<typeof editor.setCanvasKit>[1]
)
expect(editor.hitTestAtPoint(130, 130)?.id).toBe(child.id)
})

View file

@ -7,8 +7,18 @@ describe('booleanOperationSelected', () => {
test('wraps selected nodes in a boolean operation container', () => {
const editor = createEditor()
const pageId = editor.state.currentPageId
const first = editor.graph.createNode('RECTANGLE', pageId, { x: 10, y: 20, width: 30, height: 40 })
const second = editor.graph.createNode('ELLIPSE', pageId, { x: 80, y: 90, width: 20, height: 10 })
const first = editor.graph.createNode('RECTANGLE', pageId, {
x: 10,
y: 20,
width: 30,
height: 40
})
const second = editor.graph.createNode('ELLIPSE', pageId, {
x: 80,
y: 90,
width: 20,
height: 10
})
editor.select([first.id, second.id])
editor.booleanOperationSelected('UNION')
@ -26,7 +36,10 @@ describe('booleanOperationSelected', () => {
test('does not wrap unsupported text nodes', () => {
const editor = createEditor()
const pageId = editor.state.currentPageId
const first = editor.graph.createNode('TEXT', pageId, { text: 'Nope', fontFamily: 'Definitely Missing Font' })
const first = editor.graph.createNode('TEXT', pageId, {
text: 'Nope',
fontFamily: 'Definitely Missing Font'
})
const second = editor.graph.createNode('RECTANGLE', pageId)
editor.select([first.id, second.id])
@ -57,7 +70,16 @@ describe('booleanOperationSelected', () => {
const editor = createEditor()
const pageId = editor.state.currentPageId
const first = editor.graph.createNode('RECTANGLE', pageId, {
fills: [{ type: 'IMAGE', imageHash: 'image', imageScaleMode: 'FILL', color: TRANSPARENT, opacity: 1, visible: true }]
fills: [
{
type: 'IMAGE',
imageHash: 'image',
imageScaleMode: 'FILL',
color: TRANSPARENT,
opacity: 1,
visible: true
}
]
})
const second = editor.graph.createNode('RECTANGLE', pageId)
@ -82,7 +104,12 @@ describe('booleanOperationSelected', () => {
expect(editor.graph.getNode(pageId)?.childIds).toEqual([before.id, booleanId, after.id])
editor.undo.undo()
expect(editor.graph.getNode(pageId)?.childIds).toEqual([before.id, first.id, second.id, after.id])
expect(editor.graph.getNode(pageId)?.childIds).toEqual([
before.id,
first.id,
second.id,
after.id
])
expect(editor.state.selectedIds).toEqual(new Set([first.id, second.id]))
editor.undo.redo()

View file

@ -45,8 +45,18 @@ describe('frameSelection', () => {
test('undo and redo restore frame selection state', () => {
const editor = createEditor()
const pageId = editor.state.currentPageId
const first = editor.graph.createNode('RECTANGLE', pageId, { x: 20, y: 30, width: 40, height: 50 })
const second = editor.graph.createNode('ELLIPSE', pageId, { x: 100, y: 120, width: 30, height: 20 })
const first = editor.graph.createNode('RECTANGLE', pageId, {
x: 20,
y: 30,
width: 40,
height: 50
})
const second = editor.graph.createNode('ELLIPSE', pageId, {
x: 100,
y: 120,
width: 30,
height: 20
})
editor.select([first.id, second.id])
editor.frameSelection()

View file

@ -46,4 +46,3 @@ describe('FigmaAPI.createImage', () => {
expect(hash).toMatch(/^[0-9a-f]{40}$/)
})
})

View file

@ -21,4 +21,3 @@ describe('degToRad / radToDeg', () => {
expect(radToDeg(degToRad(-90))).toBeCloseTo(-90, 10)
})
})

View file

@ -59,4 +59,3 @@ describe('computeAbsoluteBounds', () => {
expect(result).toEqual({ x: 10, y: 20, width: 60, height: 60 })
})
})

View file

@ -92,4 +92,3 @@ describe('rotatedBBox', () => {
expect(pos.bottom - pos.top).toBeCloseTo(neg.bottom - neg.top, 5)
})
})

View file

@ -62,7 +62,8 @@ describe('text node export', () => {
await initCodec()
const { unzipSync, inflateSync } = await import('fflate')
const { decodeBinarySchema, compileSchema, ByteBuffer } = await import('#core/kiwi/schema-runtime')
const { decodeBinarySchema, compileSchema, ByteBuffer } =
await import('#core/kiwi/schema-runtime')
const { parseFigKiwiChunks } = await import('@open-pencil/core')
const graph = new SceneGraph()
@ -123,7 +124,8 @@ describe('text node export', () => {
await initCodec()
const { unzipSync, inflateSync } = await import('fflate')
const { decodeBinarySchema, compileSchema, ByteBuffer } = await import('#core/kiwi/schema-runtime')
const { decodeBinarySchema, compileSchema, ByteBuffer } =
await import('#core/kiwi/schema-runtime')
const { parseFigKiwiChunks } = await import('@open-pencil/core')
const graph = new SceneGraph()
@ -148,7 +150,10 @@ describe('text node export', () => {
}
const message = compiled.decodeMessage(inflateSync(chunks?.[1] ?? new Uint8Array()))
const nodeChanges = message.nodeChanges as Array<Record<string, unknown>>
const textNc = expectDefined(nodeChanges.find((nc) => nc.type === 'TEXT'), 'text node change')
const textNc = expectDefined(
nodeChanges.find((nc) => nc.type === 'TEXT'),
'text node change'
)
const derivedTextData = textNc.derivedTextData as Record<string, unknown>
const fontMetaData = expectDefined(
derivedTextData.fontMetaData as Array<Record<string, unknown>> | undefined,
@ -163,7 +168,8 @@ describe('text node export', () => {
await initCodec()
const { unzipSync, inflateSync } = await import('fflate')
const { decodeBinarySchema, compileSchema, ByteBuffer } = await import('#core/kiwi/schema-runtime')
const { decodeBinarySchema, compileSchema, ByteBuffer } =
await import('#core/kiwi/schema-runtime')
const { parseFigKiwiChunks } = await import('@open-pencil/core')
const graph = new SceneGraph()
@ -195,7 +201,10 @@ describe('text node export', () => {
}
const message = compiled.decodeMessage(inflateSync(chunks?.[1] ?? new Uint8Array()))
const nodeChanges = message.nodeChanges as Array<Record<string, unknown>>
const textNc = expectDefined(nodeChanges.find((nc) => nc.type === 'TEXT'), 'text node change')
const textNc = expectDefined(
nodeChanges.find((nc) => nc.type === 'TEXT'),
'text node change'
)
expect(textNc.textAutoResize).toBe('HEIGHT')
expect(textNc.lineHeight).toBeUndefined()
@ -206,7 +215,8 @@ describe('text node export', () => {
await initCodec()
const { unzipSync, inflateSync } = await import('fflate')
const { decodeBinarySchema, compileSchema, ByteBuffer } = await import('#core/kiwi/schema-runtime')
const { decodeBinarySchema, compileSchema, ByteBuffer } =
await import('#core/kiwi/schema-runtime')
const { parseFigKiwiChunks } = await import('@open-pencil/core')
const graph = new SceneGraph()
@ -253,20 +263,23 @@ describe('text node export', () => {
expect(families).toContain('Regular')
})
test.if(runsHeavyTests)('material3.fig text nodes have derivedTextData after round-trip', async () => {
const original = await parseFixture('material3.fig')
test.if(runsHeavyTests)(
'material3.fig text nodes have derivedTextData after round-trip',
async () => {
const original = await parseFixture('material3.fig')
const textNodes = [...original.getAllNodes()].filter((n) => n.type === 'TEXT')
expect(textNodes.length).toBeGreaterThan(0)
const textNodes = [...original.getAllNodes()].filter((n) => n.type === 'TEXT')
expect(textNodes.length).toBeGreaterThan(0)
const exported = await exportFigFile(original)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const exported = await exportFigFile(original)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const reimportedText = [...reimported.getAllNodes()].filter((n) => n.type === 'TEXT')
expect(reimportedText.length).toBe(textNodes.length)
const reimportedText = [...reimported.getAllNodes()].filter((n) => n.type === 'TEXT')
expect(reimportedText.length).toBe(textNodes.length)
for (const node of reimportedText.slice(0, 10)) {
expect(node.text.length).toBeGreaterThan(0)
for (const node of reimportedText.slice(0, 10)) {
expect(node.text.length).toBeGreaterThan(0)
}
}
})
)
})

View file

@ -1,7 +1,7 @@
import { describe, expect, test } from 'bun:test'
import { importNodeChanges } from '#core/kiwi/fig/import'
import type { NodeChange } from '#core/kiwi/fig/codec'
import { importNodeChanges } from '#core/kiwi/fig/import'
const documentGuid = { sessionID: 0, localID: 0 }
const pageGuid = { sessionID: 0, localID: 1 }
@ -39,7 +39,13 @@ describe('Figma component property import', () => {
test('applies text data component prop assignments', () => {
const nodeChanges: NodeChange[] = [
{ guid: documentGuid, phase: 'CREATED', type: 'DOCUMENT', name: 'Document' },
{ guid: pageGuid, phase: 'CREATED', parentIndex: { guid: documentGuid, position: '!' }, type: 'CANVAS', name: 'Page' },
{
guid: pageGuid,
phase: 'CREATED',
parentIndex: { guid: documentGuid, position: '!' },
type: 'CANVAS',
name: 'Page'
},
{
guid: componentGuid,
phase: 'CREATED',
@ -80,7 +86,13 @@ describe('Figma component property import', () => {
test('propagates nested instance swaps through clone chains', () => {
const nodeChanges: NodeChange[] = [
{ guid: documentGuid, phase: 'CREATED', type: 'DOCUMENT', name: 'Document' },
{ guid: pageGuid, phase: 'CREATED', parentIndex: { guid: documentGuid, position: '!' }, type: 'CANVAS', name: 'Page' },
{
guid: pageGuid,
phase: 'CREATED',
parentIndex: { guid: documentGuid, position: '!' },
type: 'CANVAS',
name: 'Page'
},
{
guid: strokeStyleGuid,
phase: 'CREATED',
@ -177,9 +189,7 @@ describe('Figma component property import', () => {
symbolData: { symbolID: mailIconGuid },
size: { x: 16, y: 16 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
componentPropRefs: [
{ defID: iconPropGuid, componentPropNodeField: 'OVERRIDDEN_SYMBOL_ID' }
]
componentPropRefs: [{ defID: iconPropGuid, componentPropNodeField: 'OVERRIDDEN_SYMBOL_ID' }]
},
{
guid: sourceInstanceGuid,
@ -220,7 +230,9 @@ describe('Figma component property import', () => {
const graph = importNodeChanges(nodeChanges, [], undefined, { populate: 'all' })
const clone = Array.from(graph.getAllNodes()).find((node) => node.name === 'Menu item clone')
const icon = clone?.childIds.map((id) => graph.getNode(id)).find((node) => node?.type === 'INSTANCE')
const icon = clone?.childIds
.map((id) => graph.getNode(id))
.find((node) => node?.type === 'INSTANCE')
const iconChild = icon?.childIds.map((id) => graph.getNode(id)).find(Boolean)
expect(icon?.name).toBe('icon/user')
expect(iconChild?.name).toBe('user-path')

Some files were not shown because too many files have changed in this diff Show more