Fix rotated frame overlays and add snapshots
This commit is contained in:
parent
b826202b09
commit
d263d63da9
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
211
packages/core/src/renderer/selection-labels.ts
Normal file
211
packages/core/src/renderer/selection-labels.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
118
tests/e2e/frame-overlays.spec.ts
Normal file
118
tests/e2e/frame-overlays.spec.ts
Normal 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 |
Loading…
Reference in a new issue