1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-03 06:12:53 +02:00
This commit is contained in:
Arik Chakma
2025-05-20 00:48:30 +06:00
parent 697d637db8
commit f235544d8a
7 changed files with 703 additions and 11 deletions

View File

@@ -42,6 +42,14 @@
"@roadmapsh/editor": "workspace:*",
"@tailwindcss/vite": "^4.1.6",
"@tanstack/react-query": "^5.76.1",
"@tiptap/core": "^2.12.0",
"@tiptap/extension-document": "^2.12.0",
"@tiptap/extension-paragraph": "^2.12.0",
"@tiptap/extension-placeholder": "^2.12.0",
"@tiptap/extension-text": "^2.12.0",
"@tiptap/pm": "^2.12.0",
"@tiptap/react": "^2.12.0",
"@tiptap/suggestion": "^2.12.0",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"astro": "^5.7.13",
@@ -81,6 +89,7 @@
"slugify": "^1.6.6",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.6",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.10",
"turndown": "^7.2.0",
"unified": "^11.0.5",

138
pnpm-lock.yaml generated
View File

@@ -41,6 +41,30 @@ importers:
'@tanstack/react-query':
specifier: ^5.76.1
version: 5.76.1(react@19.1.0)
'@tiptap/core':
specifier: ^2.12.0
version: 2.12.0(@tiptap/pm@2.12.0)
'@tiptap/extension-document':
specifier: ^2.12.0
version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))
'@tiptap/extension-paragraph':
specifier: ^2.12.0
version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))
'@tiptap/extension-placeholder':
specifier: ^2.12.0
version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)
'@tiptap/extension-text':
specifier: ^2.12.0
version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))
'@tiptap/pm':
specifier: ^2.12.0
version: 2.12.0
'@tiptap/react':
specifier: ^2.12.0
version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@tiptap/suggestion':
specifier: ^2.12.0
version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)
'@types/react':
specifier: ^19.1.4
version: 19.1.4
@@ -158,6 +182,9 @@ importers:
tailwindcss:
specifier: ^4.1.6
version: 4.1.6
tippy.js:
specifier: ^6.3.7
version: 6.3.7
tiptap-markdown:
specifier: ^0.8.10
version: 0.8.10(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))
@@ -983,6 +1010,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
@@ -1396,9 +1426,56 @@ packages:
peerDependencies:
'@tiptap/pm': ^2.7.0
'@tiptap/extension-bubble-menu@2.12.0':
resolution: {integrity: sha512-DYijoE0igV0Oi+ZppFsp2UrQsM/4HZtmmpD78BJM9zfCbd1YvAUIxmzmXr8uqU18OHd1uQy+/zvuNoUNYyw67g==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-document@2.12.0':
resolution: {integrity: sha512-sA1Q+mxDIv0Y3qQTBkYGwknNbDcGFiJ/fyAFholXpqbrcRx3GavwR/o0chBdsJZlFht0x7AWGwUYWvIo7wYilA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-floating-menu@2.12.0':
resolution: {integrity: sha512-BYpyZx/56KCDksWuJJbhki/uNgt9sACuSSZFH5AN1yS1ISD+EzIxqf6Pzzv8QCoNJ+KcRNVaZsOlOFaJGoyzag==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-paragraph@2.12.0':
resolution: {integrity: sha512-QNK5cgewCunWFxpLlbvvoO1rrLgEtNKxiY79fctP9toV+e59R+1i1Q9lXC1O5mOfDgVxCb6uFDMsqmKhFjpPog==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-placeholder@2.12.0':
resolution: {integrity: sha512-K7irDox4P+NLAMjVrJeG72f0sulsCRYpx1Cy4gxKCdi1LTivj5VkXa6MXmi42KTCwBu3pWajBctYIOAES1FTAA==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-text@2.12.0':
resolution: {integrity: sha512-0ytN9V1tZYTXdiYDQg4FB2SQ56JAJC9r/65snefb9ztl+gZzDrIvih7CflHs1ic9PgyjexfMLeH+VzuMccNyZw==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm@2.12.0':
resolution: {integrity: sha512-TNzVwpeNzFfHAcYTOKqX9iU4fRxliyoZrCnERR+RRzeg7gWrXrCLubQt1WEx0sojMAfznshSL3M5HGsYjEbYwA==}
'@tiptap/react@2.12.0':
resolution: {integrity: sha512-D+PR+4kJO9h8AB/7XyQ/Anw8tqeS2ecv5QemBOCHi9JlMAjytauUrj6IfFBO9RbsCowlBjW5GnSpFhzpk2Gghg==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
'@tiptap/suggestion@2.12.0':
resolution: {integrity: sha512-bsXLoZbjUo1oOF1Z+XSfoGzbcnrTcYtJdfylM/FerMLU9T12dhsM/Ri2SKLX4IR5D0HJ07FcsEHCrGEy8Y5y0A==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tybys/wasm-util@0.9.0':
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
@@ -1527,6 +1604,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@@ -2795,6 +2875,7 @@ packages:
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch-native@1.6.6:
resolution: {integrity: sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==}
@@ -3482,6 +3563,9 @@ packages:
resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==}
engines: {node: '>=12.0.0'}
tippy.js@6.3.7:
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
tiptap-markdown@0.8.10:
resolution: {integrity: sha512-iDVkR2BjAqkTDtFX0h94yVvE2AihCXlF0Q7RIXSJPRSR5I0PA1TMuAg6FHFpmqTn4tPxJ0by0CK7PUMlnFLGEQ==}
peerDependencies:
@@ -4542,6 +4626,8 @@ snapshots:
dependencies:
playwright: 1.52.0
'@popperjs/core@2.11.8': {}
'@remirror/core-constants@3.0.0': {}
'@resvg/resvg-js-android-arm-eabi@2.6.2':
@@ -4861,6 +4947,35 @@ snapshots:
dependencies:
'@tiptap/pm': 2.12.0
'@tiptap/extension-bubble-menu@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
'@tiptap/pm': 2.12.0
tippy.js: 6.3.7
'@tiptap/extension-document@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
'@tiptap/extension-floating-menu@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
'@tiptap/pm': 2.12.0
tippy.js: 6.3.7
'@tiptap/extension-paragraph@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
'@tiptap/extension-placeholder@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
'@tiptap/pm': 2.12.0
'@tiptap/extension-text@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
'@tiptap/pm@2.12.0':
dependencies:
prosemirror-changeset: 2.3.0
@@ -4882,6 +4997,23 @@ snapshots:
prosemirror-transform: 1.10.4
prosemirror-view: 1.39.2
'@tiptap/react@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
'@tiptap/extension-bubble-menu': 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)
'@tiptap/extension-floating-menu': 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)
'@tiptap/pm': 2.12.0
'@types/use-sync-external-store': 0.0.6
fast-deep-equal: 3.1.3
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
use-sync-external-store: 1.5.0(react@19.1.0)
'@tiptap/suggestion@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)':
dependencies:
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)
'@tiptap/pm': 2.12.0
'@tybys/wasm-util@0.9.0':
dependencies:
tslib: 2.8.1
@@ -5028,6 +5160,8 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/use-sync-external-store@0.0.6': {}
'@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react@4.4.1(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.19.4))':
@@ -7300,6 +7434,10 @@ snapshots:
fdir: 6.4.4(picomatch@4.0.2)
picomatch: 4.0.2
tippy.js@6.3.7:
dependencies:
'@popperjs/core': 2.11.8
tiptap-markdown@0.8.10(@tiptap/core@2.12.0(@tiptap/pm@2.12.0)):
dependencies:
'@tiptap/core': 2.12.0(@tiptap/pm@2.12.0)

View File

@@ -0,0 +1,25 @@
.chat-editor .tiptap p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.chat-editor .tiptap p.is-empty::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.chat-editor .tiptap [data-type='variable'] {
font-size: 12px;
font-weight: 500;
line-height: 1.5;
padding: 2px 4px;
border-radius: 8px;
background-color: #f0f5ff;
color: #2c5df1;
}

View File

@@ -0,0 +1,59 @@
import './ChatEditor.css';
import { EditorContent, useEditor } from '@tiptap/react';
import DocumentExtension from '@tiptap/extension-document';
import ParagraphExtension from '@tiptap/extension-paragraph';
import TextExtension from '@tiptap/extension-text';
import Placeholder from '@tiptap/extension-placeholder';
import { VariableExtension } from './VariableExtension/VariableExtension';
import { variableSuggestion } from './VariableExtension/VariableSuggestion';
const extensions = [
DocumentExtension,
ParagraphExtension,
TextExtension,
Placeholder.configure({
placeholder: 'Ask AI anything about the roadmap...',
}),
VariableExtension.configure({
suggestion: variableSuggestion(),
}),
];
const content = '<p></p>';
export function ChatEditor() {
const editor = useEditor({
extensions,
content,
editorProps: {
attributes: {
class: 'focus:outline-none w-full p-2',
},
handleKeyDown(_, event) {
if (!editor) {
return false;
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
return true;
}
if (event.key === 'Enter' && event.shiftKey) {
event.preventDefault();
editor.commands.insertContent('<p></p>');
return true;
}
return false;
},
},
});
return (
<div className="chat-editor w-full">
<EditorContent editor={editor} />
</div>
);
}

View File

@@ -0,0 +1,311 @@
import { mergeAttributes, Node } from '@tiptap/core';
import { type DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model';
import { PluginKey } from '@tiptap/pm/state';
import Suggestion, { type SuggestionOptions } from '@tiptap/suggestion';
// See `addAttributes` below
export interface VariableNodeAttrs {
/**
* The identifier for the selected item that was mentioned, stored as a `data-id`
* attribute.
*/
id: string | null;
/**
* The label to be rendered by the editor as the displayed text for this mentioned
* item, if provided. Stored as a `data-label` attribute. See `renderLabel`.
*/
label?: string | null;
}
export type VariableOptions<
SuggestionItem = any,
Attrs extends Record<string, any> = VariableNodeAttrs,
> = {
/**
* The HTML attributes for a mention node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>;
/**
* A function to render the label of a mention.
* @deprecated use renderText and renderHTML instead
* @param props The render props
* @returns The label
* @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
*/
renderLabel?: (props: {
options: VariableOptions<SuggestionItem, Attrs>;
node: ProseMirrorNode;
}) => string;
/**
* A function to render the text of a mention.
* @param props The render props
* @returns The text
* @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
*/
renderText: (props: {
options: VariableOptions<SuggestionItem, Attrs>;
node: ProseMirrorNode;
}) => string;
/**
* A function to render the HTML of a mention.
* @param props The render props
* @returns The HTML as a ProseMirror DOM Output Spec
* @example ({ options, node }) => ['span', { 'data-type': 'mention' }, `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`]
*/
renderHTML: (props: {
options: VariableOptions<SuggestionItem, Attrs>;
node: ProseMirrorNode;
}) => DOMOutputSpec;
/**
* Whether to delete the trigger character with backspace.
* @default false
*/
deleteTriggerWithBackspace: boolean;
/**
* The suggestion options.
* @default {}
* @example { char: '@', pluginKey: MentionPluginKey, command: ({ editor, range, props }) => { ... } }
*/
suggestion: Omit<SuggestionOptions<SuggestionItem, Attrs>, 'editor'>;
};
export type VariableType = {
id: string;
label: string;
};
export type VariableStorage = {
variables: VariableType[];
};
/**
* The plugin key for the variable plugin.
* @default 'variable'
*/
export const VariablePluginKey = new PluginKey('variable');
export const VariableExtension = Node.create<VariableOptions, VariableStorage>({
name: 'variable',
priority: 101,
addStorage() {
return {
variables: [],
};
},
addOptions() {
return {
HTMLAttributes: {},
renderText({ options, node }) {
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
},
deleteTriggerWithBackspace: false,
renderHTML({ options, node }) {
return [
'span',
mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
`${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`,
];
},
suggestion: {
char: '@',
pluginKey: VariablePluginKey,
command: ({ editor, range, props }) => {
// increase range.to by one when the next node is of type "text"
// and starts with a space character
const nodeAfter = editor.view.state.selection.$to.nodeAfter;
const overrideSpace = nodeAfter?.text?.startsWith(' ');
if (overrideSpace) {
range.to += 1;
}
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: this.name,
attrs: props,
},
{
type: 'text',
text: ' ',
},
])
.run();
// get reference to `window` object from editor element, to support cross-frame JS usage
editor.view.dom.ownerDocument.defaultView
?.getSelection()
?.collapseToEnd();
},
allow: ({ state, range }) => {
const $from = state.doc.resolve(range.from);
const type = state.schema.nodes[this.name];
const allow = !!$from.parent.type.contentMatch.matchType(type);
return allow;
},
},
};
},
group: 'inline',
inline: true,
selectable: false,
atom: true,
addAttributes() {
return {
id: {
default: null,
parseHTML: (element) => element.getAttribute('data-id'),
renderHTML: (attributes) => {
if (!attributes.id) {
return {};
}
return {
'data-id': attributes.id,
};
},
},
label: {
default: null,
parseHTML: (element) => element.getAttribute('data-label'),
renderHTML: (attributes) => {
if (!attributes.label) {
return {};
}
return {
'data-label': attributes.label,
};
},
},
};
},
parseHTML() {
return [
{
tag: `span[data-type="${this.name}"]`,
},
];
},
renderHTML({ node, HTMLAttributes }) {
if (this.options.renderLabel !== undefined) {
console.warn(
'renderLabel is deprecated use renderText and renderHTML instead',
);
return [
'span',
mergeAttributes(
{ 'data-type': this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
this.options.renderLabel({
options: this.options,
node,
}),
];
}
const mergedOptions = { ...this.options };
mergedOptions.HTMLAttributes = mergeAttributes(
{ 'data-type': this.name },
this.options.HTMLAttributes,
HTMLAttributes,
);
const html = this.options.renderHTML({
options: mergedOptions,
node,
});
if (typeof html === 'string') {
return [
'span',
mergeAttributes(
{ 'data-type': this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
html,
];
}
return html;
},
renderText({ node }) {
if (this.options.renderLabel !== undefined) {
console.warn(
'renderLabel is deprecated use renderText and renderHTML instead',
);
return this.options.renderLabel({
options: this.options,
node,
});
}
return this.options.renderText({
options: this.options,
node,
});
},
addKeyboardShortcuts() {
return {
Backspace: () =>
this.editor.commands.command(({ tr, state }) => {
let isVariable = false;
const { selection } = state;
const { empty, anchor } = selection;
if (!empty) {
return false;
}
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
if (node.type.name === this.name) {
isVariable = true;
tr.insertText(
this.options.deleteTriggerWithBackspace
? ''
: this.options.suggestion.char || '',
pos,
pos + node.nodeSize,
);
return false;
}
});
return isVariable;
}),
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});

View File

@@ -0,0 +1,154 @@
import { ReactRenderer } from '@tiptap/react';
import type { SuggestionOptions } from '@tiptap/suggestion';
import tippy, { type GetReferenceClientRect } from 'tippy.js';
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { cn } from '../../../lib/classname';
import type { VariableStorage, VariableType } from './VariableExtension';
export type VariableListProps = {
command: (variable: VariableType) => void;
items: VariableType[];
} & SuggestionOptions;
export const VariableList = forwardRef((props: VariableListProps, ref) => {
const { items, command } = props;
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index: number) => {
const item = props.items[index];
if (!item) {
return;
}
command(item);
};
const upHandler = () => {
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === 'ArrowUp') {
upHandler();
return true;
}
if (event.key === 'ArrowDown') {
downHandler();
return true;
}
if (event.key === 'Enter') {
enterHandler();
return true;
}
return false;
},
}));
return (
<div className="flex flex-col gap-0.5 overflow-auto rounded-lg border border-gray-200 bg-white p-1 shadow-sm">
{items.length ? (
items.map((item, index) => (
<button
className={cn(
'rounded-md p-1 px-1.5 text-left text-sm hover:bg-gray-100',
index === selectedIndex && 'bg-gray-100',
)}
key={index}
onClick={() => selectItem(index)}
>
{item?.label}
</button>
))
) : (
<div className="rounded-md p-1 px-1.5 text-left text-sm">No result</div>
)}
</div>
);
});
VariableList.displayName = 'VariableList';
export function variableSuggestion(): Omit<SuggestionOptions, 'editor'> {
return {
items: ({ editor, query }) => {
const storage = editor.storage.variable as VariableStorage;
return storage.variables
.filter((variable) =>
variable.label.toLowerCase().startsWith(query.toLowerCase()),
)
.slice(0, 5);
},
render: () => {
let component: ReactRenderer<any>;
let popup: InstanceType<any> | null = null;
return {
onStart: (props) => {
component = new ReactRenderer(VariableList, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'top-start',
});
},
onUpdate(props) {
component.updateProps(props);
if (!props.clientRect) {
return;
}
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup[0].hide();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
popup[0].destroy();
component.destroy();
},
};
},
};
}

View File

@@ -4,8 +4,7 @@ import { queryClient } from '../../stores/query-client';
import { useEffect, useRef, useState } from 'react';
import { Spinner } from '../ReactIcons/Spinner';
import { BotIcon, SendIcon } from 'lucide-react';
import TextareaAutosize from 'react-textarea-autosize';
import { cn } from '../../lib/classname';
import { ChatEditor } from '../ChatEditor/ChatEditor';
type RoadmapAIChatProps = {
roadmapId: string;
@@ -28,8 +27,8 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
}, [data]);
return (
<div className="grid grow grid-cols-2">
<div className="h-full overflow-y-auto">
<div className="grid grow grid-cols-3">
<div className="col-span-2 h-full overflow-y-auto">
{isLoading && (
<div className="flex h-full w-full items-center justify-center">
<Spinner
@@ -41,7 +40,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
<div ref={roadmapContainerRef} />
</div>
<div className="flex h-full flex-col">
<div className="flex h-full flex-col border-l border-gray-200 bg-white">
<div className="flex min-h-[46px] items-center justify-between gap-2 border-gray-200 px-3 py-2 text-sm">
<span className="flex items-center gap-2 text-sm">
<BotIcon className="size-4 shrink-0 text-black" />
@@ -61,14 +60,11 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
e.preventDefault();
}}
>
<TextareaAutosize
className="h-full min-h-[41px] grow resize-none bg-transparent px-4 py-2 focus:outline-hidden"
placeholder="Ask AI anything about the roadmap..."
autoFocus={true}
/>
<ChatEditor />
<button
type="submit"
className="flex aspect-square size-[41px] items-center justify-center text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50"
className="flex aspect-square size-[36px] items-center justify-center p-2 text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50"
>
<SendIcon className="size-4 stroke-[2.5]" />
</button>