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.
148 lines
4.8 KiB
TypeScript
148 lines
4.8 KiB
TypeScript
// The dual-canvas workspace shell.
|
|
// M5: state lives in ModelStoreProvider; both canvases consume the canonical
|
|
// SysMLModel and emit ModelOps back through useApply().
|
|
|
|
"use client";
|
|
|
|
import { useMemo, useState, Suspense } from "react";
|
|
import { useSearchParams } from "next/navigation";
|
|
import { TopBar } from "./TopBar";
|
|
import { LeftRail } from "./LeftRail";
|
|
import { CanvasHeader } from "./CanvasHeader";
|
|
import { StatusBar } from "./StatusBar";
|
|
import { IssuesPanel } from "./IssuesPanel";
|
|
import { TextCanvas } from "../text-canvas/TextCanvas";
|
|
import { DiagramCanvas, type DiagramVariant } from "../diagram-canvas/DiagramCanvas";
|
|
import { SocratesDock, type Density, type SocratesPresence } from "../socrates/SocratesDock";
|
|
import type { MarkupStyle } from "../text-canvas/Chip";
|
|
import type { FixtureData } from "../../lib/fixtures/aristotle";
|
|
import { fromFixture } from "../../lib/sysml/fromFixture";
|
|
import { applyBreaks, BREAKS, type BreakName } from "../../lib/sysml/breaks";
|
|
import { ModelStoreProvider, useModelStore } from "../../lib/sync/ModelStore";
|
|
|
|
interface EditorShellProps {
|
|
data: FixtureData;
|
|
density?: Density;
|
|
markupStyle?: MarkupStyle;
|
|
diagramStyle?: DiagramVariant;
|
|
presence?: SocratesPresence;
|
|
}
|
|
|
|
export function EditorShell(props: EditorShellProps) {
|
|
return (
|
|
<Suspense fallback={null}>
|
|
<EditorShellInner {...props} />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function EditorShellInner({
|
|
data,
|
|
density = "comfortable",
|
|
markupStyle = "color",
|
|
diagramStyle = "softened",
|
|
presence = "default",
|
|
}: EditorShellProps) {
|
|
const searchParams = useSearchParams();
|
|
|
|
const breaks = useMemo<BreakName[]>(() => {
|
|
const raw = searchParams?.get("break") ?? "";
|
|
if (!raw) return [];
|
|
return raw.split(",").map(s => s.trim()).filter((s): s is BreakName => s in BREAKS);
|
|
}, [searchParams]);
|
|
|
|
const initialModel = useMemo(() => {
|
|
const base = fromFixture(data);
|
|
return breaks.length > 0 ? applyBreaks(base, breaks) : base;
|
|
}, [data, breaks]);
|
|
|
|
return (
|
|
<ModelStoreProvider initialModel={initialModel}>
|
|
<ShellBody
|
|
data={data}
|
|
density={density}
|
|
markupStyle={markupStyle}
|
|
diagramStyle={diagramStyle}
|
|
presence={presence}
|
|
breaks={breaks}
|
|
/>
|
|
</ModelStoreProvider>
|
|
);
|
|
}
|
|
|
|
function ShellBody({
|
|
data,
|
|
density,
|
|
markupStyle,
|
|
diagramStyle,
|
|
presence,
|
|
breaks,
|
|
}: Required<Omit<EditorShellProps, "data">> & { data: FixtureData; breaks: BreakName[] }) {
|
|
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
|
|
const { model, issues, issuesByElement } = useModelStore();
|
|
|
|
const stats = `SysML · ${model.blocks.length} blocks · ${model.associations.length} associations · ${model.constraints.length} constraints`;
|
|
const subtitle = breaks.length > 0 ? `${stats} · breaks active: ${breaks.join(", ")}` : stats;
|
|
|
|
return (
|
|
<div className={`shell shell-density-${density} shell-presence-${presence}`}>
|
|
<TopBar data={data} />
|
|
|
|
<div className="shell-body">
|
|
<SocratesDock thread={data.socratesThread} presence={presence} density={density} />
|
|
<LeftRail
|
|
data={data}
|
|
focusBlockId={focusBlockId}
|
|
setFocusBlockId={setFocusBlockId}
|
|
issuesByElement={issuesByElement}
|
|
/>
|
|
|
|
<main className="canvases">
|
|
<section className="canvas canvas-text">
|
|
<CanvasHeader title="Narrative" subtitle="Markup-augmented prose · synced to model" />
|
|
<div className="canvas-scroll">
|
|
<TextCanvas
|
|
data={data}
|
|
density={density}
|
|
markupStyle={markupStyle}
|
|
focusBlockId={focusBlockId}
|
|
setFocusBlockId={setFocusBlockId}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="canvas-divider" />
|
|
|
|
<section className="canvas canvas-diagram">
|
|
<CanvasHeader
|
|
title="Model"
|
|
subtitle={subtitle}
|
|
right={
|
|
<div className="canvas-actions">
|
|
<span className="canvas-mode-pill">Fit</span>
|
|
<span className="canvas-mode-pill canvas-mode-active">100%</span>
|
|
<span className="canvas-mode-pill">Layout</span>
|
|
</div>
|
|
}
|
|
/>
|
|
<div className="canvas-scroll canvas-scroll-diagram">
|
|
<DiagramCanvas
|
|
data={data}
|
|
density={density}
|
|
variant={diagramStyle}
|
|
focusBlockId={focusBlockId}
|
|
onSelect={setFocusBlockId}
|
|
issuesByElement={issuesByElement}
|
|
/>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
|
|
<StatusBar data={data} />
|
|
|
|
<IssuesPanel issues={issues} onSelectAnchor={id => setFocusBlockId(id)} />
|
|
</div>
|
|
);
|
|
}
|