Fix rotated frame overlays and add snapshots

This commit is contained in:
Danila Poyarkov 2026-03-31 16:42:03 +03:00
parent b826202b09
commit d263d63da9
9 changed files with 551 additions and 131 deletions

View file

@ -73,26 +73,29 @@ function drawSectionTitle(
const pillW = Math.min(textWidth + SECTION_TITLE_PADDING_X * 2, maxPillW)
const pillH = SECTION_TITLE_HEIGHT
const pillX = screenX
const pillY = nested ? screenY + SECTION_TITLE_GAP : screenY - pillH - SECTION_TITLE_GAP
if (node.fills.length > 0 && node.fills[0].visible) {
const c = r.resolveFillColor(node.fills[0], 0, node, graph)
r.auxFill.setColor(r.ck.Color4f(c.r, c.g, c.b, node.fills[0].opacity))
} else {
r.auxFill.setColor(r.ck.Color4f(0.37, 0.37, 0.37, 1))
}
const pillRect = r.ck.LTRBRect(pillX, pillY, pillX + pillW, pillY + pillH)
canvas.drawRRect(r.ck.RRectXY(pillRect, SECTION_TITLE_RADIUS, SECTION_TITLE_RADIUS), r.auxFill)
const localPillX = 0
const localPillY = nested ? SECTION_TITLE_GAP : -pillH - SECTION_TITLE_GAP
const pillColor =
node.fills.length > 0 && node.fills[0].visible
? r.resolveFillColor(node.fills[0], 0, node, graph)
: { r: 0.37, g: 0.37, b: 0.37, a: 1 }
canvas.save()
canvas.translate(screenX, screenY)
if (node.rotation !== 0) {
canvas.rotate(node.rotation, 0, 0)
}
r.auxFill.setColor(r.ck.Color4f(pillColor.r, pillColor.g, pillColor.b, pillColor.a))
const pillRect = r.ck.LTRBRect(localPillX, localPillY, localPillX + pillW, localPillY + pillH)
canvas.drawRRect(r.ck.RRectXY(pillRect, SECTION_TITLE_RADIUS, SECTION_TITLE_RADIUS), r.auxFill)
const lum = 0.299 * pillColor.r + 0.587 * pillColor.g + 0.114 * pillColor.b
r.auxFill.setColor(lum > 0.5 ? r.ck.BLACK : r.ck.WHITE)
const textY = pillY + pillH * 0.7
canvas.drawText(displayText, pillX + SECTION_TITLE_PADDING_X, textY, r.auxFill, font)
const textY = localPillY + pillH * 0.7
canvas.drawText(displayText, localPillX + SECTION_TITLE_PADDING_X, textY, r.auxFill, font)
canvas.restore()
}
export function drawComponentLabels(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph): void {

View file

@ -1,11 +1,5 @@
import {
HANDLE_HALF_SIZE,
LABEL_OFFSET_Y,
SIZE_PILL_PADDING_X,
SIZE_PILL_PADDING_Y,
SIZE_PILL_HEIGHT,
SIZE_PILL_RADIUS,
SIZE_PILL_TEXT_OFFSET_Y,
MARQUEE_FILL_ALPHA,
SELECTION_DASH_ALPHA,
LAYOUT_INDICATOR_STROKE,
@ -28,6 +22,21 @@ import type { Rect, Vector } from '../types'
import type { SkiaRenderer, RenderOverlays } from './renderer'
import type { Canvas } from 'canvaskit-wasm'
function getNodeTransformChain(graph: SceneGraph, node: SceneNode): SceneNode[] {
const chain: SceneNode[] = []
let current = node
for (;;) {
chain.unshift(current)
if (!current.parentId) break
const parent = graph.getNode(current.parentId)
if (!parent || parent.id === graph.rootId || parent.type === 'CANVAS') break
current = parent
}
return chain
}
export function drawHoverHighlight(
r: SkiaRenderer,
canvas: Canvas,
@ -38,22 +47,23 @@ export function drawHoverHighlight(
const node = graph.getNode(hoveredNodeId)
if (!node) return
const abs = graph.getAbsolutePosition(node.id)
const sx = abs.x * r.zoom + r.panX
const sy = abs.y * r.zoom + r.panY
r.auxStroke.setStrokeWidth(1 / r.zoom)
r.auxStroke.setColor(r.isComponentType(node.type) ? r.compColor() : r.selColor())
r.auxStroke.setPathEffect(null)
const chain = getNodeTransformChain(graph, node)
canvas.save()
canvas.translate(sx, sy)
if (node.rotation !== 0) {
const cx = (node.width / 2) * r.zoom
const cy = (node.height / 2) * r.zoom
canvas.rotate(node.rotation, cx, cy)
}
canvas.translate(r.panX, r.panY)
canvas.scale(r.zoom, r.zoom)
for (const item of chain) {
canvas.translate(item.x, item.y)
if (item.rotation !== 0) {
canvas.rotate(item.rotation, item.width / 2, item.height / 2)
}
}
r.strokeNodeShape(canvas, node, r.auxStroke)
canvas.restore()
}
@ -115,7 +125,7 @@ export function drawSelection(
const rotation =
overlays.rotationPreview?.nodeId === id ? overlays.rotationPreview.angle : node.rotation
r.drawNodeSelection(canvas, node, rotation, graph)
r.drawSelectionLabels(canvas, graph, selectedIds)
r.drawSelectionLabels(canvas, graph, selectedIds, overlays)
r.selectionPaint.setColor(r.selColor())
return
@ -144,7 +154,7 @@ export function drawSelection(
if (nodes.length === 0) return
r.drawGroupBounds(canvas, nodes, graph)
r.drawSelectionLabels(canvas, graph, selectedIds)
r.drawSelectionLabels(canvas, graph, selectedIds, overlays)
}
function withNodeBounds(
@ -194,82 +204,6 @@ export function drawNodeSelection(
})
}
export function drawSelectionLabels(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
selectedIds: Set<string>
): void {
if (!r.labelFont || !r.sizeFont) return
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
const nodes: SceneNode[] = []
for (const id of selectedIds) {
const node = graph.getNode(id)
if (!node) continue
nodes.push(node)
const abs = graph.getAbsolutePosition(id)
minX = Math.min(minX, abs.x)
minY = Math.min(minY, abs.y)
maxX = Math.max(maxX, abs.x + node.width)
maxY = Math.max(maxY, abs.y + node.height)
}
if (nodes.length === 0) return
const sx1 = minX * r.zoom + r.panX
const sy1 = minY * r.zoom + r.panY
const sx2 = maxX * r.zoom + r.panX
const sy2 = maxY * r.zoom + r.panY
const smx = (sx1 + sx2) / 2
if (nodes.length === 1) {
const node = nodes[0]
const parentNode = node.parentId ? graph.getNode(node.parentId) : null
const isTopLevel = !parentNode || parentNode.type === 'CANVAS' || parentNode.type === 'SECTION'
if (node.type === 'FRAME' && isTopLevel) {
r.auxFill.setColor(r.selColor())
canvas.drawText(node.name, sx1, sy1 - LABEL_OFFSET_Y, r.auxFill, r.labelFont)
}
}
const w = Math.round(maxX - minX)
const h = Math.round(maxY - minY)
const sizeText = `${w} × ${h}`
const glyphIds = r.sizeFont.getGlyphIDs(sizeText)
const widths = r.sizeFont.getGlyphWidths(glyphIds)
let textWidth = 0
for (const w of widths) textWidth += w
const pillW = textWidth + SIZE_PILL_PADDING_X * 2
const pillH = SIZE_PILL_HEIGHT
const pillX = smx - pillW / 2
const pillY = sy2 + SIZE_PILL_PADDING_Y
const allComponents = nodes.length > 0 && nodes.every((n) => r.isComponentType(n.type))
const pillColor = allComponents ? r.compColor() : r.selColor()
r.auxFill.setColor(pillColor)
const rrect = r.ck.RRectXY(
r.ck.LTRBRect(pillX, pillY, pillX + pillW, pillY + pillH),
SIZE_PILL_RADIUS,
SIZE_PILL_RADIUS
)
canvas.drawRRect(rrect, r.auxFill)
r.auxFill.setColor(r.ck.WHITE)
canvas.drawText(
sizeText,
pillX + SIZE_PILL_PADDING_X,
pillY + SIZE_PILL_TEXT_OFFSET_Y,
r.auxFill,
r.sizeFont
)
}
export function drawParentFrameOutlines(
r: SkiaRenderer,
canvas: Canvas,
@ -377,6 +311,8 @@ export function getRotatedCorners(r: SkiaRenderer, n: SceneNode, abs: Vector): V
return rotatedCorners(cx, cy, hw, hh, n.rotation)
}
export { drawSelectionLabels } from './selection-labels'
export function drawHandle(r: SkiaRenderer, canvas: Canvas, x: number, y: number): void {
r.auxFill.setColor(r.ck.WHITE)
const rect = r.ck.LTRBRect(

View file

@ -1,3 +1,8 @@
import {
resolveNodeFillColor,
resolveNodeStrokeColor,
type ResolvedRenderColor
} from '../color-management'
/* eslint-disable max-lines -- SkiaRenderer class; text, pen-overlay, fills, scene already extracted */
import {
SELECTION_COLOR,
@ -30,7 +35,6 @@ import {
} from '../constants'
import { computeAbsoluteBounds } from '../geometry'
import { RenderProfiler } from '../profiler'
import { resolveNodeFillColor, resolveNodeStrokeColor, type ResolvedRenderColor } from '../color-management'
import { drawAiOverlays as drawAiOverlaysFn } from './ai-overlays'
import {
getCachedDropShadow as getCachedDropShadowFn,
@ -570,25 +574,28 @@ export class SkiaRenderer {
const widths = font.getGlyphWidths(glyphIds)
let textW = 0
for (const w of widths) textW += w
const pillW =
Math.min(textW + SECTION_TITLE_PADDING_X * 2, child.width * this.zoom) / this.zoom
const pillH = SECTION_TITLE_HEIGHT / this.zoom
const gap = SECTION_TITLE_GAP / this.zoom
const localX = canvasX - ax
const localY = canvasY - ay
const pillX = ax
let pillY: number
if (insideSection) {
pillY = ay + gap
} else {
pillY = ay - pillH - gap
let hitX = localX
let hitY = localY
if (child.rotation !== 0) {
const rad = (-child.rotation * Math.PI) / 180
const cos = Math.cos(rad)
const sin = Math.sin(rad)
hitX = localX * cos - localY * sin
hitY = localX * sin + localY * cos
}
if (
canvasX >= pillX &&
canvasX <= pillX + pillW &&
canvasY >= pillY &&
canvasY <= pillY + pillH
) {
const pillX = 0
const pillY = insideSection ? gap : -pillH - gap
if (hitX >= pillX && hitX <= pillX + pillW && hitY >= pillY && hitY <= pillY + pillH) {
result = child
return
}
@ -687,16 +694,23 @@ export class SkiaRenderer {
const labelW = textW / this.zoom
const labelH = LABEL_FONT_SIZE / this.zoom
const gap = LABEL_OFFSET_Y / this.zoom
const labelX = abs.x
const labelY = abs.y - gap - labelH
const localX = canvasX - abs.x
const localY = canvasY - abs.y
if (
canvasX >= labelX &&
canvasX <= labelX + labelW &&
canvasY >= labelY &&
canvasY <= labelY + labelH
) {
let hitX = localX
let hitY = localY
if (node.rotation !== 0) {
const rad = (-node.rotation * Math.PI) / 180
const cos = Math.cos(rad)
const sin = Math.sin(rad)
hitX = localX * cos - localY * sin
hitY = localX * sin + localY * cos
}
const labelX = 0
const labelY = -LABEL_OFFSET_Y / this.zoom - labelH
if (hitX >= labelX && hitX <= labelX + labelW && hitY >= labelY && hitY <= labelY + labelH) {
return node
}
@ -1095,8 +1109,13 @@ export class SkiaRenderer {
drawNodeSelectionFn(this, canvas, node, rotation, graph)
}
drawSelectionLabels(canvas: Canvas, graph: SceneGraph, selectedIds: Set<string>): void {
drawSelectionLabelsFn(this, canvas, graph, selectedIds)
drawSelectionLabels(
canvas: Canvas,
graph: SceneGraph,
selectedIds: Set<string>,
overlays?: RenderOverlays
): void {
drawSelectionLabelsFn(this, canvas, graph, selectedIds, overlays)
}
drawParentFrameOutlines(canvas: Canvas, graph: SceneGraph, selectedIds: Set<string>): void {

View file

@ -0,0 +1,211 @@
import {
LABEL_OFFSET_Y,
SIZE_PILL_PADDING_X,
SIZE_PILL_PADDING_Y,
SIZE_PILL_HEIGHT,
SIZE_PILL_RADIUS,
SIZE_PILL_TEXT_OFFSET_Y
} from '../constants'
import { degToRad, rotatePoint, rotatedCorners } from '../geometry'
import type { SceneNode, SceneGraph } from '../scene-graph'
import type { SkiaRenderer, RenderOverlays } from './renderer'
import type { Canvas } from 'canvaskit-wasm'
function getOverlayRotation(node: SceneNode, overlays?: RenderOverlays): number {
return overlays?.rotationPreview?.nodeId === node.id
? overlays.rotationPreview.angle
: node.rotation
}
function accumulateSelectionBounds(
graph: SceneGraph,
selectedIds: Set<string>,
overlays?: RenderOverlays
): { nodes: SceneNode[]; minX: number; minY: number; maxX: number; maxY: number } {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
const nodes: SceneNode[] = []
for (const id of selectedIds) {
const node = graph.getNode(id)
if (!node) continue
nodes.push(node)
const abs = graph.getAbsolutePosition(id)
const rotation = getOverlayRotation(node, overlays)
if (rotation !== 0) {
const corners = rotatedCorners(abs.x, abs.y, node.width, node.height, rotation)
for (const corner of corners) {
minX = Math.min(minX, corner.x)
minY = Math.min(minY, corner.y)
maxX = Math.max(maxX, corner.x)
maxY = Math.max(maxY, corner.y)
}
continue
}
minX = Math.min(minX, abs.x)
minY = Math.min(minY, abs.y)
maxX = Math.max(maxX, abs.x + node.width)
maxY = Math.max(maxY, abs.y + node.height)
}
return { nodes, minX, minY, maxX, maxY }
}
function drawSingleFrameTitle(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
node: SceneNode,
overlays: RenderOverlays,
labelFont: NonNullable<SkiaRenderer['labelFont']>
): void {
const parentNode = node.parentId ? graph.getNode(node.parentId) : null
const isTopLevel = !parentNode || parentNode.type === 'CANVAS' || parentNode.type === 'SECTION'
if (node.type !== 'FRAME' || !isTopLevel) return
const abs = graph.getAbsolutePosition(node.id)
const rotation = getOverlayRotation(node, overlays)
const anchor =
rotation === 0
? { x: abs.x, y: abs.y }
: rotatePoint(
abs.x,
abs.y,
abs.x + node.width / 2,
abs.y + node.height / 2,
degToRad(rotation)
)
const labelX = anchor.x * r.zoom + r.panX
const labelY = anchor.y * r.zoom + r.panY - LABEL_OFFSET_Y
r.auxFill.setColor(r.selColor())
canvas.save()
canvas.translate(labelX, labelY)
if (rotation !== 0) {
canvas.rotate(rotation, 0, LABEL_OFFSET_Y)
}
canvas.drawText(node.name, 0, 0, r.auxFill, labelFont)
canvas.restore()
}
function drawSingleSelectionSize(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
node: SceneNode,
overlays: RenderOverlays,
sizeFont: NonNullable<SkiaRenderer['sizeFont']>
): void {
const sizeText = `${Math.round(node.width)} × ${Math.round(node.height)}`
const glyphIds = sizeFont.getGlyphIDs(sizeText)
const widths = sizeFont.getGlyphWidths(glyphIds)
let textWidth = 0
for (const width of widths) textWidth += width
const pillW = textWidth + SIZE_PILL_PADDING_X * 2
const pillH = SIZE_PILL_HEIGHT
const pillColor = r.isComponentType(node.type) ? r.compColor() : r.selColor()
const abs = graph.getAbsolutePosition(node.id)
const rotation = getOverlayRotation(node, overlays)
const cx = (abs.x + node.width / 2) * r.zoom + r.panX
const cy = (abs.y + node.height / 2) * r.zoom + r.panY
const localY = (node.height * r.zoom) / 2 + SIZE_PILL_PADDING_Y
canvas.save()
canvas.translate(cx, cy)
if (rotation !== 0) {
canvas.rotate(rotation, 0, 0)
}
r.auxFill.setColor(pillColor)
const rrect = r.ck.RRectXY(
r.ck.LTRBRect(-pillW / 2, localY, pillW / 2, localY + pillH),
SIZE_PILL_RADIUS,
SIZE_PILL_RADIUS
)
canvas.drawRRect(rrect, r.auxFill)
r.auxFill.setColor(r.ck.WHITE)
canvas.drawText(
sizeText,
-pillW / 2 + SIZE_PILL_PADDING_X,
localY + SIZE_PILL_TEXT_OFFSET_Y,
r.auxFill,
sizeFont
)
canvas.restore()
}
function drawMultiSelectionSize(
r: SkiaRenderer,
canvas: Canvas,
nodes: SceneNode[],
minX: number,
minY: number,
maxX: number,
maxY: number,
sizeFont: NonNullable<SkiaRenderer['sizeFont']>
): void {
const sizeText = `${Math.round(maxX - minX)} × ${Math.round(maxY - minY)}`
const glyphIds = sizeFont.getGlyphIDs(sizeText)
const widths = sizeFont.getGlyphWidths(glyphIds)
let textWidth = 0
for (const width of widths) textWidth += width
const pillW = textWidth + SIZE_PILL_PADDING_X * 2
const pillH = SIZE_PILL_HEIGHT
const sx1 = minX * r.zoom + r.panX
const sx2 = maxX * r.zoom + r.panX
const sy2 = maxY * r.zoom + r.panY
const smx = (sx1 + sx2) / 2
const pillX = smx - pillW / 2
const pillY = sy2 + SIZE_PILL_PADDING_Y
const allComponents = nodes.length > 0 && nodes.every((n) => r.isComponentType(n.type))
const pillColor = allComponents ? r.compColor() : r.selColor()
r.auxFill.setColor(pillColor)
const rrect = r.ck.RRectXY(
r.ck.LTRBRect(pillX, pillY, pillX + pillW, pillY + pillH),
SIZE_PILL_RADIUS,
SIZE_PILL_RADIUS
)
canvas.drawRRect(rrect, r.auxFill)
r.auxFill.setColor(r.ck.WHITE)
canvas.drawText(
sizeText,
pillX + SIZE_PILL_PADDING_X,
pillY + SIZE_PILL_TEXT_OFFSET_Y,
r.auxFill,
sizeFont
)
}
export function drawSelectionLabels(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
selectedIds: Set<string>,
overlays?: RenderOverlays
): void {
const labelFont = r.labelFont
const sizeFont = r.sizeFont
if (!labelFont || !sizeFont) return
const activeOverlays = overlays ?? {}
const { nodes, minX, minY, maxX, maxY } = accumulateSelectionBounds(
graph,
selectedIds,
activeOverlays
)
if (nodes.length === 0) return
if (nodes.length === 1) {
drawSingleFrameTitle(r, canvas, graph, nodes[0], activeOverlays, labelFont)
drawSingleSelectionSize(r, canvas, graph, nodes[0], activeOverlays, sizeFont)
return
}
drawMultiSelectionSize(r, canvas, nodes, minX, minY, maxX, maxY, sizeFont)
}

View file

@ -181,3 +181,136 @@ test('hover highlight changes canvas rendering', async () => {
expect(Buffer.compare(noHoverShot, hoverShot)).not.toBe(0)
canvas.assertNoErrors()
})
async function setupFrameChild(rotation: number) {
await canvas.clearCanvas()
const setup = await page.evaluate((frameRotation) => {
const store = window.__OPEN_PENCIL_STORE__!
const frameId = store.createShape('FRAME', 180, 160, 240, 160)
if (!frameId) return null
store.updateNode(frameId, { rotation: frameRotation })
const rectId = store.createShape('RECTANGLE', 245, 205, 60, 40)
if (!rectId) return null
store.graph.reparentNode(rectId, frameId)
store.updateNode(rectId, { x: 65, y: 45 })
store.select([])
store.requestRender()
return { frameId, rectId }
}, rotation)
expect(setup).not.toBeNull()
await canvas.waitForRender()
const state = await page.evaluate(() => {
const store = window.__OPEN_PENCIL_STORE__!
const pageId = store.state.currentPageId
const pageNode = store.graph.getNode(pageId)
const frame = pageNode?.childIds
.map((id: string) => store.graph.getNode(id))
.find((node: { type: string } | undefined) => node?.type === 'FRAME')
if (!frame) return null
const child = frame.childIds
.map((childId: string) => store.graph.getNode(childId))
.find((node: { type: string } | undefined) => node?.type === 'RECTANGLE')
if (!child) return null
const abs = store.graph.getAbsolutePosition(frame.id)
const cx = abs.x + frame.width / 2
const cy = abs.y + frame.height / 2
const childCx = abs.x + child.x + child.width / 2
const childCy = abs.y + child.y + child.height / 2
const rad = (frame.rotation * Math.PI) / 180
const dx = childCx - cx
const dy = childCy - cy
return {
childId: child.id,
hitX: cx + dx * Math.cos(rad) - dy * Math.sin(rad),
hitY: cy + dx * Math.sin(rad) + dy * Math.cos(rad),
missX: abs.x + 20,
missY: abs.y + 20,
}
})
expect(state).not.toBeNull()
return state!
}
test('frame children keep correct hover and click hit area without rotation', async () => {
const state = await setupFrameChild(0)
await canvas.hover(state.hitX, state.hitY)
const hoveredId = await page.evaluate(() => window.__OPEN_PENCIL_STORE__!.state.hoveredNodeId)
expect(hoveredId).toBe(state.childId)
await canvas.click(state.hitX, state.hitY)
await canvas.waitForRender()
const selected = await getSelectedNode(page)
expect(selected?.id).toBe(state.childId)
await canvas.hover(state.missX, state.missY)
const hoveredMiss = await page.evaluate(() => window.__OPEN_PENCIL_STORE__!.state.hoveredNodeId)
expect(hoveredMiss).not.toBe(state.childId)
canvas.assertNoErrors()
})
test('rotated frame children keep correct hover and click hit area', async () => {
const state = await setupFrameChild(35)
await canvas.hover(state.hitX, state.hitY)
const hoveredId = await page.evaluate(() => window.__OPEN_PENCIL_STORE__!.state.hoveredNodeId)
expect(hoveredId).toBe(state.childId)
await canvas.click(state.hitX, state.hitY)
await canvas.waitForRender()
const selected = await getSelectedNode(page)
expect(selected?.id).toBe(state.childId)
await canvas.hover(state.missX, state.missY)
const hoveredMiss = await page.evaluate(() => window.__OPEN_PENCIL_STORE__!.state.hoveredNodeId)
expect(hoveredMiss).not.toBe(state.childId)
canvas.assertNoErrors()
})
test('rotation drag exposes live rotation preview state', async () => {
await canvas.clearCanvas()
await canvas.drawRect(200, 200, 100, 100)
await canvas.click(250, 250)
await canvas.waitForRender()
const viewport = await page.evaluate(() => {
const store = window.__OPEN_PENCIL_STORE__!
const id = [...store.state.selectedIds][0]
const n = store.graph.getNode(id)
if (!n) return null
const abs = store.graph.getAbsolutePosition(id)
const zoom = store.state.zoom
const panX = store.state.panX
const panY = store.state.panY
const cx = (abs.x + n.width / 2) * zoom + panX
const topMidY = abs.y * zoom + panY
return { cx, topMidY }
})
expect(viewport).not.toBeNull()
const box = await page.locator('canvas').boundingBox()
if (!box) throw new Error('No canvas')
const rx = box.x + viewport!.cx
const ry = box.y + viewport!.topMidY - 24
await page.mouse.move(rx, ry)
await canvas.waitForRender()
await page.mouse.down()
await page.mouse.move(rx + 60, ry + 60, { steps: 15 })
await canvas.waitForRender()
const preview = await page.evaluate(() => window.__OPEN_PENCIL_STORE__!.state.rotationPreview)
expect(preview).not.toBeNull()
await page.mouse.up()
await canvas.waitForRender()
const clearedPreview = await page.evaluate(() => window.__OPEN_PENCIL_STORE__!.state.rotationPreview)
expect(clearedPreview).toBeNull()
canvas.assertNoErrors()
})

View file

@ -0,0 +1,118 @@
import { test, expect, type Page } from '@playwright/test'
import { CanvasHelper } from '../helpers/canvas'
let page: Page
let canvas: CanvasHelper
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await page.goto('/?test&no-chrome')
canvas = new CanvasHelper(page)
await canvas.waitForInit()
})
test.afterAll(async () => {
await page.close()
})
test.beforeEach(async () => {
await canvas.clearCanvas()
})
async function expectCanvas(name: string) {
canvas.assertNoErrors()
const buffer = await canvas.canvas.screenshot()
expect(buffer).toMatchSnapshot(`${name}.png`)
}
async function createOverlayDemo(rotation: number) {
await page.evaluate((frameRotation) => {
const store = window.__OPEN_PENCIL_STORE__!
const pageId = store.state.currentPageId
const frame = store.graph.createNode('FRAME', pageId, {
name: 'Typography',
x: 140,
y: 140,
width: 300,
height: 120,
rotation: frameRotation,
cornerRadius: 16,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, visible: true, opacity: 1 }],
strokes: []
})
store.graph.createNode('TEXT', frame.id, {
name: 'Heading',
text: 'Heading',
x: 24,
y: 20,
width: 120,
height: 32,
fontSize: 22,
fontWeight: 700,
textAutoResize: 'WIDTH_AND_HEIGHT'
})
store.graph.createNode('TEXT', frame.id, {
name: 'Body',
text: 'The quick brown fox jumps.',
x: 24,
y: 62,
width: 220,
height: 24,
fontSize: 14,
textAutoResize: 'WIDTH_AND_HEIGHT'
})
const hoverTarget = store.graph.createNode('RECTANGLE', frame.id, {
name: 'Hover Target',
x: 210,
y: 28,
width: 56,
height: 40,
cornerRadius: 10,
fills: [
{
type: 'SOLID',
color: { r: 0.42, g: 0.78, b: 0.58, a: 1 },
visible: true,
opacity: 1
}
]
})
store.select([frame.id])
store.state.hoveredNodeId = hoverTarget.id
store.requestRender()
}, rotation)
await canvas.waitForRender()
}
test('rotated frame selection labels render with hovered child', async () => {
await createOverlayDemo(18)
await expectCanvas('rotated-frame-selection-labels')
})
test('rotation preview updates frame labels before mouse up', async () => {
await createOverlayDemo(0)
await page.evaluate(() => {
const store = window.__OPEN_PENCIL_STORE__!
const frameId = [...store.state.selectedIds][0]
store.state.rotationPreview = { nodeId: frameId, angle: 28 }
store.requestRepaint()
})
await canvas.waitForRender()
await expectCanvas('rotated-frame-selection-labels-preview')
})
test('hover highlight stays aligned for child inside rotated frame', async () => {
await createOverlayDemo(28)
await expectCanvas('rotated-frame-child-hover-highlight')
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB