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:
71
apps/web/components/text-canvas/Chip.tsx
Normal file
71
apps/web/components/text-canvas/Chip.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// Inline chip referencing a model element.
|
||||
// Renders four visual styles via CSS (pill / color / underline / bracket).
|
||||
// Ported from docs/design-source/socrata/project/text-canvas.jsx (Chip).
|
||||
// M1: static. M2: becomes a TipTap node-view.
|
||||
|
||||
import type { ChipKind } from "../../lib/fixtures/aristotle";
|
||||
|
||||
export type MarkupStyle = "pill" | "color" | "underline" | "bracket";
|
||||
|
||||
const KIND_LABEL: Record<ChipKind, string> = {
|
||||
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 "§";
|
||||
}
|
||||
}
|
||||
|
||||
interface ChipProps {
|
||||
kind: ChipKind;
|
||||
id: string;
|
||||
label: string;
|
||||
markupStyle: MarkupStyle;
|
||||
focused?: boolean;
|
||||
onHover?: (id: string | null) => void;
|
||||
onClick?: (id: string, kind: ChipKind) => void;
|
||||
}
|
||||
|
||||
export function Chip({ kind, id, label, markupStyle, focused, onHover, onClick }: ChipProps) {
|
||||
const cls = `chip chip-${kind} chip-style-${markupStyle}${focused ? " chip-focus" : ""}`;
|
||||
const handlers = {
|
||||
onMouseEnter: () => onHover?.(id),
|
||||
onMouseLeave: () => onHover?.(null),
|
||||
onClick: () => onClick?.(id, kind),
|
||||
};
|
||||
|
||||
if (markupStyle === "bracket") {
|
||||
return (
|
||||
<span className={cls} {...handlers}>
|
||||
<span className="chip-bracket">[</span>
|
||||
<span className="chip-kind">{KIND_LABEL[kind]}:</span>
|
||||
<span className="chip-label">{label}</span>
|
||||
<span className="chip-bracket">]</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (markupStyle === "underline") {
|
||||
return (
|
||||
<span className={cls} {...handlers}>
|
||||
<span className="chip-glyph">{kindGlyph(kind)}</span>
|
||||
<span className="chip-label">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// pill (default) and color both render as filled chips.
|
||||
return (
|
||||
<span className={cls} {...handlers}>
|
||||
<span className="chip-glyph">{kindGlyph(kind)}</span>
|
||||
<span className="chip-label">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
82
apps/web/components/text-canvas/ChipNode.ts
Normal file
82
apps/web/components/text-canvas/ChipNode.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// TipTap node definition for inline model-element chips.
|
||||
//
|
||||
// M2: chips are inline, atomic, selectable. They carry { kind, refId, label }.
|
||||
// In MVP M5 the label will be derived from a live model lookup keyed by refId;
|
||||
// for M2 we store it directly on the node so renaming + persistence Just Work.
|
||||
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import type { ChipKind } from "../../lib/fixtures/aristotle";
|
||||
import { ChipView } from "./ChipView";
|
||||
|
||||
export interface ChipAttrs {
|
||||
kind: ChipKind;
|
||||
refId: string | null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
chip: {
|
||||
insertChip: (attrs: ChipAttrs) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const ChipNode = Node.create({
|
||||
name: "chip",
|
||||
group: "inline",
|
||||
inline: true,
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
kind: {
|
||||
default: "block",
|
||||
parseHTML: el => el.getAttribute("data-kind") ?? "block",
|
||||
renderHTML: attrs => ({ "data-kind": attrs.kind }),
|
||||
},
|
||||
refId: {
|
||||
default: null,
|
||||
parseHTML: el => el.getAttribute("data-ref-id"),
|
||||
renderHTML: attrs => (attrs.refId ? { "data-ref-id": attrs.refId } : {}),
|
||||
},
|
||||
label: {
|
||||
default: "untitled",
|
||||
parseHTML: el => el.getAttribute("data-label") ?? el.textContent ?? "untitled",
|
||||
renderHTML: attrs => ({ "data-label": attrs.label }),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "span[data-chip]" }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes({ "data-chip": "" }, HTMLAttributes),
|
||||
`${node.attrs.label}`,
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ChipView);
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertChip:
|
||||
(attrs: ChipAttrs) =>
|
||||
({ chain }) =>
|
||||
chain()
|
||||
.insertContent({ type: this.name, attrs })
|
||||
// Insert a trailing space so the caret leaves the chip cleanly
|
||||
.insertContent(" ")
|
||||
.run(),
|
||||
};
|
||||
},
|
||||
});
|
||||
73
apps/web/components/text-canvas/ChipView.tsx
Normal file
73
apps/web/components/text-canvas/ChipView.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
// React NodeView for the chip TipTap node.
|
||||
// Renders identically to the static Chip via the same CSS classes.
|
||||
|
||||
"use client";
|
||||
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import type { ChipKind } from "../../lib/fixtures/aristotle";
|
||||
import type { MarkupStyle } from "./Chip";
|
||||
import { useChipFocus } from "./FocusContext";
|
||||
|
||||
const KIND_LABEL: Record<ChipKind, string> = {
|
||||
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 label = (node.attrs.label as string) ?? "untitled";
|
||||
const refId = (node.attrs.refId as string | null) ?? null;
|
||||
// Read the current markup style from the editor's storage; defaults to "color".
|
||||
const markupStyle = ((editor.storage as Record<string, unknown>).markupStyle as MarkupStyle) ?? "color";
|
||||
|
||||
const { focusBlockId, setFocusBlockId } = useChipFocus();
|
||||
const isFocused = (refId !== null && refId === focusBlockId) || selected;
|
||||
|
||||
const cls = `chip chip-${kind} chip-style-${markupStyle}${isFocused ? " chip-focus" : ""}`;
|
||||
const handlers = refId
|
||||
? {
|
||||
onMouseEnter: () => setFocusBlockId(refId),
|
||||
onMouseLeave: () => setFocusBlockId(null),
|
||||
onClick: () => setFocusBlockId(refId),
|
||||
}
|
||||
: {};
|
||||
|
||||
if (markupStyle === "bracket") {
|
||||
return (
|
||||
<NodeViewWrapper as="span" className={cls} contentEditable={false} draggable={false} {...handlers}>
|
||||
<span className="chip-bracket">[</span>
|
||||
<span className="chip-kind">{KIND_LABEL[kind]}:</span>
|
||||
<span className="chip-label">{label}</span>
|
||||
<span className="chip-bracket">]</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (markupStyle === "underline") {
|
||||
return (
|
||||
<NodeViewWrapper as="span" className={cls} contentEditable={false} draggable={false} {...handlers}>
|
||||
<span className="chip-glyph">{kindGlyph(kind)}</span>
|
||||
<span className="chip-label">{label}</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span" className={cls} contentEditable={false} draggable={false} {...handlers}>
|
||||
<span className="chip-glyph">{kindGlyph(kind)}</span>
|
||||
<span className="chip-label">{label}</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
23
apps/web/components/text-canvas/FocusContext.tsx
Normal file
23
apps/web/components/text-canvas/FocusContext.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
// React context for cross-canvas focus state.
|
||||
//
|
||||
// The chip node views are mounted via TipTap's ReactNodeViewRenderer; they
|
||||
// can't reach into props on the editor instance, but they can use React
|
||||
// Context (it propagates through any React tree, including TipTap's).
|
||||
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export interface ChipFocusValue {
|
||||
focusBlockId: string | null;
|
||||
setFocusBlockId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const ChipFocusContext = createContext<ChipFocusValue>({
|
||||
focusBlockId: null,
|
||||
setFocusBlockId: () => {},
|
||||
});
|
||||
|
||||
export function useChipFocus(): ChipFocusValue {
|
||||
return useContext(ChipFocusContext);
|
||||
}
|
||||
20
apps/web/components/text-canvas/SlashExtension.ts
Normal file
20
apps/web/components/text-canvas/SlashExtension.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// TipTap extension wrapping the slash-menu suggestion plugin.
|
||||
|
||||
"use client";
|
||||
|
||||
import { Extension } from "@tiptap/core";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import { slashSuggestion } from "./slashSuggestion";
|
||||
|
||||
export const SlashExtension = Extension.create({
|
||||
name: "slashMenu",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...slashSuggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
110
apps/web/components/text-canvas/SlashMenu.tsx
Normal file
110
apps/web/components/text-canvas/SlashMenu.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
// 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>
|
||||
);
|
||||
});
|
||||
94
apps/web/components/text-canvas/TextCanvas.tsx
Normal file
94
apps/web/components/text-canvas/TextCanvas.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
// TipTap-backed narrative editor.
|
||||
// Renders the same prose surface as the static port, but typing actually works
|
||||
// and chips can be inserted via the slash menu (`/block`, `/property`, etc.).
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ChipNode } from "./ChipNode";
|
||||
import { SlashExtension } from "./SlashExtension";
|
||||
import { ChipFocusContext } from "./FocusContext";
|
||||
import { fixtureToDoc } from "./fixtureToDoc";
|
||||
import type { Density } from "../socrates/SocratesDock";
|
||||
import type { FixtureData } from "../../lib/fixtures/aristotle";
|
||||
import type { MarkupStyle } from "./Chip";
|
||||
|
||||
interface TextCanvasProps {
|
||||
data: FixtureData;
|
||||
density: Density;
|
||||
markupStyle: MarkupStyle;
|
||||
focusBlockId: string | null;
|
||||
setFocusBlockId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export function TextCanvas({ data, density, markupStyle, focusBlockId, setFocusBlockId }: TextCanvasProps) {
|
||||
const padY = density === "compact" ? 10 : 18;
|
||||
const padX = density === "compact" ? 22 : 36;
|
||||
const focusValue = useMemo(
|
||||
() => ({ focusBlockId, setFocusBlockId }),
|
||||
[focusBlockId, setFocusBlockId]
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2] },
|
||||
// Drop features we don't need yet
|
||||
codeBlock: false,
|
||||
blockquote: false,
|
||||
horizontalRule: false,
|
||||
bulletList: false,
|
||||
orderedList: false,
|
||||
listItem: false,
|
||||
strike: false,
|
||||
code: false,
|
||||
link: false,
|
||||
}),
|
||||
ChipNode,
|
||||
SlashExtension,
|
||||
],
|
||||
content: fixtureToDoc(data),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "text-canvas tiptap",
|
||||
style: `padding: ${padY}px ${padX}px;`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Push the markup style into the editor so the ChipView NodeView can read it.
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
(editor.storage as Record<string, unknown>).markupStyle = markupStyle;
|
||||
// Force a re-render of all chip node views so they pick up the new style.
|
||||
editor.view.dispatch(editor.state.tr.setMeta("force-update", true));
|
||||
}, [editor, markupStyle]);
|
||||
|
||||
if (!editor) {
|
||||
return (
|
||||
<div className="text-canvas" style={{ padding: `${padY}px ${padX}px` }}>
|
||||
<div style={{ color: "var(--muted)", fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||
loading editor…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChipFocusContext.Provider value={focusValue}>
|
||||
<EditorContent editor={editor} />
|
||||
<div className="margin-note" style={{ margin: `0 ${padX}px ${padY}px ${padX}px`, maxWidth: 720 }}>
|
||||
<span className="margin-note-glyph">Σ</span>
|
||||
<span>
|
||||
<span className="margin-note-who">Socrates · margin</span>
|
||||
<span className="margin-note-text">
|
||||
“Refuses to produce solutions” is a strong constraint. Have you decided what counts as a “solution” vs. a “scaffold”? This boundary will determine whether the refusal policy is enforceable.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</ChipFocusContext.Provider>
|
||||
);
|
||||
}
|
||||
43
apps/web/components/text-canvas/fixtureToDoc.ts
Normal file
43
apps/web/components/text-canvas/fixtureToDoc.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Convert the fixture's narrative shape to a ProseMirror document JSON.
|
||||
// Used as the initial content for the TipTap editor.
|
||||
|
||||
import type { JSONContent } from "@tiptap/react";
|
||||
import type { FixtureData, NarrativeNode } from "../../lib/fixtures/aristotle";
|
||||
|
||||
export function fixtureToDoc(data: FixtureData): JSONContent {
|
||||
const content: JSONContent[] = data.narrative.map(node => narrativeNodeToJSON(node));
|
||||
return {
|
||||
type: "doc",
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
function narrativeNodeToJSON(node: NarrativeNode): JSONContent {
|
||||
if (node.type === "h1") {
|
||||
return {
|
||||
type: "heading",
|
||||
attrs: { level: 1 },
|
||||
content: node.text ? [{ type: "text", text: node.text }] : [],
|
||||
};
|
||||
}
|
||||
if (node.type === "h2") {
|
||||
return {
|
||||
type: "heading",
|
||||
attrs: { level: 2 },
|
||||
content: node.text ? [{ type: "text", text: node.text }] : [],
|
||||
};
|
||||
}
|
||||
// paragraph
|
||||
const inline: JSONContent[] = [];
|
||||
for (const child of node.children ?? []) {
|
||||
if (child.t === "text") {
|
||||
inline.push({ type: "text", text: child.v });
|
||||
} else if (child.t === "chip") {
|
||||
inline.push({
|
||||
type: "chip",
|
||||
attrs: { kind: child.kind, refId: child.id, label: child.label },
|
||||
});
|
||||
}
|
||||
}
|
||||
return { type: "paragraph", content: inline };
|
||||
}
|
||||
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