// React NodeView for the chip TipTap node. // // M5: chips look up their displayed label from the SysMLModel via refId. // Click a chip → opens an inline rename popover that emits an update-block // op via useApply(). Renaming here updates every other chip with the same // refId AND the diagram block label. "use client"; import { NodeViewWrapper } from "@tiptap/react"; import type { NodeViewProps } from "@tiptap/react"; import { useEffect, useRef, useState } from "react"; import type { ChipKind } from "../../lib/fixtures/aristotle"; import type { MarkupStyle } from "./Chip"; import { useChipFocus } from "./FocusContext"; import { useModelStore } from "../../lib/sync/ModelStore"; import { useAnalysis } from "../../lib/workspace/analysisStore"; import { useOpenPanes } from "../../lib/workspace/openPanesStore"; import { updateBlock, updateRequirement, updateAssociation } from "../../lib/sync/ops"; const KIND_LABEL: Record = { block: "block", property: "property", association: "assoc", requirement: "req", }; function kindGlyph(kind: ChipKind): string { switch (kind) { case "block": return "▢"; case "property": return "·"; case "association": return "→"; case "requirement": return "§"; } } export function ChipView({ node, selected, editor }: NodeViewProps) { const kind = (node.attrs.kind as ChipKind) ?? "block"; const refId = (node.attrs.refId as string | null) ?? null; const fallbackLabel = (node.attrs.label as string) ?? "untitled"; const markupStyle = ((editor.storage as unknown as Record).markupStyle as MarkupStyle | undefined) ?? "color"; const { focusBlockId, setFocusBlockId } = useChipFocus(); const { model, apply } = useModelStore(); const { getTerm } = useAnalysis(); const { setStack } = useOpenPanes(); // Resolve the live label from the model, falling back to the node's stored // label (e.g. for chips created via slash-menu before they're bound). const liveLabel = useMemo_label(model, kind, refId) ?? fallbackLabel; const isFocused = (refId !== null && refId === focusBlockId) || selected; const [isEditing, setIsEditing] = useState(false); const [draft, setDraft] = useState(liveLabel); const inputRef = useRef(null); useEffect(() => { if (isEditing) { setDraft(liveLabel); // Allow the input to mount before focusing. requestAnimationFrame(() => { inputRef.current?.focus(); inputRef.current?.select(); }); } }, [isEditing, liveLabel]); function commitRename() { setIsEditing(false); const next = draft.trim(); if (!next || next === liveLabel || !refId) return; if (kind === "block") { apply([updateBlock(refId, { label: next })]); } else if (kind === "requirement") { apply([updateRequirement(refId, { tag: next })]); } else if (kind === "association") { apply([updateAssociation(refId, { label: next })]); } // property and (potential future) constraint chips: rename in the // model is more involved (need parent block id resolution); skip for M5. } const cls = `chip chip-${kind} chip-style-${markupStyle}${isFocused ? " chip-focus" : ""}${isEditing ? " chip-editing" : ""}`; const handlers = refId ? { onMouseEnter: () => setFocusBlockId(refId), onMouseLeave: () => !isEditing && setFocusBlockId(null), onDoubleClick: (e: React.MouseEvent) => { e.preventDefault(); if (refId) setIsEditing(true); }, onClick: () => { // Single-click signals local diagram focus AND, if the chip refers // to a term, opens that term as a Concepts → term column chain so // the user lands in a coherent place. setFocusBlockId(refId); if (kind === "block" && getTerm(refId)) { setStack([ { kind: "section", id: "concepts" }, { kind: "term", id: refId }, ]); } }, } : {}; // Inline rename input — replaces the label visual, preserves the chip frame if (isEditing && refId) { return ( {markupStyle === "bracket" && [} {markupStyle === "bracket" && {KIND_LABEL[kind]}:} {markupStyle !== "bracket" && {kindGlyph(kind)}} setDraft(e.target.value)} onBlur={commitRename} onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLInputElement).blur(); } else if (e.key === "Escape") { setDraft(liveLabel); setIsEditing(false); } }} size={Math.max(draft.length, 6)} /> {markupStyle === "bracket" && ]} ); } if (markupStyle === "bracket") { return ( [ {KIND_LABEL[kind]}: {liveLabel} ] ); } if (markupStyle === "underline") { return ( {kindGlyph(kind)} {liveLabel} ); } return ( {kindGlyph(kind)} {liveLabel} ); } /** * Look up the live label for a chip from the model. Inline-imported and * collocated to keep the resolution rules near the consumer. * * For block / requirement / association / constraint chips we resolve by * matching `refId` against the corresponding element. Property chips use a * composite refId ("blockId.propertyId") — handled with a `.` split. */ function useMemo_label(model: ReturnType["model"], kind: ChipKind, refId: string | null): string | null { if (!refId) return null; if (kind === "block") { return model.blocks.find(b => b.id === refId)?.label ?? null; } if (kind === "requirement") { return model.requirements.find(r => r.id === refId || r.tag === refId)?.tag ?? null; } if (kind === "association") { return model.associations.find(a => a.id === refId)?.label ?? null; } if (kind === "property") { const dot = refId.indexOf("."); if (dot < 0) { // Bare property name from the fixture; do a flat search. for (const b of model.blocks) { const p = b.properties.find(p => p.name === refId || p.id === refId); if (p) return p.name; } return null; } const blockId = refId.slice(0, dot); const propId = refId.slice(dot + 1); const b = model.blocks.find(x => x.id === blockId); return b?.properties.find(p => p.id === propId || p.name === propId)?.name ?? null; } return null; }