Skip to content

Commit 1c12524

Browse files
fix(library): preserve waypoint compatibility
1 parent c35e1a8 commit 1c12524

9 files changed

Lines changed: 176 additions & 50 deletions

File tree

docs/library/api/model-contract.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ A v4 model is:
4343

4444
```ts no-check
4545
type UMLModel = {
46-
version: `4.${number}.${number}` // wire-format version, e.g. "4.0.0"
46+
version: `4.${number}.${number}` // wire-format version, currently "4.2.0"
4747
id: string
4848
title: string
4949
type: UMLDiagramType // "ClassDiagram" | "BPMN" | … (13 values)
@@ -79,8 +79,16 @@ normalization rules and the addressing API.
7979
## Versioning policy
8080
8181
`version` tracks the **wire-format major line (4.x)** — _not_ the npm package
82-
version. `importDiagram` stamps `4.0.0` when it _converts_ a v2 / v3 payload;
83-
an already-v4 model passes through with its existing version string untouched.
82+
version. The current canonical model version is `4.2.0`. `importDiagram`
83+
accepts every supported v2, v3, and v4 payload, migrates it in place where
84+
necessary, and returns the current v4 representation.
85+
86+
The 4.2 minor adds optional, interior-only waypoints to straight connections.
87+
When loading a 4.0 or 4.1 model, Apollon discards `data.points` only on those
88+
straight edge families: older releases used that field for inert full-route
89+
geometry, which must not become visible user-authored bends. Orthogonal edge
90+
waypoints and all other model data are preserved. Models from 4.2 and later keep
91+
their straight-edge waypoints unchanged.
8492
8593
| Change | Bump | What you do |
8694
| --------------------- | ----- | ------------------------------------------------- |

library/lib/edges/GenericEdge.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,7 @@ export const EdgeWaypointHandles = ({
573573
className: string,
574574
key: string,
575575
onPointerDown: (event: ReactPointerEvent<SVGRectElement>) => void,
576-
accessibleName: string,
576+
accessibleName?: string,
577577
onDoubleClick?: () => void,
578578
onKeyDown?: (event: ReactKeyboardEvent<SVGRectElement>) => void
579579
) => (
@@ -595,9 +595,9 @@ export const EdgeWaypointHandles = ({
595595
rx={hit / 2}
596596
ry={hit / 2}
597597
pointerEvents="all"
598-
tabIndex={0}
599-
role="button"
600-
aria-label={accessibleName}
598+
tabIndex={onKeyDown && accessibleName ? 0 : undefined}
599+
role={onKeyDown && accessibleName ? "button" : undefined}
600+
aria-label={onKeyDown ? accessibleName : undefined}
601601
style={{ cursor: "grab", fill: "transparent", zIndex: 9999 }}
602602
onPointerDown={onPointerDown}
603603
onDoubleClick={(event) => {
@@ -618,8 +618,7 @@ export const EdgeWaypointHandles = ({
618618
midpoint.position,
619619
"edge-circle edge-waypoint-handle edge-waypoint-handle--proposed",
620620
`midpoint-${midpoint.segmentIndex}`,
621-
(event) => onGhostPointerDown(event, midpoint.segmentIndex),
622-
t.addEdgeWaypoint
621+
(event) => onGhostPointerDown(event, midpoint.segmentIndex)
623622
)
624623
)}
625624
{interior.map((waypoint, index) =>

library/lib/edges/edgeRoutingBehavior.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
*/
1111

1212
/**
13-
* Edge types rendered by the straight-path hook (`useStraightPathEdge`): a plain
14-
* two-point line between the adjusted endpoints, with no obstacle or neighbour
15-
* routing. Other (step) edges still route AROUND these lines — the solver emits
16-
* their two-point polylines into the shared route map for that reason.
13+
* Edge types rendered by the straight-path hook (`useStraightPathEdge`): a direct
14+
* line through any user-authored interior waypoints, with no automatic obstacle
15+
* or neighbour routing. Other (step) edges still route AROUND these polylines —
16+
* the solver emits their complete authored routes into the shared map.
1717
*/
1818
export const STRAIGHT_HOOK_EDGE_TYPES: ReadonlySet<string> = new Set([
1919
"UseCaseAssociation",

library/lib/i18n/labels.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@ export interface ApollonLabels {
2121
redoHint: string
2222
multiSelection: string
2323
multiSelectionHint: string
24-
/** Accessible name for a straight-edge segment midpoint create handle. */
25-
addEdgeWaypoint: string
26-
/** Accessible name for an authored straight-edge waypoint handle. */
27-
moveEdgeWaypoint: string
24+
/**
25+
* Accessible name for an authored straight-edge waypoint handle.
26+
* Optional so a complete dictionary written against an older library release
27+
* remains assignable; {@link mergeLabels} always fills the English default.
28+
*/
29+
moveEdgeWaypoint?: string
2830

2931
// Minimap
3032
miniMap: string
@@ -297,8 +299,14 @@ function defaultNodeTypeLabel(nodeType?: string): string {
297299
.trim()
298300
}
299301

302+
/**
303+
* The fully resolved dictionary used inside the editor. Public host dictionaries
304+
* may omit keys added in later minor releases; merging always restores them.
305+
*/
306+
export type ResolvedApollonLabels = Required<ApollonLabels>
307+
300308
/** The shipped English strings — the fallback for any key a host doesn't override. */
301-
export const DEFAULT_LABELS: ApollonLabels = Object.freeze<ApollonLabels>({
309+
const RESOLVED_DEFAULT_LABELS: ResolvedApollonLabels = Object.freeze({
302310
zoomToolbar: "Zoom, history and selection controls",
303311
zoomIn: "Zoom in",
304312
zoomOut: "Zoom out",
@@ -311,7 +319,6 @@ export const DEFAULT_LABELS: ApollonLabels = Object.freeze<ApollonLabels>({
311319
redoHint: "Redo (Ctrl+Y or Ctrl+Shift+Z)",
312320
multiSelection: "Select multiple elements",
313321
multiSelectionHint: "Select multiple: click elements to add or remove",
314-
addEdgeWaypoint: "Drag to add a waypoint",
315322
moveEdgeWaypoint:
316323
"Waypoint: drag to move, double-click or press Delete to remove",
317324
miniMap: "Mini map",
@@ -512,8 +519,17 @@ export const DEFAULT_LABELS: ApollonLabels = Object.freeze<ApollonLabels>({
512519
nodeWord: "node",
513520
})
514521

522+
/**
523+
* Publicly retain the historical `ApollonLabels` type. The value is fully
524+
* populated, but consumers that used `typeof DEFAULT_LABELS` for a translation
525+
* dictionary must not acquire new required keys in a minor release.
526+
*/
527+
export const DEFAULT_LABELS: ApollonLabels = RESOLVED_DEFAULT_LABELS
528+
515529
/** Merge a host's partial overrides over the English defaults (shallow, per key). */
516530
export const mergeLabels = (
517531
overrides?: Partial<ApollonLabels>
518-
): ApollonLabels =>
519-
overrides ? { ...DEFAULT_LABELS, ...overrides } : DEFAULT_LABELS
532+
): ResolvedApollonLabels =>
533+
overrides
534+
? { ...RESOLVED_DEFAULT_LABELS, ...overrides }
535+
: RESOLVED_DEFAULT_LABELS

library/lib/store/metadataStore.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { getDiagramMetadata, STORE_ORIGIN } from "@/sync/ydoc"
66
import { UMLDiagramType } from "@/types"
77
import { ApollonMode, ApollonView } from "@/typings"
88
import { IPoint } from "@/edges/Connection"
9-
import { DEFAULT_LABELS, type ApollonLabels } from "@/i18n/labels"
9+
import { mergeLabels, type ResolvedApollonLabels } from "@/i18n/labels"
1010
import type { Edge } from "@xyflow/react"
1111
import { DISABLED_TAG_CONFIG, type TagConfig } from "@/utils/tagUtils"
1212

@@ -45,7 +45,7 @@ export type MetadataStore = {
4545
/** Whether the editor answers `APOLLON_SHORTCUTS` at all. */
4646
keyboardShortcuts: boolean
4747
/** User-facing strings for the editor's own chrome; host-overridable for i18n. */
48-
labels: ApollonLabels
48+
labels: ResolvedApollonLabels
4949
/** Element-tag authoring config; disabled until a host opts in. */
5050
tagConfig: TagConfig
5151
scrollEnabled: boolean
@@ -73,7 +73,7 @@ export type MetadataStore = {
7373
setScrollLock: (scrollLock: boolean) => void
7474
setMultiSelectionMode: (multiSelectionMode: boolean) => void
7575
setKeyboardShortcuts: (keyboardShortcuts: boolean) => void
76-
setLabels: (labels: ApollonLabels) => void
76+
setLabels: (labels: ResolvedApollonLabels) => void
7777
setTagConfig: (tagConfig: TagConfig) => void
7878
setScrollEnabled: (scrollEnabled: boolean) => void
7979
startConnectionGuidance: (
@@ -101,7 +101,7 @@ type InitialMetadataState = {
101101
scrollLock: boolean
102102
multiSelectionMode: boolean
103103
keyboardShortcuts: boolean
104-
labels: ApollonLabels
104+
labels: ResolvedApollonLabels
105105
tagConfig: TagConfig
106106
scrollEnabled: boolean
107107
connectionGuidanceActive: boolean
@@ -124,7 +124,7 @@ const initialMetadataState: InitialMetadataState = {
124124
scrollLock: false,
125125
multiSelectionMode: false,
126126
keyboardShortcuts: true,
127-
labels: DEFAULT_LABELS,
127+
labels: mergeLabels(),
128128
tagConfig: DISABLED_TAG_CONFIG,
129129
scrollEnabled: false,
130130
connectionGuidanceActive: false,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { render } from "@testing-library/react"
2+
import { ReactFlowProvider } from "@xyflow/react"
3+
import { describe, expect, it, vi } from "vitest"
4+
import * as Y from "yjs"
5+
import { EdgeWaypointHandles } from "@/edges/GenericEdge"
6+
import { MetadataStoreContext } from "@/store/context"
7+
import { createMetadataStore } from "@/store/metadataStore"
8+
9+
const renderHandles = ({
10+
route,
11+
interior,
12+
}: {
13+
route: { x: number; y: number }[]
14+
interior: { x: number; y: number }[]
15+
}) =>
16+
render(
17+
<MetadataStoreContext value={createMetadataStore(new Y.Doc())}>
18+
<ReactFlowProvider>
19+
<svg>
20+
<EdgeWaypointHandles
21+
route={route}
22+
interior={interior}
23+
selectedWaypointIndex={null}
24+
onWaypointPointerDown={vi.fn()}
25+
onWaypointDoubleClick={vi.fn()}
26+
onWaypointKeyDown={vi.fn()}
27+
onGhostPointerDown={vi.fn()}
28+
/>
29+
</svg>
30+
</ReactFlowProvider>
31+
</MetadataStoreContext>
32+
)
33+
34+
describe("EdgeWaypointHandles", () => {
35+
it("keeps pointer-only midpoint handles out of the keyboard tab order", () => {
36+
const { container } = renderHandles({
37+
route: [
38+
{ x: 0, y: 0 },
39+
{ x: 100, y: 0 },
40+
],
41+
interior: [],
42+
})
43+
const midpoint = container.querySelector(".edge-waypoint-hit-target")
44+
45+
expect(midpoint).not.toHaveAttribute("tabindex")
46+
expect(midpoint).not.toHaveAttribute("role")
47+
expect(midpoint).not.toHaveAttribute("aria-label")
48+
})
49+
50+
it("keeps authored waypoints keyboard focusable and named", () => {
51+
const waypoint = { x: 50, y: 40 }
52+
const { container } = renderHandles({
53+
route: [{ x: 0, y: 0 }, waypoint, { x: 100, y: 0 }],
54+
interior: [waypoint],
55+
})
56+
const target = container.querySelector(
57+
'.edge-waypoint-hit-target[role="button"]'
58+
)
59+
60+
expect(target).toHaveAttribute("tabindex", "0")
61+
expect(target).toHaveAttribute(
62+
"aria-label",
63+
"Waypoint: drag to move, double-click or press Delete to remove"
64+
)
65+
})
66+
})

library/tests/unit/labels.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { describe, expect, it } from "vitest"
2+
import { DEFAULT_LABELS, mergeLabels } from "@/i18n/labels"
3+
4+
describe("mergeLabels", () => {
5+
it("fills labels introduced after a host's partial dictionary was written", () => {
6+
const labels = mergeLabels({ zoomIn: "Vergrößern" })
7+
8+
expect(labels.zoomIn).toBe("Vergrößern")
9+
expect(labels.moveEdgeWaypoint).toBe(DEFAULT_LABELS.moveEdgeWaypoint)
10+
})
11+
})

library/tests/unit/versionConverter.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1564,6 +1564,24 @@ describe("normalizeStraightEdgeWaypoints", () => {
15641564
)
15651565
})
15661566

1567+
it("does not rewrite or discard waypoints from a future v4 minor", () => {
1568+
const model = makeV4Model({
1569+
version: "4.9.0",
1570+
edges: [
1571+
{
1572+
id: "straight",
1573+
type: "UseCaseAssociation",
1574+
data: { points: [...points] },
1575+
},
1576+
],
1577+
})
1578+
normalizeStraightEdgeWaypoints(model)
1579+
expect(model.version).toBe("4.9.0")
1580+
expect((model.edges[0].data as { points: unknown[] }).points).toEqual(
1581+
points
1582+
)
1583+
})
1584+
15671585
it("runs on the universal import path", () => {
15681586
const model = makeV4Model({
15691587
version: "4.0.0",

standalone/webapp/tests/e2e/straight-edge-waypoint-ux.spec.ts

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ fixture.nodes = fixture.nodes.slice(0, 2)
3434
fixture.edges = fixture.edges.slice(0, 1)
3535

3636
const edgeId = fixture.edges[0].id
37+
const midpointHandleSelector =
38+
".edge-waypoint-handle--proposed + .edge-waypoint-hit-target"
3739

3840
async function persistedPoints(page: Page): Promise<unknown[] | null> {
3941
return page.evaluate((id) => {
@@ -94,13 +96,34 @@ test.beforeEach(async ({ page }) => {
9496
await selectEdgeOnPath(page, edgeId)
9597
})
9698

99+
test("legacy straight-edge route caches do not become visible bends", async ({
100+
page,
101+
}) => {
102+
const legacyFixture = structuredClone(fixture)
103+
legacyFixture.version = "4.1.0"
104+
legacyFixture.edges[0].data.points = [
105+
{ x: 40, y: 40 },
106+
{ x: 180, y: 220 },
107+
]
108+
109+
await openFixtureInLocalEditor(page, legacyFixture)
110+
await waitForCanvasReady(page)
111+
await selectEdgeOnPath(page, edgeId)
112+
113+
await expect.poll(() => persistedPoints(page)).toEqual([])
114+
await expect.poll(() => pathExcessLength(page)).toBeLessThan(1)
115+
await expect(
116+
page
117+
.locator(`.react-flow__edge[data-id="${edgeId}"]`)
118+
.getByRole("button", { name: /^Waypoint:/ })
119+
).toHaveCount(0)
120+
})
121+
97122
test("straight and step bend handles share one opaque visual state", async ({
98123
page,
99124
}) => {
100125
const edge = page.locator(`.react-flow__edge[data-id="${edgeId}"]`)
101-
const createTarget = edge
102-
.getByRole("button", { name: "Drag to add a waypoint" })
103-
.first()
126+
const createTarget = edge.locator(midpointHandleSelector).first()
104127
const createCircle = edge.locator(".edge-waypoint-handle--proposed").first()
105128

106129
// Selection clicks the route midpoint, so the newly-mounted midpoint handle
@@ -117,12 +140,6 @@ test("straight and step bend handles share one opaque visual state", async ({
117140
await page.mouse.move(280, 680)
118141
await expect(createCircle).toHaveCSS("fill", straightFill)
119142

120-
// Keyboard users retain a clear darkened-circle focus state, but the browser
121-
// must not draw a square/ring around the invisible hit rectangle.
122-
await createTarget.focus()
123-
await expect(createTarget).toHaveCSS("outline-style", "none")
124-
await expect(createCircle).not.toHaveCSS("fill", straightFill)
125-
126143
await openFixtureInLocalEditor(page, structuredClone(stepFixture))
127144
await waitForCanvasReady(page)
128145
const stepEdgeId = "edge-bidirectional-dog-imovable"
@@ -139,9 +156,7 @@ test("straight waypoints feel editable and collapse live back to a line", async
139156
page,
140157
}) => {
141158
const edge = page.locator(`.react-flow__edge[data-id="${edgeId}"]`)
142-
const createHandle = edge.getByRole("button", {
143-
name: "Drag to add a waypoint",
144-
})
159+
const createHandle = edge.locator(midpointHandleSelector)
145160
await expect(createHandle.first()).toBeVisible()
146161
await dragBy(page, createHandle.first(), 70, 70)
147162

@@ -186,15 +201,13 @@ test("a focused waypoint can be removed with the keyboard", async ({
186201
page,
187202
}) => {
188203
const edge = page.locator(`.react-flow__edge[data-id="${edgeId}"]`)
189-
await dragBy(
190-
page,
191-
edge.getByRole("button", { name: "Drag to add a waypoint" }).first(),
192-
70,
193-
70
194-
)
204+
await dragBy(page, edge.locator(midpointHandleSelector).first(), 70, 70)
195205

196206
const waypoint = edge.getByRole("button", { name: /^Waypoint:/ })
197207
await waypoint.focus()
208+
// Authored points are keyboard actions. Their circle provides the focus
209+
// feedback; the invisible SVG hit rectangle must never acquire a square ring.
210+
await expect(waypoint).toHaveCSS("outline-style", "none")
198211
await page.keyboard.press("Delete")
199212

200213
await expect(waypoint).toHaveCount(0)
@@ -209,12 +222,7 @@ test("the edge toolbar never traps a waypoint underneath it", async ({
209222
await selectEdgeOnPath(page, edgeId)
210223

211224
const edge = page.locator(`.react-flow__edge[data-id="${edgeId}"]`)
212-
await dragBy(
213-
page,
214-
edge.getByRole("button", { name: "Drag to add a waypoint" }).first(),
215-
-70,
216-
-70
217-
)
225+
await dragBy(page, edge.locator(midpointHandleSelector).first(), -70, -70)
218226

219227
const waypoint = edge.getByRole("button", { name: /^Waypoint:/ })
220228
await expect(waypoint).toHaveCount(1)

0 commit comments

Comments
 (0)