Both canvases now share a canonical SysMLModel through React context.
Renames in the diagram inspector ripple to every chip in the narrative
referencing the same refId, and double-clicking a chip emits an update op
that re-renders the diagram block. Validation re-runs on every successful
apply. State is in-memory; M5.9 adds Postgres persistence.
apps/web/lib/sync (new)
- ops.ts: ModelOp alphabet (18 op kinds across block / property /
association / constraint / requirement / relation, add/update/remove for
each). tempId() helper + per-kind constructors.
- applyOps.ts: pure (model, ops) → { model, idMapping, errors, applied }
reducer. Atomic per batch. Cascades on remove-block (drops incident
associations + constraint applies-to + requirement satisfiers). tempId
resolution rewrites to canonical ids on duplicate-id collision.
- ModelStore.tsx: React provider exposing { model, apply, issues,
issuesByElement }. Validation memoized on every model change.
Editor refactor
- EditorShell wraps in ModelStoreProvider. Initial model derived from
fixture (+ optional ?break= corruptions). Validation now lives in the
store, not duplicated here.
- LeftRail consumes useModel(): Model section lists real blocks +
constraints (sorted by kind), Requirements section lists real
requirements with traced/untraced status from r.relations.
Diagram refactor (the tricky piece)
- React Flow now owns ephemeral state via useNodesState / useEdgesState.
Positions, drag-in-progress, selection are all RF-internal.
- Model → RF: a useEffect runs on model change, applies targeted setNodes
updates only for elements whose semantic data (label, kind, properties)
changed. Object identity preserved for unchanged nodes — fixes the
"re-render storm on drag" + RF measurement-cache loss.
- RF → Model: onNodesChange / onEdgesChange / onConnect / onDrop emit
ops via useApply(). Constraint-applies edges decompose into
updateConstraint ops. deleteKeyCode={[Backspace, Delete]}.
- onNodesChange now handles type:'remove' too (was missing — that's why
selecting a block + Del removed only the edges, leaving the block).
Chip refactor
- ChipView resolves displayed label from useModel() via refId lookup
(block / requirement / association / property). Double-click chip →
inline rename input → emit update-{block,requirement,association} op.
All other chips with the same refId update on the next render.
- Slash-menu inserted chips have refId=null and skip the rename
affordance (until M6 wires real model element resolution).
Removed obsolete components/diagram-canvas/fixtureToFlow.ts; replaced
with modelToFlow.ts. Bumped CSS for the chip-rename input.
84 lines
2.7 KiB
TypeScript
84 lines
2.7 KiB
TypeScript
// React provider exposing the canonical SysMLModel + applyOps.
|
|
// Used by both canvases (and the rail) so they stay in sync.
|
|
|
|
"use client";
|
|
|
|
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
|
import { applyOps, type ApplyResult } from "./applyOps";
|
|
import { validate, type ValidationIssue } from "../sysml/validate";
|
|
import type { SysMLModel } from "../sysml/model";
|
|
import type { ModelOp } from "./ops";
|
|
|
|
export interface ModelStoreValue {
|
|
model: SysMLModel;
|
|
apply: (ops: ModelOp[]) => ApplyResult;
|
|
/** Validation issues, recomputed on each successful apply. */
|
|
issues: ValidationIssue[];
|
|
issuesByElement: Map<string, ValidationIssue[]>;
|
|
}
|
|
|
|
const ModelStoreContext = createContext<ModelStoreValue | null>(null);
|
|
|
|
interface ModelStoreProviderProps {
|
|
initialModel: SysMLModel;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export function ModelStoreProvider({ initialModel, children }: ModelStoreProviderProps) {
|
|
const [model, setModel] = useState<SysMLModel>(initialModel);
|
|
|
|
const apply = useCallback((ops: ModelOp[]): ApplyResult => {
|
|
let result: ApplyResult = { model, idMapping: {}, errors: [], applied: false };
|
|
setModel(current => {
|
|
result = applyOps(current, ops);
|
|
return result.applied ? result.model : current;
|
|
});
|
|
return result;
|
|
}, [model]);
|
|
|
|
const { issues, issuesByElement } = useMemo(() => {
|
|
const issues = validate(model);
|
|
const issuesByElement = new Map<string, ValidationIssue[]>();
|
|
for (const i of issues) {
|
|
const k = anchorKey(i.anchor);
|
|
if (!k) continue;
|
|
const prev = issuesByElement.get(k) ?? [];
|
|
prev.push(i);
|
|
issuesByElement.set(k, prev);
|
|
}
|
|
return { issues, issuesByElement };
|
|
}, [model]);
|
|
|
|
const value: ModelStoreValue = useMemo(
|
|
() => ({ model, apply, issues, issuesByElement }),
|
|
[model, apply, issues, issuesByElement]
|
|
);
|
|
|
|
return <ModelStoreContext.Provider value={value}>{children}</ModelStoreContext.Provider>;
|
|
}
|
|
|
|
export function useModelStore(): ModelStoreValue {
|
|
const ctx = useContext(ModelStoreContext);
|
|
if (!ctx) throw new Error("useModelStore must be used inside ModelStoreProvider");
|
|
return ctx;
|
|
}
|
|
|
|
export function useModel(): SysMLModel {
|
|
return useModelStore().model;
|
|
}
|
|
|
|
export function useApply(): (ops: ModelOp[]) => ApplyResult {
|
|
return useModelStore().apply;
|
|
}
|
|
|
|
function anchorKey(anchor: ValidationIssue["anchor"]): string | null {
|
|
switch (anchor.kind) {
|
|
case "block": return anchor.id;
|
|
case "association": return `assoc:${anchor.id}`;
|
|
case "constraint": return `constraint:${anchor.id}`;
|
|
case "requirement": return `req:${anchor.id}`;
|
|
case "property": return anchor.blockId;
|
|
case "model": return null;
|
|
}
|
|
}
|