apps/web — Next.js 16 + TypeScript + React 19 (no Tailwind) M1 Visual port - Manuscript theme + base styles ported verbatim from design-source - 100dvh layout with internal scroll regions (rail / narrative / dock thread) - TopBar, LeftRail (collapsible sections + collapsed strip), CanvasHeader, StatusBar, EditorShell composing the dual-canvas - SocratesDock (subtle/default/prominent) with bubbles + numbered options - Sigil (Σ + laurel) used across editor and seed screens - SeedScreen with emerging-seed rail, mini-graph, confidence bar, thread, typing indicator, input row - Routes: / (homepage), /editor/[projectId], /seed - Aristotle fixture in lib/fixtures (ported from data.js) M2 Real text editor - TipTap (StarterKit + custom Chip atom inline node + ReactNodeViewRenderer) - Slash-menu insertion via @tiptap/suggestion with arrow / number-key shortcuts - ChipFocusContext bridges narrative ↔ diagram hover/selection across the TipTap render boundary - fixtureToDoc converts the fixture narrative → ProseMirror JSON M3 Real diagram - React Flow (@xyflow/react) custom node (block / actor / constraint / system) matching prototype's softened look - Custom edge with bezier path + label renderer (association / composition / constraint variants) - Drag-create from a left-side palette via HTML5 DnD + screenToFlowPosition - NodeInspector panel: edit kind / label / properties (or expression for constraints); delete cascades to incident edges - Bidirectional focus highlighting between chips (narrative) and blocks (diagram) - Removed redundant legend (stereotype labels on nodes serve same purpose) State for M3 lives in component memory; persistence + bidirectional sync land in M4–M5.
111 lines
3.3 KiB
TypeScript
111 lines
3.3 KiB
TypeScript
// Slash menu — appears when the user types `/`. Shows the four chip kinds.
|
||
// On selection, inserts a chip with a placeholder label that the user can
|
||
// then rename inline.
|
||
|
||
"use client";
|
||
|
||
import { useEffect, useImperativeHandle, useState, forwardRef } from "react";
|
||
import type { ChipKind } from "../../lib/fixtures/aristotle";
|
||
|
||
export interface SlashItem {
|
||
kind: ChipKind;
|
||
label: string;
|
||
hint: string;
|
||
glyph: string;
|
||
}
|
||
|
||
export const SLASH_ITEMS: SlashItem[] = [
|
||
{ kind: "block", label: "Block", hint: "an entity in the system", glyph: "▢" },
|
||
{ kind: "property", label: "Property", hint: "an attribute of a block", glyph: "·" },
|
||
{ kind: "association", label: "Association", hint: "a relationship", glyph: "→" },
|
||
{ kind: "requirement", label: "Requirement", hint: "a stated goal (REQ-NNN)", glyph: "§" },
|
||
];
|
||
|
||
export interface SlashMenuHandle {
|
||
onKeyDown: (event: KeyboardEvent) => boolean;
|
||
}
|
||
|
||
interface SlashMenuProps {
|
||
query: string;
|
||
command: (item: SlashItem) => void;
|
||
}
|
||
|
||
export const SlashMenu = forwardRef<SlashMenuHandle, SlashMenuProps>(function SlashMenu(
|
||
{ query, command },
|
||
ref
|
||
) {
|
||
const filtered = SLASH_ITEMS.filter(
|
||
item =>
|
||
query.length === 0 ||
|
||
item.kind.toLowerCase().startsWith(query.toLowerCase()) ||
|
||
item.label.toLowerCase().startsWith(query.toLowerCase())
|
||
);
|
||
const [activeIndex, setActiveIndex] = useState(0);
|
||
|
||
useEffect(() => {
|
||
setActiveIndex(0);
|
||
}, [query]);
|
||
|
||
useImperativeHandle(ref, () => ({
|
||
onKeyDown(event: KeyboardEvent) {
|
||
if (filtered.length === 0) return false;
|
||
if (event.key === "ArrowUp") {
|
||
setActiveIndex(prev => (prev - 1 + filtered.length) % filtered.length);
|
||
return true;
|
||
}
|
||
if (event.key === "ArrowDown") {
|
||
setActiveIndex(prev => (prev + 1) % filtered.length);
|
||
return true;
|
||
}
|
||
if (event.key === "Enter" || event.key === "Tab") {
|
||
const choice = filtered[activeIndex];
|
||
if (choice) {
|
||
command(choice);
|
||
return true;
|
||
}
|
||
}
|
||
// Number-key shortcut: 1–4 picks the corresponding item
|
||
const num = parseInt(event.key, 10);
|
||
if (!Number.isNaN(num) && num >= 1 && num <= filtered.length) {
|
||
const choice = filtered[num - 1];
|
||
if (choice) {
|
||
command(choice);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
},
|
||
}));
|
||
|
||
if (filtered.length === 0) {
|
||
return (
|
||
<div className="slash-menu slash-menu-empty">
|
||
<span className="slash-menu-empty-text">no matches for “{query}”</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="slash-menu">
|
||
{filtered.map((item, idx) => (
|
||
<button
|
||
key={item.kind}
|
||
type="button"
|
||
className={`slash-menu-item ${idx === activeIndex ? "slash-menu-item-active" : ""}`}
|
||
onMouseEnter={() => setActiveIndex(idx)}
|
||
onMouseDown={e => {
|
||
// mousedown so the click registers before the editor blurs
|
||
e.preventDefault();
|
||
command(item);
|
||
}}
|
||
>
|
||
<span className={`slash-menu-glyph slash-menu-glyph-${item.kind}`}>{item.glyph}</span>
|
||
<span className="slash-menu-label">{item.label}</span>
|
||
<span className="slash-menu-hint">{item.hint}</span>
|
||
<span className="slash-menu-key">{idx + 1}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
});
|