diff --git a/packages/core/src/renderer/labels.ts b/packages/core/src/renderer/labels.ts index b8cb9355e..7a460faea 100644 --- a/packages/core/src/renderer/labels.ts +++ b/packages/core/src/renderer/labels.ts @@ -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 { diff --git a/packages/core/src/renderer/overlays.ts b/packages/core/src/renderer/overlays.ts index 5cc580514..f9a580f6e 100644 --- a/packages/core/src/renderer/overlays.ts +++ b/packages/core/src/renderer/overlays.ts @@ -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 -): 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( diff --git a/packages/core/src/renderer/renderer.ts b/packages/core/src/renderer/renderer.ts index 8780b044d..6d2d7410a 100644 --- a/packages/core/src/renderer/renderer.ts +++ b/packages/core/src/renderer/renderer.ts @@ -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): void { - drawSelectionLabelsFn(this, canvas, graph, selectedIds) + drawSelectionLabels( + canvas: Canvas, + graph: SceneGraph, + selectedIds: Set, + overlays?: RenderOverlays + ): void { + drawSelectionLabelsFn(this, canvas, graph, selectedIds, overlays) } drawParentFrameOutlines(canvas: Canvas, graph: SceneGraph, selectedIds: Set): void { diff --git a/packages/core/src/renderer/selection-labels.ts b/packages/core/src/renderer/selection-labels.ts new file mode 100644 index 000000000..bf98f87dc --- /dev/null +++ b/packages/core/src/renderer/selection-labels.ts @@ -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, + 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 +): 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 +): 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 +): 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, + 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) +} diff --git a/tests/e2e/canvas-manipulation.spec.ts b/tests/e2e/canvas-manipulation.spec.ts index 86544f168..414ea81f3 100644 --- a/tests/e2e/canvas-manipulation.spec.ts +++ b/tests/e2e/canvas-manipulation.spec.ts @@ -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() +}) diff --git a/tests/e2e/frame-overlays.spec.ts b/tests/e2e/frame-overlays.spec.ts new file mode 100644 index 000000000..ce6f2c26e --- /dev/null +++ b/tests/e2e/frame-overlays.spec.ts @@ -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') +}) diff --git a/tests/e2e/frame-overlays.spec.ts-snapshots/rotated-frame-child-hover-highlight-openpencil-darwin.png b/tests/e2e/frame-overlays.spec.ts-snapshots/rotated-frame-child-hover-highlight-openpencil-darwin.png new file mode 100644 index 000000000..4d91cf6c4 Binary files /dev/null and b/tests/e2e/frame-overlays.spec.ts-snapshots/rotated-frame-child-hover-highlight-openpencil-darwin.png differ diff --git a/tests/e2e/frame-overlays.spec.ts-snapshots/rotated-frame-selection-labels-openpencil-darwin.png b/tests/e2e/frame-overlays.spec.ts-snapshots/rotated-frame-selection-labels-openpencil-darwin.png new file mode 100644 index 000000000..95d391c72 Binary files /dev/null and b/tests/e2e/frame-overlays.spec.ts-snapshots/rotated-frame-selection-labels-openpencil-darwin.png differ diff --git a/tests/e2e/frame-overlays.spec.ts-snapshots/rotated-frame-selection-labels-preview-openpencil-darwin.png b/tests/e2e/frame-overlays.spec.ts-snapshots/rotated-frame-selection-labels-preview-openpencil-darwin.png new file mode 100644 index 000000000..d8ecd08e1 Binary files /dev/null and b/tests/e2e/frame-overlays.spec.ts-snapshots/rotated-frame-selection-labels-preview-openpencil-darwin.png differ