fix: match Figma's panel X, Y, rotation and themed page backgrounds (#940)

* fix(core): start new layers with Figma's defaults in the editor and plugin API

The plugin API created bare nodes: frames, components, and shapes without fills, and lines and vectors without strokes, so scripts written for Figma drew nothing. Drawn lines also had a black fill instead of a stroke and were invisible. Both paths now share newLayerDefaults, recorded from Figma desktop 126: frames and components white with frames clipping their content, shapes #D9D9D9, lines and vectors a black 1 px stroke, text black. A stroke a script adds gets the 1 px default weight, and an empty vector has no render bounds.

* fix(core): combine variants as Figma does from the canvas and from scripts

The plugin API and the editor command each built component sets their own way, both with 40 px of padding and a grey fill. Figma's command pads the variants by 20 and outlines the set with a 1 px dashed #8A38F5 stroke; its plugin API wraps them exactly with no fill or stroke. One variantSetProps now places and styles the set for both, with a canvas or script style, and applyVariantProperties derives variant properties for both.

* fix(core): report group children in their container's space in the plugin API

Figma's plugin API places children of groups and booleans relative to the nearest real container and refits a group whenever a script changes one of its children. Ours reported group-relative positions and never refit, so scripts placing layers inside groups landed them in the wrong place. x, y, and relativeTransform now map through the groups around a node, and geometry changes, appendChild, insertChild, and remove refit the surrounding groups. The refit moves to Scene Graph as fitEnclosingGroups, shared by the canvas (with undo) and the plugin API.

* test(core): pass script-style strokes and typed components in parity tests

* test(e2e): expect Figma's default shape grey in the scene freshness spec

* fix(vue): draw lines by length and angle as Figma does

The Line tool sized a line as the box spanned by the drag. With the stroke a new line now gets, that box drew as a rectangle outline. A line now starts at the press point with the drag length as its width, no height, and the drag angle as its rotation, as Figma's Line tool makes it; Shift snaps the angle to 45° steps, as the docs already described, and a click makes a 100 px horizontal line.

* fix(core): give each new layer its own copy of the default paints

The defaults spread each paint shallowly, so every layer shared the colour object of the module-level default and editing one layer's colour in place changed the next new layer. Copy the paints with the Scene Graph copy helpers.

* fix(core): group, ungroup, and combine layers through shared code in the plugin API

The plugin API wrapped layers, ungrouped, made booleans, and made components from layers with its own code. Ungroup moved the children to the top of the stack, booleans were named "Boolean union", and a component made from a frame cloned its children under new ids. These now run through the editor's shared wrap, ungroup, and boolean functions, with the placement and defaults recorded in Figma desktop 126: a group or boolean without an index goes on top, ungrouped children take the group's place, booleans are named after the operation and filled with the default grey, a frame becomes a component in its place with its children, and any other layer is wrapped in a white component named after it. Undoing a wrap in the editor now returns each layer to its own place in the stack.

* fix(core): group, frame, combine, and make components from the canvas as Figma does

Recorded in Figma desktop 126: a container made from the canvas takes the topmost selected layer's place, Frame selection adds no fill and does not clip, a component wrapped around layers is white and takes a single layer's name, and a boolean is filled like its topmost operand, or its base for Subtract, without strokes. The canvas commands and the plugin API now share the wrap parent check, stack ordering, component rules, and boolean paints, and the plugin API's createComponentFromNode converts groups in place as Figma does. Undoing a boolean returns each operand to its own place in the stack.

* refactor(core): reuse translate when centering pasted layers

* fix: match Figma's transforms, strokes, booleans, sections, and names

Each behaviour was recorded in Figma desktop 126 with the same script, or
from its canvas, and both the editor and the plugin API now share it.

- Scripts turn a layer counterclockwise about its top-left corner, read x
  and y as that corner, keep it in place on resize, can set
  relativeTransform, and get absoluteBoundingBox around the turned layer.
  appendChild and insertChild keep x, y, and rotation in the new parent
  instead of the canvas position, which the create and reparent tools
  inherit.
- A layer keeps its stroke weight and alignment without strokes, in the
  model and through .fig export and import. strokeWeight and strokeAlign
  apply to every stroke, and a stroke added from the panel or a script
  takes the layer's.
- Booleans size to their result when a renderer can measure it, from the
  canvas, from scripts, and when an operand moves.
- New sections take Figma's fill for the interface theme, a faint white
  stroke, and 2 px corners; sections and component sets draw with their
  own radius.
- Layers drawn on the canvas and the containers it wraps layers in are
  numbered past the highest number on the page; scripts keep plain names.

* test(core): dispose the editor in the canvas boolean bounds test

Attaching CanvasKit installs a global text measurer, and the test left it installed, so later text measurement tests in the same process found it.

* fix(core): undo a boolean's group refit and clamp dashed set corners

Sizing a boolean to its result also refits the groups around it, and undo left them refitted, so the operands came back displaced. createBooleanOperation returns the fit, and undo reverses it first. A component set's dashed outline now clamps its radius to its bounds as the fill does, and a script's resize refits the enclosing groups once, after the corner is restored.

* fix: show and edit X, Y, and rotation as Figma's properties panel does

Recorded in Figma desktop 126: the panel's X and Y are the top-left of a turned layer's box on the canvas, measured from its frame or page through any groups, rotation is counterclockwise with a flip reading as 180, a typed rotation turns the layer about its center, and a typed X or Y moves its box. The panel showed our clockwise angle and the unturned box. The conversions live in @open-pencil/core/geometry beside figmaRotation, which the plugin API now shares, and the position controls preview a change worked out for each selected node.

* fix: start new pages on Figma's background for the interface theme

Recorded in Figma desktop 126: a page made in the dark theme is #1E1E1E and in the light theme #F5F5F5, from the canvas and from scripts. New documents, pages the editor adds, and figma.createPage now store that background; existing pages keep theirs. The unused OS-scheme helper getDefaultCanvasBgColor goes. Playwright runs the app in its dark theme, so CanvasHelper keeps fresh documents on the light page the canvas snapshots were drawn on, and a dedicated spec covers both themes.

* test(e2e): switch the theme through the app module in the page background spec

Writing localStorage directly breaks the no-direct-storage-access rule. The spec now sets the light theme the way the chat harness does, reloads, and checks the theme took effect before reading the page color.
This commit is contained in:
Danila Poyarkov 2026-10-07 10:02:52 +00:00 committed by GitHub
parent af18eec514
commit 76988569b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 405 additions and 63 deletions

View file

@ -4,6 +4,7 @@
### Breaking changes
- `usePosition` from `@open-pencil/vue` reports and edits `x`, `y`, and `rotation` as Figma's properties panel does: the turned layer's box on the canvas, measured from its frame or page, and its counterclockwise angle. `getDefaultCanvasBgColor` and `CANVAS_BG_COLOR_DARK` are removed from `@open-pencil/core/constants`; new pages use `PAGE_DEFAULT_BACKGROUNDS`, keyed by interface theme.
- `SkiaRenderer.hitTestFrameTitle` no longer takes the selected IDs: it finds the name of any frame on the page or in a section under a point, selected or not.
- `SceneNode` from `@open-pencil/scene-graph` has `strokeWeight` and `strokeAlign`, the stroke weight and alignment a layer keeps without strokes, so code that builds `SceneNode` objects itself must include them. `SECTION_DEFAULT_FILL` from `@open-pencil/core/constants` is replaced by `SECTION_DEFAULT_FILLS`, keyed by interface theme.
- `SceneNode.booleanOperation` is a required key whose value may be `undefined`, like every other scene node field, so code that builds `SceneNode` objects itself must include it.
@ -81,6 +82,8 @@
- The desktop app shows an available update in a Software Update window with formatted, scrollable release notes, a link to the full notes, and download progress you can cancel, instead of a system dialog that showed raw Markdown and could grow taller than the screen (#743). On macOS and Linux the update installs first and you choose when to restart; restarting, and on Windows installing, first asks about unsaved documents as Quit does.
- `openpencil eval` prints the value of a script's last expression, as the `eval` tool and app automation already do, so `-c 'figma.currentPage.children.length'` prints the count without a `return`.
- In scripts, children of groups and booleans report `x`, `y`, and `relativeTransform` in their container's space, as in Figma, and a group refits whenever a script moves, resizes, rotates, adds, or removes one of its children; a group left without children is removed. The canvas and the plugin API share the refit.
- The properties panel shows and edits X, Y, and rotation as Figma's does: X and Y are the top-left of a turned layer's box on the canvas, measured from its frame or page through any groups, rotation is counterclockwise, and a typed rotation turns the layer about its center. Previously a layer Figma shows at 30° read −30°, and a turned layer's X and Y were those of its unturned box.
- New pages and new documents take Figma's background for the interface theme: #1E1E1E in the dark theme and #F5F5F5 in the light one, from the canvas and from scripts. Existing pages keep theirs.
- In scripts, layers turn, move, and resize as in Figma: `rotation` turns a layer counterclockwise about its top-left corner, `x` and `y` are that corner in the parent, `resize()` keeps it in place, `relativeTransform` can be set, and `absoluteBoundingBox` covers the turned layer. `appendChild()` and `insertChild()` keep a layer's `x`, `y`, and rotation in its new parent, so it moves with that parent, instead of keeping its place on the canvas. The AI and MCP `create_shape`, `create_slice`, and vector tools likewise place a layer given a `parent_id` at `x` and `y` inside that parent, and `reparent_node` keeps its position inside the new parent.
- A layer keeps its stroke weight and alignment without strokes, as in Figma: a weight or alignment set before adding a stroke, or kept after removing them, applies to the next stroke, survives `.fig` export and import, and scales with `rescale()`. `strokeWeight` and `strokeAlign` in scripts apply to every stroke rather than the first, and a stroke added from the panel takes the layer's weight and alignment. New strokes align inside, centered on lines and vectors, and outside text.
- Boolean operations take the size of the shape they produce, as in Figma, from the canvas and from scripts when a renderer is attached, and refit when an operand moves; an empty result keeps its operands' size.

View file

@ -19,36 +19,6 @@ export const MEASUREMENT_PILL_HEIGHT = 18
export const MEASUREMENT_PILL_RADIUS = 3
export const MEASUREMENT_TEXT_BASELINE = 4
export const CANVAS_BG_COLOR = { r: 0.96, g: 0.96, b: 0.96, a: 1 } satisfies Color
export const CANVAS_BG_COLOR_DARK = { r: 0.173, g: 0.173, b: 0.173, a: 1 } satisfies Color // #2c2c2c, Figma-ish dark canvas
/**
* Returns the canvas background to initialize new pages with. Defers
* to the OS `prefers-color-scheme` so users on a dark desktop don't
* get a white flash every time they open a document.
*
* NOTE: this is deliberately the runtime/new-page path only. The
* `.fig` serialization path continues to write the static light
* `CANVAS_BG_COLOR` so files stay portable — a dark-theme user saving
* a file must not force darkness on recipients.
*/
export function getDefaultCanvasBgColor(): Color {
if (IS_BROWSER) {
const params = new URLSearchParams(window.location.search)
if ('env' in import.meta && import.meta.env.DEV && params.has('test')) {
return CANVAS_BG_COLOR
}
}
if (
IS_BROWSER &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
return CANVAS_BG_COLOR_DARK
}
return CANVAS_BG_COLOR
}
export const SNAP_THRESHOLD_SCREEN_PX = 5
export const RULER_SIZE = 20
@ -536,9 +506,15 @@ export const DEFAULT_FRAME_FILL: Fill = {
visible: true
}
/** The light or dark interface a new section takes its fill from, as in Figma. */
/** The light or dark interface a new page or section takes its colors from, as in Figma. */
export type InterfaceTheme = 'light' | 'dark'
/** A new page's background, which Figma picks from the interface theme it was made in. */
export const PAGE_DEFAULT_BACKGROUNDS: Record<InterfaceTheme, Color> = {
light: CANVAS_BG_COLOR,
dark: { r: 0x1e / 255, g: 0x1e / 255, b: 0x1e / 255, a: 1 }
}
/** A new section's fill, which Figma picks from the interface theme it was made in. */
export const SECTION_DEFAULT_FILLS: Record<InterfaceTheme, Fill> = {
light: { type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true },

View file

@ -8,6 +8,7 @@ import { UndoManager } from '@open-pencil/scene-graph/undo'
import type { SkiaRenderer } from '#core/canvas/renderer'
import { prefetchFigmaSchema } from '#core/clipboard'
import { IS_BROWSER } from '#core/constants'
import { getPageColor, setDefaultPageBackground } from '#core/figma-api/page-backgrounds'
import { releaseFigPopulationWorker } from '#core/kiwi/fig/population/client'
import { releaseOriginalFigArchive } from '#core/kiwi/fig/session/original-archive'
import { installTextMeasurer } from '#core/layout'
@ -186,6 +187,12 @@ export function createEditor(options?: EditorOptions) {
if (!skipInitialGraphSetup) {
subscribeToGraph()
// A new document's first page takes Figma's background for the interface theme the app gives.
const firstPage = _graph.getPages()[0]
if (options?.state?.theme) {
setDefaultPageBackground(_graph, firstPage, options.state.theme)
options.state.pageColor = getPageColor(firstPage)
}
}
// Build the shared context

View file

@ -3,7 +3,11 @@ import { limitAsync } from 'es-toolkit/promise'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { Color } from '@open-pencil/scene-graph/primitives'
import { getPageColor, setPageBackgrounds } from '#core/figma-api/page-backgrounds'
import {
getPageColor,
setDefaultPageBackground,
setPageBackgrounds
} from '#core/figma-api/page-backgrounds'
import {
canUseFigPopulationWorker,
createFigPopulationWorker
@ -263,6 +267,7 @@ export function createPageActions(ctx: EditorContext) {
const pages = ctx.graph.getPages()
const pageName = name ?? `Page ${pages.length + 1}`
const page = ctx.graph.addPage(pageName)
setDefaultPageBackground(ctx.graph, page, ctx.state.theme)
void switchPage(page.id)
return page.id
}

View file

@ -28,12 +28,12 @@ import type { NodeProxyHost } from '#core/figma-api/proxy'
import { computeAbsoluteRenderBounds } from '#core/figma-api/render-bounds'
import {
containerTransform,
figmaRotation,
setContainerTransform,
withFigmaRotation,
withOrigin
} from '#core/figma-api/transform'
import type { FigmaTransform } from '#core/figma-api/types'
import { figmaRotation } from '#core/geometry/figma'
const TRANSFORM_FIELDS: ReadonlySet<string> = new Set(NODE_TRANSFORM_FIELDS)

View file

@ -23,6 +23,7 @@ import { newLayerDefaults } from '#core/editor/shapes/defaults'
import { booleanOperationPaints, createBooleanOperation } from '#core/editor/structure/boolean'
import { wrapNodes } from '#core/editor/structure/container-wrap'
import { ungroupNode } from '#core/editor/structure/group'
import { setDefaultPageBackground } from '#core/figma-api/page-backgrounds'
import type { RasterCodec } from '#core/io/formats/raster'
import { reconcileVariableLayouts } from '#core/layout/variables'
import { documentFontStatus, type DocumentFontStatus } from '#core/text/font/status'
@ -213,6 +214,7 @@ export class FigmaAPI implements NodeProxyHost {
createPage(): FigmaNodeProxy {
const page = this.graph.addPage('Page')
setDefaultPageBackground(this.graph, page, this.theme)
return this.wrapNode(page.id)
}

View file

@ -3,7 +3,7 @@ import type { Fill, SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { copyFills } from '@open-pencil/scene-graph/copy'
import type { Color } from '@open-pencil/scene-graph/primitives'
import { CANVAS_BG_COLOR } from '#core/constants'
import { CANVAS_BG_COLOR, PAGE_DEFAULT_BACKGROUNDS, type InterfaceTheme } from '#core/constants'
function isColor(value: unknown): value is Color {
if (!value || typeof value !== 'object') return false
@ -38,6 +38,18 @@ export function getPageColor(page: SceneNode | undefined): Color {
return { ...paint.color, a: paint.color.a * paint.opacity }
}
/** Gives a new page Figma's background for the interface theme it is made in. */
export function setDefaultPageBackground(
graph: SceneGraph,
page: SceneNode,
theme: InterfaceTheme = 'light'
): void {
const color = { ...PAGE_DEFAULT_BACKGROUNDS[theme] }
setPageBackgrounds(graph, page, [
{ type: 'SOLID', color, opacity: 1, visible: true, blendMode: 'NORMAL' }
])
}
export function setPageBackgrounds(
graph: SceneGraph,
page: SceneNode,

View file

@ -7,6 +7,8 @@ import {
} from '@open-pencil/scene-graph'
import Matrix, { type Mat3 } from '@open-pencil/scene-graph/matrix'
import { figmaRotation } from '#core/geometry/figma'
/**
* A node's transform into its container's space, which Figma's plugin API reports as
* `relativeTransform` and reads `x`, `y`, and `rotation` from. Groups and booleans set no space of
@ -23,14 +25,6 @@ export function setContainerTransform(scene: SceneGraph, node: SceneNode, matrix
if (local) scene.updateNode(node.id, local)
}
/** Figma's rotation of a transform: counterclockwise degrees in (-180, 180]. */
export function figmaRotation(matrix: Mat3): number {
const degrees = (Math.atan2(-matrix[3], matrix[0]) * 180) / Math.PI
// Figma reports the angle a script set, not its floating-point round trip.
const rounded = Math.round(degrees * 1e6) / 1e6
return Math.abs(degrees - rounded) < 1e-9 ? rounded + 0 : degrees
}
/**
* `matrix` turned to Figma's rotation `degrees` about its origin, the node's top-left corner, as
* Figma's plugin API turns a node; its flip and position stay.

View file

@ -0,0 +1,83 @@
import {
FITTED_CONTAINER_TYPES,
getAxisAlignedWorldBounds,
getWorldMatrix,
type SceneGraph,
type SceneNode
} from '@open-pencil/scene-graph'
import Matrix, { type Mat3 } from '@open-pencil/scene-graph/matrix'
import type { Vector } from '@open-pencil/scene-graph/primitives'
/**
* Figma's rotation of a transform: counterclockwise degrees in (-180, 180]. The plugin API and the
* properties panel both report it; our nodes store a clockwise angle about their center.
*/
export function figmaRotation(matrix: Mat3): number {
const degrees = (Math.atan2(-matrix[3], matrix[0]) * 180) / Math.PI
// Figma reports the angle a script set, not its floating-point round trip.
const rounded = Math.round(degrees * 1e6) / 1e6
return Math.abs(degrees - rounded) < 1e-9 ? rounded + 0 : degrees
}
/** The nearest ancestor that sets a coordinate space: groups and booleans set none. */
function containerOf(node: SceneNode, graph: Pick<SceneGraph, 'getNode'>): SceneNode | undefined {
let parent = node.parentId ? graph.getNode(node.parentId) : undefined
while (parent && FITTED_CONTAINER_TYPES.has(parent.type)) {
parent = parent.parentId ? graph.getNode(parent.parentId) : undefined
}
return parent
}
/**
* X and Y as Figma's properties panel shows them: the top-left of the node's box on the canvas,
* measured from its container's box, so a turned node reads where it starts on screen.
*/
export function panelPosition(node: SceneNode, graph: Pick<SceneGraph, 'getNode'>): Vector {
const bounds = getAxisAlignedWorldBounds(node, graph)
const container = containerOf(node, graph)
const origin =
container && container.type !== 'CANVAS'
? getAxisAlignedWorldBounds(container, graph)
: { x: 0, y: 0 }
return { x: bounds.x - origin.x, y: bounds.y - origin.y }
}
/** Rotation as Figma's properties panel shows it: on the canvas, counterclockwise, -180 as 180. */
export function panelRotation(node: SceneNode, graph: Pick<SceneGraph, 'getNode'>): number {
const degrees = figmaRotation(getWorldMatrix(node, graph))
return degrees === -180 ? 180 : degrees
}
/** The move that makes the panel show `value` for `axis`, as typing it in Figma's panel does. */
export function panelPositionChange(
node: SceneNode,
graph: Pick<SceneGraph, 'getNode'>,
axis: 'x' | 'y',
value: number
): Pick<SceneNode, 'x' | 'y'> {
const delta = value - panelPosition(node, graph)[axis]
// The canvas move, in the axes of the node's parent.
const parent = node.parentId ? graph.getNode(node.parentId) : undefined
const parentWorld =
parent && parent.type !== 'CANVAS' ? getWorldMatrix(parent, graph) : Matrix.identity()
const linear = [parentWorld[0], parentWorld[1], 0, parentWorld[3], parentWorld[4], 0, 0, 0, 1]
const toParent = Matrix.invert(linear) ?? Matrix.identity()
const [dx, dy] = Matrix.mapPoints(toParent, axis === 'x' ? [delta, 0] : [0, delta])
return { x: node.x + dx, y: node.y + dy }
}
/**
* The turn that makes the panel show `degrees`: about the node's center, as Figma's panel turns
* it. A flip on the node or its ancestors reverses which way the stored angle goes.
*/
export function panelRotationChange(
node: SceneNode,
graph: Pick<SceneGraph, 'getNode'>,
degrees: number
): Pick<SceneNode, 'rotation'> {
const world = getWorldMatrix(node, graph)
const mirrored = world[0] * world[4] - world[1] * world[3] < 0
// The shortest way round, so the stored angle stays within a turn.
const turn = ((((degrees - panelRotation(node, graph) + 180) % 360) + 360) % 360) - 180
return { rotation: node.rotation + (mirrored ? turn : -turn) }
}

View file

@ -1,3 +1,10 @@
export {
figmaRotation,
panelPosition,
panelPositionChange,
panelRotation,
panelRotationChange
} from './figma'
export { createSceneGeometry, nodeOrientationMatrix, projectedNode, viewportMatrix } from './scene'
export type { SceneGeometry } from './scene'
export { selectionPath, selectionHandleRect, rotationHandleLayout } from './selection'

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
import { createDefaultEditorState, createEditor } from '@open-pencil/core/editor'
import { FigmaAPI } from '@open-pencil/core/figma-api'
import { exportFigFile, parseFigFile } from '@open-pencil/core/io'
import { SceneGraph } from '@open-pencil/scene-graph'
@ -94,3 +94,32 @@ describe('editor page backgrounds', () => {
}
})
})
// Recorded in Figma desktop 126: a page made in the dark theme is #1E1E1E, in the light one #F5F5F5.
describe('new page backgrounds follow the interface theme', () => {
const dark: Color = { r: 0x1e / 255, g: 0x1e / 255, b: 0x1e / 255, a: 1 }
const light: Color = { r: 0.96, g: 0.96, b: 0.96, a: 1 }
test('a new document and the pages added to it', async () => {
const graph = new SceneGraph()
const state = createDefaultEditorState(graph.getPages()[0].id)
state.theme = 'dark'
const editor = createEditor({ graph, state })
try {
expect(editor.state.pageColor).toEqual(dark)
await editor.switchPage(editor.addPage())
expect(editor.state.pageColor).toEqual(dark)
editor.state.theme = 'light'
await editor.switchPage(editor.addPage())
expect(editor.state.pageColor).toEqual(light)
} finally {
editor.dispose()
}
})
test('pages made by scripts', () => {
const figma = new FigmaAPI(new SceneGraph())
figma.theme = 'dark'
expect(figma.createPage().backgrounds[0]?.color).toEqual(dark)
})
})

View file

@ -0,0 +1,105 @@
import { describe, expect, test } from 'bun:test'
import { FigmaAPI } from '@open-pencil/core/figma-api'
import {
panelPosition,
panelPositionChange,
panelRotation,
panelRotationChange
} from '@open-pencil/core/geometry'
import { SceneGraph, type SceneNode } from '@open-pencil/scene-graph'
// Recorded in Figma desktop 126 by reading and typing into the properties panel's X, Y, and
// rotation fields for layers a script made.
const round = (value: number) => Math.round(value * 1000) / 1000
function setup() {
const graph = new SceneGraph()
const figma = new FigmaAPI(graph)
const rect = (rotation = 0) => {
const node = figma.createRectangle()
node.x = 100
node.y = 100
node.resize(100, 50)
node.rotation = rotation
return node
}
const raw = (id: string): SceneNode => {
const node = graph.getNode(id)
if (!node) throw new Error(`Missing ${id}`)
return node
}
const shown = (id: string) => {
const { x, y } = panelPosition(raw(id), graph)
return [round(x), round(y), round(panelRotation(raw(id), graph))]
}
return { graph, figma, rect, raw, shown }
}
describe('properties panel position and rotation', () => {
test('X and Y are the top-left of the turned layer’s box, rotation counterclockwise', () => {
const { rect, shown } = setup()
expect(shown(rect().id)).toEqual([100, 100, 0])
expect(shown(rect(30).id)).toEqual([100, 50, 30])
expect(shown(rect(-30).id)).toEqual([75, 100, -30])
})
test('a horizontal flip reads as 180°', () => {
const { figma, shown } = setup()
const node = figma.createRectangle()
node.resize(100, 50)
node.relativeTransform = [
[-1, 0, 200],
[0, 1, 100]
]
expect(shown(node.id)).toEqual([100, 100, 180])
})
test('inside groups and turned frames, positions are measured from the container’s box', () => {
const { figma, shown } = setup()
const frame = figma.createFrame()
frame.x = 500
frame.y = 40
frame.resize(300, 300)
const inner = figma.createRectangle()
frame.appendChild(inner)
inner.x = 30
inner.y = 60
inner.resize(40, 20)
const other = figma.createRectangle()
frame.appendChild(other)
other.x = 120
other.y = 80
const group = figma.group([inner, other], frame)
expect(shown(inner.id)).toEqual([30, 60, 0])
expect(shown(group.id)).toEqual([30, 60, 0])
const turned = figma.createFrame()
turned.x = 900
turned.y = 40
turned.resize(200, 200)
turned.rotation = 90
const child = figma.createRectangle()
turned.appendChild(child)
child.x = 10
child.y = 20
child.resize(40, 20)
expect(shown(child.id)).toEqual([20, 150, 90])
})
test('typing a rotation turns the layer about its center, typing X or Y moves its box', () => {
const { graph, rect, raw, shown } = setup()
const { id } = rect()
const apply = (changes: Partial<SceneNode>) => graph.updateNode(id, changes)
apply(panelRotationChange(raw(id), graph, 30))
expect(shown(id)).toEqual([94.199, 78.349, 30])
apply(panelPositionChange(raw(id), graph, 'x', 0))
expect(shown(id)).toEqual([0, 78.349, 30])
apply(panelPositionChange(raw(id), graph, 'y', 0))
expect(shown(id)).toEqual([0, 0, 30])
apply(panelRotationChange(raw(id), graph, -45))
expect(shown(id)).toEqual([2.768, -6.382, -45])
})
})

View file

@ -15,6 +15,8 @@ It exposes selected-node values like:
- `height`
- `rotation`
`x`, `y`, and `rotation` read as Figma's properties panel shows them: the top-left of the turned layer's box on the canvas, measured from its frame or page, and its counterclockwise angle. `updateProp('x' | 'y' | 'rotation', value)` moves or turns each selected node so it shows that value, turning about its center. `panelProp(key)` gives the shared value across a multi-selection, or `MIXED`.
and actions like:
- align

View file

@ -9,14 +9,21 @@ export function useNodePreview(editor: Editor) {
let preview: NodePreview | undefined
let targets: readonly string[] = []
function update(ids: readonly string[], changes: Partial<SceneNode>, label: string) {
/** Previews `changes` on every target, or the changes a function gives for each one. */
function update(
ids: readonly string[],
changes: Partial<SceneNode> | ((id: string) => Partial<SceneNode>),
label: string
) {
if (!preview) {
if (!ids.length) return
targets = [...ids]
preview = editor.beginNodePreview(label)
}
try {
for (const id of targets) preview.update(id, changes)
for (const id of targets) {
preview.update(id, typeof changes === 'function' ? changes(id) : changes)
}
} catch (error) {
cancel()
throw error

View file

@ -1,8 +1,14 @@
import { computed } from 'vue'
import type { NumericNodeProperty } from '@open-pencil/scene-graph'
import {
panelPosition,
panelPositionChange,
panelRotation,
panelRotationChange
} from '@open-pencil/core/geometry'
import type { NumericNodeProperty, SceneNode } from '@open-pencil/scene-graph'
import { useNodeProps } from '#vue/controls/node-props/use'
import { MIXED, useNodeProps, type MixedValue } from '#vue/controls/node-props/use'
import { usePropScrub } from '#vue/controls/prop-scrub/use'
import { useEditor } from '#vue/editor/context'
@ -18,20 +24,47 @@ export function usePosition() {
const { nodes, node, active, isMulti, prop } = useNodeProps()
const ids = computed(() => nodes.value.map((n) => n.id))
const x = computed(() => node.value?.x ?? 0)
const y = computed(() => node.value?.y ?? 0)
// X, Y, and rotation read as Figma's panel shows them: the turned layer's box on the canvas and
// its counterclockwise angle; see `panelPosition` and `panelRotation`.
function shown(target: SceneNode, key: 'x' | 'y' | 'rotation'): number {
if (key === 'rotation') return Math.round(panelRotation(target, editor.graph))
return Math.round(panelPosition(target, editor.graph)[key])
}
/** The shown value of every selected node, or MIXED when they differ. */
function panelProp(key: 'x' | 'y' | 'rotation') {
return computed<MixedValue<number>>(() => {
const values = nodes.value.map((target) => shown(target, key))
if (values.length === 0) return 0
const first = values[0]
return values.every((value) => value === first) ? first : MIXED
})
}
const x = computed(() => (node.value ? shown(node.value, 'x') : 0))
const y = computed(() => (node.value ? shown(node.value, 'y') : 0))
const width = computed(() => node.value?.width ?? 0)
const height = computed(() => node.value?.height ?? 0)
const rotation = computed(() => Math.round(node.value?.rotation ?? 0))
const rotation = computed(() => (node.value ? shown(node.value, 'rotation') : 0))
const {
updateProp: _updateProp,
updateEach,
commitProp: _commitProp,
cancelProp: _cancelProp
} = usePropScrub(editor)
// Typed X, Y, and rotation become each node's own move or turn, as typing them in Figma does.
function updateProp(key: NumericNodeProperty, value: number) {
_updateProp(nodes.value, key, value)
if (key === 'x' || key === 'y') {
updateEach(nodes.value, key, (target) =>
panelPositionChange(target, editor.graph, key, value)
)
} else if (key === 'rotation') {
updateEach(nodes.value, key, (target) => panelRotationChange(target, editor.graph, value))
} else {
_updateProp(nodes.value, key, value)
}
}
function commitProp(key: NumericNodeProperty, value: number, previous: number) {
@ -61,6 +94,7 @@ export function usePosition() {
active,
isMulti,
prop,
panelProp,
ids,
x,
y,

View file

@ -14,6 +14,22 @@ export function usePropScrub(editor: Editor) {
)
}
/** Previews a change worked out for each node from its current state. */
function updateEach(
nodes: SceneNode[],
key: NumericNodeProperty,
changes: (node: SceneNode) => Partial<SceneNode>
) {
preview.update(
nodes.map((node) => node.id),
(id) => {
const node = editor.graph.getNode(id)
return node ? changes(node) : {}
},
`Change ${key}`
)
}
function commitProp(
_nodes: SceneNode[],
_key: NumericNodeProperty,
@ -27,5 +43,5 @@ export function usePropScrub(editor: Editor) {
preview.cancel()
}
return { updateProp, commitProp, cancelProp }
return { updateProp, updateEach, commitProp, cancelProp }
}

View file

@ -8,27 +8,27 @@ const {
updateProp,
commitProp,
cancelProp,
node,
ids,
align,
flip,
rotate,
isMulti,
active,
prop: multiProp
prop: multiProp,
panelProp,
x,
y,
rotation
} = usePosition()
const xValue = computed(() =>
isMulti.value ? multiProp('x').value : Math.round(node.value?.x ?? 0)
)
const yValue = computed(() =>
isMulti.value ? multiProp('y').value : Math.round(node.value?.y ?? 0)
)
const multiX = panelProp('x')
const multiY = panelProp('y')
const multiRotation = panelProp('rotation')
const xValue = computed(() => (isMulti.value ? multiX.value : x.value))
const yValue = computed(() => (isMulti.value ? multiY.value : y.value))
const wValue = multiProp('width')
const hValue = multiProp('height')
const rotationValue = computed(() =>
isMulti.value ? multiProp('rotation').value : Math.round(node.value?.rotation ?? 0)
)
const rotationValue = computed(() => (isMulti.value ? multiRotation.value : rotation.value))
const actions = {
updateProp,
commitProp,

View file

@ -5,10 +5,13 @@ import type { PresentationColorSpace } from '@open-pencil/vue'
import type { EditorPreparation } from '@/app/editor/preparation/types'
import type { NodeEditState } from '@/app/editor/vector/types'
import { appPreferences } from '@/app/settings/preferences/store'
import { resolvedAppTheme } from '@/app/shell/theme'
export function createInitialAppEditorState(pageId: string): AppEditorState {
return {
...createDefaultEditorState(pageId),
// New pages and sections take their colors from the interface theme, as in Figma.
theme: resolvedAppTheme.value,
snappingPreferences: { ...appPreferences.value.editing.snapping },
showUI: true,
showRulers: true,

View file

@ -0,0 +1,41 @@
import { expect, test, type Page } from '@playwright/test'
// Recorded in Figma desktop 126: a page made in the dark theme is #1E1E1E, in the light one #F5F5F5.
// These specs open the app without CanvasHelper, whose init keeps other specs on the light page.
const DARK = 0x1e / 255
const LIGHT = 0.96
async function openFresh(page: Page) {
await page.goto('/')
await page.getByTestId('canvas-element').and(page.locator('[data-ready="1"]')).waitFor()
await page.locator('#loader').waitFor({ state: 'detached' })
}
function currentPageColor(page: Page) {
return page.evaluate(() => window.openPencil?.getStore?.()?.state.pageColor.r)
}
test('a new document and its new pages take the dark page in the dark theme', async ({ page }) => {
await openFresh(page)
expect(await currentPageColor(page)).toBeCloseTo(DARK, 3)
const pageId = await page.evaluate(() => window.openPencil?.getStore?.()?.addPage())
await expect
.poll(() => page.evaluate(() => window.openPencil?.getStore?.()?.state.currentPageId))
.toBe(pageId)
expect(await currentPageColor(page)).toBeCloseTo(DARK, 3)
})
test('the light theme starts documents on the light page', async ({ page }) => {
await openFresh(page)
await page.evaluate(async () => {
const themeModulePath = '/src/app/shell/theme.ts'
const themeModule = await import(themeModulePath)
themeModule.setAppTheme('light')
})
// The theme setting persists, so the reloaded app starts a new document in it.
await page.reload()
await page.getByTestId('canvas-element').and(page.locator('[data-ready="1"]')).waitFor()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
expect(await currentPageColor(page)).toBeCloseTo(LIGHT, 3)
})

View file

@ -33,6 +33,22 @@ export class CanvasHelper {
.waitFor({ timeout: 30000 })
await this.page.getByTestId('canvas-loading').waitFor({ state: 'hidden', timeout: 30000 })
await this.page.locator('#loader').waitFor({ state: 'detached', timeout: 30000 })
await this.useLightPage()
}
/**
* Canvas specs draw on Figma's light page. Playwright runs the app in its dark theme, where a new
* document starts on Figma's dark page, so a fresh document is switched back; documents a spec
* opened keep their own background. `tests/e2e/pages/theme-background.spec.ts` covers the theme.
*/
async useLightPage() {
await this.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
const color = store?.state.pageColor
const darkDefault = 0x1e / 255
if (!store || !color || color.r !== darkDefault || color.g !== darkDefault) return
store.setPageColor({ r: 0.96, g: 0.96, b: 0.96, a: 1 })
})
}
async clearCanvas() {