MVP M1–M3: visual port, TipTap text editor, React Flow diagram
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.
This commit is contained in:
92
apps/web/components/text-canvas/slashSuggestion.ts
Normal file
92
apps/web/components/text-canvas/slashSuggestion.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user