mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
feat(diagram-engine): corners, borderless fills, shadows and strikethrough
Four more Tailwind classes, all four verified against draw.io's own source in
public/drawio rather than against a prose reference — which is how three earlier
exclusions turned out to be wrong:
rounded-* mxShape.js:1172-1189 — absoluteArcSize=1 switches arcSize to
absolute pixels and halves it, so the same class is the same
corner on every box. Previously excluded as 'percentage only'
shadow-sm..xl mxShape.js:505-535 — getShadowStyle reads five independent
params, not one flag, so Tailwind's offset+blur rungs map one
to one. Previously excluded as 'six sizes collapse to one'
line-through mxConstants.js:2054 FONT_STRIKETHROUGH: 8, read at both
mxText.js:723 and :1040. The bitmask has four bits, not three
border-none the only one of the four that adds something previously
inexpressible: a fill with no outline
Also fixes an edge style growing 76 characters per re-layout, without bound. The
router recomputes ports on every pass, and appending them to a style recovered
from the canvas — which already carried the previous pass's eight port keys —
grew the string forever. draw.io resolves duplicates last-wins so the arrow
always looked right; a byte-identity check is what caught it.
Two traps found while wiring the readback, both the same shape: a value the
THEME emits being recorded as one the model asked for. strokeColor=none from a
filled or ghost role, and rounded=0 from the fallback style. Either one would
outlive a set_role, since that clears style but keeps text.
Deliberately not included, with reasons in tw.ts: per-side borders and per-corner
radius (both would take the shape slot, and what a node IS matters more than
which of its edges show), per-side padding (draw.io's keys pad the label, not the
room left for children), text-shadow (a bare flag with no offset or blur),
opacity (Tailwind's is any integer, not a scale), tracking/uppercase/leading
(absent from draw.io — zero grep hits, not merely coarse).
615 tests, 90 new. Browser-verified: four shadow rungs visibly differ, radius is
real pixels, terminator + rounded-lg becomes a small-cornered rounded rect while
an untouched terminator stays a stadium.
This commit is contained in:
237
tests/unit/diagram-engine-tw-compose.test.ts
Normal file
237
tests/unit/diagram-engine-tw-compose.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import type { Operation } from "@/lib/diagram-engine"
|
||||
import { restructureDiagram } from "@/lib/diagram-engine"
|
||||
import { absoluteRects, escapesParent, outsidePage } from "./fixtures/geometry"
|
||||
|
||||
/**
|
||||
* The text and border classes used together on a realistic diagram.
|
||||
*
|
||||
* Every class is unit-tested in isolation above; this checks they compose — that an explicit
|
||||
* alignment survives alongside a role, that a dashed frame does not leak onto its sibling,
|
||||
* and that none of it disturbs the geometry or the round-trip.
|
||||
*/
|
||||
describe("text classes on a real diagram", () => {
|
||||
it("a comparison sheet using type, alignment and dashed borders", () => {
|
||||
const r = restructureDiagram("", [
|
||||
{ op: "set_page", aspect: 1.3 },
|
||||
{
|
||||
op: "add_container",
|
||||
id: "page",
|
||||
label: "",
|
||||
dir: "col",
|
||||
class: "gap-4",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "mast",
|
||||
parent: "page",
|
||||
label: "Deployment options",
|
||||
role: "banner",
|
||||
class: "self-stretch text-center text-2xl font-bold",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "cols",
|
||||
parent: "page",
|
||||
label: "",
|
||||
dir: "row",
|
||||
class: "gap-4",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "now",
|
||||
parent: "cols",
|
||||
label: "Today",
|
||||
dir: "col",
|
||||
class: "grow-1 min-w-0 gap-2 p-3 items-stretch",
|
||||
group: "now",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "plan",
|
||||
parent: "cols",
|
||||
label: "Planned",
|
||||
dir: "col",
|
||||
class: "grow-1 min-w-0 gap-2 p-3 items-stretch border-2 border-dashed",
|
||||
group: "plan",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "n1",
|
||||
parent: "now",
|
||||
label: "Single region",
|
||||
class: "text-left",
|
||||
group: "now",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "n2",
|
||||
parent: "now",
|
||||
label: "99.9% uptime",
|
||||
class: "font-bold text-lg",
|
||||
group: "now",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "p1",
|
||||
parent: "plan",
|
||||
label: "Multi region",
|
||||
class: "text-left italic",
|
||||
group: "plan",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "p2",
|
||||
parent: "plan",
|
||||
label: "99.99% uptime",
|
||||
class: "font-bold text-lg",
|
||||
group: "plan",
|
||||
},
|
||||
] as Operation[])
|
||||
|
||||
expect(r.errors).toEqual([])
|
||||
expect(r.warnings).toEqual([])
|
||||
const xml = r.xml as string
|
||||
const st = (id: string) =>
|
||||
xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))![1]
|
||||
const k = (s: string, key: string) => {
|
||||
const all = [
|
||||
...s.matchAll(new RegExp(`(?:^|;)${key}=([^;]*)`, "g")),
|
||||
]
|
||||
return all.length ? all[all.length - 1][1] : undefined
|
||||
}
|
||||
|
||||
// The masthead: centred 24px bold, and it spans the page.
|
||||
expect(k(st("mast"), "align")).toBe("center")
|
||||
expect(k(st("mast"), "fontSize")).toBe("24")
|
||||
expect(k(st("mast"), "fontStyle")).toBe("1")
|
||||
|
||||
// The "planned" column is dashed; "today" is not.
|
||||
expect(k(st("plan"), "dashed")).toBe("1")
|
||||
expect(k(st("plan"), "strokeWidth")).toBe("2")
|
||||
expect(k(st("now"), "dashed")).toBeUndefined()
|
||||
|
||||
// Type overrides land on the leaves too.
|
||||
expect(k(st("n2"), "fontSize")).toBe("18")
|
||||
expect(k(st("p1"), "fontStyle")).toBe("2") // italic only
|
||||
expect(k(st("p1"), "align")).toBe("left")
|
||||
|
||||
// Nothing broken by any of it.
|
||||
const rects = absoluteRects(xml)
|
||||
expect(escapesParent(xml)).toEqual([])
|
||||
expect(outsidePage(xml)).toEqual([])
|
||||
expect(rects.get("mast")!.w).toBe(rects.get("cols")!.w)
|
||||
|
||||
// And it settles.
|
||||
const again = restructureDiagram(xml, [])
|
||||
expect(again.errors).toEqual([])
|
||||
const third = restructureDiagram(again.xml as string, [])
|
||||
expect(third.xml).toBe(again.xml)
|
||||
})
|
||||
|
||||
it("radius, shadow and borderless compose with roles, groups and shapes", () => {
|
||||
const r = restructureDiagram("", [
|
||||
{ op: "set_page", aspect: 1.2 },
|
||||
{
|
||||
op: "add_container",
|
||||
id: "page",
|
||||
label: "",
|
||||
dir: "col",
|
||||
class: "gap-4",
|
||||
},
|
||||
// A shape that ALREADY owns rounded/arcSize, with a radius class on top. The
|
||||
// class is meant to win: a terminator is a rounded rectangle either way, and
|
||||
// changing how round it is does not change what it is.
|
||||
{
|
||||
op: "add_box",
|
||||
id: "start",
|
||||
parent: "page",
|
||||
label: "Start",
|
||||
shape: "terminator",
|
||||
class: "rounded-lg self-stretch",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "cards",
|
||||
parent: "page",
|
||||
label: "",
|
||||
dir: "row",
|
||||
class: "gap-4",
|
||||
},
|
||||
// A raised card: radius and shadow together, on top of a role and a group.
|
||||
{
|
||||
op: "add_box",
|
||||
id: "card",
|
||||
parent: "cards",
|
||||
label: "Raised card",
|
||||
role: "body",
|
||||
group: "one",
|
||||
class: "grow-1 min-w-0 rounded-xl shadow-md",
|
||||
},
|
||||
// A plain colour field: no outline at all, which nothing else could express.
|
||||
{
|
||||
op: "add_box",
|
||||
id: "field",
|
||||
parent: "cards",
|
||||
label: "Colour field",
|
||||
role: "callout",
|
||||
class: "grow-1 min-w-0 border-none rounded-2xl",
|
||||
},
|
||||
// Struck-through beside bold, to prove the bits add rather than replace.
|
||||
{
|
||||
op: "add_box",
|
||||
id: "gone",
|
||||
parent: "page",
|
||||
label: "Superseded step",
|
||||
class: "line-through font-bold self-stretch",
|
||||
},
|
||||
] as Operation[])
|
||||
|
||||
expect(r.errors).toEqual([])
|
||||
expect(r.warnings).toEqual([])
|
||||
const xml = r.xml as string
|
||||
const st = (id: string) =>
|
||||
xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))![1]
|
||||
const k = (s: string, key: string) => {
|
||||
const all = [
|
||||
...s.matchAll(new RegExp(`(?:^|;)${key}=([^;]*)`, "g")),
|
||||
]
|
||||
return all.length ? all[all.length - 1][1] : undefined
|
||||
}
|
||||
|
||||
// The class wins over the shape's own proportional corner, and brings the flag that
|
||||
// reinterprets the number as pixels. Without absoluteArcSize, 16 would mean 16% of
|
||||
// the box — a different radius on every node.
|
||||
expect(k(st("start"), "shape")).toBeUndefined() // terminator is rounded=1, not shape=
|
||||
expect(k(st("start"), "rounded")).toBe("1")
|
||||
expect(k(st("start"), "absoluteArcSize")).toBe("1")
|
||||
expect(k(st("start"), "arcSize")).toBe("16")
|
||||
|
||||
// The card keeps its role's fill while taking the class's radius and shadow.
|
||||
expect(k(st("card"), "arcSize")).toBe("24")
|
||||
expect(k(st("card"), "shadow")).toBe("1")
|
||||
expect(k(st("card"), "shadowBlur")).toBe("6")
|
||||
expect(k(st("card"), "fillColor")).not.toBe("none")
|
||||
|
||||
// borderless beats the role's own stroke; the role's fill survives, which is the
|
||||
// point of a colour field.
|
||||
expect(k(st("field"), "strokeColor")).toBe("none")
|
||||
expect(k(st("field"), "fillColor")).not.toBe("none")
|
||||
expect(k(st("field"), "arcSize")).toBe("32")
|
||||
|
||||
// Bold (1) plus strikethrough (8) in one key.
|
||||
expect(k(st("gone"), "fontStyle")).toBe("9")
|
||||
|
||||
// None of it disturbs the layout.
|
||||
expect(escapesParent(xml)).toEqual([])
|
||||
expect(outsidePage(xml)).toEqual([])
|
||||
const rects = absoluteRects(xml)
|
||||
expect(rects.get("card")!.w).toBeCloseTo(rects.get("field")!.w, 0)
|
||||
|
||||
// And it settles.
|
||||
const again = restructureDiagram(xml, [])
|
||||
expect(again.errors).toEqual([])
|
||||
const third = restructureDiagram(again.xml as string, [])
|
||||
expect(third.xml).toBe(again.xml)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user