mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-03 06:12:53 +02:00
wip
This commit is contained in:
@@ -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
138
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
25
src/components/ChatEditor/ChatEditor.css
Normal file
25
src/components/ChatEditor/ChatEditor.css
Normal 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;
|
||||
}
|
59
src/components/ChatEditor/ChatEditor.tsx
Normal file
59
src/components/ChatEditor/ChatEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
@@ -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();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user