feat(plugin): Add chart plugin and formula plugin

This commit is contained in:
robin 2023-09-20 17:52:00 +08:00
parent c20838ae14
commit acbd31906a
30 changed files with 739 additions and 0 deletions

View File

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
ui/src/plugins/answer-chart/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,3 @@
# chart plugin
This plugin is used to extend the toolbar of the markdown editor of the answer project.
It provides a toolbar button to insert a chart into the markdown editor.

View File

@ -0,0 +1,36 @@
{
"name": "answer-chart",
"private": true,
"version": "0.0.1",
"files": [
"dist",
"README.md"
],
"main": "./dist/answer-chart.umd.js",
"module": "./dist/answer-chart.es.js",
"exports": {
".": {
"import": "./dist/answer-chart.es.js",
"require": "./dist/answer-chart.umd.js"
}
},
"scripts": {
"dev": "vite build --mode development --watch",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"typescript": "^5.0.2",
"vite": "^4.4.5"
},
"dependencies": {
"mermaid": "^9.1.7"
}
}

View File

@ -0,0 +1,157 @@
import { FC, useState } from 'react';
import { Dropdown } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import icon from './icon.svg';
import { useRenderChart } from './hooks';
interface ChartProps {
editor;
previewElement: HTMLElement;
}
const Chart: FC<ChartProps> = ({ editor, previewElement }) => {
useRenderChart(previewElement);
const { t } = useTranslation('plugin', {
keyPrefix: 'chart',
});
const [isLocked, setLockState] = useState(false);
const handleMouseEnter = () => {
if (isLocked) {
return;
}
setLockState(true);
};
const handleMouseLeave = () => {
setLockState(false);
};
const headerList = [
{
label: t('flow_chart'),
tpl: `graph TD
A[Christmas] -->|Get money| B(Go shopping)
B --> C{Let me think}
C -->|One| D[Laptop]
C -->|Two| E[iPhone]
C -->|Three| F[fa:fa-car Car]`,
},
{
label: t('sequence_diagram'),
tpl: `sequenceDiagram
Alice->>+John: Hello John, how are you?
Alice->>+John: John, can you hear me?
John-->>-Alice: Hi Alice, I can hear you!
John-->>-Alice: I feel great!
`,
},
{
label: t('state_diagram'),
tpl: `stateDiagram-v2
[*] --> Still
Still --> [*]
Still --> Moving
Moving --> Still
Moving --> Crash
Crash --> [*]
`,
},
{
label: t('class_diagram'),
tpl: `classDiagram
Animal <|-- Duck
Animal <|-- Fish
Animal <|-- Zebra
Animal : +int age
Animal : +String gender
Animal: +isMammal()
Animal: +mate()
class Duck{
+String beakColor
+swim()
+quack()
}
class Fish{
-int sizeInFeet
-canEat()
}
class Zebra{
+bool is_wild
+run()
}
`,
},
{
label: t('pie_chart'),
tpl: `pie title Pets adopted by volunteers
"Dogs" : 386
"Cats" : 85
"Rats" : 15
`,
},
{
label: t('gantt_chart'),
tpl: `gantt
title A Gantt Diagram
dateFormat YYYY-MM-DD
section Section
A task :a1, 2014-01-01, 30d
Another task :after a1 , 20d
section Another
Task in sec :2014-01-12 , 12d
another task : 24d
`,
},
{
label: t('entity_relationship_diagram'),
tpl: `erDiagram
CUSTOMER }|..|{ DELIVERY-ADDRESS : has
CUSTOMER ||--o{ ORDER : places
CUSTOMER ||--o{ INVOICE : "liable for"
DELIVERY-ADDRESS ||--o{ ORDER : receives
INVOICE ||--|{ ORDER : covers
ORDER ||--|{ ORDER-ITEM : includes
PRODUCT-CATEGORY ||--|{ PRODUCT : contains
PRODUCT ||--o{ ORDER-ITEM : "ordered in"
`,
},
];
const handleChange = (tpl: string) => {
const { ch } = editor.getCursor();
editor.replaceSelection(`${ch ? '\n' : ''}\`\`\`mermaid\n${tpl}\n\`\`\`\n`);
};
return (
<div className="toolbar-item-wrap">
<Dropdown className="p-0 b-0 btn-no-border btn btn-link" title="chart">
<Dropdown.Toggle
type="button"
as="button"
className="p-0 b-0 btn-no-border btn btn-link">
<img src={icon} alt="chart" />
</Dropdown.Toggle>
<Dropdown.Menu
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}>
{headerList.map((header) => {
return (
<Dropdown.Item
key={header.label}
onClick={(e) => {
e.preventDefault();
handleChange(header.tpl);
}}>
{header.label}
</Dropdown.Item>
);
})}
</Dropdown.Menu>
</Dropdown>
</div>
);
};
export default Chart;

View File

@ -0,0 +1,42 @@
import { useEffect } from 'react';
// @ts-ignore
import mermaid from 'mermaid';
const useRenderChart = (element: HTMLElement) => {
const render = (element) => {
mermaid.initialize({ startOnLoad: false });
element.querySelectorAll('.language-mermaid').forEach((pre) => {
const flag = Date.now();
mermaid.render(
`theGraph${flag}`,
pre.textContent || '',
function (svgCode: string) {
const p = document.createElement('p');
p.className = 'text-center';
p.innerHTML = svgCode;
pre.parentNode?.replaceChild(p, pre);
},
);
});
};
useEffect(() => {
if (!element) {
return;
}
render(element);
const observer = new MutationObserver(() => {
render(element);
});
observer.observe(element, {
childList: true,
attributes: true,
subtree: true,
});
}, [element]);
};
export { useRenderChart };

View File

@ -0,0 +1,15 @@
{
"plugin": {
"chart": {
"text": "Chart",
"flow_chart": "Flow chart",
"sequence_diagram": "Sequence diagram",
"class_diagram": "Class diagram",
"state_diagram": "State diagram",
"entity_relationship_diagram": "Entity relationship diagram",
"user_defined_diagram": "User defined diagram",
"gantt_chart": "Gantt chart",
"pie_chart": "Pie chart"
}
}
}

View File

@ -0,0 +1,7 @@
import en_US from './en_US.json';
import zh_CN from './zh_CN.json';
export default {
en_US,
zh_CN,
};

View File

@ -0,0 +1,15 @@
{
"plugin": {
"chart": {
"text": "图表",
"flow_chart": "流程图",
"sequence_diagram": "时序图",
"class_diagram": "类图",
"state_diagram": "状态图",
"entity_relationship_diagram": "ER 图",
"user_defined_diagram": "用户自定义图表",
"gantt_chart": "甘特图",
"pie_chart": "饼图"
}
}
}

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="3" fill="white" fill-opacity="0.01"/>
<path d="M4.125 6.9375C4.125 6.22656 4.69922 5.625 5.4375 5.625H8.0625C8.77344 5.625 9.375 6.22656 9.375 6.9375V7.375H14.625V6.9375C14.625 6.22656 15.1992 5.625 15.9375 5.625H18.5625C19.2734 5.625 19.875 6.22656 19.875 6.9375V9.5625C19.875 10.3008 19.2734 10.875 18.5625 10.875H15.9375C15.1992 10.875 14.625 10.3008 14.625 9.5625V9.125H9.375V9.5625C9.375 9.61719 9.34766 9.67188 9.34766 9.69922L11.5625 12.625H14.1875C14.8984 12.625 15.5 13.2266 15.5 13.9375V16.5625C15.5 17.3008 14.8984 17.875 14.1875 17.875H11.5625C10.8242 17.875 10.25 17.3008 10.25 16.5625V13.9375C10.25 13.9102 10.25 13.8555 10.25 13.8008L8.0625 10.875H5.4375C4.69922 10.875 4.125 10.3008 4.125 9.5625V6.9375Z" fill="#495057"/>
</svg>

After

Width:  |  Height:  |  Size: 874 B

View File

@ -0,0 +1,15 @@
import Chart from './Chart';
import i18nConfig from './i18n';
import { useRenderChart } from './hooks';
export default {
info: {
type: 'editor',
slug_name: 'chart',
},
component: Chart,
i18nConfig,
hooks: {
useRender: [useRenderChart],
},
};

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noImplicitAny": false,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,32 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: 'src/index.ts',
name: 'answer-chart',
fileName: (format) => `answer-chart.${format}.js`,
},
rollupOptions: {
external: [
'react',
'react-dom',
'react-i18next',
'react-bootstrap',
'mermaid',
],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
'react-i18next': 'reactI18next',
'react-bootstrap': 'reactBootstrap',
mermaid: 'mermaid',
},
},
},
},
});

View File

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,3 @@
# formula plugin
This plugin is used to extend the toolbar of the markdown editor of the answer project.
It provides a toolbar button to insert a formula into the markdown editor.

View File

@ -0,0 +1,37 @@
{
"name": "answer-formula",
"private": true,
"version": "0.0.1",
"files": [
"dist",
"README.md"
],
"main": "./dist/answer-formula.umd.js",
"module": "./dist/answer-formula.es.js",
"exports": {
".": {
"import": "./dist/answer-formula.es.js",
"require": "./dist/answer-formula.umd.js"
}
},
"scripts": {
"dev": "vite build --mode development --watch",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"typescript": "^5.0.2",
"vite": "^4.4.5",
"vite-plugin-css-injected-by-js": "^3.3.0"
},
"dependencies": {
"katex": "^0.16.2"
}
}

View File

@ -0,0 +1,92 @@
import { FC, useState } from 'react';
import { Dropdown } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import 'katex/dist/katex.min.css';
import icon from './icon.svg';
import { useRenderFormula } from './hooks';
interface FormulaProps {
editor;
previewElement: HTMLElement;
}
const Formula: FC<FormulaProps> = ({ editor, previewElement }) => {
useRenderFormula(previewElement);
const { t } = useTranslation('plugin', {
keyPrefix: 'formula',
});
const [isLocked, setLockState] = useState(false);
const handleMouseEnter = () => {
if (isLocked) {
return;
}
setLockState(true);
};
const handleMouseLeave = () => {
setLockState(false);
};
const formulaList = [
{
type: 'line',
label: t('options.inline'),
},
{
type: 'block',
label: t('options.block'),
},
];
const handleClick = (type: string, label: string) => {
if (!editor) {
return;
}
const { wrapText } = editor;
if (type === 'line') {
wrapText('\\\\( ', ' \\\\)', label);
} else {
const cursor = editor.getCursor();
wrapText('\n$$\n', '\n$$\n', label);
editor.setSelection(
{ line: cursor.line + 2, ch: 0 },
{ line: cursor.line + 2, ch: label.length },
);
}
editor?.focus();
};
return (
<div className="toolbar-item-wrap">
<Dropdown className="p-0 b-0 btn-no-border btn btn-link" title="chart">
<Dropdown.Toggle
type="button"
as="button"
className="p-0 b-0 btn-no-border btn btn-link">
<img src={icon} alt="formula" />
</Dropdown.Toggle>
<Dropdown.Menu
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}>
{formulaList.map((formula) => {
return (
<Dropdown.Item
key={formula.label}
onClick={(e) => {
e.preventDefault();
handleClick(formula.type, formula.label);
}}>
{formula.label}
</Dropdown.Item>
);
})}
</Dropdown.Menu>
</Dropdown>
</div>
);
};
export default Formula;

View File

@ -0,0 +1,43 @@
import { useEffect } from 'react';
// @ts-ignore
import katexRender from 'katex/contrib/auto-render/auto-render';
const useRenderFormula = (element: HTMLElement) => {
const render = (element) => {
katexRender(element, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$$<br>', right: '<br>$$', display: true },
{
left: '\\begin{equation}',
right: '\\end{equation}',
display: true,
},
{ left: '\\begin{align}', right: '\\end{align}', display: true },
{ left: '\\begin{alignat}', right: '\\end{alignat}', display: true },
{ left: '\\begin{gather}', right: '\\end{gather}', display: true },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
],
});
};
useEffect(() => {
if (!element) {
return;
}
render(element);
const observer = new MutationObserver(() => {
render(element);
});
observer.observe(element, {
childList: true,
attributes: true,
subtree: true,
});
}, [element]);
};
export { useRenderFormula };

View File

@ -0,0 +1,11 @@
{
"plugin": {
"formula": {
"text": "Formula",
"options": {
"inline": "Inline formula",
"block": "Block formula"
}
}
}
}

View File

@ -0,0 +1,7 @@
import en_US from './en_US.json';
import zh_CN from './zh_CN.json';
export default {
en_US,
zh_CN,
};

View File

@ -0,0 +1,11 @@
{
"plugin": {
"formula": {
"text": "公式",
"options": {
"inline": "行内公式",
"block": "公式块"
}
}
}
}

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.59125 14.1212V5.17749H2.12375L0 6.72374V8.14499L2.05 6.66999H2.125V14.1212H3.5925H3.59125ZM7.6075 7.75874V7.67999C7.6075 6.90749 8.1575 6.21874 9.1025 6.21874C9.9475 6.21874 10.57 6.76874 10.57 7.60124C10.57 8.38124 10.045 8.97749 9.56125 9.50874L6.2375 13.1912V14.1212H12.2125V12.8837H8.30375V12.7975L10.5125 10.315C11.325 9.40999 12.0588 8.64999 12.0588 7.47749C12.0575 6.06124 10.9038 4.99999 9.135 4.99999C7.16875 4.99999 6.185 6.32999 6.185 7.68749V7.75874H7.6075ZM15.8063 10.1125H16.7887C17.8175 10.1125 18.5063 10.7137 18.5125 11.5862C18.525 12.47 17.825 13.1062 16.7362 13.0987C15.7738 13.0925 15.0788 12.575 15.0125 11.9075H13.6438C13.6962 13.2237 14.8162 14.305 16.7237 14.305C18.5712 14.305 20.0262 13.2562 19.9987 11.625C19.9737 10.1962 18.8463 9.56124 18.06 9.48249V9.40374C18.7288 9.29124 19.7437 8.57874 19.7175 7.30624C19.685 5.98999 18.5513 4.98749 16.7687 4.99999C14.8938 5.00624 13.8725 6.09999 13.8338 7.37249H15.2287C15.2687 6.74999 15.8512 6.19249 16.7362 6.19249C17.615 6.19249 18.2437 6.73624 18.2437 7.52999C18.25 8.32999 17.6138 8.91249 16.7438 8.91249H15.8063V10.1125Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,15 @@
import Formula from './Formula';
import i18nConfig from './i18n';
import { useRenderFormula } from './hooks';
export default {
info: {
type: 'editor',
slug_name: 'formula',
},
component: Formula,
i18nConfig,
hooks: {
useRender: [useRenderFormula],
},
};

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noImplicitAny": false,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,33 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), cssInjectedByJsPlugin()],
build: {
lib: {
entry: 'src/index.ts',
name: 'answer-formula',
fileName: (format) => `answer-formula.${format}.js`,
},
rollupOptions: {
external: [
'react',
'react-dom',
'react-i18next',
'react-bootstrap',
'katex',
],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
'react-i18next': 'reactI18next',
'react-bootstrap': 'reactBootstrap',
katex: 'katex',
},
},
},
},
});