refactor(editor): unify editor into features/editor with single markdown pipeline
Replace three divergent data paths (Marked HTML loading, regex post-processing saving, separate paste parsing) with one symmetric path through @tiptap/markdown. Key changes: - Create features/editor/ module with ContentEditor (unified edit+readonly) and TitleEditor, replacing components/common/ editor files - Load content via contentType: 'markdown' instead of markdownToHtml() hack - Save content via editor.getMarkdown() directly, no post-processing - Merge RichTextEditor + ReadonlyEditor into single ContentEditor with editable prop - Extract extensions into separate modules (mention, file-upload, markdown-paste, submit-shortcut, code-block-view) - Extract shared preprocessMentionShortcodes to components/markdown/mentions.ts - Add copyMarkdown utility for clipboard operations - Upgrade all @tiptap packages from 3.20.5 to 3.22.1 (lexer isolation fix, HTML entity roundtrip fix, table alignment support) - Delete markdownToHtml.ts, readonly-editor.tsx, and 10 old component files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8eb1caa72b
commit
27e58d91af
29 changed files with 848 additions and 999 deletions
52
apps/web/features/editor/extensions/code-block-view.tsx
Normal file
52
apps/web/features/editor/extensions/code-block-view.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
|
||||
function CodeBlockView({ node }: NodeViewProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const language = node.attrs.language || "";
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = node.textContent;
|
||||
if (!text) return;
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="code-block-wrapper group/code relative my-2">
|
||||
<div
|
||||
contentEditable={false}
|
||||
className="code-block-header absolute top-0 right-0 z-10 flex items-center gap-1.5 px-2 py-1.5 opacity-0 transition-opacity group-hover/code:opacity-100"
|
||||
>
|
||||
{language && (
|
||||
<span className="text-xs text-muted-foreground select-none">
|
||||
{language}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
title="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<pre spellCheck={false}>
|
||||
{/* @ts-expect-error -- NodeViewContent supports as="code" at runtime */}
|
||||
<NodeViewContent as="code" />
|
||||
</pre>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export { CodeBlockView };
|
||||
119
apps/web/features/editor/extensions/file-upload.ts
Normal file
119
apps/web/features/editor/extensions/file-upload.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function removeImageBySrc(editor: any, src: string) {
|
||||
if (!editor) return;
|
||||
const { tr } = editor.state;
|
||||
let deleted = false;
|
||||
editor.state.doc.descendants((node: any, pos: number) => {
|
||||
if (deleted) return false;
|
||||
if (node.type.name === "image" && node.attrs.src === src) {
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
deleted = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (deleted) editor.view.dispatch(tr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared upload flow: insert blob preview → upload → replace with real URL.
|
||||
* Used by both paste/drop (at cursor) and button upload (at end of doc).
|
||||
*/
|
||||
export async function uploadAndInsertFile(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
editor: any,
|
||||
file: File,
|
||||
handler: (file: File) => Promise<UploadResult | null>,
|
||||
pos?: number,
|
||||
) {
|
||||
const isImage = file.type.startsWith("image/");
|
||||
|
||||
if (isImage) {
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
const imgAttrs = { src: blobUrl, alt: file.name, uploading: true };
|
||||
if (pos !== undefined) {
|
||||
editor.chain().focus().insertContentAt(pos, { type: "image", attrs: imgAttrs }).run();
|
||||
} else {
|
||||
editor.chain().focus().setImage(imgAttrs).run();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(file);
|
||||
if (result) {
|
||||
const { tr } = editor.state;
|
||||
editor.state.doc.descendants((node: { type: { name: string }; attrs: { src: string } }, nodePos: number) => {
|
||||
if (node.type.name === "image" && node.attrs.src === blobUrl) {
|
||||
tr.setNodeMarkup(nodePos, undefined, {
|
||||
...node.attrs,
|
||||
src: result.link,
|
||||
alt: result.filename,
|
||||
uploading: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
editor.view.dispatch(tr);
|
||||
} else {
|
||||
removeImageBySrc(editor, blobUrl);
|
||||
}
|
||||
} catch {
|
||||
removeImageBySrc(editor, blobUrl);
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
} else {
|
||||
// Non-image: upload first, then insert link
|
||||
const result = await handler(file);
|
||||
if (!result) return;
|
||||
const linkText = `[${result.filename}](${result.link})`;
|
||||
if (pos !== undefined) {
|
||||
editor.chain().focus().insertContentAt(pos, linkText).run();
|
||||
} else {
|
||||
editor.chain().focus().insertContent(linkText).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createFileUploadExtension(
|
||||
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
|
||||
) {
|
||||
return Extension.create({
|
||||
name: "fileUpload",
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
|
||||
const handleFiles = async (files: FileList) => {
|
||||
const handler = onUploadFileRef.current;
|
||||
if (!handler) return false;
|
||||
for (const file of Array.from(files)) {
|
||||
await uploadAndInsertFile(editor, file, handler);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("fileUpload"),
|
||||
props: {
|
||||
handlePaste(_view, event) {
|
||||
const files = event.clipboardData?.files;
|
||||
if (!files?.length) return false;
|
||||
if (!onUploadFileRef.current) return false;
|
||||
handleFiles(files);
|
||||
return true;
|
||||
},
|
||||
handleDrop(_view, event) {
|
||||
const files = (event as DragEvent).dataTransfer?.files;
|
||||
if (!files?.length) return false;
|
||||
if (!onUploadFileRef.current) return false;
|
||||
handleFiles(files);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
110
apps/web/features/editor/extensions/index.ts
Normal file
110
apps/web/features/editor/extensions/index.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import type { RefObject } from "react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import TableRow from "@tiptap/extension-table-row";
|
||||
import TableHeader from "@tiptap/extension-table-header";
|
||||
import TableCell from "@tiptap/extension-table-cell";
|
||||
import { Table } from "@tiptap/extension-table";
|
||||
import { Markdown } from "@tiptap/markdown";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import type { AnyExtension } from "@tiptap/core";
|
||||
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||
import { BaseMentionExtension } from "./mention-extension";
|
||||
import { createMentionSuggestion } from "./mention-suggestion";
|
||||
import { CodeBlockView } from "./code-block-view";
|
||||
import { createMarkdownPasteExtension } from "./markdown-paste";
|
||||
import { createSubmitExtension } from "./submit-shortcut";
|
||||
import { createFileUploadExtension } from "./file-upload";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
const LinkEditable = Link.extend({ inclusive: false }).configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
linkOnPaste: false,
|
||||
HTMLAttributes: {
|
||||
class: "text-primary hover:underline cursor-pointer",
|
||||
},
|
||||
});
|
||||
|
||||
const LinkReadonly = Link.configure({
|
||||
openOnClick: false,
|
||||
autolink: false,
|
||||
HTMLAttributes: {
|
||||
class: "text-primary hover:underline cursor-pointer",
|
||||
},
|
||||
});
|
||||
|
||||
const ImageExtension = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
uploading: {
|
||||
default: false,
|
||||
renderHTML: (attrs: Record<string, unknown>) =>
|
||||
attrs.uploading ? { "data-uploading": "" } : {},
|
||||
parseHTML: (el: HTMLElement) => el.hasAttribute("data-uploading"),
|
||||
},
|
||||
};
|
||||
},
|
||||
}).configure({
|
||||
inline: false,
|
||||
allowBase64: false,
|
||||
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
|
||||
});
|
||||
|
||||
export interface EditorExtensionsOptions {
|
||||
editable: boolean;
|
||||
placeholder?: string;
|
||||
onSubmitRef?: RefObject<(() => void) | undefined>;
|
||||
onUploadFileRef?: RefObject<
|
||||
((file: File) => Promise<UploadResult | null>) | undefined
|
||||
>;
|
||||
}
|
||||
|
||||
export function createEditorExtensions(
|
||||
options: EditorExtensionsOptions,
|
||||
): AnyExtension[] {
|
||||
const { editable, placeholder: placeholderText } = options;
|
||||
|
||||
const extensions: AnyExtension[] = [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
link: false,
|
||||
codeBlock: false,
|
||||
}),
|
||||
CodeBlockLowlight.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CodeBlockView);
|
||||
},
|
||||
}).configure({ lowlight }),
|
||||
editable ? LinkEditable : LinkReadonly,
|
||||
ImageExtension,
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Markdown,
|
||||
BaseMentionExtension.configure({
|
||||
HTMLAttributes: { class: "mention" },
|
||||
...(editable ? { suggestion: createMentionSuggestion() } : {}),
|
||||
}),
|
||||
];
|
||||
|
||||
if (editable) {
|
||||
extensions.push(
|
||||
Typography,
|
||||
Placeholder.configure({ placeholder: placeholderText }),
|
||||
createMarkdownPasteExtension(),
|
||||
createSubmitExtension(() => options.onSubmitRef?.current?.()),
|
||||
createFileUploadExtension(options.onUploadFileRef!),
|
||||
);
|
||||
}
|
||||
|
||||
return extensions;
|
||||
}
|
||||
31
apps/web/features/editor/extensions/markdown-paste.ts
Normal file
31
apps/web/features/editor/extensions/markdown-paste.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Slice } from "@tiptap/pm/model";
|
||||
|
||||
export function createMarkdownPasteExtension() {
|
||||
return Extension.create({
|
||||
name: "markdownPaste",
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("markdownPaste"),
|
||||
props: {
|
||||
clipboardTextParser(text, _context, plainText) {
|
||||
if (!plainText && editor.markdown) {
|
||||
const json = editor.markdown.parse(text);
|
||||
const node = editor.schema.nodeFromJSON(json);
|
||||
return Slice.maxOpen(node.content);
|
||||
}
|
||||
// Plain text fallback
|
||||
const p = editor.schema.nodes.paragraph!;
|
||||
const doc = editor.schema.nodes.doc!;
|
||||
const paragraph = p.create(null, text ? editor.schema.text(text) : undefined);
|
||||
return new Slice(doc.create(null, paragraph).content, 0, 0);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
64
apps/web/features/editor/extensions/mention-extension.ts
Normal file
64
apps/web/features/editor/extensions/mention-extension.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import Mention from "@tiptap/extension-mention";
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { MentionView } from "./mention-view";
|
||||
|
||||
export const BaseMentionExtension = Mention.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MentionView);
|
||||
},
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const type = node.attrs.type ?? "member";
|
||||
const prefix = type === "issue" ? "" : "@";
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(
|
||||
{ "data-type": "mention" },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
{
|
||||
"data-mention-type": node.attrs.type ?? "member",
|
||||
"data-mention-id": node.attrs.id,
|
||||
},
|
||||
),
|
||||
`${prefix}${node.attrs.label ?? node.attrs.id}`,
|
||||
];
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
type: {
|
||||
default: "member",
|
||||
parseHTML: (el: HTMLElement) =>
|
||||
el.getAttribute("data-mention-type") ?? "member",
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
};
|
||||
},
|
||||
markdownTokenizer: {
|
||||
name: "mention",
|
||||
level: "inline" as const,
|
||||
start(src: string) {
|
||||
return src.search(/\[@?[^\]]+\]\(mention:\/\//);
|
||||
},
|
||||
tokenize(src: string) {
|
||||
const match = src.match(
|
||||
/^\[@?([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
|
||||
);
|
||||
if (!match) return undefined;
|
||||
return {
|
||||
type: "mention",
|
||||
raw: match[0],
|
||||
attributes: { label: match[1], type: match[2] ?? "member", id: match[3] },
|
||||
};
|
||||
},
|
||||
},
|
||||
parseMarkdown: (token: any, helpers: any) => {
|
||||
return helpers.createNode("mention", token.attributes);
|
||||
},
|
||||
renderMarkdown: (node: any) => {
|
||||
const { id, label, type = "member" } = node.attrs || {};
|
||||
const prefix = type === "issue" ? "" : "@";
|
||||
return `[${prefix}${label ?? id}](mention://${type}/${id})`;
|
||||
},
|
||||
});
|
||||
325
apps/web/features/editor/extensions/mention-suggestion.tsx
Normal file
325
apps/web/features/editor/extensions/mention-suggestion.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { StatusIcon } from "@/features/issues/components/status-icon";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { IssueStatus } from "@/shared/types";
|
||||
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MentionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "member" | "agent" | "issue" | "all";
|
||||
/** Secondary text shown beside the label (e.g. issue title) */
|
||||
description?: string;
|
||||
/** Issue status for StatusIcon rendering */
|
||||
status?: IssueStatus;
|
||||
}
|
||||
|
||||
interface MentionListProps {
|
||||
items: MentionItem[];
|
||||
command: (item: MentionItem) => void;
|
||||
}
|
||||
|
||||
export interface MentionListRef {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group items by section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MentionGroup {
|
||||
label: string;
|
||||
items: MentionItem[];
|
||||
}
|
||||
|
||||
function groupItems(items: MentionItem[]): MentionGroup[] {
|
||||
const users: MentionItem[] = [];
|
||||
const issues: MentionItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === "issue") {
|
||||
issues.push(item);
|
||||
} else {
|
||||
users.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const groups: MentionGroup[] = [];
|
||||
if (users.length > 0) groups.push({ label: "Users", items: users });
|
||||
if (issues.length > 0) groups.push({ label: "Issues", items: issues });
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MentionList — the popup rendered inside the editor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MentionList = forwardRef<MentionListRef, MentionListProps>(
|
||||
function MentionList({ items, command }, ref) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" });
|
||||
}, [selectedIndex]);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
if (item) command(item);
|
||||
},
|
||||
[items, command],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
setSelectedIndex((i) => (i + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
setSelectedIndex((i) => (i + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
selectItem(selectedIndex);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border bg-popover p-2 text-xs text-muted-foreground shadow-md">
|
||||
No results
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const groups = groupItems(items);
|
||||
|
||||
// Build a flat index mapping: globalIndex → item
|
||||
let globalIndex = 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-popover py-1 shadow-md w-72 max-h-[300px] overflow-y-auto">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.items.map((item) => {
|
||||
const idx = globalIndex++;
|
||||
return (
|
||||
<MentionRow
|
||||
key={`${item.type}-${item.id}`}
|
||||
item={item}
|
||||
selected={idx === selectedIndex}
|
||||
onSelect={() => selectItem(idx)}
|
||||
buttonRef={(el) => { itemRefs.current[idx] = el; }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MentionRow — single item in the list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MentionRow({
|
||||
item,
|
||||
selected,
|
||||
onSelect,
|
||||
buttonRef,
|
||||
}: {
|
||||
item: MentionItem;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
buttonRef: (el: HTMLButtonElement | null) => void;
|
||||
}) {
|
||||
if (item.type === "issue") {
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
|
||||
selected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{item.status && (
|
||||
<StatusIcon status={item.status} className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="shrink-0 text-muted-foreground">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="truncate text-muted-foreground">{item.description}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
|
||||
selected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<ActorAvatar
|
||||
actorType={item.type === "all" ? "member" : item.type}
|
||||
actorId={item.id}
|
||||
size={20}
|
||||
/>
|
||||
<span className="truncate font-medium">{item.label}</span>
|
||||
{item.type === "agent" && (
|
||||
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1.5">Agent</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suggestion config factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createMentionSuggestion(): Omit<
|
||||
SuggestionOptions<MentionItem>,
|
||||
"editor"
|
||||
> {
|
||||
return {
|
||||
items: ({ query }) => {
|
||||
const { members, agents } = useWorkspaceStore.getState();
|
||||
const { issues } = useIssueStore.getState();
|
||||
const q = query.toLowerCase();
|
||||
|
||||
// Show "All members" option when query is empty or matches "all"
|
||||
const allItem: MentionItem[] =
|
||||
"all members".includes(q) || "all".includes(q)
|
||||
? [{ id: "all", label: "All members", type: "all" as const }]
|
||||
: [];
|
||||
|
||||
const memberItems: MentionItem[] = members
|
||||
.filter((m) => m.name.toLowerCase().includes(q))
|
||||
.map((m) => ({
|
||||
id: m.user_id,
|
||||
label: m.name,
|
||||
type: "member" as const,
|
||||
}));
|
||||
|
||||
const agentItems: MentionItem[] = agents
|
||||
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
|
||||
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
|
||||
|
||||
const issueItems: MentionItem[] = issues
|
||||
.filter(
|
||||
(i) =>
|
||||
i.identifier.toLowerCase().includes(q) ||
|
||||
i.title.toLowerCase().includes(q),
|
||||
)
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
label: i.identifier,
|
||||
type: "issue" as const,
|
||||
description: i.title,
|
||||
status: i.status as IssueStatus,
|
||||
}));
|
||||
|
||||
return [...allItem, ...memberItems, ...agentItems, ...issueItems].slice(0, 10);
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let renderer: ReactRenderer<MentionListRef> | null = null;
|
||||
let popup: HTMLDivElement | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: SuggestionProps<MentionItem>) => {
|
||||
renderer = new ReactRenderer(MentionList, {
|
||||
props: { items: props.items, command: props.command },
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
popup = document.createElement("div");
|
||||
popup.style.position = "fixed";
|
||||
popup.style.zIndex = "50";
|
||||
popup.appendChild(renderer.element);
|
||||
document.body.appendChild(popup);
|
||||
|
||||
updatePosition(popup, props.clientRect);
|
||||
},
|
||||
|
||||
onUpdate: (props: SuggestionProps<MentionItem>) => {
|
||||
renderer?.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
if (popup) updatePosition(popup, props.clientRect);
|
||||
},
|
||||
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
cleanup();
|
||||
return true;
|
||||
}
|
||||
return renderer?.ref?.onKeyDown(props) ?? false;
|
||||
},
|
||||
|
||||
onExit: () => {
|
||||
cleanup();
|
||||
},
|
||||
};
|
||||
|
||||
function updatePosition(
|
||||
el: HTMLDivElement,
|
||||
clientRect: (() => DOMRect | null) | null | undefined,
|
||||
) {
|
||||
if (!clientRect) return;
|
||||
const virtualEl = {
|
||||
getBoundingClientRect: () => clientRect() ?? new DOMRect(),
|
||||
};
|
||||
computePosition(virtualEl, el, {
|
||||
placement: "bottom-start",
|
||||
strategy: "fixed",
|
||||
middleware: [offset(4), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
el.style.left = `${x}px`;
|
||||
el.style.top = `${y}px`;
|
||||
});
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
renderer?.destroy();
|
||||
renderer = null;
|
||||
popup?.remove();
|
||||
popup = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
64
apps/web/features/editor/extensions/mention-view.tsx
Normal file
64
apps/web/features/editor/extensions/mention-view.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { StatusIcon } from "@/features/issues/components/status-icon";
|
||||
|
||||
export function MentionView({ node }: NodeViewProps) {
|
||||
const { type, id, label } = node.attrs;
|
||||
|
||||
if (type === "issue") {
|
||||
return (
|
||||
<NodeViewWrapper as="span" className="inline">
|
||||
<IssueMention issueId={id} fallbackLabel={label} />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span" className="inline">
|
||||
<span className="mention">@{label ?? id}</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueMention({
|
||||
issueId,
|
||||
fallbackLabel,
|
||||
}: {
|
||||
issueId: string;
|
||||
fallbackLabel?: string;
|
||||
}) {
|
||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(`/issues/${issueId}`, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
if (!issue) {
|
||||
return (
|
||||
<a
|
||||
href={`/issues/${issueId}`}
|
||||
onClick={handleClick}
|
||||
className="issue-mention text-primary font-medium cursor-pointer hover:underline"
|
||||
>
|
||||
{fallbackLabel ?? issueId.slice(0, 8)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/issues/${issueId}`}
|
||||
onClick={handleClick}
|
||||
className="issue-mention inline-flex items-center align-middle gap-1.5 rounded-md border px-2 py-0.5 text-sm hover:bg-accent transition-colors cursor-pointer"
|
||||
>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
|
||||
<span className="font-medium text-muted-foreground">{issue.identifier}</span>
|
||||
<span className="text-foreground">{issue.title}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
15
apps/web/features/editor/extensions/submit-shortcut.ts
Normal file
15
apps/web/features/editor/extensions/submit-shortcut.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Extension } from "@tiptap/core";
|
||||
|
||||
export function createSubmitExtension(onSubmit: () => void) {
|
||||
return Extension.create({
|
||||
name: "submitShortcut",
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-Enter": () => {
|
||||
onSubmit();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue