diff --git a/CHANGELOG.md b/CHANGELOG.md index d6da4d79c..0b0a8d74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 328485f49..29acefb0c 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -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 = { + 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 = { light: { type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }, diff --git a/packages/core/src/editor/create.ts b/packages/core/src/editor/create.ts index 501c3df7e..296cbf95e 100644 --- a/packages/core/src/editor/create.ts +++ b/packages/core/src/editor/create.ts @@ -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 diff --git a/packages/core/src/editor/pages.ts b/packages/core/src/editor/pages.ts index f4ba6a0b1..98d5f74e6 100644 --- a/packages/core/src/editor/pages.ts +++ b/packages/core/src/editor/pages.ts @@ -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 } diff --git a/packages/core/src/figma-api/accessors/basic.ts b/packages/core/src/figma-api/accessors/basic.ts index 602c0a9cb..c3a734c8a 100644 --- a/packages/core/src/figma-api/accessors/basic.ts +++ b/packages/core/src/figma-api/accessors/basic.ts @@ -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 = new Set(NODE_TRANSFORM_FIELDS) diff --git a/packages/core/src/figma-api/index.ts b/packages/core/src/figma-api/index.ts index 549b63895..2af8dcbad 100644 --- a/packages/core/src/figma-api/index.ts +++ b/packages/core/src/figma-api/index.ts @@ -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) } diff --git a/packages/core/src/figma-api/page-backgrounds.ts b/packages/core/src/figma-api/page-backgrounds.ts index ecdc6dc01..64ed44ef5 100644 --- a/packages/core/src/figma-api/page-backgrounds.ts +++ b/packages/core/src/figma-api/page-backgrounds.ts @@ -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, diff --git a/packages/core/src/figma-api/transform.ts b/packages/core/src/figma-api/transform.ts index 521690c97..d29aa50f7 100644 --- a/packages/core/src/figma-api/transform.ts +++ b/packages/core/src/figma-api/transform.ts @@ -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. diff --git a/packages/core/src/geometry/figma.ts b/packages/core/src/geometry/figma.ts new file mode 100644 index 000000000..dafe0a60c --- /dev/null +++ b/packages/core/src/geometry/figma.ts @@ -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): 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): 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): 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, + axis: 'x' | 'y', + value: number +): Pick { + 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, + degrees: number +): Pick { + 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) } +} diff --git a/packages/core/src/geometry/index.ts b/packages/core/src/geometry/index.ts index 4ee9cdcdd..5bb082610 100644 --- a/packages/core/src/geometry/index.ts +++ b/packages/core/src/geometry/index.ts @@ -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' diff --git a/packages/core/tests/editor/page-backgrounds.test.ts b/packages/core/tests/editor/page-backgrounds.test.ts index facca00f9..d0d86645b 100644 --- a/packages/core/tests/editor/page-backgrounds.test.ts +++ b/packages/core/tests/editor/page-backgrounds.test.ts @@ -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) + }) +}) diff --git a/packages/core/tests/geometry/figma.test.ts b/packages/core/tests/geometry/figma.test.ts new file mode 100644 index 000000000..d430c9098 --- /dev/null +++ b/packages/core/tests/geometry/figma.test.ts @@ -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) => 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]) + }) +}) diff --git a/packages/docs/programmable/sdk/api/composables/use-position.md b/packages/docs/programmable/sdk/api/composables/use-position.md index 5269ce975..87c444305 100644 --- a/packages/docs/programmable/sdk/api/composables/use-position.md +++ b/packages/docs/programmable/sdk/api/composables/use-position.md @@ -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 diff --git a/packages/vue/src/controls/node-preview/use.ts b/packages/vue/src/controls/node-preview/use.ts index 12bbd35f9..513520361 100644 --- a/packages/vue/src/controls/node-preview/use.ts +++ b/packages/vue/src/controls/node-preview/use.ts @@ -9,14 +9,21 @@ export function useNodePreview(editor: Editor) { let preview: NodePreview | undefined let targets: readonly string[] = [] - function update(ids: readonly string[], changes: Partial, label: string) { + /** Previews `changes` on every target, or the changes a function gives for each one. */ + function update( + ids: readonly string[], + changes: Partial | ((id: string) => Partial), + 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 diff --git a/packages/vue/src/controls/position/use.ts b/packages/vue/src/controls/position/use.ts index 376d277e5..46492d923 100644 --- a/packages/vue/src/controls/position/use.ts +++ b/packages/vue/src/controls/position/use.ts @@ -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>(() => { + 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, diff --git a/packages/vue/src/controls/prop-scrub/use.ts b/packages/vue/src/controls/prop-scrub/use.ts index 2c2f4a0ce..aaa97e8e8 100644 --- a/packages/vue/src/controls/prop-scrub/use.ts +++ b/packages/vue/src/controls/prop-scrub/use.ts @@ -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 + ) { + 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 } } diff --git a/packages/vue/src/primitives/PositionControls/PositionControlsRoot.vue b/packages/vue/src/primitives/PositionControls/PositionControlsRoot.vue index 0a30b2e4a..43612cc76 100644 --- a/packages/vue/src/primitives/PositionControls/PositionControlsRoot.vue +++ b/packages/vue/src/primitives/PositionControls/PositionControlsRoot.vue @@ -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, diff --git a/src/app/editor/session/types.ts b/src/app/editor/session/types.ts index 584d181bb..2cd52e953 100644 --- a/src/app/editor/session/types.ts +++ b/src/app/editor/session/types.ts @@ -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, diff --git a/tests/e2e/pages/theme-background.spec.ts b/tests/e2e/pages/theme-background.spec.ts new file mode 100644 index 000000000..3fe26e37b --- /dev/null +++ b/tests/e2e/pages/theme-background.spec.ts @@ -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) +}) diff --git a/tests/helpers/canvas.ts b/tests/helpers/canvas.ts index 995603c95..83e2e48f9 100644 --- a/tests/helpers/canvas.ts +++ b/tests/helpers/canvas.ts @@ -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() {