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.
93 lines
2.7 KiB
TypeScript
93 lines
2.7 KiB
TypeScript
// Suggestion plugin config that wires the slash menu into TipTap.
|
|
// Renders SlashMenu in a fixed-position floating panel near the caret.
|
|
|
|
"use client";
|
|
|
|
import type { Editor, Range } from "@tiptap/core";
|
|
import type { SuggestionOptions, SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { createElement, createRef } from "react";
|
|
import { SlashMenu, SLASH_ITEMS, type SlashItem, type SlashMenuHandle } from "./SlashMenu";
|
|
|
|
export const slashSuggestion: Omit<SuggestionOptions<SlashItem, SlashItem>, "editor"> = {
|
|
char: "/",
|
|
startOfLine: false,
|
|
allowSpaces: false,
|
|
|
|
items: ({ query }) =>
|
|
SLASH_ITEMS.filter(
|
|
item =>
|
|
query.length === 0 ||
|
|
item.kind.toLowerCase().startsWith(query.toLowerCase()) ||
|
|
item.label.toLowerCase().startsWith(query.toLowerCase())
|
|
),
|
|
|
|
command: ({ editor, range, props }: { editor: Editor; range: Range; props: SlashItem }) => {
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.deleteRange(range)
|
|
.insertChip({
|
|
kind: props.kind,
|
|
refId: null,
|
|
label: props.kind === "requirement" ? "REQ-001" : "untitled",
|
|
})
|
|
.run();
|
|
},
|
|
|
|
render: () => {
|
|
let container: HTMLDivElement | null = null;
|
|
let root: Root | null = null;
|
|
const handleRef = createRef<SlashMenuHandle>();
|
|
|
|
function position(rect: DOMRect | null) {
|
|
if (!container || !rect) return;
|
|
container.style.position = "fixed";
|
|
container.style.top = `${rect.bottom + 6}px`;
|
|
container.style.left = `${rect.left}px`;
|
|
container.style.zIndex = "1000";
|
|
}
|
|
|
|
function rerender(props: SuggestionProps<SlashItem>) {
|
|
if (!root) return;
|
|
root.render(
|
|
createElement(SlashMenu, {
|
|
ref: handleRef,
|
|
query: props.query,
|
|
command: (item: SlashItem) => props.command(item),
|
|
})
|
|
);
|
|
}
|
|
|
|
return {
|
|
onStart(props) {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
rerender(props);
|
|
position(props.clientRect?.() ?? null);
|
|
},
|
|
|
|
onUpdate(props) {
|
|
rerender(props);
|
|
position(props.clientRect?.() ?? null);
|
|
},
|
|
|
|
onKeyDown(props: SuggestionKeyDownProps) {
|
|
if (props.event.key === "Escape") {
|
|
props.event.preventDefault();
|
|
return true;
|
|
}
|
|
return handleRef.current?.onKeyDown(props.event) ?? false;
|
|
},
|
|
|
|
onExit() {
|
|
if (root) root.unmount();
|
|
if (container && container.parentNode) container.parentNode.removeChild(container);
|
|
root = null;
|
|
container = null;
|
|
},
|
|
};
|
|
},
|
|
};
|