fix(canvas): keep labels readable and refine section badges
Choose readable opposite edges for rotated frame titles and size badges, sharing placement with hit testing. Render section titles as compact inset badges with contrast-aware borders and hover feedback.
|
|
@ -60,7 +60,8 @@
|
|||
### Fixed
|
||||
|
||||
- Keep newly created and edited objects visible during zoom instead of replaying outdated scene content.
|
||||
- Keep property fields and paint previews live during editing, and keep rotated selection labels aligned and object edges stable when previews settle.
|
||||
- Keep property fields and paint previews live during editing, rotated selection labels readable and aligned, and object edges stable when previews settle.
|
||||
- Show compact bordered section labels with inset nested titles and clearer hover feedback.
|
||||
- Keep Undo and Redo commands available as edit history changes, without requiring another scene edit.
|
||||
- Avoid recursive desktop HTTP proxy requests when font downloads intercept Tauri IPC traffic.
|
||||
- Keep FIT image fills proportional, centered, and fully visible without stretching or cropped edges.
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ function drawSectionTitle(
|
|||
): void {
|
||||
let layout = labelLayout('section', node.width * r.zoom, nested)
|
||||
if (!layout) return
|
||||
const { background, foreground } = sectionLabelColors(r, graph, node)
|
||||
const { background, foreground, border, hover } = sectionLabelColors(r, graph, node)
|
||||
const metrics = r.labelParagraphCache.measure(
|
||||
r.ck,
|
||||
provider,
|
||||
|
|
@ -45,7 +45,8 @@ function drawSectionTitle(
|
|||
layout.fontSize,
|
||||
layout.maxTextWidth,
|
||||
foreground,
|
||||
r.fontGeneration
|
||||
r.fontGeneration,
|
||||
layout.fontWeight
|
||||
)
|
||||
layout = labelLayout('section', node.width * r.zoom, nested, metrics)
|
||||
if (!layout) return
|
||||
|
|
@ -53,14 +54,20 @@ function drawSectionTitle(
|
|||
canvas.concat(labelScreenMatrix(labelTransform(node, graph, overlays?.rotationPreview), r))
|
||||
r.auxFill.setColor(r.ck.Color4f(background.r, background.g, background.b, background.a))
|
||||
const { x, y, width, height } = layout.bounds
|
||||
canvas.drawRRect(
|
||||
r.ck.RRectXY(
|
||||
r.ck.LTRBRect(x, y, x + width, y + height),
|
||||
SECTION_TITLE_RADIUS,
|
||||
SECTION_TITLE_RADIUS
|
||||
),
|
||||
r.auxFill
|
||||
const bounds = r.ck.RRectXY(
|
||||
r.ck.LTRBRect(x, y, x + width, y + height),
|
||||
SECTION_TITLE_RADIUS,
|
||||
SECTION_TITLE_RADIUS
|
||||
)
|
||||
canvas.drawRRect(bounds, r.auxFill)
|
||||
if (overlays?.hoveredNodeId === node.id) {
|
||||
r.auxFill.setColor(hover)
|
||||
canvas.drawRRect(bounds, r.auxFill)
|
||||
}
|
||||
r.auxStroke.setColor(border)
|
||||
r.auxStroke.setStrokeWidth(1)
|
||||
r.auxStroke.setPathEffect(null)
|
||||
canvas.drawRRect(bounds, r.auxStroke)
|
||||
r.auxFill.setColor(foreground)
|
||||
r.labelParagraphCache.draw(
|
||||
r.ck,
|
||||
|
|
@ -72,7 +79,8 @@ function drawSectionTitle(
|
|||
foreground,
|
||||
r.fontGeneration,
|
||||
layout.text.x,
|
||||
layout.text.y
|
||||
layout.text.y,
|
||||
layout.fontWeight
|
||||
)
|
||||
canvas.restore()
|
||||
}
|
||||
|
|
@ -151,7 +159,8 @@ export function drawComponentLabels(
|
|||
compColor,
|
||||
r.fontGeneration,
|
||||
layout.text.x,
|
||||
layout.text.y
|
||||
layout.text.y,
|
||||
layout.fontWeight
|
||||
)
|
||||
canvas.restore()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
type LabelTextMetrics
|
||||
} from './layout'
|
||||
import { measureGlyphWidth } from './paragraph-cache'
|
||||
import { labelLocalPoint, labelTransform } from './transform'
|
||||
import { frameLabelPlacement, labelLocalPoint, labelTransform } from './transform'
|
||||
|
||||
export interface LabelHitOptions {
|
||||
preview?: RotationPreview | null
|
||||
|
|
@ -32,19 +32,24 @@ function hitLabel(
|
|||
font: Font,
|
||||
options: LabelHitOptions
|
||||
): SceneNode | null {
|
||||
let layout = labelLayout(kind, node.width * zoom, inside)
|
||||
const placement =
|
||||
kind === 'frame'
|
||||
? frameLabelPlacement(node, graph, options.preview)
|
||||
: { ...labelTransform(node, graph, options.preview), width: node.width }
|
||||
let layout = labelLayout(kind, placement.width * zoom, inside)
|
||||
if (!layout) return null
|
||||
const point = labelLocalPoint(labelTransform(node, graph, options.preview), zoom, {
|
||||
const point = labelLocalPoint(placement, zoom, {
|
||||
x: canvasX,
|
||||
y: canvasY
|
||||
})
|
||||
if (!point || point.x < 0 || point.x > node.width * zoom || point.y < layout.bounds.y) return null
|
||||
if (!point || point.x < 0 || point.x > placement.width * zoom || point.y < layout.bounds.y)
|
||||
return null
|
||||
if (kind === 'section' && point.y > layout.bounds.y + layout.bounds.height) return null
|
||||
const metrics = options.measure
|
||||
? options.measure(node, layout)
|
||||
: { width: measureGlyphWidth(font, node.name), height: layout.fontSize }
|
||||
if (!metrics) return null
|
||||
layout = labelLayout(kind, node.width * zoom, inside, metrics)
|
||||
layout = labelLayout(kind, placement.width * zoom, inside, metrics)
|
||||
if (!layout) return null
|
||||
const { x, y, width, height } = layout.bounds
|
||||
return point.x >= x && point.x <= x + width && point.y >= y && point.y <= y + height ? node : null
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export interface LabelLayout {
|
|||
text: Vector
|
||||
icon: Rect | null
|
||||
fontSize: number
|
||||
fontWeight: number
|
||||
maxTextWidth: number
|
||||
}
|
||||
|
||||
|
|
@ -31,6 +32,34 @@ export function hasFrameTitle(node: SceneNode, parent?: SceneNode | null): boole
|
|||
return node.type === 'FRAME' && (!parent || parent.type === 'CANVAS' || parent.type === 'SECTION')
|
||||
}
|
||||
|
||||
function sectionLabelLayout(
|
||||
screenWidth: number,
|
||||
inside: boolean,
|
||||
metrics?: LabelTextMetrics
|
||||
): LabelLayout | null {
|
||||
const x = inside ? SECTION_TITLE_GAP : 0
|
||||
if (screenWidth <= x) return null
|
||||
const y = inside ? SECTION_TITLE_GAP : -SECTION_TITLE_HEIGHT - SECTION_TITLE_GAP
|
||||
const textWidth = metrics?.width ?? screenWidth
|
||||
return {
|
||||
kind: 'section',
|
||||
bounds: {
|
||||
x,
|
||||
y,
|
||||
width: Math.min(textWidth + SECTION_TITLE_PADDING_X * 2, screenWidth - x),
|
||||
height: SECTION_TITLE_HEIGHT
|
||||
},
|
||||
text: {
|
||||
x: x + SECTION_TITLE_PADDING_X,
|
||||
y: y + (SECTION_TITLE_HEIGHT - (metrics?.height ?? SECTION_TITLE_FONT_SIZE)) / 2
|
||||
},
|
||||
icon: null,
|
||||
fontSize: SECTION_TITLE_FONT_SIZE,
|
||||
fontWeight: 600,
|
||||
maxTextWidth: Math.max(1, screenWidth - x - SECTION_TITLE_PADDING_X * 2)
|
||||
}
|
||||
}
|
||||
|
||||
/** Pixel-local geometry shared by drawing and hit-testing, including ellipsis and icon bounds. */
|
||||
export function labelLayout(
|
||||
kind: LabelKind,
|
||||
|
|
@ -39,26 +68,8 @@ export function labelLayout(
|
|||
metrics?: LabelTextMetrics
|
||||
): LabelLayout | null {
|
||||
if (screenWidth <= 0) return null
|
||||
if (kind === 'section') return sectionLabelLayout(screenWidth, inside, metrics)
|
||||
const textWidth = metrics?.width ?? screenWidth
|
||||
if (kind === 'section') {
|
||||
const y = inside ? SECTION_TITLE_GAP : -SECTION_TITLE_HEIGHT - SECTION_TITLE_GAP
|
||||
return {
|
||||
kind,
|
||||
bounds: {
|
||||
x: 0,
|
||||
y,
|
||||
width: Math.min(textWidth + SECTION_TITLE_PADDING_X * 2, screenWidth),
|
||||
height: SECTION_TITLE_HEIGHT
|
||||
},
|
||||
text: {
|
||||
x: SECTION_TITLE_PADDING_X,
|
||||
y: y + (SECTION_TITLE_HEIGHT - (metrics?.height ?? SECTION_TITLE_FONT_SIZE)) / 2
|
||||
},
|
||||
icon: null,
|
||||
fontSize: SECTION_TITLE_FONT_SIZE,
|
||||
maxTextWidth: Math.max(1, screenWidth - SECTION_TITLE_PADDING_X * 2)
|
||||
}
|
||||
}
|
||||
const component = kind === 'component'
|
||||
const fontSize = component ? COMPONENT_LABEL_FONT_SIZE : LABEL_FONT_SIZE
|
||||
const textX = component ? COMPONENT_LABEL_ICON_SIZE + COMPONENT_LABEL_ICON_GAP : 0
|
||||
|
|
@ -85,6 +96,7 @@ export function labelLayout(
|
|||
text: { x: textX, y: textY },
|
||||
icon,
|
||||
fontSize,
|
||||
fontWeight: 400,
|
||||
maxTextWidth
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import type { Canvas, CanvasKit, Font, Paragraph, TypefaceFontProvider } from 'canvaskit-wasm'
|
||||
import type {
|
||||
Canvas,
|
||||
CanvasKit,
|
||||
Font,
|
||||
FontWeight,
|
||||
Paragraph,
|
||||
TypefaceFontProvider
|
||||
} from 'canvaskit-wasm'
|
||||
|
||||
interface LabelParagraphEntry {
|
||||
paragraph: Paragraph
|
||||
|
|
@ -25,9 +32,10 @@ export class LabelParagraphCache {
|
|||
fontSize: number,
|
||||
maxWidth: number,
|
||||
color: Float32Array,
|
||||
generation: number
|
||||
generation: number,
|
||||
fontWeight = 400
|
||||
): Pick<LabelParagraphEntry, 'width' | 'height'> {
|
||||
return this.entry(ck, provider, text, fontSize, maxWidth, color, generation)
|
||||
return this.entry(ck, provider, text, fontSize, maxWidth, color, generation, fontWeight)
|
||||
}
|
||||
|
||||
draw(
|
||||
|
|
@ -40,9 +48,10 @@ export class LabelParagraphCache {
|
|||
color: Float32Array,
|
||||
generation: number,
|
||||
x: number,
|
||||
y: number
|
||||
y: number,
|
||||
fontWeight = 400
|
||||
): number {
|
||||
const entry = this.entry(ck, provider, text, fontSize, maxWidth, color, generation)
|
||||
const entry = this.entry(ck, provider, text, fontSize, maxWidth, color, generation, fontWeight)
|
||||
canvas.drawParagraph(entry.paragraph, x, y)
|
||||
return entry.width
|
||||
}
|
||||
|
|
@ -63,20 +72,26 @@ export class LabelParagraphCache {
|
|||
fontSize: number,
|
||||
maxWidth: number,
|
||||
color: Float32Array,
|
||||
generation: number
|
||||
generation: number,
|
||||
fontWeight: number
|
||||
): LabelParagraphEntry {
|
||||
if (generation !== this.fontGeneration) {
|
||||
this.clear()
|
||||
this.fontGeneration = generation
|
||||
}
|
||||
const boundedWidth = Math.max(1, maxWidth)
|
||||
const key = `${fontSize}\0${boundedWidth}\0${Array.from(color).join(',')}\0${text}`
|
||||
const key = `${fontSize}\0${fontWeight}\0${boundedWidth}\0${Array.from(color).join(',')}\0${text}`
|
||||
let entry = this.entries.get(key)
|
||||
if (!entry) {
|
||||
const style = new ck.ParagraphStyle({
|
||||
maxLines: 1,
|
||||
ellipsis: '…',
|
||||
textStyle: { color, fontFamilies: ['Inter'], fontSize }
|
||||
textStyle: {
|
||||
color,
|
||||
fontFamilies: ['Inter'],
|
||||
fontSize,
|
||||
fontStyle: { weight: { value: fontWeight } as FontWeight }
|
||||
}
|
||||
})
|
||||
const builder = ck.ParagraphBuilder.MakeFromFontProvider(style, provider)
|
||||
builder.addText(text)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { createSceneGeometry } from '#core/geometry'
|
|||
|
||||
import { hasFrameTitle, labelLayout } from './layout'
|
||||
import { measureGlyphWidth } from './paragraph-cache'
|
||||
import { labelScreenMatrix, labelTransform } from './transform'
|
||||
import { frameLabelPlacement, labelScreenMatrix } from './transform'
|
||||
|
||||
function accumulateSelectionBounds(
|
||||
graph: SceneGraph,
|
||||
|
|
@ -47,11 +47,11 @@ function drawSingleFrameTitle(
|
|||
const provider = r.fontProvider
|
||||
if (!hasFrameTitle(node, parentNode) || !provider) return
|
||||
|
||||
const transform = labelTransform(node, graph, overlays.rotationPreview)
|
||||
const transform = frameLabelPlacement(node, graph, overlays.rotationPreview)
|
||||
|
||||
r.auxFill.setColor(r.selColor())
|
||||
|
||||
const layout = labelLayout('frame', node.width * r.zoom)
|
||||
const layout = labelLayout('frame', transform.width * r.zoom)
|
||||
if (!layout) return
|
||||
|
||||
canvas.save()
|
||||
|
|
@ -67,7 +67,8 @@ function drawSingleFrameTitle(
|
|||
r.selColor(),
|
||||
r.fontGeneration,
|
||||
layout.text.x,
|
||||
layout.text.y
|
||||
layout.text.y,
|
||||
layout.fontWeight
|
||||
)
|
||||
canvas.restore()
|
||||
}
|
||||
|
|
@ -112,9 +113,9 @@ export function drawSingleSelectionSize(
|
|||
): void {
|
||||
const sizeText = `${Math.round(node.width)} × ${Math.round(node.height)}`
|
||||
const pillColor = r.isComponentType(node.type) ? r.compColor() : r.selColor()
|
||||
const transform = labelTransform(node, graph, overlays.rotationPreview, {
|
||||
x: node.width / 2,
|
||||
y: node.height
|
||||
const transform = frameLabelPlacement(node, graph, overlays.rotationPreview, {
|
||||
x: 0.5,
|
||||
y: 1
|
||||
})
|
||||
|
||||
// Keep the label's typography and gap in screen pixels rather than scaling them.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export function sectionLabelColors(r: SkiaRenderer, graph: SceneGraph, node: Sce
|
|||
const foreground = canvasLabelForeground(background, r.pageColor)
|
||||
return {
|
||||
background,
|
||||
border: r.ck.Color4f(foreground.r, foreground.g, foreground.b, 0.22),
|
||||
hover: r.ck.Color4f(foreground.r, foreground.g, foreground.b, 0.08),
|
||||
foreground: r.ck.Color4f(foreground.r, foreground.g, foreground.b, foreground.a)
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +41,8 @@ export function measureLabel(
|
|||
layout.fontSize,
|
||||
layout.maxTextWidth,
|
||||
color,
|
||||
r.fontGeneration
|
||||
r.fontGeneration,
|
||||
layout.fontWeight
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,34 @@ export function labelTransform(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the most horizontal pair of edges, with left-to-right screen text.
|
||||
* The anchor uses normalized coordinates within that readable rectangle.
|
||||
*/
|
||||
export function frameLabelPlacement(
|
||||
node: SceneNode,
|
||||
graph: SceneGraph,
|
||||
preview: RotationPreview | null | undefined,
|
||||
anchor: Vector = { x: 0, y: 0 }
|
||||
) {
|
||||
const world = createSceneGeometry(graph, preview).unreflectedWorldMatrix(node)
|
||||
const orientation = (Math.atan2(world[3], world[0]) * 180) / Math.PI
|
||||
const turns = Math.floor((orientation + 45) / 90)
|
||||
const corner = ((turns % 4) + 4) % 4
|
||||
const origin = Matrix.mapPoint(world, {
|
||||
x: corner >= 2 ? node.width : 0,
|
||||
y: corner === 1 || corner === 2 ? node.height : 0
|
||||
})
|
||||
const width = corner % 2 === 0 ? node.width : node.height
|
||||
const height = corner % 2 === 0 ? node.height : node.width
|
||||
const rotation = orientation - turns * 90
|
||||
const offset = Matrix.mapPoint(Matrix.rotated((rotation * Math.PI) / 180), {
|
||||
x: anchor.x * width,
|
||||
y: anchor.y * height
|
||||
})
|
||||
return { x: origin.x + offset.x, y: origin.y + offset.y, rotation, width, height }
|
||||
}
|
||||
|
||||
type LabelTransform = ReturnType<typeof labelTransform>
|
||||
|
||||
function labelWorldMatrix(transform: LabelTransform, zoom: number) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ import Matrix from '@open-pencil/scene-graph/matrix'
|
|||
import type { Vector } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
import type { RenderOverlays, SkiaRenderer } from '#core/canvas/renderer'
|
||||
import { HANDLE_HALF_SIZE, ROTATION_HANDLE_DISTANCE, SELECTION_DASH_ALPHA } from '#core/constants'
|
||||
import {
|
||||
HANDLE_HALF_SIZE,
|
||||
ROTATION_HANDLE_DISTANCE,
|
||||
SELECTION_DASH_ALPHA,
|
||||
SECTION_HOVER_STROKE_WIDTH
|
||||
} from '#core/constants'
|
||||
import {
|
||||
createSceneGeometry,
|
||||
viewportMatrix,
|
||||
|
|
@ -26,7 +31,7 @@ export function drawHoverHighlight(
|
|||
): void {
|
||||
const node = hoveredNodeId ? graph.getNode(hoveredNodeId) : undefined
|
||||
if (!node) return
|
||||
r.auxStroke.setStrokeWidth(1 / r.zoom)
|
||||
r.auxStroke.setStrokeWidth((node.type === 'SECTION' ? SECTION_HOVER_STROKE_WIDTH : 1) / r.zoom)
|
||||
r.auxStroke.setColor(r.isComponentType(node.type) ? r.compColor() : r.selColor())
|
||||
r.auxStroke.setPathEffect(null)
|
||||
canvas.save()
|
||||
|
|
|
|||
|
|
@ -109,10 +109,11 @@ export const AUTO_LAYOUT_HOVER_MAGENTA_FILL = { r: 1, g: 0.32, b: 0.68, a: 0.1 }
|
|||
|
||||
export const SECTION_CORNER_RADIUS = 5
|
||||
export const SECTION_TITLE_HEIGHT = 24
|
||||
export const SECTION_TITLE_PADDING_X = 8
|
||||
export const SECTION_TITLE_PADDING_X = 6
|
||||
export const SECTION_TITLE_RADIUS = 5
|
||||
export const SECTION_TITLE_FONT_SIZE = 12
|
||||
export const SECTION_TITLE_FONT_SIZE = 11
|
||||
export const SECTION_TITLE_GAP = 6
|
||||
export const SECTION_HOVER_STROKE_WIDTH = 2
|
||||
|
||||
export const COMPONENT_SET_DASH = 6
|
||||
export const COMPONENT_SET_DASH_GAP = 4
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
103
tests/e2e/canvas/label-anatomy.spec.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
import { expect, test } from '#tests/e2e/fixtures'
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
import { CanvasHelper } from '#tests/helpers/canvas'
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 2 })
|
||||
|
||||
for (const rotation of [-145, -40, 50]) {
|
||||
test(`frame titles and dimensions remain readable at ${rotation} degrees`, async ({ page }) => {
|
||||
await page.goto('/?test&no-rulers')
|
||||
const canvas = new CanvasHelper(page)
|
||||
await canvas.waitForInit()
|
||||
await page.evaluate((angle) => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('Editor unavailable')
|
||||
const id = store.createShape('FRAME', 250, 250, 300, 180)
|
||||
store.updateNode(id, { name: 'Frame', rotation: angle })
|
||||
store.select([id])
|
||||
}, rotation)
|
||||
const box = expectDefined(await canvas.canvas.boundingBox(), 'canvas bounds')
|
||||
await expect(page).toHaveScreenshot(`readable-frame-${rotation}.png`, {
|
||||
clip: { x: box.x + 150, y: box.y + 100, width: 500, height: 500 },
|
||||
maxDiffPixels: 0,
|
||||
threshold: 0,
|
||||
scale: 'device'
|
||||
})
|
||||
canvas.assertNoErrors()
|
||||
})
|
||||
}
|
||||
|
||||
for (const zoom of [0.5, 1, 2]) {
|
||||
test(`section pills retain their size and hover target at ${zoom} zoom`, async ({ page }) => {
|
||||
await page.goto('/?test&no-rulers')
|
||||
const canvas = new CanvasHelper(page)
|
||||
await canvas.waitForInit()
|
||||
const lightId = await page.evaluate((scale) => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('Editor unavailable')
|
||||
const sectionPaint = {
|
||||
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
|
||||
strokes: [
|
||||
{
|
||||
color: { r: 0.7, g: 0.7, b: 0.7, a: 1 },
|
||||
weight: 1,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
align: 'INSIDE'
|
||||
}
|
||||
]
|
||||
} satisfies Pick<SceneNode, 'fills' | 'strokes'>
|
||||
const color = store.graph.createNode('SECTION', store.state.currentPageId, {
|
||||
name: 'Color',
|
||||
x: 60,
|
||||
y: 100,
|
||||
width: 280,
|
||||
height: 150,
|
||||
...structuredClone(sectionPaint)
|
||||
})
|
||||
const light = store.graph.createNode('SECTION', color.id, {
|
||||
name: 'Light',
|
||||
x: 16,
|
||||
y: 30,
|
||||
width: 248,
|
||||
height: 100,
|
||||
...structuredClone(sectionPaint)
|
||||
})
|
||||
store.graph.createNode('RECTANGLE', light.id, {
|
||||
x: 20,
|
||||
y: 60,
|
||||
width: 60,
|
||||
height: 24,
|
||||
fills: [
|
||||
{ type: 'SOLID', color: { r: 0.05, g: 0.05, b: 0.05, a: 1 }, opacity: 1, visible: true }
|
||||
]
|
||||
})
|
||||
store.clearSelection()
|
||||
store.setZoomAroundPoint(scale, 0, 0)
|
||||
store.requestRender()
|
||||
return light.id
|
||||
}, zoom)
|
||||
const box = expectDefined(await canvas.canvas.boundingBox(), 'canvas bounds')
|
||||
const clip = { x: box.x + 10, y: box.y + 10, width: 700, height: 560 }
|
||||
await expect(page).toHaveScreenshot(`section-pills-${zoom}.png`, {
|
||||
clip,
|
||||
maxDiffPixels: 0,
|
||||
threshold: 0,
|
||||
scale: 'device'
|
||||
})
|
||||
// Nested titles have a fixed six-pixel inset and a screen-sized pill.
|
||||
await page.mouse.move(box.x + 76 * zoom + 18, box.y + 130 * zoom + 18)
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.openPencil?.getStore?.().state.hoveredNodeId))
|
||||
.toBe(lightId)
|
||||
await expect(page).toHaveScreenshot(`section-pills-hover-${zoom}.png`, {
|
||||
clip,
|
||||
maxDiffPixels: 0,
|
||||
threshold: 0,
|
||||
scale: 'device'
|
||||
})
|
||||
canvas.assertNoErrors()
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 92 KiB |
|
|
@ -7,7 +7,11 @@ import Matrix from '@open-pencil/scene-graph/matrix'
|
|||
|
||||
import { hitTestComponentLabel, hitTestFrameTitle } from '#core/canvas/labels/hit-test'
|
||||
import { labelLayout } from '#core/canvas/labels/layout'
|
||||
import { labelLocalPoint, labelScreenMatrix, labelTransform } from '#core/canvas/labels/transform'
|
||||
import {
|
||||
frameLabelPlacement,
|
||||
labelLocalPoint,
|
||||
labelScreenMatrix
|
||||
} from '#core/canvas/labels/transform'
|
||||
import { createSceneGeometry } from '#core/geometry'
|
||||
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
|
|
@ -28,8 +32,16 @@ test('layout bounds include clipped text, padding and the entire component icon'
|
|||
'section layout'
|
||||
)
|
||||
expect(section.bounds).toEqual({ x: 0, y: -30, width: 60, height: 24 })
|
||||
expect(section.text).toEqual({ x: 8, y: -25 })
|
||||
expect(section.maxTextWidth).toBe(44)
|
||||
expect(section.text).toEqual({ x: 6, y: -25 })
|
||||
expect(section.maxTextWidth).toBe(48)
|
||||
const nested = expectDefined(
|
||||
labelLayout('section', 60, true, { width: 100, height: 14 }),
|
||||
'nested section layout'
|
||||
)
|
||||
expect(nested.bounds).toEqual({ x: 6, y: 6, width: 54, height: 24 })
|
||||
expect(nested.text).toEqual({ x: 12, y: 11 })
|
||||
expect(nested.maxTextWidth).toBe(42)
|
||||
expect(nested.fontWeight).toBe(600)
|
||||
const component = expectDefined(
|
||||
labelLayout('component', 80, false, { width: 100, height: 11 }),
|
||||
'component layout'
|
||||
|
|
@ -47,7 +59,7 @@ for (const flipX of [false, true]) {
|
|||
const page = expectDefined(graph.getPages()[0], 'page')
|
||||
const { section, frame } = nestedGeometryFixture(graph, page.id, flipX, flipY)
|
||||
const preview = { nodeId: section.id, angle: 70 }
|
||||
const transform = labelTransform(frame, graph, preview)
|
||||
const transform = frameLabelPlacement(frame, graph, preview)
|
||||
const screen = Matrix.mapPoint(labelScreenMatrix(transform, { panX: 0, panY: 0, zoom: 2 }), {
|
||||
x: 4,
|
||||
y: -10
|
||||
|
|
@ -60,7 +72,14 @@ for (const flipX of [false, true]) {
|
|||
createSceneGeometry(graph, preview).toLocal(frame, world),
|
||||
'node-local point'
|
||||
)
|
||||
expect(nodeLocal.y < 0 || nodeLocal.y > frame.height).toBe(true)
|
||||
expect(
|
||||
nodeLocal.x < 0 ||
|
||||
nodeLocal.x > frame.width ||
|
||||
nodeLocal.y < 0 ||
|
||||
nodeLocal.y > frame.height
|
||||
).toBe(true)
|
||||
expect(transform.rotation).toBeGreaterThanOrEqual(-45)
|
||||
expect(transform.rotation).toBeLessThan(45)
|
||||
expect(
|
||||
hitTestFrameTitle(graph, world.x, world.y, 2, new Set([frame.id]), font, { preview })?.id
|
||||
).toBe(frame.id)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ describe('label paragraph cache', () => {
|
|||
|
||||
cache.measure(ck, provider, 'Primitives', 12, 200, ck.BLACK, 1)
|
||||
expect(cache.size()).toBe(1)
|
||||
const semibold = cache.measure(ck, provider, 'Primitives', 12, 200, ck.BLACK, 1, 600)
|
||||
expect(semibold).not.toBe(metrics)
|
||||
expect(cache.measure(ck, provider, 'Primitives', 12, 200, ck.BLACK, 1, 600)).toBe(semibold)
|
||||
expect(cache.size()).toBe(2)
|
||||
cache.measure(ck, provider, 'Primitives', 12, 200, ck.BLACK, 2)
|
||||
expect(cache.size()).toBe(1)
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { SceneGraph } from '@open-pencil/scene-graph'
|
|||
|
||||
import { LabelCache } from '#core/canvas/labels/cache'
|
||||
import { hitTestSectionTitle, hitTestFrameTitle } from '#core/canvas/labels/hit-test'
|
||||
import { labelTransform } from '#core/canvas/labels/transform'
|
||||
import { frameLabelPlacement, labelTransform } from '#core/canvas/labels/transform'
|
||||
import { createSceneGeometry } from '#core/geometry'
|
||||
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
|
|
@ -58,6 +58,38 @@ test('label origins and edge anchors include a live ancestor rotation without mu
|
|||
expect(createSceneGeometry(graph, null).bounds(frame)).toEqual(bounds)
|
||||
})
|
||||
|
||||
for (const [angle, rotation, width, height] of [
|
||||
[40, 40, 80, 40],
|
||||
[50, -40, 40, 80],
|
||||
[140, -40, 80, 40],
|
||||
[-145, 35, 80, 40],
|
||||
[180, 0, 80, 40]
|
||||
] as const) {
|
||||
test(`frame labels choose readable opposite edges at ${angle} degrees`, () => {
|
||||
const { graph, frame } = fixture()
|
||||
graph.updateNode(frame.id, { rotation: angle })
|
||||
const title = frameLabelPlacement(frame, graph, null)
|
||||
const size = frameLabelPlacement(frame, graph, null, { x: 0.5, y: 1 })
|
||||
expect(title.rotation).toBeCloseTo(rotation, 9)
|
||||
expect(title.width).toBe(width)
|
||||
expect(title.height).toBe(height)
|
||||
const radians = (rotation * Math.PI) / 180
|
||||
const dx = size.x - title.x
|
||||
const dy = size.y - title.y
|
||||
expect(dx * Math.cos(radians) + dy * Math.sin(radians)).toBeCloseTo(width / 2, 9)
|
||||
expect(-dx * Math.sin(radians) + dy * Math.cos(radians)).toBeCloseTo(height, 9)
|
||||
})
|
||||
}
|
||||
|
||||
test('readability thresholds use identical edges during preview and after commit', () => {
|
||||
for (const angle of [-135, -45, 45, 135]) {
|
||||
const { graph, frame } = fixture()
|
||||
const preview = frameLabelPlacement(frame, graph, { nodeId: frame.id, angle })
|
||||
graph.updateNode(frame.id, { rotation: angle })
|
||||
expect(frameLabelPlacement(frame, graph, null)).toEqual(preview)
|
||||
}
|
||||
})
|
||||
|
||||
test('section culling uses preview world bounds rather than cached unrotated coordinates', () => {
|
||||
const { graph, page, section } = fixture()
|
||||
const cache = new LabelCache()
|
||||
|
|
@ -78,6 +110,7 @@ test('section and nested frame hit targets follow their rendered title transform
|
|||
for (const catalog of [undefined, cache]) {
|
||||
expect(hitTestSectionTitle(graph, 260, 54, 1, page.id, font, catalog)?.id).toBe(section.id)
|
||||
}
|
||||
// Nested frame title local point (4, -10), through (220, 70) at 90 degrees.
|
||||
expect(hitTestFrameTitle(graph, 230, 74, 1, new Set([frame.id]), font)?.id).toBe(frame.id)
|
||||
// The readable frame title moves to the horizontal edge at (180, 70).
|
||||
expect(hitTestFrameTitle(graph, 184, 60, 1, new Set([frame.id]), font)?.id).toBe(frame.id)
|
||||
expect(hitTestFrameTitle(graph, 230, 74, 1, new Set([frame.id]), font)).toBeNull()
|
||||
})
|
||||
|
|
|
|||