Skip to content

Commit 2900276

Browse files
Merge remote-tracking branch 'origin/main' into feat/ios-icon-update
# Conflicts: # pnpm-lock.yaml # standalone/webapp/tests/visual/svg-export.visual.spec.ts-snapshots/svg-export-template-bridge.png
2 parents cb7c4c2 + 991d9c1 commit 2900276

272 files changed

Lines changed: 38724 additions & 2940 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/clear-signs-live.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tumaet/webapp": patch
3+
---
4+
5+
Start every bundled design-pattern template in a clean, visually balanced editing state, with stable shared hierarchy trunks and automatic routing preserved for associations.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tumaet/apollon": minor
3+
---
4+
5+
Create cleaner diagrams with automatic edges that balance connection points, reduce collisions with nodes and nearby edges, and stay responsive while you drag, without losing pinned endpoints, hand-placed bends, aligned component interfaces, or compatibility with older diagrams; embedding apps can also normalize diagrams in server-side code through the new `@tumaet/apollon/model` entry.

.github/workflows/pr-health-checks.yml

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -393,18 +393,30 @@ jobs:
393393
run: pnpm --filter @tumaet/apollon run test
394394

395395
- name: Verify ESM exports-map resolution
396-
# The library is ESM-only. `import.meta.resolve` (sync, no side
397-
# effects) validates every subpath in the exports map — actually
398-
# evaluating the module would require jsdom.
396+
# The library is ESM-only. `import.meta.resolve` validates every
397+
# subpath in the exports map. The model-only entry is intentionally
398+
# DOM-free; the follow-up check evaluates its complete runtime graph.
399399
working-directory: standalone/server
400400
run: |
401401
node --input-type=module -e '
402402
const root = import.meta.resolve("@tumaet/apollon");
403403
const internals = import.meta.resolve("@tumaet/apollon/internals");
404404
const exportEntry = import.meta.resolve("@tumaet/apollon/export");
405+
const model = import.meta.resolve("@tumaet/apollon/model");
405406
const css = import.meta.resolve("@tumaet/apollon/style.css");
406-
console.log({ root, internals, exportEntry, css });
407+
const schema = import.meta.resolve("@tumaet/apollon/schema");
408+
const packageJson = import.meta.resolve("@tumaet/apollon/package.json");
409+
console.log({
410+
root,
411+
internals,
412+
exportEntry,
413+
model,
414+
css,
415+
schema,
416+
packageJson,
417+
});
407418
'
419+
node ../../scripts/check-model-entry.mjs
408420
409421
- name: Lint published package shape (publint)
410422
working-directory: library

docs/library/api/model-contract.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ import { importDiagram } from "@tumaet/apollon"
9797
editor.model = importDiagram(maybeV2OrV3Json) // → guaranteed v4
9898
```
9999

100+
Server code that only needs to normalize model JSON can use the DOM-free entry:
101+
102+
```ts
103+
import { importDiagram } from "@tumaet/apollon/model"
104+
105+
export const normalizeDiagram = (data: unknown) => importDiagram(data)
106+
```
107+
100108
The v2 / v3 detectors and converters live behind `@tumaet/apollon/internals`
101109
and are **not** part of the stability guarantee — only `importDiagram` is.
102110

docs/library/embedding/react.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,9 +244,16 @@ export default function Page() {
244244

245245
Remix and Nuxt have equivalent client-only loading.
246246

247-
`@tumaet/apollon` is a client module in its entirety (`"use client"`), so its
248-
pure helpers (`importDiagram`, `createApollonTheme`, …) are client-only too —
249-
import them from client components, not Server Components.
247+
`@tumaet/apollon` is a client module in its entirety (`"use client"`), so
248+
import the editor and theme helpers from client components, not Server
249+
Components. Model migration is the exception: server code can import the
250+
DOM-free entry directly.
251+
252+
```ts
253+
import { importDiagram } from "@tumaet/apollon/model"
254+
255+
export const normalizeDiagram = (data: unknown) => importDiagram(data)
256+
```
250257

251258
Non-React hosts that want imperative control mount `ApollonEditor` directly —
252259
see [Vanilla JS / CDN](/library/embedding/vanilla).

docs/library/upgrading.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,14 @@ to React 19 before upgrading Apollon — on React 18 the install fails with an
6666

6767
## `"use client"` / RSC
6868

69-
The main entry is now a client module (`"use client"`) in its entirety, so its
70-
pure helpers (`importDiagram`, `createApollonTheme`, …) are client-only too.
71-
React Server Components must treat `@tumaet/apollon` as client-only: import it
72-
from client components, not Server Components. See
73-
[SSR](/library/embedding/react#ssr-nextjs-remix-nuxt-sveltekit).
69+
The main entry is now a client module (`"use client"`) in its entirety. React
70+
Server Components must therefore treat `@tumaet/apollon` as client-only and
71+
import it from client components, not Server Components.
72+
73+
Model migration is also available from the DOM-free
74+
`@tumaet/apollon/model` entry. Server code that only needs to normalize a
75+
diagram can import `importDiagram` from that subpath without loading the
76+
editor. See [SSR](/library/embedding/react#ssr-nextjs-remix-nuxt-sveltekit).
7477

7578
## Removed deep imports (CDN caveat)
7679

knip.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
"ignoreExportsUsedInFile": true,
44
"workspaces": {
55
"library": {
6-
"entry": ["lib/index.tsx", "lib/internals.ts", "lib/export/index.ts"],
6+
"entry": [
7+
"lib/index.tsx",
8+
"lib/internals.ts",
9+
"lib/export/index.ts",
10+
"lib/model.ts"
11+
],
712
"project": ["lib/**/*.{ts,tsx}"],
813
"ignoreBinaries": ["build", "run"]
914
},
@@ -28,7 +33,6 @@
2833
"ignoreDependencies": [
2934
"@capacitor/file-transfer",
3035
"@capacitor/splash-screen",
31-
"@capacitor/android",
3236
"tailwindcss",
3337
"jspdf",
3438
"svg2pdf.js",

library/lib/App.tsx

Lines changed: 37 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@ import {
33
ReactFlowInstance,
44
ConnectionMode,
55
ReactFlow,
6-
type Edge,
76
} from "@xyflow/react"
8-
import { type MouseEvent as ReactMouseEvent, useCallback } from "react"
7+
import { useCallback } from "react"
98
import {
109
CustomBackground,
11-
ReconnectConnectionLine,
1210
AssessmentSelectionDebug,
1311
ScrollOverlay,
1412
AlignmentGuides,
1513
} from "@/components"
14+
// Imported DIRECTLY, not via the `@/components` barrel: this component pulls in the
15+
// edge-geometry solver to preview the committed auto route, and the barrel is
16+
// imported by `constants.ts`, so routing it through the barrel forms a
17+
// `constants → components → solver → edgeAnchoring` import cycle that leaves module
18+
// constants undefined at init. The direct path keeps the solver out of the barrel.
19+
import { ConnectionPreviewLine } from "@/components/ConnectionPreviewLine"
1620
import { OverlayLayer } from "@/overlay/OverlayLayer"
1721
import "@xyflow/react/dist/style.css"
1822
// Shared, embed-safe @tumaet/ui primitives + --apollon-/--home- design tokens
@@ -26,6 +30,7 @@ import "@/styles/fonts.css"
2630
import "@/styles/app.css"
2731
import {
2832
useDiagramStore,
33+
useEdgeGeometryStore,
2934
useMetadataStore,
3035
useOverlayStore,
3136
} from "./store/context"
@@ -36,7 +41,6 @@ import { diagramEdgeTypes } from "./edges"
3641
import {
3742
useNodeDragStop,
3843
useConnect,
39-
useReconnect,
4044
useElementInteractions,
4145
useDragOver,
4246
useNodeDrag,
@@ -50,37 +54,30 @@ import {
5054
useRemoteDraggingNodes,
5155
applyDraggingOverlay,
5256
} from "./hooks/useRemoteDraggingNodes"
53-
import {
54-
getConnectionLineType,
55-
resolveReconnectPreviewBasePoints,
56-
} from "./utils/edgeUtils"
57-
import { IPoint } from "./edges/Connection"
57+
import { getConnectionLineType } from "./utils/edgeUtils"
5858
import {
5959
CollaborationLayer,
6060
type CollaborationAwarenessApi,
6161
type CollaborationLayerOptions,
6262
} from "@/components/collaboration/CollaborationLayer"
6363
import { TooltipProvider } from "@/components/ui"
64+
import { EdgeGeometrySolver } from "@/components/EdgeGeometrySolver"
6465

6566
interface AppProps {
6667
onReactFlowInit: (instance: ReactFlowInstance) => void
6768
collaboration: CollaborationLayerOptions
6869
awareness: CollaborationAwarenessApi
70+
/** Disabled by the off-screen export mount, which must materialize the whole
71+
* model regardless of its synthetic viewport. */
72+
onlyRenderVisibleElements?: boolean
6973
}
7074
const proOptions = { hideAttribution: true }
71-
const isPointArray = (value: unknown): value is IPoint[] =>
72-
Array.isArray(value) &&
73-
value.every(
74-
(point) =>
75-
typeof point === "object" &&
76-
point !== null &&
77-
"x" in point &&
78-
"y" in point &&
79-
typeof point.x === "number" &&
80-
typeof point.y === "number"
81-
)
82-
83-
function App({ onReactFlowInit, collaboration, awareness }: AppProps) {
75+
function App({
76+
onReactFlowInit,
77+
collaboration,
78+
awareness,
79+
onlyRenderVisibleElements = true,
80+
}: AppProps) {
8481
useKeyboardShortcuts()
8582

8683
const { nodes, onNodesChange, edges, onEdgesChange, diagramId, previewMode } =
@@ -102,8 +99,6 @@ function App({ onReactFlowInit, collaboration, awareness }: AppProps) {
10299
scrollEnabled,
103100
keyboardShortcuts,
104101
connectionGuidanceActive,
105-
startReconnectPreview,
106-
stopReconnectPreview,
107102
} = useMetadataStore(
108103
useShallow((state) => ({
109104
diagramType: state.diagramType,
@@ -112,8 +107,6 @@ function App({ onReactFlowInit, collaboration, awareness }: AppProps) {
112107
scrollEnabled: state.scrollEnabled,
113108
keyboardShortcuts: state.keyboardShortcuts,
114109
connectionGuidanceActive: state.connectionGuidanceActive,
115-
startReconnectPreview: state.startReconnectPreview,
116-
stopReconnectPreview: state.stopReconnectPreview,
117110
}))
118111
)
119112

@@ -143,11 +136,11 @@ function App({ onReactFlowInit, collaboration, awareness }: AppProps) {
143136
const onDragOver = useDragOver()
144137
const { onConnect, onConnectEnd, onConnectStart, onEdgesDelete } =
145138
useConnect()
146-
const onReconnect = useReconnect()
147139
const { onBeforeDelete, onNodeDoubleClick, onEdgeDoubleClick } =
148140
useElementInteractions()
149141
const { onPaneClicked } = usePaneClicked()
150142
const multiSelectionMode = useMultiSelectionMode()
143+
const routingReady = useEdgeGeometryStore((state) => state.routingReady)
151144

152145
const handleReactFlowInit = useCallback(
153146
(instance: ReactFlowInstance) => {
@@ -156,25 +149,6 @@ function App({ onReactFlowInit, collaboration, awareness }: AppProps) {
156149
[onReactFlowInit]
157150
)
158151

159-
const handleReconnectStart = useCallback(
160-
(_event: ReactMouseEvent, edge: Edge, handleType: "source" | "target") => {
161-
const storedPoints = isPointArray(edge.data?.points)
162-
? edge.data.points
163-
: undefined
164-
165-
startReconnectPreview(
166-
edge.id,
167-
handleType,
168-
resolveReconnectPreviewBasePoints(storedPoints, undefined, [])
169-
)
170-
},
171-
[startReconnectPreview]
172-
)
173-
174-
const handleReconnectEnd = useCallback(() => {
175-
stopReconnectPreview()
176-
}, [stopReconnectPreview])
177-
178152
return (
179153
<TooltipProvider>
180154
<div
@@ -205,7 +179,20 @@ function App({ onReactFlowInit, collaboration, awareness }: AppProps) {
205179
nodeTypes={diagramNodeTypes}
206180
edgeTypes={diagramEdgeTypes}
207181
nodes={displayNodes}
208-
edges={edges}
182+
// The solver reads DiagramStore directly. Keep provisional React
183+
// Flow edges unmounted until this model's first exact generation;
184+
// nodes still mount below so their runtime handles can be measured.
185+
edges={routingReady ? edges : []}
186+
// React Flow's viewport culling keeps large off-screen diagrams out
187+
// of the DOM while the central solver still optimizes every edge.
188+
// This is purely a rendering boundary: export and exact geometry
189+
// continue to use the complete diagram.
190+
// Bootstrap one complete measurement pass before enabling viewport
191+
// culling. Otherwise off-screen nodes never mount their handles and
192+
// the holistic router cannot produce a stable first generation.
193+
onlyRenderVisibleElements={
194+
routingReady ? onlyRenderVisibleElements : false
195+
}
209196
onDragOver={onDragOver}
210197
onNodesChange={onNodesChange}
211198
onEdgesChange={onEdgesChange}
@@ -216,11 +203,8 @@ function App({ onReactFlowInit, collaboration, awareness }: AppProps) {
216203
zoomOnDoubleClick={false}
217204
onNodeDrag={onNodeDrag}
218205
onNodeDragStop={onNodeDragStop}
219-
onReconnect={onReconnect}
220-
onReconnectStart={handleReconnectStart}
221-
onReconnectEnd={handleReconnectEnd}
222206
connectionLineType={connectionLineType}
223-
connectionLineComponent={ReconnectConnectionLine}
207+
connectionLineComponent={ConnectionPreviewLine}
224208
connectionMode={ConnectionMode.Loose}
225209
// Lift the selected edge (and its bend/endpoint handles) above other
226210
// edges so an overlapping edge's interaction ribbon can't steal the
@@ -244,7 +228,7 @@ function App({ onReactFlowInit, collaboration, awareness }: AppProps) {
244228
onBeforeDelete={onBeforeDelete}
245229
onPaneClick={onPaneClicked}
246230
proOptions={proOptions}
247-
edgesReconnectable={isDiagramModifiable}
231+
edgesReconnectable={false}
248232
nodesConnectable={isDiagramModifiable}
249233
nodesDraggable={isDiagramModifiable}
250234
panOnScroll={!scrollLock || scrollEnabled}
@@ -278,6 +262,7 @@ function App({ onReactFlowInit, collaboration, awareness }: AppProps) {
278262
<CustomBackground />
279263
<AlignmentGuides />
280264
<AssessmentSelectionDebug />
265+
<EdgeGeometrySolver />
281266
{/* Renders every registered control (built-in + host-injected) into
282267
its region: header, rails, corners, on-canvas. The chrome itself is
283268
registered at construction (imperative) or by the React wrapper. */}

0 commit comments

Comments
 (0)