feat: add AI command suggestions extension for Tiptap editor
- Implemented Commands plugin to handle command suggestions triggered by '@' character. - Created suggestion configuration to filter and render command items. - Added various command items including date, time, formatting, templates, and task management commands. - Introduced CSS styles for the Tiptap editor to enhance UI. - Configured TypeScript settings and Vite build for the project.
This commit is contained in:
55
.cnb.yml
Normal file
55
.cnb.yml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# .cnb.yml
|
||||||
|
$:
|
||||||
|
vscode:
|
||||||
|
- docker:
|
||||||
|
image: docker.cnb.cool/kevisual/dev-env:latest
|
||||||
|
services:
|
||||||
|
- vscode
|
||||||
|
- docker
|
||||||
|
imports: https://cnb.cool/kevisual/env/-/blob/main/env.yml
|
||||||
|
# 开发环境启动后会执行的任务
|
||||||
|
# stages:
|
||||||
|
# - name: pnpm install
|
||||||
|
# script: pnpm install
|
||||||
|
|
||||||
|
|
||||||
|
main:
|
||||||
|
web_trigger_sync_to_gitea:
|
||||||
|
- services:
|
||||||
|
- docker
|
||||||
|
imports:
|
||||||
|
- https://cnb.cool/kevisual/env/-/blob/main/env.yml
|
||||||
|
stages:
|
||||||
|
- name: 'show username'
|
||||||
|
script: echo "GITEA_USERNAME is ${GITEA_USERNAME} and GITEA_PASSWORD is ${GITEA_PASSWORD}"
|
||||||
|
- name: sync to gitea
|
||||||
|
image: tencentcom/git-sync
|
||||||
|
settings:
|
||||||
|
target_url: https://git.xiongxiao.me/kevisual/cnb.git
|
||||||
|
auth_type: https
|
||||||
|
username: "oauth2"
|
||||||
|
password: ${GITEA_TOKEN}
|
||||||
|
git_user: "abearxiong"
|
||||||
|
git_email: "xiongxiao@xiongxiao.me"
|
||||||
|
sync_mode: rebase
|
||||||
|
branch: main
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"**":
|
||||||
|
web_trigger_test:
|
||||||
|
- stages:
|
||||||
|
- name: 执行任务
|
||||||
|
script: echo "job"
|
||||||
|
|
||||||
|
# .cnb/web_trigger.yml
|
||||||
|
branch:
|
||||||
|
# 如下按钮在分支名以 release 开头的分支详情页面显示
|
||||||
|
- reg: "^main"
|
||||||
|
buttons:
|
||||||
|
- name: 同步代码到gitea
|
||||||
|
desc: 同步代码到gitea
|
||||||
|
event: web_trigger_sync_to_gitea
|
||||||
|
- name: 同步gitea代码到当前仓库
|
||||||
|
desc: 同步gitea代码到当前仓库
|
||||||
|
event: web_trigger_sync_from_gitea
|
||||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
.env
|
||||||
|
!.env*development
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
dist
|
||||||
|
pack-dist
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
|
.vite
|
||||||
|
|
||||||
|
.astro
|
||||||
15
bun.config.ts
Normal file
15
bun.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { build } from 'bun';
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entrypoints: ["./src/lib.ts"],
|
||||||
|
outdir: './dist',
|
||||||
|
target: 'browser',
|
||||||
|
format: 'esm',
|
||||||
|
naming: {
|
||||||
|
entry: 'app.js',
|
||||||
|
},
|
||||||
|
minify: false,
|
||||||
|
sourcemap: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Build complete: dist/app.js');
|
||||||
20
index.html
Normal file
20
index.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Light Code</title>
|
||||||
|
<!-- <link href="./dist/kv-md.css" rel="stylesheet" /> -->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./src/main.tsx"></script>
|
||||||
|
<!-- <kv-md-editor id="editor"></kv-md-editor> -->
|
||||||
|
<!-- <script type="module">
|
||||||
|
import './dist/kv-md.js';
|
||||||
|
</script> -->
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
25
kevisual.json
Normal file
25
kevisual.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"name": "kevisual",
|
||||||
|
"share": "public"
|
||||||
|
},
|
||||||
|
"registry": "https://kevisual.cn/root/ai/kevisual/frontend/simple-lit-vite",
|
||||||
|
"clone": {
|
||||||
|
".": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"syncd": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"registry": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sync": {
|
||||||
|
".gitignore": {
|
||||||
|
"url": "/gitignore.txt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
mod.ts
Normal file
2
mod.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import './src/index.css'
|
||||||
|
export * from './src/md-editor.ts';
|
||||||
67
package.json
Normal file
67
package.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"name": "@kevisual/kv-md",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"basename": "/root/kv-md",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"build:lib": "vite build --config vite.config.lib.ts",
|
||||||
|
"prepub": "rm -rf ./dist && rimraf ./pack-dist && pnpm run build:test",
|
||||||
|
"pub": "envision deploy ./dist -k kv-md -v 0.0.1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
|
||||||
|
"license": "MIT",
|
||||||
|
"packageManager": "pnpm@10.25.0",
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"mod.ts",
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@kevisual/app": "^0.0.1",
|
||||||
|
"@kevisual/context": "^0.0.4",
|
||||||
|
"@tiptap/core": "^3.13.0",
|
||||||
|
"@tiptap/extension-code-block-lowlight": "^3.13.0",
|
||||||
|
"@tiptap/extension-dropcursor": "^3.13.0",
|
||||||
|
"@tiptap/extension-highlight": "^3.13.0",
|
||||||
|
"@tiptap/extension-placeholder": "^3.13.0",
|
||||||
|
"@tiptap/extension-typography": "^3.13.0",
|
||||||
|
"@tiptap/pm": "^3.13.0",
|
||||||
|
"@tiptap/starter-kit": "^3.13.0",
|
||||||
|
"@tiptap/suggestion": "^3.13.0",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"lit-html": "^3.3.1",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
|
"react": "^19.2.1",
|
||||||
|
"react-dom": "^19.2.1",
|
||||||
|
"tiptap-markdown": "^0.9.0",
|
||||||
|
"zustand": "^5.0.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@kevisual/query": "0.0.31",
|
||||||
|
"@kevisual/types": "^0.0.10",
|
||||||
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
|
"@types/bun": "^1.3.4",
|
||||||
|
"@types/node": "^24.10.2",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"tailwindcss": "^4.1.17",
|
||||||
|
"vite": "^7.2.7"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./dist/kv-md.js",
|
||||||
|
"./kv-md.js": "./dist/kv-md.js",
|
||||||
|
"./kv-md.css": "./dist/kv-md.css",
|
||||||
|
"./mod.ts": "./mod.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
2637
pnpm-lock.yaml
generated
Normal file
2637
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
readme.md
Normal file
15
readme.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# simple-lit-vite
|
||||||
|
|
||||||
|
这是一个使用 Vite 构建的简单 Lit 项目模板。您可以使用此模板快速启动一个新的 Lit 项目,并利用 AI 助手生成代码片段以加速开发过程。少用git。
|
||||||
|
|
||||||
|
## cli
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ev sync clone -i https://kevisual.cn/root/ai/kevisual/frontend/simple-lit-vite/kevisual.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 同步命令
|
||||||
|
```sh
|
||||||
|
ev sync list
|
||||||
|
ev sync upload
|
||||||
|
```
|
||||||
71
src/app.tsx
Normal file
71
src/app.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { TextEditor, TextEditorProps } from './tiptap/editor.ts';
|
||||||
|
const markdown = `
|
||||||
|
# 欢迎使用 KeVisual Markdown 编辑器!
|
||||||
|
这是一个基于 Tiptap 和 React 构建的富文本编辑器,支持 Markdown 语法。
|
||||||
|
|
||||||
|
## 功能特点
|
||||||
|
- 实时预览
|
||||||
|
- 丰富的文本格式化选项
|
||||||
|
- 支持图片和链接插入
|
||||||
|
- 自定义快捷键
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
只需在编辑区域输入 Markdown 语法,即可实时看到格式化后的内容。
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
\`\`\`markdown
|
||||||
|
# 这是一个标题
|
||||||
|
**加粗文本**
|
||||||
|
*斜体文本*
|
||||||
|
- 列表项 1
|
||||||
|
- 列表项 2
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
享受写作的乐趣吧!
|
||||||
|
|
||||||
|
ts code
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
function greet(name: string): string {
|
||||||
|
return \`Hello, \${name}!\`;
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
`
|
||||||
|
const init = () => {
|
||||||
|
const editor = new TextEditor();
|
||||||
|
const editorContainer = document.getElementById('editor') as HTMLDivElement;
|
||||||
|
|
||||||
|
const opts: TextEditorProps = {
|
||||||
|
markdown,
|
||||||
|
placeholder: '输入内容...',
|
||||||
|
onUpdateHtml: (html) => {
|
||||||
|
console.log('Content changed:', html);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
editor.createEditor(editorContainer, opts);
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
export const App = () => {
|
||||||
|
// useEffect(() => {
|
||||||
|
// const editor = init();
|
||||||
|
// return () => {
|
||||||
|
// editor.destroy();
|
||||||
|
// };
|
||||||
|
// }, []);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="bg-red-200">Hello Vite + React Simple vite</h1>
|
||||||
|
<div className='card tiptap'>
|
||||||
|
<button type='button' onClick={() => alert('Button clicked!')}>
|
||||||
|
Click me
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<kv-md-editor markdown={markdown} placeholder='请输入内容...'>
|
||||||
|
</kv-md-editor>
|
||||||
|
|
||||||
|
{/* <kv-template></kv-template> */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
2
src/index.css
Normal file
2
src/index.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "./tiptap/tiptap.css";
|
||||||
49
src/lib.ts
Normal file
49
src/lib.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { render } from 'lit-html';
|
||||||
|
import { html, TemplateResult } from 'lit-html';
|
||||||
|
|
||||||
|
class KvTemplate extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(): void {
|
||||||
|
const content: TemplateResult = html`
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="container">
|
||||||
|
<h1>KV Template Component</h1>
|
||||||
|
<p>This is a sample custom element using Lit-HTML.</p>
|
||||||
|
<p>Current time: ${new Date().toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
render(content, this.shadowRoot!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the custom element globally
|
||||||
|
customElements.define('kv-template', KvTemplate);
|
||||||
7
src/main.tsx
Normal file
7
src/main.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './lib.ts'
|
||||||
|
import { App } from './app.tsx';
|
||||||
|
import './index.css';
|
||||||
|
import './md-editor.ts';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(<App />);
|
||||||
124
src/md-editor.ts
Normal file
124
src/md-editor.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { render } from 'lit-html';
|
||||||
|
import { html, TemplateResult } from 'lit-html';
|
||||||
|
import { TextEditor, TextEditorProps } from './tiptap/editor.ts';
|
||||||
|
|
||||||
|
class KvMdEditor extends HTMLElement {
|
||||||
|
private editor: TextEditor;
|
||||||
|
private editorContainer?: HTMLDivElement;
|
||||||
|
private _markdown: string = '';
|
||||||
|
private _placeholder: string = '输入内容...';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
this.editor = new TextEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
static get observedAttributes() {
|
||||||
|
return ['markdown', 'placeholder'];
|
||||||
|
}
|
||||||
|
getId() {
|
||||||
|
return this.id;
|
||||||
|
}
|
||||||
|
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
|
||||||
|
if (oldValue === newValue) return;
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case 'markdown':
|
||||||
|
this._markdown = newValue || '';
|
||||||
|
if (this.editorContainer) {
|
||||||
|
this.editor.setContent(this._markdown);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'placeholder':
|
||||||
|
this._placeholder = newValue || this._placeholder;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.render();
|
||||||
|
this.initEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this.editor.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initEditor(): void {
|
||||||
|
if (!this.editorContainer) return;
|
||||||
|
|
||||||
|
const opts: TextEditorProps = {
|
||||||
|
markdown: this._markdown,
|
||||||
|
placeholder: this._placeholder,
|
||||||
|
onUpdateHtml: (html) => {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('content-change', {
|
||||||
|
detail: { html, text: this.editor.getContent() },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.editor.createEditor(this.editorContainer, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(): void {
|
||||||
|
const content: TemplateResult = html`
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<slot></slot>
|
||||||
|
`;
|
||||||
|
|
||||||
|
render(content, this.shadowRoot!);
|
||||||
|
let id = this.getId();
|
||||||
|
if(!id) {
|
||||||
|
id = `kv-md-editor-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
const inlineId = `${id}-inline`;
|
||||||
|
let editor = this.shadowRoot!.querySelector(`#${inlineId}`)!;
|
||||||
|
if(!editor) {
|
||||||
|
editor = document.createElement('div');
|
||||||
|
editor.id = inlineId;
|
||||||
|
const host = document.querySelector(`#${id}`);
|
||||||
|
host!.appendChild(editor);
|
||||||
|
}
|
||||||
|
this.editorContainer = editor as HTMLDivElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公开的 API 方法
|
||||||
|
public getHtml(): string {
|
||||||
|
return this.editor.getHtml() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public getContent(): string {
|
||||||
|
return this.editor.getContent() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public setContent(content: string): void {
|
||||||
|
this.editor.setContent(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public focus(): void {
|
||||||
|
this.editor.foucus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the custom element globally
|
||||||
|
customElements.define('kv-md-editor', KvMdEditor);
|
||||||
|
|
||||||
|
export {
|
||||||
|
TextEditor,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
TextEditorProps
|
||||||
|
}
|
||||||
112
src/tiptap/components/CommandsList.tsx
Normal file
112
src/tiptap/components/CommandsList.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
|
||||||
|
import { CommandItem } from '../extensions/suggestions/commands';
|
||||||
|
|
||||||
|
interface CommandsListProps {
|
||||||
|
items: CommandItem[];
|
||||||
|
command: (props: { content: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommandsList = forwardRef((props: CommandsListProps, ref) => {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
const selectItem = (index: number) => {
|
||||||
|
const item = props.items[index];
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
props.command({ content: item.content });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const upHandler = () => {
|
||||||
|
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downHandler = () => {
|
||||||
|
setSelectedIndex((selectedIndex + 1) % props.items.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const enterHandler = () => {
|
||||||
|
selectItem(selectedIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => setSelectedIndex(0), [props.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;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Scroll to selected item when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const element = document.getElementById(`command-item-${selectedIndex}`);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-md shadow-lg border border-gray-200 overflow-hidden" style={{ width: '350px', maxHeight: '80vh' }}>
|
||||||
|
<div className="p-2 bg-gray-50 border-b border-gray-200 sticky top-0 z-10">
|
||||||
|
<div className="text-sm font-medium text-gray-700">Commands ({props.items.length})</div>
|
||||||
|
<div className="text-xs text-gray-500">Type to filter commands</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-72 overflow-y-auto">
|
||||||
|
{props.items.length ? (
|
||||||
|
props.items.map((item, index) => (
|
||||||
|
<button
|
||||||
|
id={`command-item-${index}`}
|
||||||
|
key={index}
|
||||||
|
className={`block w-full text-left px-4 py-2 text-sm transition-colors ${
|
||||||
|
index === selectedIndex ? 'bg-blue-100 border-l-4 border-blue-500' : 'border-l-4 border-transparent'
|
||||||
|
} hover:bg-gray-50`}
|
||||||
|
onClick={() => selectItem(index)}
|
||||||
|
>
|
||||||
|
<div className="font-medium flex items-center">
|
||||||
|
!{item.title}
|
||||||
|
{index === selectedIndex && (
|
||||||
|
<span className="ml-2 text-xs bg-blue-500 text-white px-2 py-0.5 rounded">
|
||||||
|
Press Enter to select
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500 text-xs">{item.description}</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-2 text-sm text-gray-500">No results</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 px-3 py-2 text-xs text-gray-500 border-t flex justify-between items-center sticky bottom-0 z-10">
|
||||||
|
<span className="inline-flex items-center">
|
||||||
|
<kbd className="px-2 py-1 bg-white rounded border border-gray-300 shadow-sm mr-1">↑</kbd>
|
||||||
|
<kbd className="px-2 py-1 bg-white rounded border border-gray-300 shadow-sm">↓</kbd>
|
||||||
|
<span className="ml-1">to navigate</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center">
|
||||||
|
<kbd className="px-2 py-1 bg-white rounded border border-gray-300 shadow-sm">Enter</kbd>
|
||||||
|
<span className="ml-1">to select</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
CommandsList.displayName = 'CommandsList';
|
||||||
42
src/tiptap/components/ReactRenderer.tsx
Normal file
42
src/tiptap/components/ReactRenderer.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
export class ReactRenderer {
|
||||||
|
component: any;
|
||||||
|
element: HTMLElement;
|
||||||
|
ref: React.RefObject<any>;
|
||||||
|
props: any;
|
||||||
|
editor: any;
|
||||||
|
root: any;
|
||||||
|
|
||||||
|
constructor(component: any, { props, editor }: any) {
|
||||||
|
this.component = component;
|
||||||
|
this.element = document.createElement('div');
|
||||||
|
this.ref = React.createRef();
|
||||||
|
this.props = {
|
||||||
|
...props,
|
||||||
|
ref: this.ref,
|
||||||
|
};
|
||||||
|
this.editor = editor;
|
||||||
|
this.root = createRoot(this.element);
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProps(props: any) {
|
||||||
|
this.props = {
|
||||||
|
...this.props,
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.root.render(React.createElement(this.component, this.props));
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.root.unmount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReactRenderer;
|
||||||
173
src/tiptap/editor.ts
Normal file
173
src/tiptap/editor.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { Editor } from '@tiptap/core';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import Highlight from '@tiptap/extension-highlight';
|
||||||
|
import Typography from '@tiptap/extension-typography';
|
||||||
|
import { Markdown } from 'tiptap-markdown';
|
||||||
|
import Dropcursor from '@tiptap/extension-dropcursor';
|
||||||
|
// import Gapcursor from '@tiptap/extension-gapcursor';
|
||||||
|
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
|
import { Commands, getSuggestionItems, CommandItem } from './extensions/suggestions';
|
||||||
|
// import { DragHandle } from './extensions/dragHandle';
|
||||||
|
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||||
|
import { all, createLowlight } from 'lowlight';
|
||||||
|
import 'highlight.js/styles/github.css';
|
||||||
|
// 根据需要引入的语言支持
|
||||||
|
import js from 'highlight.js/lib/languages/javascript';
|
||||||
|
import ts from 'highlight.js/lib/languages/typescript';
|
||||||
|
import html from 'highlight.js/lib/languages/xml';
|
||||||
|
import css from 'highlight.js/lib/languages/css';
|
||||||
|
import markdown from 'highlight.js/lib/languages/markdown';
|
||||||
|
// import './editor.css';
|
||||||
|
|
||||||
|
const lowlight = createLowlight(all);
|
||||||
|
|
||||||
|
// you can also register individual languages
|
||||||
|
lowlight.register('html', html);
|
||||||
|
lowlight.register('css', css);
|
||||||
|
lowlight.register('js', js);
|
||||||
|
lowlight.register('ts', ts);
|
||||||
|
lowlight.register('markdown', markdown);
|
||||||
|
|
||||||
|
export type TextEditorProps = {
|
||||||
|
markdown?: string;
|
||||||
|
html?: string;
|
||||||
|
items?: CommandItem[];
|
||||||
|
placeholder?: string;
|
||||||
|
onUpdateHtml?: (html: string) => void; //
|
||||||
|
};
|
||||||
|
export class TextEditor {
|
||||||
|
private editor?: Editor;
|
||||||
|
private opts?: TextEditorProps;
|
||||||
|
private element?: HTMLElement;
|
||||||
|
private isInitialSetup: boolean = true;
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
createEditor(el: HTMLElement, opts?: TextEditorProps) {
|
||||||
|
if (this.editor) {
|
||||||
|
this.destroy();
|
||||||
|
}
|
||||||
|
this.opts = opts;
|
||||||
|
this.element = el;
|
||||||
|
const html = opts?.markdown || opts?.html || '';
|
||||||
|
const items = opts?.items || getSuggestionItems();
|
||||||
|
const placeholder = opts?.placeholder || 'Type @ to see commands (e.g., @today, @list @test )...';
|
||||||
|
// const suggestionConfig = createSuggestionConfig(items);
|
||||||
|
this.isInitialSetup = true;
|
||||||
|
this.editor = new Editor({
|
||||||
|
element: el, // 指定编辑器容器
|
||||||
|
editable: true,
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
codeBlock: false,
|
||||||
|
// 禁用内置的拖拽功能,使用我们的自定义拖拽
|
||||||
|
dropcursor: false,
|
||||||
|
}), // 使用 StarterKit 包含基础功能
|
||||||
|
Highlight,
|
||||||
|
Dropcursor.configure({
|
||||||
|
color: '#3b82f6', // 设置拖拽指示器颜色
|
||||||
|
width: 2, // 设置拖拽指示器宽度
|
||||||
|
}),
|
||||||
|
// Gapcursor, // 允许在难以选择的位置放置光标
|
||||||
|
// DragHandle,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder,
|
||||||
|
}),
|
||||||
|
Typography,
|
||||||
|
Markdown,
|
||||||
|
CodeBlockLowlight.extend({
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
Tab: () => {
|
||||||
|
const { state, dispatch } = this.editor.view;
|
||||||
|
const { tr, selection } = state;
|
||||||
|
const { from, to } = selection;
|
||||||
|
|
||||||
|
// 插入4个空格的缩进
|
||||||
|
dispatch(tr.insertText(' ', from, to));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
'Shift-Tab': () => {
|
||||||
|
const { state, dispatch } = this.editor.view;
|
||||||
|
const { tr, selection } = state;
|
||||||
|
const { from, to } = selection;
|
||||||
|
|
||||||
|
// 获取当前选中的文本
|
||||||
|
const selectedText = state.doc.textBetween(from, to, '\n');
|
||||||
|
|
||||||
|
// 取消缩进:移除前面的4个空格
|
||||||
|
const unindentedText = selectedText.replace(/^ {1,4}/gm, '');
|
||||||
|
dispatch(tr.insertText(unindentedText, from, to));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}).configure({
|
||||||
|
lowlight,
|
||||||
|
}),
|
||||||
|
// Commands.configure({
|
||||||
|
// suggestion: suggestionConfig,
|
||||||
|
// }),
|
||||||
|
],
|
||||||
|
content: html, // 初始化内容,
|
||||||
|
onUpdate: () => {
|
||||||
|
if (this.isInitialSetup) {
|
||||||
|
this.isInitialSetup = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.opts?.onUpdateHtml) {
|
||||||
|
this.opts.onUpdateHtml(this.editor?.getHTML() || '');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
init(el: HTMLElement, opts?: TextEditorProps) {
|
||||||
|
return this.createEditor(el, opts);
|
||||||
|
}
|
||||||
|
updateSugestionConfig(items: CommandItem[]) {
|
||||||
|
if (!this.element) return;
|
||||||
|
const element = this.element;
|
||||||
|
if (this.editor) {
|
||||||
|
const content = this.editor.getHTML(); // Save current content
|
||||||
|
const opts = { ...this.opts, html: content, items };
|
||||||
|
this.createEditor(element, opts); // Recreate the editor with the new config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setContent(html: string, emitUpdate?: boolean) {
|
||||||
|
this.editor?.commands.setContent(html, { emitUpdate });
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* before set options ,you should has element and editor
|
||||||
|
* @param opts
|
||||||
|
*/
|
||||||
|
setOptions(opts: { markdown?: string; html?: string; items?: CommandItem[]; onUpdateHtml?: (html: string) => void }) {
|
||||||
|
this.opts = { ...this.opts, ...opts };
|
||||||
|
this.createEditor(this.element!, this.opts!);
|
||||||
|
}
|
||||||
|
getHtml() {
|
||||||
|
return this.editor?.getHTML();
|
||||||
|
}
|
||||||
|
getContent() {
|
||||||
|
return this.editor?.getText();
|
||||||
|
}
|
||||||
|
foucus() {
|
||||||
|
this.editor?.view?.focus?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.editor?.destroy();
|
||||||
|
this.editor = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* use example
|
||||||
|
* const textEditor = new TextEditor();
|
||||||
|
textEditor.createEditor(document.getElementById('editor')!, {
|
||||||
|
markdown: '# Hello World',
|
||||||
|
onUpdateHtml: (html) => {
|
||||||
|
console.log('Updated HTML:', html);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
*/
|
||||||
179
src/tiptap/extensions/dragHandle.ts
Normal file
179
src/tiptap/extensions/dragHandle.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { Extension } from '@tiptap/core';
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||||
|
import { TextSelection } from '@tiptap/pm/state';
|
||||||
|
|
||||||
|
function setupDraggableElements(editor: any) {
|
||||||
|
if (editor?.view?.dom) {
|
||||||
|
const blockElements = editor.view.dom.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre');
|
||||||
|
blockElements.forEach((element: HTMLElement) => {
|
||||||
|
element.draggable = true;
|
||||||
|
element.classList.add('draggable-block');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DragHandle = Extension.create({
|
||||||
|
name: 'dragHandle',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: new PluginKey('dragHandle'),
|
||||||
|
props: {
|
||||||
|
handleDOMEvents: {
|
||||||
|
dragstart: (view, event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
|
// 查找最近的块级元素
|
||||||
|
let blockElement = target;
|
||||||
|
while (blockElement && blockElement !== view.dom) {
|
||||||
|
if (['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'BLOCKQUOTE', 'PRE'].includes(blockElement.nodeName)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
blockElement = blockElement.parentElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blockElement || blockElement === view.dom) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取块元素在文档中的位置并选中整个节点
|
||||||
|
try {
|
||||||
|
const pos = view.posAtDOM(blockElement, 0);
|
||||||
|
if (pos !== null && pos >= 0) {
|
||||||
|
const resolvedPos = view.state.doc.resolve(pos);
|
||||||
|
|
||||||
|
// 查找包含此位置的块级节点
|
||||||
|
let node = resolvedPos.parent;
|
||||||
|
let from = resolvedPos.start();
|
||||||
|
let to = resolvedPos.end();
|
||||||
|
|
||||||
|
// 如果当前不是块级节点,向上查找
|
||||||
|
let depth = resolvedPos.depth;
|
||||||
|
while (node && !node.isBlock && depth > 0) {
|
||||||
|
depth--;
|
||||||
|
const parentPos = view.state.doc.resolve(resolvedPos.before(depth + 1));
|
||||||
|
node = parentPos.parent;
|
||||||
|
from = parentPos.start();
|
||||||
|
to = parentPos.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node && node.isBlock) {
|
||||||
|
// 选中整个块级节点
|
||||||
|
const tr = view.state.tr.setSelection(
|
||||||
|
TextSelection.create(view.state.doc, from, to)
|
||||||
|
);
|
||||||
|
view.dispatch(tr);
|
||||||
|
|
||||||
|
console.log('Selected node:', { from, to, type: node.type.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error selecting node:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置拖拽数据类型标识
|
||||||
|
event.dataTransfer?.setData('application/x-tiptap-drag', 'true');
|
||||||
|
event.dataTransfer?.setData('text/html', blockElement.outerHTML);
|
||||||
|
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
}
|
||||||
|
|
||||||
|
blockElement.classList.add('dragging');
|
||||||
|
|
||||||
|
console.log('Drag started for:', blockElement.nodeName);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
dragover: (view, event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
drop: (view, event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// 检查是否是我们的拖拽
|
||||||
|
if (!event.dataTransfer?.getData('application/x-tiptap-drag')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinates = view.posAtCoords({
|
||||||
|
left: event.clientX,
|
||||||
|
top: event.clientY,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!coordinates) {
|
||||||
|
console.log('No coordinates found');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用内置的拖拽处理
|
||||||
|
const slice = view.state.selection.content();
|
||||||
|
const { tr } = view.state;
|
||||||
|
|
||||||
|
// 删除原来的内容
|
||||||
|
tr.deleteSelection();
|
||||||
|
|
||||||
|
// 在新位置插入
|
||||||
|
let insertPos = coordinates.pos;
|
||||||
|
if (insertPos > view.state.selection.from) {
|
||||||
|
insertPos -= (view.state.selection.to - view.state.selection.from);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.insert(insertPos, slice.content);
|
||||||
|
|
||||||
|
view.dispatch(tr);
|
||||||
|
|
||||||
|
console.log('Drop completed at position:', insertPos);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
dragend: (view, event) => {
|
||||||
|
// 清理拖拽样式
|
||||||
|
const draggingElements = view.dom.querySelectorAll('.dragging');
|
||||||
|
draggingElements.forEach((el) => {
|
||||||
|
el.classList.remove('dragging');
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
onUpdate() {
|
||||||
|
setupDraggableElements(this.editor);
|
||||||
|
},
|
||||||
|
|
||||||
|
onCreate() {
|
||||||
|
setTimeout(() => {
|
||||||
|
setupDraggableElements(this.editor);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// 监听内容变化
|
||||||
|
if (this.editor?.view?.dom) {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
setupDraggableElements(this.editor);
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(this.editor.view.dom, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
(this as any)._observer = observer;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onDestroy() {
|
||||||
|
if ((this as any)._observer) {
|
||||||
|
(this as any)._observer.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
40
src/tiptap/extensions/suggestions/commands.ts
Normal file
40
src/tiptap/extensions/suggestions/commands.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Extension } from '@tiptap/core';
|
||||||
|
import Suggestion from '@tiptap/suggestion';
|
||||||
|
import { PluginKey } from '@tiptap/pm/state';
|
||||||
|
|
||||||
|
export const CommandsPluginKey = new PluginKey('commands');
|
||||||
|
|
||||||
|
export interface CommandItem {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Commands = Extension.create({
|
||||||
|
name: 'ai-commands',
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
suggestion: {
|
||||||
|
char: '@',
|
||||||
|
command: ({ editor, range, props }: any) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.insertContent(props.content)
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion({
|
||||||
|
editor: this.editor,
|
||||||
|
...this.options.suggestion,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
3
src/tiptap/extensions/suggestions/index.ts
Normal file
3
src/tiptap/extensions/suggestions/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './commands';
|
||||||
|
// export * from './suggestionConfig';
|
||||||
|
export * from './suggestionItems';
|
||||||
121
src/tiptap/extensions/suggestions/suggestionConfig.ts
Normal file
121
src/tiptap/extensions/suggestions/suggestionConfig.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { CommandItem } from './commands';
|
||||||
|
import { CommandsList } from '../../components/CommandsList';
|
||||||
|
import ReactRenderer from '../../components/ReactRenderer';
|
||||||
|
|
||||||
|
export const createSuggestionConfig = (items: CommandItem[]) => {
|
||||||
|
return {
|
||||||
|
items: ({ query }: { query: string }) => {
|
||||||
|
return items.filter(item => item.title.toLowerCase().startsWith(query.toLowerCase()));
|
||||||
|
},
|
||||||
|
render: () => {
|
||||||
|
let component: ReactRenderer | null = null;
|
||||||
|
let popup: HTMLElement | null = null;
|
||||||
|
|
||||||
|
const calculatePosition = (view: any, from: number) => {
|
||||||
|
const coords = view.coordsAtPos(from);
|
||||||
|
const editorRect = view.dom.getBoundingClientRect();
|
||||||
|
const popupRect = popup?.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (!popup || !popupRect) return { left: coords.left, top: coords.bottom + 10 };
|
||||||
|
|
||||||
|
// Default position below the cursor
|
||||||
|
let left = coords.left;
|
||||||
|
let top = coords.bottom + 10;
|
||||||
|
|
||||||
|
// Check if we're near the bottom of the viewport
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const bottomSpace = viewportHeight - coords.bottom;
|
||||||
|
const popupHeight = popupRect.height;
|
||||||
|
|
||||||
|
// If there's not enough space below, position above
|
||||||
|
if (bottomSpace < popupHeight + 10 && coords.top > popupHeight + 10) {
|
||||||
|
top = coords.top - popupHeight - 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're near the right edge of the viewport
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const rightSpace = viewportWidth - coords.left;
|
||||||
|
const popupWidth = popupRect.width;
|
||||||
|
|
||||||
|
// If there's not enough space to the right, align right edge
|
||||||
|
if (rightSpace < popupWidth) {
|
||||||
|
left = Math.max(10, viewportWidth - popupWidth - 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure popup stays within editor bounds horizontally if possible
|
||||||
|
if (left < editorRect.left) {
|
||||||
|
left = editorRect.left;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { left, top };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: (props: any) => {
|
||||||
|
component = new ReactRenderer(CommandsList, {
|
||||||
|
props,
|
||||||
|
editor: props.editor,
|
||||||
|
});
|
||||||
|
|
||||||
|
popup = document.createElement('div');
|
||||||
|
popup.className = 'commands-popup';
|
||||||
|
popup.style.position = 'fixed'; // Use fixed instead of absolute for better viewport positioning
|
||||||
|
popup.style.zIndex = '9999';
|
||||||
|
document.body.appendChild(popup);
|
||||||
|
|
||||||
|
popup.appendChild(component.element);
|
||||||
|
|
||||||
|
// Initial position
|
||||||
|
const { view } = props.editor;
|
||||||
|
const { from } = props.range;
|
||||||
|
|
||||||
|
// Set initial position to get popup dimensions
|
||||||
|
popup.style.left = '0px';
|
||||||
|
popup.style.top = '0px';
|
||||||
|
|
||||||
|
// Calculate proper position after the popup is rendered
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!popup) return;
|
||||||
|
const { left, top } = calculatePosition(view, from);
|
||||||
|
popup.style.left = `${left}px`;
|
||||||
|
popup.style.top = `${top}px`;
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
onUpdate: (props: any) => {
|
||||||
|
if (!component) return;
|
||||||
|
|
||||||
|
component.updateProps(props);
|
||||||
|
|
||||||
|
if (!popup) return;
|
||||||
|
|
||||||
|
// Update position
|
||||||
|
const { view } = props.editor;
|
||||||
|
const { from } = props.range;
|
||||||
|
const { left, top } = calculatePosition(view, from);
|
||||||
|
|
||||||
|
popup.style.left = `${left}px`;
|
||||||
|
popup.style.top = `${top}px`;
|
||||||
|
},
|
||||||
|
onKeyDown: (props: any) => {
|
||||||
|
if (props.event.key === 'Escape') {
|
||||||
|
if (popup) popup.remove();
|
||||||
|
if (component) component.destroy();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (component && component.ref && component.ref.current) {
|
||||||
|
return component.ref.current.onKeyDown(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
onExit: () => {
|
||||||
|
if (popup) popup.remove();
|
||||||
|
if (component) component.destroy();
|
||||||
|
component = null;
|
||||||
|
popup = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
203
src/tiptap/extensions/suggestions/suggestionItems.ts
Normal file
203
src/tiptap/extensions/suggestions/suggestionItems.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { CommandItem } from './commands';
|
||||||
|
|
||||||
|
export const getSuggestionItems = (): CommandItem[] => {
|
||||||
|
// Basic commands
|
||||||
|
const basicCommands = [
|
||||||
|
{
|
||||||
|
title: 'today',
|
||||||
|
description: 'Insert today\'s date',
|
||||||
|
content: new Date().toLocaleDateString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'now',
|
||||||
|
description: 'Insert current time',
|
||||||
|
content: new Date().toLocaleTimeString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'datetime',
|
||||||
|
description: 'Insert current date and time',
|
||||||
|
content: new Date().toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'list',
|
||||||
|
description: 'Insert a bullet list',
|
||||||
|
content: '<ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'numbered',
|
||||||
|
description: 'Insert a numbered list',
|
||||||
|
content: '<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'good',
|
||||||
|
description: 'Insert a positive message',
|
||||||
|
content: 'Great job! Keep up the good work! 👍',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'meeting',
|
||||||
|
description: 'Insert meeting template',
|
||||||
|
content: '<h3>Meeting Notes</h3><p><strong>Date:</strong> ' + new Date().toLocaleDateString() + '</p><p><strong>Attendees:</strong></p><ul><li>Person 1</li><li>Person 2</li></ul><p><strong>Agenda:</strong></p><ol><li>Topic 1</li><li>Topic 2</li></ol><p><strong>Action Items:</strong></p><ul><li>[ ] Task 1</li><li>[ ] Task 2</li></ul>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'signature',
|
||||||
|
description: 'Insert your signature',
|
||||||
|
content: '<p>Best regards,<br>Your Name<br>your.email@example.com</p>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Text formatting commands
|
||||||
|
const formattingCommands = [
|
||||||
|
{
|
||||||
|
title: 'h1',
|
||||||
|
description: 'Insert heading 1',
|
||||||
|
content: '<h1>Heading 1</h1>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'h2',
|
||||||
|
description: 'Insert heading 2',
|
||||||
|
content: '<h2>Heading 2</h2>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'h3',
|
||||||
|
description: 'Insert heading 3',
|
||||||
|
content: '<h3>Heading 3</h3>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'quote',
|
||||||
|
description: 'Insert blockquote',
|
||||||
|
content: '<blockquote>This is a quote</blockquote>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'code',
|
||||||
|
description: 'Insert code block',
|
||||||
|
content: '<pre><code>// Your code here\nconsole.log("Hello world");</code></pre>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'bold',
|
||||||
|
description: 'Insert bold text',
|
||||||
|
content: '<strong>Bold text</strong>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'italic',
|
||||||
|
description: 'Insert italic text',
|
||||||
|
content: '<em>Italic text</em>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'underline',
|
||||||
|
description: 'Insert underlined text',
|
||||||
|
content: '<u>Underlined text</u>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'strike',
|
||||||
|
description: 'Insert strikethrough text',
|
||||||
|
content: '<s>Strikethrough text</s>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'highlight',
|
||||||
|
description: 'Insert highlighted text',
|
||||||
|
content: '<mark>Highlighted text</mark>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Template commands
|
||||||
|
const templateCommands = [
|
||||||
|
{
|
||||||
|
title: 'email',
|
||||||
|
description: 'Insert email template',
|
||||||
|
content: '<p>Subject: [Your Subject]</p><p>Dear [Name],</p><p>I hope this email finds you well.</p><p>[Your message here]</p><p>Thank you for your time and consideration.</p><p>Best regards,<br>Your Name</p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'letter',
|
||||||
|
description: 'Insert formal letter template',
|
||||||
|
content: '<p>[Your Name]<br>[Your Address]<br>[City, State ZIP]<br>[Your Email]<br>[Your Phone]</p><p>[Date]</p><p>[Recipient Name]<br>[Recipient Title]<br>[Company Name]<br>[Street Address]<br>[City, State ZIP]</p><p>Dear [Recipient Name],</p><p>[Letter content]</p><p>Sincerely,</p><p>[Your Name]</p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'report',
|
||||||
|
description: 'Insert report template',
|
||||||
|
content: '<h1>Report Title</h1><p><strong>Date:</strong> ' + new Date().toLocaleDateString() + '</p><p><strong>Author:</strong> Your Name</p><h2>Executive Summary</h2><p>[Brief summary of the report]</p><h2>Introduction</h2><p>[Introduction text]</p><h2>Findings</h2><p>[Detailed findings]</p><h2>Conclusion</h2><p>[Conclusion text]</p><h2>Recommendations</h2><p>[Recommendations]</p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'proposal',
|
||||||
|
description: 'Insert proposal template',
|
||||||
|
content: '<h1>Project Proposal</h1><p><strong>Date:</strong> ' + new Date().toLocaleDateString() + '</p><p><strong>Prepared by:</strong> Your Name</p><h2>Project Overview</h2><p>[Brief description of the project]</p><h2>Objectives</h2><ul><li>[Objective 1]</li><li>[Objective 2]</li></ul><h2>Scope of Work</h2><p>[Detailed scope]</p><h2>Timeline</h2><p>[Project timeline]</p><h2>Budget</h2><p>[Budget details]</p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'invoice',
|
||||||
|
description: 'Insert invoice template',
|
||||||
|
content: '<h1>INVOICE</h1><p><strong>Invoice #:</strong> [Number]</p><p><strong>Date:</strong> ' + new Date().toLocaleDateString() + '</p><p><strong>Due Date:</strong> [Due Date]</p><div><strong>From:</strong><br>[Your Name/Company]<br>[Your Address]<br>[Your Contact Info]</div><div><strong>To:</strong><br>[Client Name/Company]<br>[Client Address]</div><table style="width:100%; border-collapse: collapse;"><tr style="border-bottom: 1px solid #ddd;"><th style="text-align:left; padding: 8px;">Description</th><th style="text-align:right; padding: 8px;">Amount</th></tr><tr style="border-bottom: 1px solid #ddd;"><td style="padding: 8px;">[Item/Service Description]</td><td style="text-align:right; padding: 8px;">[Amount]</td></tr><tr><td style="text-align:right; padding: 8px;"><strong>Total</strong></td><td style="text-align:right; padding: 8px;"><strong>[Total Amount]</strong></td></tr></table><p><strong>Payment Terms:</strong> [Terms]</p><p><strong>Payment Method:</strong> [Method]</p>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Task management commands
|
||||||
|
const taskCommands = [
|
||||||
|
{
|
||||||
|
title: 'todo',
|
||||||
|
description: 'Insert todo list',
|
||||||
|
content: '<h3>To-Do List</h3><ul><li>[ ] Task 1</li><li>[ ] Task 2</li><li>[ ] Task 3</li></ul>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'checklist',
|
||||||
|
description: 'Insert checklist',
|
||||||
|
content: '<h3>Checklist</h3><ul><li>[ ] Item 1</li><li>[ ] Item 2</li><li>[ ] Item 3</li></ul>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'progress',
|
||||||
|
description: 'Insert progress tracker',
|
||||||
|
content: '<h3>Project Progress</h3><ul><li>[x] Planning - Complete</li><li>[x] Research - Complete</li><li>[ ] Implementation - In Progress</li><li>[ ] Testing</li><li>[ ] Deployment</li></ul>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'timeline',
|
||||||
|
description: 'Insert project timeline',
|
||||||
|
content: '<h3>Project Timeline</h3><ul><li><strong>Week 1:</strong> Planning and Research</li><li><strong>Week 2-3:</strong> Design and Development</li><li><strong>Week 4:</strong> Testing</li><li><strong>Week 5:</strong> Deployment</li></ul>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'goals',
|
||||||
|
description: 'Insert goals list',
|
||||||
|
content: '<h3>Goals</h3><ol><li>Short-term goal 1</li><li>Short-term goal 2</li><li>Long-term goal 1</li><li>Long-term goal 2</li></ol>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Table commands
|
||||||
|
const tableCommands = [
|
||||||
|
{
|
||||||
|
title: 'table2x2',
|
||||||
|
description: 'Insert 2x2 table',
|
||||||
|
content: '<table style="width:100%; border-collapse: collapse;"><tr style="border-bottom: 1px solid #ddd;"><th style="border: 1px solid #ddd; padding: 8px;">Header 1</th><th style="border: 1px solid #ddd; padding: 8px;">Header 2</th></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Row 1, Cell 1</td><td style="border: 1px solid #ddd; padding: 8px;">Row 1, Cell 2</td></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Row 2, Cell 1</td><td style="border: 1px solid #ddd; padding: 8px;">Row 2, Cell 2</td></tr></table>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'table3x3',
|
||||||
|
description: 'Insert 3x3 table',
|
||||||
|
content: '<table style="width:100%; border-collapse: collapse;"><tr style="border-bottom: 1px solid #ddd;"><th style="border: 1px solid #ddd; padding: 8px;">Header 1</th><th style="border: 1px solid #ddd; padding: 8px;">Header 2</th><th style="border: 1px solid #ddd; padding: 8px;">Header 3</th></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Row 1, Cell 1</td><td style="border: 1px solid #ddd; padding: 8px;">Row 1, Cell 2</td><td style="border: 1px solid #ddd; padding: 8px;">Row 1, Cell 3</td></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Row 2, Cell 1</td><td style="border: 1px solid #ddd; padding: 8px;">Row 2, Cell 2</td><td style="border: 1px solid #ddd; padding: 8px;">Row 2, Cell 3</td></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Row 3, Cell 1</td><td style="border: 1px solid #ddd; padding: 8px;">Row 3, Cell 2</td><td style="border: 1px solid #ddd; padding: 8px;">Row 3, Cell 3</td></tr></table>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'schedule',
|
||||||
|
description: 'Insert schedule table',
|
||||||
|
content: '<table style="width:100%; border-collapse: collapse;"><tr style="border-bottom: 1px solid #ddd;"><th style="border: 1px solid #ddd; padding: 8px;">Time</th><th style="border: 1px solid #ddd; padding: 8px;">Monday</th><th style="border: 1px solid #ddd; padding: 8px;">Tuesday</th><th style="border: 1px solid #ddd; padding: 8px;">Wednesday</th><th style="border: 1px solid #ddd; padding: 8px;">Thursday</th><th style="border: 1px solid #ddd; padding: 8px;">Friday</th></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">9:00 AM</td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">10:00 AM</td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">11:00 AM</td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td></tr></table>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'comparison',
|
||||||
|
description: 'Insert comparison table',
|
||||||
|
content: '<table style="width:100%; border-collapse: collapse;"><tr style="border-bottom: 1px solid #ddd;"><th style="border: 1px solid #ddd; padding: 8px;">Feature</th><th style="border: 1px solid #ddd; padding: 8px;">Option A</th><th style="border: 1px solid #ddd; padding: 8px;">Option B</th><th style="border: 1px solid #ddd; padding: 8px;">Option C</th></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Feature 1</td><td style="border: 1px solid #ddd; padding: 8px;">✓</td><td style="border: 1px solid #ddd; padding: 8px;">✓</td><td style="border: 1px solid #ddd; padding: 8px;">✓</td></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Feature 2</td><td style="border: 1px solid #ddd; padding: 8px;">✓</td><td style="border: 1px solid #ddd; padding: 8px;">✗</td><td style="border: 1px solid #ddd; padding: 8px;">✓</td></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Feature 3</td><td style="border: 1px solid #ddd; padding: 8px;">✗</td><td style="border: 1px solid #ddd; padding: 8px;">✓</td><td style="border: 1px solid #ddd; padding: 8px;">✓</td></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Price</td><td style="border: 1px solid #ddd; padding: 8px;">$</td><td style="border: 1px solid #ddd; padding: 8px;">$$</td><td style="border: 1px solid #ddd; padding: 8px;">$$$</td></tr></table>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Additional commands to reach 100 total
|
||||||
|
const additionalCommands = Array.from({ length: 100 - (basicCommands.length + formattingCommands.length + templateCommands.length + taskCommands.length + tableCommands.length) }, (_, i) => {
|
||||||
|
const index = i + 1;
|
||||||
|
return {
|
||||||
|
title: `command${index}`,
|
||||||
|
description: `Example command ${index}`,
|
||||||
|
content: `<p>This is example command ${index}</p>`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine all command categories
|
||||||
|
return [
|
||||||
|
...basicCommands,
|
||||||
|
...formattingCommands,
|
||||||
|
...templateCommands,
|
||||||
|
...taskCommands,
|
||||||
|
...tableCommands,
|
||||||
|
...additionalCommands,
|
||||||
|
];
|
||||||
|
};
|
||||||
203
src/tiptap/extensions/suggestions/suggestions.ts
Normal file
203
src/tiptap/extensions/suggestions/suggestions.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { CommandItem } from './commands';
|
||||||
|
|
||||||
|
export const getSuggestionItems = (): CommandItem[] => {
|
||||||
|
// Basic commands
|
||||||
|
const basicCommands = [
|
||||||
|
{
|
||||||
|
title: 'today',
|
||||||
|
description: 'Insert today\'s date',
|
||||||
|
content: new Date().toLocaleDateString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'now',
|
||||||
|
description: 'Insert current time',
|
||||||
|
content: new Date().toLocaleTimeString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'datetime',
|
||||||
|
description: 'Insert current date and time',
|
||||||
|
content: new Date().toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'list',
|
||||||
|
description: 'Insert a bullet list',
|
||||||
|
content: '<ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'numbered',
|
||||||
|
description: 'Insert a numbered list',
|
||||||
|
content: '<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'good',
|
||||||
|
description: 'Insert a positive message',
|
||||||
|
content: 'Great job! Keep up the good work! 👍',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'meeting',
|
||||||
|
description: 'Insert meeting template',
|
||||||
|
content: '<h3>Meeting Notes</h3><p><strong>Date:</strong> ' + new Date().toLocaleDateString() + '</p><p><strong>Attendees:</strong></p><ul><li>Person 1</li><li>Person 2</li></ul><p><strong>Agenda:</strong></p><ol><li>Topic 1</li><li>Topic 2</li></ol><p><strong>Action Items:</strong></p><ul><li>[ ] Task 1</li><li>[ ] Task 2</li></ul>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'signature',
|
||||||
|
description: 'Insert your signature',
|
||||||
|
content: '<p>Best regards,<br>Your Name<br>your.email@example.com</p>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Text formatting commands
|
||||||
|
const formattingCommands = [
|
||||||
|
{
|
||||||
|
title: 'h1',
|
||||||
|
description: 'Insert heading 1',
|
||||||
|
content: '<h1>Heading 1</h1>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'h2',
|
||||||
|
description: 'Insert heading 2',
|
||||||
|
content: '<h2>Heading 2</h2>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'h3',
|
||||||
|
description: 'Insert heading 3',
|
||||||
|
content: '<h3>Heading 3</h3>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'quote',
|
||||||
|
description: 'Insert blockquote',
|
||||||
|
content: '<blockquote>This is a quote</blockquote>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'code',
|
||||||
|
description: 'Insert code block',
|
||||||
|
content: '<pre><code>// Your code here\nconsole.log("Hello world");</code></pre>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'bold',
|
||||||
|
description: 'Insert bold text',
|
||||||
|
content: '<strong>Bold text</strong>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'italic',
|
||||||
|
description: 'Insert italic text',
|
||||||
|
content: '<em>Italic text</em>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'underline',
|
||||||
|
description: 'Insert underlined text',
|
||||||
|
content: '<u>Underlined text</u>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'strike',
|
||||||
|
description: 'Insert strikethrough text',
|
||||||
|
content: '<s>Strikethrough text</s>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'highlight',
|
||||||
|
description: 'Insert highlighted text',
|
||||||
|
content: '<mark>Highlighted text</mark>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Template commands
|
||||||
|
const templateCommands = [
|
||||||
|
{
|
||||||
|
title: 'email',
|
||||||
|
description: 'Insert email template',
|
||||||
|
content: '<p>Subject: [Your Subject]</p><p>Dear [Name],</p><p>I hope this email finds you well.</p><p>[Your message here]</p><p>Thank you for your time and consideration.</p><p>Best regards,<br>Your Name</p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'letter',
|
||||||
|
description: 'Insert formal letter template',
|
||||||
|
content: '<p>[Your Name]<br>[Your Address]<br>[City, State ZIP]<br>[Your Email]<br>[Your Phone]</p><p>[Date]</p><p>[Recipient Name]<br>[Recipient Title]<br>[Company Name]<br>[Street Address]<br>[City, State ZIP]</p><p>Dear [Recipient Name],</p><p>[Letter content]</p><p>Sincerely,</p><p>[Your Name]</p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'report',
|
||||||
|
description: 'Insert report template',
|
||||||
|
content: '<h1>Report Title</h1><p><strong>Date:</strong> ' + new Date().toLocaleDateString() + '</p><p><strong>Author:</strong> Your Name</p><h2>Executive Summary</h2><p>[Brief summary of the report]</p><h2>Introduction</h2><p>[Introduction text]</p><h2>Findings</h2><p>[Detailed findings]</p><h2>Conclusion</h2><p>[Conclusion text]</p><h2>Recommendations</h2><p>[Recommendations]</p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'proposal',
|
||||||
|
description: 'Insert proposal template',
|
||||||
|
content: '<h1>Project Proposal</h1><p><strong>Date:</strong> ' + new Date().toLocaleDateString() + '</p><p><strong>Prepared by:</strong> Your Name</p><h2>Project Overview</h2><p>[Brief description of the project]</p><h2>Objectives</h2><ul><li>[Objective 1]</li><li>[Objective 2]</li></ul><h2>Scope of Work</h2><p>[Detailed scope]</p><h2>Timeline</h2><p>[Project timeline]</p><h2>Budget</h2><p>[Budget details]</p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'invoice',
|
||||||
|
description: 'Insert invoice template',
|
||||||
|
content: '<h1>INVOICE</h1><p><strong>Invoice #:</strong> [Number]</p><p><strong>Date:</strong> ' + new Date().toLocaleDateString() + '</p><p><strong>Due Date:</strong> [Due Date]</p><div><strong>From:</strong><br>[Your Name/Company]<br>[Your Address]<br>[Your Contact Info]</div><div><strong>To:</strong><br>[Client Name/Company]<br>[Client Address]</div><table style="width:100%; border-collapse: collapse;"><tr style="border-bottom: 1px solid #ddd;"><th style="text-align:left; padding: 8px;">Description</th><th style="text-align:right; padding: 8px;">Amount</th></tr><tr style="border-bottom: 1px solid #ddd;"><td style="padding: 8px;">[Item/Service Description]</td><td style="text-align:right; padding: 8px;">[Amount]</td></tr><tr><td style="text-align:right; padding: 8px;"><strong>Total</strong></td><td style="text-align:right; padding: 8px;"><strong>[Total Amount]</strong></td></tr></table><p><strong>Payment Terms:</strong> [Terms]</p><p><strong>Payment Method:</strong> [Method]</p>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Task management commands
|
||||||
|
const taskCommands = [
|
||||||
|
{
|
||||||
|
title: 'todo',
|
||||||
|
description: 'Insert todo list',
|
||||||
|
content: '<h3>To-Do List</h3><ul><li>[ ] Task 1</li><li>[ ] Task 2</li><li>[ ] Task 3</li></ul>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'checklist',
|
||||||
|
description: 'Insert checklist',
|
||||||
|
content: '<h3>Checklist</h3><ul><li>[ ] Item 1</li><li>[ ] Item 2</li><li>[ ] Item 3</li></ul>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'progress',
|
||||||
|
description: 'Insert progress tracker',
|
||||||
|
content: '<h3>Project Progress</h3><ul><li>[x] Planning - Complete</li><li>[x] Research - Complete</li><li>[ ] Implementation - In Progress</li><li>[ ] Testing</li><li>[ ] Deployment</li></ul>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'timeline',
|
||||||
|
description: 'Insert project timeline',
|
||||||
|
content: '<h3>Project Timeline</h3><ul><li><strong>Week 1:</strong> Planning and Research</li><li><strong>Week 2-3:</strong> Design and Development</li><li><strong>Week 4:</strong> Testing</li><li><strong>Week 5:</strong> Deployment</li></ul>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'goals',
|
||||||
|
description: 'Insert goals list',
|
||||||
|
content: '<h3>Goals</h3><ol><li>Short-term goal 1</li><li>Short-term goal 2</li><li>Long-term goal 1</li><li>Long-term goal 2</li></ol>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Table commands
|
||||||
|
const tableCommands = [
|
||||||
|
{
|
||||||
|
title: 'table2x2',
|
||||||
|
description: 'Insert 2x2 table',
|
||||||
|
content: '<table style="width:100%; border-collapse: collapse;"><tr style="border-bottom: 1px solid #ddd;"><th style="border: 1px solid #ddd; padding: 8px;">Header 1</th><th style="border: 1px solid #ddd; padding: 8px;">Header 2</th></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Row 1, Cell 1</td><td style="border: 1px solid #ddd; padding: 8px;">Row 1, Cell 2</td></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Row 2, Cell 1</td><td style="border: 1px solid #ddd; padding: 8px;">Row 2, Cell 2</td></tr></table>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'table3x3',
|
||||||
|
description: 'Insert 3x3 table',
|
||||||
|
content: '<table style="width:100%; border-collapse: collapse;"><tr style="border-bottom: 1px solid #ddd;"><th style="border: 1px solid #ddd; padding: 8px;">Header 1</th><th style="border: 1px solid #ddd; padding: 8px;">Header 2</th><th style="border: 1px solid #ddd; padding: 8px;">Header 3</th></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Row 1, Cell 1</td><td style="border: 1px solid #ddd; padding: 8px;">Row 1, Cell 2</td><td style="border: 1px solid #ddd; padding: 8px;">Row 1, Cell 3</td></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Row 2, Cell 1</td><td style="border: 1px solid #ddd; padding: 8px;">Row 2, Cell 2</td><td style="border: 1px solid #ddd; padding: 8px;">Row 2, Cell 3</td></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Row 3, Cell 1</td><td style="border: 1px solid #ddd; padding: 8px;">Row 3, Cell 2</td><td style="border: 1px solid #ddd; padding: 8px;">Row 3, Cell 3</td></tr></table>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'schedule',
|
||||||
|
description: 'Insert schedule table',
|
||||||
|
content: '<table style="width:100%; border-collapse: collapse;"><tr style="border-bottom: 1px solid #ddd;"><th style="border: 1px solid #ddd; padding: 8px;">Time</th><th style="border: 1px solid #ddd; padding: 8px;">Monday</th><th style="border: 1px solid #ddd; padding: 8px;">Tuesday</th><th style="border: 1px solid #ddd; padding: 8px;">Wednesday</th><th style="border: 1px solid #ddd; padding: 8px;">Thursday</th><th style="border: 1px solid #ddd; padding: 8px;">Friday</th></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">9:00 AM</td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">10:00 AM</td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">11:00 AM</td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td><td style="border: 1px solid #ddd; padding: 8px;"></td></tr></table>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'comparison',
|
||||||
|
description: 'Insert comparison table',
|
||||||
|
content: '<table style="width:100%; border-collapse: collapse;"><tr style="border-bottom: 1px solid #ddd;"><th style="border: 1px solid #ddd; padding: 8px;">Feature</th><th style="border: 1px solid #ddd; padding: 8px;">Option A</th><th style="border: 1px solid #ddd; padding: 8px;">Option B</th><th style="border: 1px solid #ddd; padding: 8px;">Option C</th></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Feature 1</td><td style="border: 1px solid #ddd; padding: 8px;">✓</td><td style="border: 1px solid #ddd; padding: 8px;">✓</td><td style="border: 1px solid #ddd; padding: 8px;">✓</td></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Feature 2</td><td style="border: 1px solid #ddd; padding: 8px;">✓</td><td style="border: 1px solid #ddd; padding: 8px;">✗</td><td style="border: 1px solid #ddd; padding: 8px;">✓</td></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Feature 3</td><td style="border: 1px solid #ddd; padding: 8px;">✗</td><td style="border: 1px solid #ddd; padding: 8px;">✓</td><td style="border: 1px solid #ddd; padding: 8px;">✓</td></tr><tr><td style="border: 1px solid #ddd; padding: 8px;">Price</td><td style="border: 1px solid #ddd; padding: 8px;">$</td><td style="border: 1px solid #ddd; padding: 8px;">$$</td><td style="border: 1px solid #ddd; padding: 8px;">$$$</td></tr></table>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Additional commands to reach 100 total
|
||||||
|
const additionalCommands = Array.from({ length: 100 - (basicCommands.length + formattingCommands.length + templateCommands.length + taskCommands.length + tableCommands.length) }, (_, i) => {
|
||||||
|
const index = i + 1;
|
||||||
|
return {
|
||||||
|
title: `command${index}`,
|
||||||
|
description: `Example command ${index}`,
|
||||||
|
content: `<p>This is example command ${index}</p>`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine all command categories
|
||||||
|
return [
|
||||||
|
...basicCommands,
|
||||||
|
...formattingCommands,
|
||||||
|
...templateCommands,
|
||||||
|
...taskCommands,
|
||||||
|
...tableCommands,
|
||||||
|
...additionalCommands,
|
||||||
|
];
|
||||||
|
};
|
||||||
126
src/tiptap/tiptap.css
Normal file
126
src/tiptap/tiptap.css
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
:root {
|
||||||
|
--purple-light: #e0e0ff; /* 默认浅紫色背景 */
|
||||||
|
--black: #000000; /* 默认黑色 */
|
||||||
|
--white: #ffffff; /* 默认白色 */
|
||||||
|
--gray-3: #d3d3d3; /* 默认灰色3 */
|
||||||
|
--gray-2: #e5e5e5; /* 默认灰色2 */
|
||||||
|
}
|
||||||
|
.ProseMirror-focused.tiptap {
|
||||||
|
outline: 1px solid #999;
|
||||||
|
}
|
||||||
|
.tiptap-preview {
|
||||||
|
.tiptap {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tiptap {
|
||||||
|
/* margin: 0.5rem 1rem; */
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
/* Basic editor styles */
|
||||||
|
.tiptap:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List styles */
|
||||||
|
.tiptap ul,
|
||||||
|
.tiptap ol {
|
||||||
|
padding: 0 1rem;
|
||||||
|
margin: 1.25rem 1rem 1.25rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul li p,
|
||||||
|
.tiptap ol li p {
|
||||||
|
margin-top: 0.25em;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Heading styles */
|
||||||
|
.tiptap h1,
|
||||||
|
.tiptap h2,
|
||||||
|
.tiptap h3,
|
||||||
|
.tiptap h4,
|
||||||
|
.tiptap h5,
|
||||||
|
.tiptap h6 {
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h1,
|
||||||
|
.tiptap h2 {
|
||||||
|
/* margin-top: 3.5rem; */
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h4,
|
||||||
|
.tiptap h5,
|
||||||
|
.tiptap h6 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code and preformatted text styles */
|
||||||
|
.tiptap code {
|
||||||
|
background-color: var(--purple-light);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
color: var(--black);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25em 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap pre {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
/* background: var(--black); */
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
/* color: var(--white); */
|
||||||
|
font-family: 'JetBrainsMono', monospace;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap pre code {
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap mark {
|
||||||
|
background-color: #faf594;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap blockquote {
|
||||||
|
border-left: 3px solid var(--gray-3);
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--gray-2);
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "@kevisual/types/json/frontend.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"strict": false,
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
],
|
||||||
|
"@/agent": [
|
||||||
|
"./src/agent"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*",
|
||||||
|
"agent/**/*",
|
||||||
|
"typings.d.ts"
|
||||||
|
],
|
||||||
|
}
|
||||||
15
typings.d.ts
vendored
Normal file
15
typings.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
interface KvMdEditorProps extends HTMLAttributes<HTMLElement> {
|
||||||
|
markdown?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'react' {
|
||||||
|
namespace JSX {
|
||||||
|
interface IntrinsicElements {
|
||||||
|
'kv-template': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
|
||||||
|
'kv-md-editor': DetailedHTMLProps<KvMdEditorProps, HTMLElement>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
vite.config.lib.ts
Normal file
27
vite.config.lib.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
const plugins = [react(), tailwindcss()];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://vitejs.dev/config/
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins,
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: path.resolve(__dirname, 'mod.ts'),
|
||||||
|
name: 'KvMd',
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: () => `kv-md.js`,
|
||||||
|
},
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
44
vite.config.ts
Normal file
44
vite.config.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
import pkgs from './package.json';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
const plugins = [react(), tailwindcss()];
|
||||||
|
|
||||||
|
let target = process.env.VITE_API_URL || 'http://localhost:51015';
|
||||||
|
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
|
||||||
|
let proxy = {
|
||||||
|
'/root/': apiProxy,
|
||||||
|
'/api': apiProxy,
|
||||||
|
'/client': apiProxy,
|
||||||
|
};
|
||||||
|
const ENV_BASE_NAME = process.env.BASE_NAME;
|
||||||
|
|
||||||
|
const _basename = ENV_BASE_NAME || pkgs.basename;
|
||||||
|
const basename = isDev ? undefined : `${_basename}`;
|
||||||
|
/**
|
||||||
|
* @see https://vitejs.dev/config/
|
||||||
|
*/
|
||||||
|
export default defineConfig(() => {
|
||||||
|
return {
|
||||||
|
plugins,
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
base: basename,
|
||||||
|
define: {
|
||||||
|
BASE_NAME: JSON.stringify(basename),
|
||||||
|
BUILD_TIME: JSON.stringify(new Date().toISOString()),
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 7008,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
proxy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user