feat:初始化 -融骅
This commit is contained in:
4
editor/.browserslistrc
Normal file
4
editor/.browserslistrc
Normal file
@@ -0,0 +1,4 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
||||
not ie 11
|
||||
8
editor/.env
Normal file
8
editor/.env
Normal file
@@ -0,0 +1,8 @@
|
||||
# 页面标题
|
||||
VUE_APP_TITLE = 'haoque'
|
||||
|
||||
# 开发环境配置
|
||||
ENV = 'development'
|
||||
|
||||
# 开发环境
|
||||
VUE_APP_BASE_URL = '/api'
|
||||
35
editor/.eslintrc.js
Normal file
35
editor/.eslintrc.js
Normal file
@@ -0,0 +1,35 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
extends: ['plugin:vue/vue3-essential', 'eslint:recommended', '@vue/typescript/recommended', 'plugin:prettier/recommended'],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': 'off',
|
||||
'@typescript-eslint/no-namespace': 'off',
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'generator-star-spacing': 'off',
|
||||
'no-mixed-operators': 0,
|
||||
'no-irregular-whitespace': 0,
|
||||
'no-empty': 0,
|
||||
'space-before-function-paren': 0,
|
||||
'no-multi-spaces': 0,
|
||||
'no-unused-vars': 0,
|
||||
'vue/attribute-hyphenation': 0,
|
||||
'vue/html-self-closing': 0,
|
||||
'vue/component-name-in-template-casing': 0,
|
||||
'vue/html-closing-bracket-spacing': 0,
|
||||
'vue/singleline-html-element-content-newline': 0,
|
||||
'vue/no-unused-components': 0,
|
||||
'vue/multiline-html-element-content-newline': 0,
|
||||
'vue/html-closing-bracket-newline': 0,
|
||||
'vue/no-parsing-error': 0,
|
||||
'no-mixed-spaces-and-tabs': [0],
|
||||
'vue/no-unused-vars': 0,
|
||||
'vue/multi-word-component-names': 'off',
|
||||
},
|
||||
};
|
||||
23
editor/.gitignore
vendored
Normal file
23
editor/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
8
editor/.prettierrc.js
Normal file
8
editor/.prettierrc.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
printWidth: 200,
|
||||
tabWidth: 2,
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
bracketSpacing: true,
|
||||
arrowParens: 'avoid',
|
||||
};
|
||||
24
editor/README.md
Normal file
24
editor/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# am-editor-vue3
|
||||
|
||||
## Project setup
|
||||
```
|
||||
yarn install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
```
|
||||
yarn serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
```
|
||||
yarn build
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
```
|
||||
yarn lint
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||
3
editor/babel.config.js
Normal file
3
editor/babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
presets: ['@vue/cli-plugin-babel/preset'],
|
||||
};
|
||||
94
editor/package.json
Normal file
94
editor/package.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "Text-Editor",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^6.1.0",
|
||||
"@aomao/engine": "^2.10.21",
|
||||
"@aomao/plugin-alignment": "^2.10.0",
|
||||
"@aomao/plugin-backcolor": "^2.10.0",
|
||||
"@aomao/plugin-bold": "^2.10.0",
|
||||
"@aomao/plugin-code": "^2.10.0",
|
||||
"@aomao/plugin-codeblock-vue": "^2.10.0",
|
||||
"@aomao/plugin-embed": "^2.10.1",
|
||||
"@aomao/plugin-file": "^2.10.0",
|
||||
"@aomao/plugin-fontcolor": "^2.10.0",
|
||||
"@aomao/plugin-fontfamily": "^2.10.0",
|
||||
"@aomao/plugin-fontsize": "^2.10.0",
|
||||
"@aomao/plugin-heading": "^2.10.0",
|
||||
"@aomao/plugin-hr": "^2.10.1",
|
||||
"@aomao/plugin-image": "^2.10.0",
|
||||
"@aomao/plugin-indent": "^2.10.0",
|
||||
"@aomao/plugin-italic": "^2.10.0",
|
||||
"@aomao/plugin-line-height": "^2.10.1",
|
||||
"@aomao/plugin-link-vue": "^2.10.1",
|
||||
"@aomao/plugin-mark": "^2.10.0",
|
||||
"@aomao/plugin-mark-range": "^2.10.3",
|
||||
"@aomao/plugin-math": "^2.10.0",
|
||||
"@aomao/plugin-mention": "^2.10.3",
|
||||
"@aomao/plugin-orderedlist": "^2.10.0",
|
||||
"@aomao/plugin-paintformat": "^2.10.0",
|
||||
"@aomao/plugin-quote": "^2.10.0",
|
||||
"@aomao/plugin-redo": "^2.10.0",
|
||||
"@aomao/plugin-removeformat": "^2.10.0",
|
||||
"@aomao/plugin-selectall": "^2.10.0",
|
||||
"@aomao/plugin-status": "^2.10.0",
|
||||
"@aomao/plugin-strikethrough": "^2.10.0",
|
||||
"@aomao/plugin-sub": "^2.10.1",
|
||||
"@aomao/plugin-sup": "^2.10.1",
|
||||
"@aomao/plugin-table": "^2.10.8",
|
||||
"@aomao/plugin-tasklist": "^2.10.0",
|
||||
"@aomao/plugin-underline": "^2.10.0",
|
||||
"@aomao/plugin-undo": "^2.10.0",
|
||||
"@aomao/plugin-unorderedlist": "^2.10.0",
|
||||
"@aomao/plugin-video": "^2.10.0",
|
||||
"@aomao/toolbar-vue": "^2.10.3",
|
||||
"ant-design-vue": "^3.2.12",
|
||||
"axios": "^1.5.0",
|
||||
"core-js": "^3.8.3",
|
||||
"cropperjs": "^1.6.0",
|
||||
"embed-drawio": "^0.0.15",
|
||||
"html2canvas": "^1.4.1",
|
||||
"katex": "^0.16.8",
|
||||
"lodash": "^4.17.21",
|
||||
"markdown-it": "^13.0.1",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
"moment": "^2.29.4",
|
||||
"photoswipe": "4.1.3",
|
||||
"qiankun": "^3.0.0-alpha.1",
|
||||
"uuid": "^9.0.0",
|
||||
"vue": "^3.2.13",
|
||||
"vue-class-component": "^8.0.0-0",
|
||||
"vue-router": "^4.0.3",
|
||||
"vuex": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/katex": "^0.16.2",
|
||||
"@types/lodash": "^4.14.197",
|
||||
"@types/markdown-it": "^13.0.0",
|
||||
"@types/markdown-it-container": "^2.0.6",
|
||||
"@types/photoswipe": "4.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.4.0",
|
||||
"@typescript-eslint/parser": "^5.4.0",
|
||||
"@vue/cli-plugin-babel": "~5.0.0",
|
||||
"@vue/cli-plugin-eslint": "~5.0.0",
|
||||
"@vue/cli-plugin-router": "~5.0.0",
|
||||
"@vue/cli-plugin-typescript": "~5.0.0",
|
||||
"@vue/cli-plugin-vuex": "~5.0.0",
|
||||
"@vue/cli-service": "~5.0.0",
|
||||
"@vue/eslint-config-typescript": "^9.1.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-vue": "^8.0.3",
|
||||
"less": "^3.0.4",
|
||||
"less-loader": "^5.0.0",
|
||||
"prettier": "^2.4.1",
|
||||
"typescript": "~4.5.5"
|
||||
}
|
||||
}
|
||||
BIN
editor/public/favicon.ico
Normal file
BIN
editor/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
17
editor/public/index.html
Normal file
17
editor/public/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
||||
27
editor/src/App.vue
Normal file
27
editor/src/App.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<ConfigProvider :locale="locale">
|
||||
<router-view />
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ConfigProvider } from 'ant-design-vue';
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN';
|
||||
import { defineComponent } from 'vue';
|
||||
export default defineComponent({
|
||||
components: { ConfigProvider },
|
||||
setup() {
|
||||
const locale = zhCN;
|
||||
return { locale };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: #2c3e50;
|
||||
}
|
||||
</style>
|
||||
31
editor/src/apis/manual.ts
Normal file
31
editor/src/apis/manual.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import httpRequest from "@/utils/request";
|
||||
|
||||
// 手册相关
|
||||
const manualUrl = '/manuals-manage';
|
||||
export const getManualListApi = (classify?: number) => {
|
||||
if (classify) {
|
||||
return httpRequest.get(`${manualUrl}/bycla/${classify}`);
|
||||
} else {
|
||||
return httpRequest.get(manualUrl);
|
||||
}
|
||||
};
|
||||
export const getManualInfoApi = (id: string) => httpRequest.get(`${manualUrl}/${id}`);
|
||||
export const addManualApi = (data: any) => httpRequest.post(manualUrl, data);
|
||||
export const editManualApi = (id: string, data: any) => httpRequest.patch(`${manualUrl}/${id}`, data);
|
||||
export const deleteManualApi = (data: any) => httpRequest.post(`${manualUrl}/portion`, data);
|
||||
|
||||
// 手册分类相关
|
||||
const manualClassifyUrl = '/manuals-manage/classify';
|
||||
export const getManualClassifyListApi = (params?: any) => httpRequest.get(manualClassifyUrl, params);
|
||||
export const getManualClassifyInfoApi = (params: any) => httpRequest.get(manualClassifyUrl, params);
|
||||
export const addManualClassifyApi = (data: any) => httpRequest.post(manualClassifyUrl, data);
|
||||
export const editManualClassifyApi = (data: any) => httpRequest.post(`${manualClassifyUrl}/editor`, data);
|
||||
export const deleteManualClassifyApi = (id: number) => httpRequest.delete(`${manualClassifyUrl}/${id}`);
|
||||
|
||||
// 手册文章
|
||||
const manualArticleUrl = '/article-manage';
|
||||
export const getManualArticleListApi = (id: number) => httpRequest.get(`${manualArticleUrl}/manuals/${id}`);
|
||||
export const getManualArticleInfoApi = (id: number) => httpRequest.get(`${manualArticleUrl}/${id}`);
|
||||
export const addManualArticleApi = (data: any) => httpRequest.post(manualArticleUrl, data);
|
||||
export const editManualArticleApi = (data: any) => httpRequest.patch(manualArticleUrl, data);
|
||||
export const deleteManualArticleApi = (data: any) => httpRequest.post(`${manualArticleUrl}/portion`, data);
|
||||
39
editor/src/assets/iconfont/iconfont.css
Normal file
39
editor/src/assets/iconfont/iconfont.css
Normal file
@@ -0,0 +1,39 @@
|
||||
@font-face {
|
||||
font-family: "iconfont"; /* Project id 4246922 */
|
||||
src: url('iconfont.woff2?t=1694426600917') format('woff2'),
|
||||
url('iconfont.woff?t=1694426600917') format('woff'),
|
||||
url('iconfont.ttf?t=1694426600917') format('truetype');
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-family: "iconfont" !important;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-rili:before {
|
||||
content: "\e600";
|
||||
}
|
||||
|
||||
.icon-shangpinpaixu:before {
|
||||
content: "\e62a";
|
||||
}
|
||||
|
||||
.icon-date-asce:before {
|
||||
content: "\e62b";
|
||||
}
|
||||
|
||||
.icon-date-desc:before {
|
||||
content: "\e618";
|
||||
}
|
||||
|
||||
.icon-tubiao:before {
|
||||
content: "\e60c";
|
||||
}
|
||||
|
||||
.icon-list:before {
|
||||
content: "\e613";
|
||||
}
|
||||
|
||||
1
editor/src/assets/iconfont/iconfont.js
Normal file
1
editor/src/assets/iconfont/iconfont.js
Normal file
File diff suppressed because one or more lines are too long
51
editor/src/assets/iconfont/iconfont.json
Normal file
51
editor/src/assets/iconfont/iconfont.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"id": "4246922",
|
||||
"name": "haoque",
|
||||
"font_family": "iconfont",
|
||||
"css_prefix_text": "icon-",
|
||||
"description": "",
|
||||
"glyphs": [
|
||||
{
|
||||
"icon_id": "1718350",
|
||||
"name": "日历28",
|
||||
"font_class": "rili",
|
||||
"unicode": "e600",
|
||||
"unicode_decimal": 58880
|
||||
},
|
||||
{
|
||||
"icon_id": "7730889",
|
||||
"name": "商品排序",
|
||||
"font_class": "shangpinpaixu",
|
||||
"unicode": "e62a",
|
||||
"unicode_decimal": 58922
|
||||
},
|
||||
{
|
||||
"icon_id": "37319803",
|
||||
"name": "按时间排序-升序",
|
||||
"font_class": "date-asce",
|
||||
"unicode": "e62b",
|
||||
"unicode_decimal": 58923
|
||||
},
|
||||
{
|
||||
"icon_id": "13410868",
|
||||
"name": "按时间排序-降序",
|
||||
"font_class": "date-desc",
|
||||
"unicode": "e618",
|
||||
"unicode_decimal": 58904
|
||||
},
|
||||
{
|
||||
"icon_id": "582661",
|
||||
"name": "ioc_table",
|
||||
"font_class": "tubiao",
|
||||
"unicode": "e60c",
|
||||
"unicode_decimal": 58892
|
||||
},
|
||||
{
|
||||
"icon_id": "1000805",
|
||||
"name": "list",
|
||||
"font_class": "list",
|
||||
"unicode": "e613",
|
||||
"unicode_decimal": 58899
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
editor/src/assets/iconfont/iconfont.ttf
Normal file
BIN
editor/src/assets/iconfont/iconfont.ttf
Normal file
Binary file not shown.
BIN
editor/src/assets/iconfont/iconfont.woff
Normal file
BIN
editor/src/assets/iconfont/iconfont.woff
Normal file
Binary file not shown.
BIN
editor/src/assets/iconfont/iconfont.woff2
Normal file
BIN
editor/src/assets/iconfont/iconfont.woff2
Normal file
Binary file not shown.
5
editor/src/assets/styles/custom-theme.less
Normal file
5
editor/src/assets/styles/custom-theme.less
Normal file
@@ -0,0 +1,5 @@
|
||||
@primary-color: #11a6b4; // #1890ff; // 全局主色
|
||||
@link-color: #11a6b4; // 链接色
|
||||
@success-color: #34cb80; // #52c41a; // 成功色
|
||||
@warning-color: #faad14; // 警告色
|
||||
@error-color: #f05b59; // #f5222d; // 错误色
|
||||
2
editor/src/assets/styles/index.less
Normal file
2
editor/src/assets/styles/index.less
Normal file
@@ -0,0 +1,2 @@
|
||||
@import '~ant-design-vue/dist/antd.less'; // 引入官方提供的 less 样式入口文件
|
||||
@import 'custom-theme.less'; // 用于覆盖上面定义的变量
|
||||
301
editor/src/components/editor/config.ts
Normal file
301
editor/src/components/editor/config.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { PluginEntry, CardEntry, PluginOptions, NodeInterface, RangeInterface, EngineInterface } from '@aomao/engine';
|
||||
//引入插件 begin
|
||||
import Redo from '@aomao/plugin-redo';
|
||||
import Undo from '@aomao/plugin-undo';
|
||||
import Bold from '@aomao/plugin-bold';
|
||||
import Code from '@aomao/plugin-code';
|
||||
import Backcolor from '@aomao/plugin-backcolor';
|
||||
import Fontcolor from '@aomao/plugin-fontcolor';
|
||||
import Fontsize from '@aomao/plugin-fontsize';
|
||||
import Italic from '@aomao/plugin-italic';
|
||||
import Underline from '@aomao/plugin-underline';
|
||||
import Hr, { HrComponent } from '@aomao/plugin-hr';
|
||||
import Tasklist, { CheckboxComponent } from '@aomao/plugin-tasklist';
|
||||
import Orderedlist from '@aomao/plugin-orderedlist';
|
||||
import Unorderedlist from '@aomao/plugin-unorderedlist';
|
||||
import Indent from '@aomao/plugin-indent';
|
||||
import Heading from '@aomao/plugin-heading';
|
||||
import Strikethrough from '@aomao/plugin-strikethrough';
|
||||
import Sub from '@aomao/plugin-sub';
|
||||
import Sup from '@aomao/plugin-sup';
|
||||
import Alignment from '@aomao/plugin-alignment';
|
||||
import Mark from '@aomao/plugin-mark';
|
||||
import Quote from '@aomao/plugin-quote';
|
||||
import PaintFormat from '@aomao/plugin-paintformat';
|
||||
import RemoveFormat from '@aomao/plugin-removeformat';
|
||||
import SelectAll from '@aomao/plugin-selectall';
|
||||
import Link from '@aomao/plugin-link-vue';
|
||||
import Codeblock, { CodeBlockComponent } from '@aomao/plugin-codeblock-vue';
|
||||
import Image, { ImageComponent, ImageUploader } from '@/plugins/image'; // "@aomao/plugin-image";
|
||||
import Table, { TableComponent } from '@aomao/plugin-table';
|
||||
import File, { FileComponent, FileUploader } from '@aomao/plugin-file';
|
||||
import Video, { VideoComponent, VideoUploader } from '@aomao/plugin-video';
|
||||
import Math, { MathComponent } from '@/plugins/math'; // "@aomao/plugin-math";
|
||||
import Fontfamily from '@aomao/plugin-fontfamily';
|
||||
import Status, { StatusComponent } from '@aomao/plugin-status';
|
||||
import LineHeight from '@aomao/plugin-line-height';
|
||||
import Mention, { MentionComponent } from '@aomao/plugin-mention';
|
||||
import Embed, { EmbedComponent } from '@aomao/plugin-embed';
|
||||
import MarkRange from '@aomao/plugin-mark-range';
|
||||
import Lightblock, { LightblockComponent } from '@/plugins/lightblock';
|
||||
import Audio, { AudioComponent, AudioUploader } from '@/plugins/audio';
|
||||
import Draw, { DrawComponent } from '@/plugins/draw';
|
||||
import Tag, { TagComponent } from '@/plugins/tag';
|
||||
import Test, { TestComponent } from '@/plugins/test';
|
||||
import { ToolbarPlugin, ToolbarComponent, fontFamilyDefaultData } from '@aomao/toolbar-vue';
|
||||
|
||||
import { Empty } from 'ant-design-vue';
|
||||
import { createApp } from 'vue';
|
||||
import Loading from './loading.vue';
|
||||
import MentionPopover from './mention-popover.vue';
|
||||
|
||||
const DOMAIN = process.env.VUE_APP_BASE_URL || '';
|
||||
const TOKEN = localStorage.getItem("token");
|
||||
|
||||
export const plugins: Array<PluginEntry> = [
|
||||
Redo,
|
||||
Undo,
|
||||
Bold,
|
||||
Code,
|
||||
Backcolor,
|
||||
Fontcolor,
|
||||
Fontsize,
|
||||
Italic,
|
||||
Underline,
|
||||
Hr,
|
||||
Tasklist,
|
||||
Orderedlist,
|
||||
Unorderedlist,
|
||||
Indent,
|
||||
Heading,
|
||||
Strikethrough,
|
||||
Sub,
|
||||
Sup,
|
||||
Alignment,
|
||||
Mark,
|
||||
Quote,
|
||||
PaintFormat,
|
||||
RemoveFormat,
|
||||
SelectAll,
|
||||
Link,
|
||||
Codeblock,
|
||||
Image,
|
||||
ImageUploader,
|
||||
Table,
|
||||
File,
|
||||
FileUploader,
|
||||
Video,
|
||||
VideoUploader,
|
||||
Math,
|
||||
ToolbarPlugin,
|
||||
Fontfamily,
|
||||
Status,
|
||||
LineHeight,
|
||||
Mention,
|
||||
Embed,
|
||||
MarkRange,
|
||||
Lightblock,
|
||||
Audio,
|
||||
AudioUploader,
|
||||
Draw,
|
||||
Tag,
|
||||
Test,
|
||||
];
|
||||
|
||||
export const cards: Array<CardEntry> = [
|
||||
HrComponent,
|
||||
CheckboxComponent,
|
||||
CodeBlockComponent,
|
||||
ImageComponent,
|
||||
TableComponent,
|
||||
FileComponent,
|
||||
VideoComponent,
|
||||
MathComponent,
|
||||
ToolbarComponent,
|
||||
StatusComponent,
|
||||
MentionComponent,
|
||||
EmbedComponent,
|
||||
LightblockComponent,
|
||||
AudioComponent,
|
||||
DrawComponent,
|
||||
TagComponent,
|
||||
TestComponent,
|
||||
];
|
||||
let engine: EngineInterface | null = null;
|
||||
|
||||
export const onLoad = (e: EngineInterface) => {
|
||||
engine = e;
|
||||
};
|
||||
export const pluginConfig: { [key: string]: PluginOptions } = {
|
||||
[MarkRange.pluginName]: {
|
||||
//标记类型集合
|
||||
keys: ['mark'],
|
||||
//标记数据更新后触发
|
||||
onChange: (addIds: { [key: string]: Array<string> }, removeIds: { [key: string]: Array<string> }) => {
|
||||
// 新增的标记
|
||||
const commentAddIds = addIds['comment'] || [];
|
||||
// 删除的标记
|
||||
const commentRemoveIds = removeIds['comment'] || [];
|
||||
},
|
||||
//光标改变时触发
|
||||
onSelect: (range: RangeInterface, selectInfo?: { key: string; id: string }) => {
|
||||
const { key, id } = selectInfo || {};
|
||||
// 移除预览标记
|
||||
engine?.command.executeMethod('mark-range', 'action', 'comment', 'revoke');
|
||||
if (key === 'mark' && id) {
|
||||
engine?.command.executeMethod('mark-range', 'action', key, 'preview', id);
|
||||
}
|
||||
},
|
||||
},
|
||||
[Italic.pluginName]: {
|
||||
// 默认为 _ 下划线,这里修改为单个 * 号
|
||||
markdown: '*',
|
||||
},
|
||||
[ImageUploader.pluginName]: {
|
||||
file: {
|
||||
action: `${DOMAIN}/resource/upload`, // `${DOMAIN}/upload/image`,
|
||||
crossOrigin: false,
|
||||
headers: { Authorization: `${TOKEN}` },
|
||||
limitSize: 1024 * 1024 * 50,
|
||||
},
|
||||
remote: {
|
||||
action: `${DOMAIN}/resource/upload`, // `${DOMAIN}/upload/image`,
|
||||
},
|
||||
isRemote: (src: string) => src.indexOf(DOMAIN) < 0,
|
||||
parse: (response: any) => {
|
||||
const res: any = {};
|
||||
if (response.msg == 'success') {
|
||||
res.result = true;
|
||||
res.data = `${DOMAIN}/file-bucket/${response.data.diskname}`;
|
||||
} else {
|
||||
res.result = false;
|
||||
res.data = response.message || response.data.message;
|
||||
}
|
||||
return res;
|
||||
},
|
||||
},
|
||||
[Image.pluginName]: {
|
||||
onBeforeRender: (status: string, url: string) => {
|
||||
if (url.startsWith('data:image/')) return url;
|
||||
return `${url}`;
|
||||
},
|
||||
},
|
||||
[FileUploader.pluginName]: {
|
||||
action: `${DOMAIN}/resource/upload`, // `${DOMAIN}/upload/file`,
|
||||
crossOrigin: false,
|
||||
headers: { Authorization: `${TOKEN}` },
|
||||
parse: (response: any) => {
|
||||
const res: any = {};
|
||||
if (response.msg == 'success') {
|
||||
res.result = true;
|
||||
res.data = response.data.diskname;
|
||||
} else {
|
||||
res.result = false;
|
||||
res.data = response.message || response.data.message;
|
||||
}
|
||||
return res;
|
||||
},
|
||||
},
|
||||
[File.pluginName]: {
|
||||
onBeforeRender: (action: 'download' | 'preview', url: string) => {
|
||||
return `${DOMAIN}/file-bucket/${url}`;
|
||||
},
|
||||
},
|
||||
[VideoUploader.pluginName]: {
|
||||
action: `${DOMAIN}/resource/upload`, // `${DOMAIN}/upload/video`,
|
||||
crossOrigin: false,
|
||||
headers: { Authorization: `${TOKEN}` },
|
||||
limitSize: 1024 * 1024 * 50,
|
||||
parse: (response: any) => {
|
||||
const res: any = {};
|
||||
if (response.msg == 'success') {
|
||||
res.result = true;
|
||||
res.data = response.data.diskname;
|
||||
} else {
|
||||
res.result = false;
|
||||
res.data = response.message || response.data.message;
|
||||
}
|
||||
return res;
|
||||
},
|
||||
},
|
||||
[Video.pluginName]: {
|
||||
onBeforeRender: (status: string, url: string) => {
|
||||
return `${DOMAIN}/file-bucket/${url}`;
|
||||
},
|
||||
},
|
||||
[AudioUploader.pluginName]: {
|
||||
action: `${DOMAIN}/resource/upload`, // `${DOMAIN}/upload/video`,
|
||||
crossOrigin: false,
|
||||
headers: { Authorization: `${TOKEN}` },
|
||||
limitSize: 1024 * 1024 * 50,
|
||||
parse: (response: any) => {
|
||||
const res: any = {};
|
||||
if (response.msg == 'success') {
|
||||
res.result = true;
|
||||
res.data = response.data.diskname;
|
||||
} else {
|
||||
res.result = false;
|
||||
res.data = response.message || response.data.message;
|
||||
}
|
||||
return res;
|
||||
},
|
||||
},
|
||||
[Audio.pluginName]: {
|
||||
onBeforeRender: (status: string, url: string) => {
|
||||
return `${DOMAIN}/file-bucket/${url}`;
|
||||
},
|
||||
},
|
||||
[Math.pluginName]: {
|
||||
action: `${DOMAIN}/latex`,
|
||||
parse: (res: any) => {
|
||||
if (res.success) return { result: true, data: res.svg };
|
||||
return { result: false, data: '' };
|
||||
},
|
||||
},
|
||||
[Mention.pluginName]: {
|
||||
action: `${DOMAIN}/user/search`,
|
||||
onLoading: (root: NodeInterface) => {
|
||||
const vm = createApp(Loading);
|
||||
vm.mount(root.get<HTMLElement>()!);
|
||||
},
|
||||
onEmpty: (root: NodeInterface) => {
|
||||
const vm = createApp(Empty);
|
||||
vm.mount(root.get<HTMLElement>()!);
|
||||
},
|
||||
onClick: (root: NodeInterface, { key, name }: { key: string; name: string }) => {
|
||||
console.log('mention click:', key, '-', name);
|
||||
},
|
||||
onMouseEnter: (layout: NodeInterface, { name }: { key: string; name: string }) => {
|
||||
const vm = createApp(MentionPopover, {
|
||||
name,
|
||||
});
|
||||
vm.mount(layout.get<HTMLElement>()!);
|
||||
},
|
||||
},
|
||||
[Fontsize.pluginName]: {
|
||||
//配置粘贴后需要过滤的字体大小
|
||||
filter: (fontSize: string) => {
|
||||
return ['12px', '13px', '14px', '15px', '16px', '19px', '22px', '24px', '29px', '32px', '40px', '48px'].indexOf(fontSize) > -1;
|
||||
},
|
||||
},
|
||||
[Fontfamily.pluginName]: {
|
||||
//配置粘贴后需要过滤的字体
|
||||
filter: (fontfamily: string) => {
|
||||
const item = fontFamilyDefaultData.find(item => fontfamily.split(',').some(name => item.value.toLowerCase().indexOf(name.replace(/"/, '').toLowerCase()) > -1));
|
||||
return item ? item.value : false;
|
||||
},
|
||||
},
|
||||
[LineHeight.pluginName]: {
|
||||
//配置粘贴后需要过滤的行高
|
||||
filter: (lineHeight: string) => {
|
||||
if (lineHeight === '14px') return '1';
|
||||
if (lineHeight === '16px') return '1.15';
|
||||
if (lineHeight === '21px') return '1.5';
|
||||
if (lineHeight === '28px') return '2';
|
||||
if (lineHeight === '35px') return '2.5';
|
||||
if (lineHeight === '42px') return '3';
|
||||
// 不满足条件就移除掉
|
||||
return ['1', '1.15', '1.5', '2', '2.5', '3'].indexOf(lineHeight) > -1;
|
||||
},
|
||||
},
|
||||
};
|
||||
34
editor/src/components/editor/loading.vue
Normal file
34
editor/src/components/editor/loading.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<spin className="loading" :tip="text" :spinning="loading">
|
||||
<slot></slot>
|
||||
</spin>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { Spin } from 'ant-design-vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'am-loading',
|
||||
components: {
|
||||
Spin,
|
||||
},
|
||||
props: {
|
||||
text: String,
|
||||
loading: Boolean,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style css>
|
||||
.loading {
|
||||
padding: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ant-spin-nested-loading {
|
||||
position: inherit;
|
||||
}
|
||||
.ant-spin-nested-loading .ant-spin-container {
|
||||
position: inherit;
|
||||
}
|
||||
</style>
|
||||
24
editor/src/components/editor/mention-popover.vue
Normal file
24
editor/src/components/editor/mention-popover.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="mention-container">
|
||||
<p>This is name: {{ name }}</p>
|
||||
<p>配置 mention 插件的 onMouseEnter 方法</p>
|
||||
<p>此处使用 createApp().mount 自定义渲染</p>
|
||||
<p>Use createApp().mount to customize rendering here</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'am-mention',
|
||||
props: {
|
||||
name: String,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style css>
|
||||
.mention-container {
|
||||
padding: 5px;
|
||||
}
|
||||
</style>
|
||||
205
editor/src/components/editor/outline.vue
Normal file
205
editor/src/components/editor/outline.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="editor-outline">
|
||||
<Tabs v-model:activeKey="activeKey" @change="handleChange">
|
||||
<TabPane key="outline" tab="大纲">
|
||||
<div v-if="outlineData[activeKey] && outlineData[activeKey].length > 0" class="editor-outline-list">
|
||||
<a v-for="item in outlineData[activeKey]" :key="`outline-${item.id}`" :href="`#${item.id}`" :class="`editor-outline-item editor-outline-item-${item.depth}`" :title="item.text">
|
||||
{{ item.text }}
|
||||
</a>
|
||||
</div>
|
||||
<Empty v-else :image="simpleImage" />
|
||||
</TabPane>
|
||||
<TabPane key="image" tab="图片">
|
||||
<ImagePreviewGroup v-if="outlineData[activeKey] && outlineData[activeKey].length > 0" class="editor-image-list">
|
||||
<Image v-for="(item, index) in outlineData[activeKey]" :key="`image-${index}`" :class="`editor-image-item`" :src="item.src" />
|
||||
</ImagePreviewGroup>
|
||||
<Empty v-else :image="simpleImage" />
|
||||
</TabPane>
|
||||
<TabPane key="video" tab="视频">
|
||||
<div v-if="outlineData[activeKey] && outlineData[activeKey].length > 0" class="editor-video-list">
|
||||
<video
|
||||
v-for="(item, index) in outlineData[activeKey]"
|
||||
:key="`video-${index}`"
|
||||
:class="`editor-video-item`"
|
||||
preload="metadata"
|
||||
:src="item.src"
|
||||
webkit-playsinline="webkit-playsinline"
|
||||
playsinline
|
||||
controls
|
||||
></video>
|
||||
</div>
|
||||
<Empty v-else :image="simpleImage" />
|
||||
</TabPane>
|
||||
<TabPane key="audio" tab="音频">
|
||||
<div v-if="outlineData[activeKey] && outlineData[activeKey].length > 0" class="editor-audio-list">
|
||||
<audio
|
||||
v-for="(item, index) in outlineData[activeKey]"
|
||||
:key="`audio-${index}`"
|
||||
:class="`editor-audio-item`"
|
||||
preload="none"
|
||||
:src="item.src"
|
||||
webkit-playsinline="webkit-playsinline"
|
||||
playsinline
|
||||
controls
|
||||
></audio>
|
||||
</div>
|
||||
<Empty v-else :image="simpleImage" />
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted, ref, reactive, PropType } from 'vue';
|
||||
import { $, EngineInterface } from '@aomao/engine';
|
||||
import { Outline, OutlineData } from '@aomao/plugin-heading';
|
||||
import { throttle } from 'lodash';
|
||||
import { Tabs, TabPane, Image, ImagePreviewGroup, Empty } from 'ant-design-vue';
|
||||
|
||||
const outline = new Outline();
|
||||
|
||||
export default defineComponent({
|
||||
name: 'am-outline',
|
||||
components: { Tabs, TabPane, Image, ImagePreviewGroup, Empty },
|
||||
props: {
|
||||
engine: { type: Object as PropType<EngineInterface>, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
simpleImage: Empty.PRESENTED_IMAGE_SIMPLE,
|
||||
};
|
||||
},
|
||||
setup(props) {
|
||||
const { engine } = { ...props };
|
||||
const activeKey = ref<string>('outline');
|
||||
const outlineData = reactive<any>({
|
||||
outline: [],
|
||||
image: [],
|
||||
video: [],
|
||||
audio: [],
|
||||
});
|
||||
const readingSection = ref<number>(-1);
|
||||
onMounted(() => {
|
||||
handleChange();
|
||||
// 监听编辑器值改变事件
|
||||
engine.on('change', throttle(handleChange, 400));
|
||||
});
|
||||
|
||||
const handleChange = () => {
|
||||
const toc = getTocData(engine, activeKey.value);
|
||||
outlineData[activeKey.value] = toc;
|
||||
};
|
||||
|
||||
const getTocData = (editor: any, activeKey: string) => {
|
||||
const nodes: Array<Element> = [];
|
||||
const { card } = editor;
|
||||
switch (activeKey) {
|
||||
case 'outline':
|
||||
editor.container.find('h1,h2,h3,h4,h5,h6').each((child: any) => {
|
||||
const node = $(child);
|
||||
// Card 里的标题,不纳入大纲
|
||||
if (card.closest(node)) {
|
||||
return;
|
||||
}
|
||||
// 非一级深度标题,不纳入大纲
|
||||
if (!node.parent()?.isRoot()) {
|
||||
return;
|
||||
}
|
||||
nodes.push(node.get<Element>()!);
|
||||
});
|
||||
return outline.normalize(nodes);
|
||||
case 'image':
|
||||
editor.container.find('img').each((child: any) => {
|
||||
const node = $(child);
|
||||
// Card 里的图片
|
||||
if (card.closest(node) && node.parent()?.hasClass('data-image-meta')) {
|
||||
nodes.push(node.get<Element>()!);
|
||||
}
|
||||
});
|
||||
return nodes;
|
||||
case 'video':
|
||||
editor.container.find('video').each((child: any) => {
|
||||
const node = $(child);
|
||||
// Card 里的视频
|
||||
if (card.closest(node) && node.parent()?.hasClass('data-video-content')) {
|
||||
nodes.push(node.get<Element>()!);
|
||||
}
|
||||
});
|
||||
return nodes;
|
||||
case 'audio':
|
||||
editor.container.find('audio').each((child: any) => {
|
||||
const node = $(child);
|
||||
// Card 里的视频
|
||||
if (card.closest(node) && node.parent()?.hasClass('data-audio-content')) {
|
||||
nodes.push(node.get<Element>()!);
|
||||
}
|
||||
});
|
||||
return nodes;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
activeKey,
|
||||
outlineData,
|
||||
readingSection,
|
||||
handleChange,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.editor-outline {
|
||||
/* position: absolute;
|
||||
top: 0px;
|
||||
left: calc(100% + 10px); */
|
||||
width: 320px;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
}
|
||||
.editor-outline-item {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: inherit;
|
||||
}
|
||||
.editor-outline .editor-outline-item-active,
|
||||
.editor-outline .editor-outline-item:hover,
|
||||
.editor-outline .editor-outline-item:focus {
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.editor-outline .editor-outline-item-2 {
|
||||
padding-left: 16px;
|
||||
}
|
||||
.editor-outline .editor-outline-item-3 {
|
||||
padding-left: 32px;
|
||||
}
|
||||
.editor-outline .editor-outline-item-4 {
|
||||
padding-left: 48px;
|
||||
}
|
||||
.editor-outline .editor-outline-item-5 {
|
||||
padding-left: 64px;
|
||||
}
|
||||
.editor-outline .editor-outline-item-6 {
|
||||
padding-left: 80px;
|
||||
}
|
||||
.editor-video-list,
|
||||
.editor-image-list,
|
||||
.editor-audio-list {
|
||||
margin: -5px;
|
||||
}
|
||||
.editor-video-item,
|
||||
.editor-image-item,
|
||||
.editor-audio-item {
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
}
|
||||
</style>
|
||||
48
editor/src/main.ts
Normal file
48
editor/src/main.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import './publice-path'
|
||||
import { createApp, reactive } from 'vue';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import store from './store';
|
||||
import './assets/styles/index.less';
|
||||
import './assets/iconfont/iconfont.css';
|
||||
|
||||
|
||||
let instance: any = null;
|
||||
|
||||
|
||||
|
||||
function render(props: any = null) {
|
||||
instance = createApp(App);
|
||||
const token = JSON.parse(localStorage.getItem("token") || '')
|
||||
instance.config.globalProperties.$globalVariable = reactive({
|
||||
token,
|
||||
baseUrl: process.env.VUE_APP_BASE_URL,
|
||||
resourceBaseUrl: `${process.env.VUE_APP_BASE_URL}/file-bucket`,
|
||||
});
|
||||
instance.use(store).use(router);
|
||||
instance.mount(props ? props.container.querySelector('#app') : '#app')
|
||||
}
|
||||
// 独立运行时
|
||||
if (!window.__POWERED_BY_QIANKUN__) {
|
||||
const token =
|
||||
"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0NTRkYWMxMy1jZjVmLTQ3MTgtOWQ4MC00MjM5MzM5YWVhZDUiLCJhdXRoIjoxLCJiYXNlIjoxLCJvcmciOjEsImlhdCI6MTY5NTA5MDAwNiwiZXhwIjoxNjk1Njk0ODA2fQ.-ub4h04wpIY227ZJbamtuTv_XUjNG7YdX7VN6nMO4W4";
|
||||
localStorage.setItem('token', JSON.stringify(token));
|
||||
render();
|
||||
console.log('独立运行');
|
||||
}
|
||||
|
||||
// 抛出qiankun执行依赖----------START
|
||||
export async function bootstrap() {
|
||||
console.log('[vue] vue app bootstraped');
|
||||
}
|
||||
// 加载
|
||||
export async function mount(props: any) {
|
||||
render(props);
|
||||
}
|
||||
// 注销
|
||||
export async function unmount() {
|
||||
instance?.$destroy?.();
|
||||
instance && instance.$el && (instance.$el.innerHTML = '')
|
||||
instance = null;
|
||||
}
|
||||
// 抛出qiankun执行依赖----------END
|
||||
86
editor/src/plugins/audio/component/index.css
Normal file
86
editor/src/plugins/audio/component/index.css
Normal file
@@ -0,0 +1,86 @@
|
||||
[data-card-key="audio"] {
|
||||
outline: 1px solid #ddd;
|
||||
border-radius: 54px;
|
||||
}
|
||||
.data-audio-content {
|
||||
position: relative;
|
||||
height: 54px;
|
||||
background: #f7f7f7;
|
||||
}
|
||||
.data-audio-content audio {
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
.data-audio-uploading,
|
||||
.data-audio-uploaded,
|
||||
.data-audio-error {
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 54px;
|
||||
background: #f6f6f6;
|
||||
}
|
||||
.data-audio-done {
|
||||
height: auto;
|
||||
border: none;
|
||||
background: none;
|
||||
line-height: 0;
|
||||
}
|
||||
.data-audio-active {
|
||||
outline: 1px solid #d9d9d9;
|
||||
border-radius: 54px;
|
||||
}
|
||||
.data-audio-center {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
margin-top: -48px;
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
}
|
||||
.data-audio-center .data-audio-icon,
|
||||
.data-audio-center .data-audio-name,
|
||||
.data-audio-center .data-audio-message,
|
||||
.data-audio-center .data-audio-progress,
|
||||
.data-audio-center .data-audio-transcoding {
|
||||
text-align: center;
|
||||
}
|
||||
.data-audio-center .data-audio-icon {
|
||||
font-size: 24px;
|
||||
color: #BFBFBF;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.data-audio-center .data-audio-name {
|
||||
color: #595959;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.data-audio-center .data-audio-message {
|
||||
color: #595959;
|
||||
}
|
||||
.data-audio-center .data-audio-anticon {
|
||||
display: inline-block;
|
||||
font-style: normal;
|
||||
vertical-align: -0.125em;
|
||||
text-align: center;
|
||||
text-transform: none;
|
||||
line-height: 0;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.data-audio-center .data-audio-anticon .data-audio-anticon-spin {
|
||||
display: inline-block;
|
||||
-webkit-animation: loadingCircle 1s infinite linear;
|
||||
animation: loadingCircle 1s infinite linear;
|
||||
}
|
||||
.data-audio-center .data-error-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-block;
|
||||
background: #F5222D;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
padding: 1px 0 0 0;
|
||||
line-height: 16px;
|
||||
border-radius: 100%;
|
||||
vertical-align: middle;
|
||||
margin: -2px 5px 0 0;
|
||||
}
|
||||
375
editor/src/plugins/audio/component/index.ts
Normal file
375
editor/src/plugins/audio/component/index.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { CardValue, Tooltip } from '@aomao/engine';
|
||||
import { $, Card, CardToolbarItemOptions, CardType, escape, getFileSize, isEngine, isMobile, NodeInterface, sanitizeUrl, ToolbarItemOptions } from '@aomao/engine';
|
||||
import './index.css';
|
||||
|
||||
export interface AudioValue extends CardValue {
|
||||
/**
|
||||
* 音频唯一标识
|
||||
*/
|
||||
audio_id?: string;
|
||||
/**
|
||||
* 音频名称
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* 音频地址
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* 下载地址
|
||||
*/
|
||||
download?: string;
|
||||
/**
|
||||
* 状态
|
||||
* uploading 上传中
|
||||
* done 上传成功
|
||||
*/
|
||||
status?: 'uploading' | 'transcoding' | 'done' | 'error';
|
||||
/**
|
||||
* 上传进度
|
||||
*/
|
||||
percent?: number;
|
||||
/**
|
||||
* 音频大小
|
||||
*/
|
||||
size?: number;
|
||||
/**
|
||||
* 错误状态下的错误信息
|
||||
*/
|
||||
message?: string;
|
||||
}
|
||||
|
||||
class AudioComponent<V extends AudioValue = AudioValue> extends Card<V> {
|
||||
static get cardName() {
|
||||
return 'audio';
|
||||
}
|
||||
|
||||
static get cardType() {
|
||||
return CardType.BLOCK;
|
||||
}
|
||||
|
||||
static get autoSelected() {
|
||||
return false;
|
||||
}
|
||||
|
||||
private container?: NodeInterface;
|
||||
|
||||
getLocales() {
|
||||
return this.editor.language.get<{ [key: string]: string }>('audio');
|
||||
}
|
||||
|
||||
renderTemplate(value: AudioValue) {
|
||||
const { name, status, size, message, percent } = value;
|
||||
const locales = this.getLocales();
|
||||
|
||||
const icons = {
|
||||
audio: `<div class="data-audio-icon">
|
||||
<svg width="32px" height="24px" viewBox="0 0 32 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" fill-opacity="0.25"><g transform="translate(-704.000000, -550.000000)" fill="#000000" fill-rule="nonzero"> <g transform="translate(704.000000, 550.000000)"> <g> <path d="M13.09375,17.30625 L20.65625,12.375 C20.95,12.16875 20.95,11.809375 20.65625,11.603125 L13.09375,6.696875 C12.66875,6.4 12,6.6375 12,7.084375 L12,16.921875 C12,17.365625 12.671875,17.603125 13.09375,17.30625 Z" id="Path"></path> <path d="M30,0 L2,0 C0.896875,0 0,0.896875 0,2 L0,22 C0,23.103125 0.896875,24 2,24 L30,24 C31.103125,24 32,23.103125 32,22 L32,2 C32,0.896875 31.103125,0 30,0 Z M5.25,21.25 C5.25,21.525 5.025,21.75 4.75,21.75 L2.5,21.75 C2.225,21.75 2,21.525 2,21.25 L2,18.5 C2,18.225 2.225,18 2.5,18 L4.75,18 C5.025,18 5.25,18.225 5.25,18.5 L5.25,21.25 Z M5.25,13.375 C5.25,13.65 5.025,13.875 4.75,13.875 L2.5,13.875 C2.225,13.875 2,13.65 2,13.375 L2,10.625 C2,10.35 2.225,10.125 2.5,10.125 L4.75,10.125 C5.025,10.125 5.25,10.35 5.25,10.625 L5.25,13.375 Z M5.25,5.5 C5.25,5.775 5.025,6 4.75,6 L2.5,6 C2.225,6 2,5.775 2,5.5 L2,2.75 C2,2.475 2.225,2.25 2.5,2.25 L4.75,2.25 C5.025,2.25 5.25,2.475 5.25,2.75 L5.25,5.5 Z M24.75,21.75 L7.25,21.75 L7.25,2.25 L24.75,2.25 L24.75,21.75 Z M30,21.25 C30,21.525 29.775,21.75 29.5,21.75 L27.25,21.75 C26.975,21.75 26.75,21.525 26.75,21.25 L26.75,18.5 C26.75,18.225 26.975,18 27.25,18 L29.5,18 C29.775,18 30,18.225 30,18.5 L30,21.25 Z M30,13.375 C30,13.65 29.775,13.875 29.5,13.875 L27.25,13.875 C26.975,13.875 26.75,13.65 26.75,13.375 L26.75,10.625 C26.75,10.35 26.975,10.125 27.25,10.125 L29.5,10.125 C29.775,10.125 30,10.35 30,10.625 L30,13.375 Z M30,5.5 C30,5.775 29.775,6 29.5,6 L27.25,6 C26.975,6 26.75,5.775 26.75,5.5 L26.75,2.75 C26.75,2.475 26.975,2.25 27.25,2.25 L29.5,2.25 C29.775,2.25 30,2.475 30,2.75 L30,5.5 Z" id="Shape"></path> </g> </g></g> </g></svg></div>`,
|
||||
spin: `<i class="data-audio-anticon"><svg viewBox="0 0 1024 1024" class="data-audio-anticon-spin" data-icon="loading" width="1em" height="1em" fill="currentColor" aria-hidden="true"> <path d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 0 0-94.3-139.9 437.71 437.71 0 0 0-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z"></path></svg></i>`,
|
||||
warn: `<div class="data-audio-icon"><svg width="41px" height="29px" viewBox="0 0 41 29" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"><g transform="translate(-704.000000, -550.000000)"> <g id="Group-2" transform="translate(704.000000, 550.000000)"> <g id="audio" fill="#000000" fill-rule="nonzero" opacity="0.449999988"> <path d="M13.09375,17.30625 C12.671875,17.603125 12,17.365625 12,16.921875 L12,7.084375 C12,6.6375 12.66875,6.4 13.09375,6.696875 L20.65625,11.603125 C20.95,11.809375 20.95,12.16875 20.65625,12.375 L13.09375,17.30625 Z M30,0 C31.103125,0 32,0.896875 32,2 L32,22 C32,23.103125 31.103125,24 30,24 L2,24 C0.896875,24 0,23.103125 0,22 L0,2 C0,0.896875 0.896875,0 2,0 L30,0 Z M5.25,21.25 L5.25,18.5 C5.25,18.225 5.025,18 4.75,18 L2.5,18 C2.225,18 2,18.225 2,18.5 L2,21.25 C2,21.525 2.225,21.75 2.5,21.75 L4.75,21.75 C5.025,21.75 5.25,21.525 5.25,21.25 Z M5.25,13.375 L5.25,10.625 C5.25,10.35 5.025,10.125 4.75,10.125 L2.5,10.125 C2.225,10.125 2,10.35 2,10.625 L2,13.375 C2,13.65 2.225,13.875 2.5,13.875 L4.75,13.875 C5.025,13.875 5.25,13.65 5.25,13.375 Z M5.25,5.5 L5.25,2.75 C5.25,2.475 5.025,2.25 4.75,2.25 L2.5,2.25 C2.225,2.25 2,2.475 2,2.75 L2,5.5 C2,5.775 2.225,6 2.5,6 L4.75,6 C5.025,6 5.25,5.775 5.25,5.5 Z M24.75,21.75 L24.75,2.25 L7.25,2.25 L7.25,21.75 L24.75,21.75 Z M30,21.25 L30,18.5 C30,18.225 29.775,18 29.5,18 L27.25,18 C26.975,18 26.75,18.225 26.75,18.5 L26.75,21.25 C26.75,21.525 26.975,21.75 27.25,21.75 L29.5,21.75 C29.775,21.75 30,21.525 30,21.25 Z M30,13.375 L30,10.625 C30,10.35 29.775,10.125 29.5,10.125 L27.25,10.125 C26.975,10.125 26.75,10.35 26.75,10.625 L26.75,13.375 C26.75,13.65 26.975,13.875 27.25,13.875 L29.5,13.875 C29.775,13.875 30,13.65 30,13.375 Z M30,5.5 L30,2.75 C30,2.475 29.775,2.25 29.5,2.25 L27.25,2.25 C26.975,2.25 26.75,2.475 26.75,2.75 L26.75,5.5 C26.75,5.775 26.975,6 27.25,6 L29.5,6 C29.775,6 30,5.775 30,5.5 Z" id="Combined-Shape"></path> </g> <g id="error-fill" transform="translate(21.000000, 10.000000)"> <rect id="Rectangle" fill="#000000" opacity="0" x="0" y="0" width="20" height="20"></rect> <path d="M19.0267927,16.510301 L19.0272631,16.5111171 C19.4269215,17.2064579 18.9263267,18.0729167 18.125,18.0729167 L1.875,18.0729167 C1.07367326,18.0729167 0.573078461,17.2064579 0.973207261,16.510301 L9.0970084,2.44988987 C9.28650026,2.11750251 9.63068515,1.92708333 10,1.92708333 C10.368224,1.92708333 10.7098796,2.11659543 10.9017927,2.447801 L19.0267927,16.510301 Z" id="Path" stroke="#FFFFFF" stroke-width="0.833333333" fill="#FFFFFF"></path> <path d="M18.6660156,16.71875 L10.5410156,2.65625 C10.4199219,2.44726562 10.2109375,2.34375 10,2.34375 C9.7890625,2.34375 9.578125,2.44726562 9.45898438,2.65625 L1.33398438,16.71875 C1.09375,17.1367188 1.39453125,17.65625 1.875,17.65625 L18.125,17.65625 C18.6054688,17.65625 18.90625,17.1367188 18.6660156,16.71875 Z M9.375,8.125 C9.375,8.0390625 9.4453125,7.96875 9.53125,7.96875 L10.46875,7.96875 C10.5546875,7.96875 10.625,8.0390625 10.625,8.125 L10.625,11.71875 C10.625,11.8046875 10.5546875,11.875 10.46875,11.875 L9.53125,11.875 C9.4453125,11.875 9.375,11.8046875 9.375,11.71875 L9.375,8.125 Z M10,15 C9.48242188,15 9.0625,14.5800781 9.0625,14.0625 C9.0625,13.5449219 9.48242188,13.125 10,13.125 C10.5175781,13.125 10.9375,13.5449219 10.9375,14.0625 C10.9375,14.5800781 10.5175781,15 10,15 Z" id="Shape" fill="#FAAD14" fill-rule="nonzero"></path></g></g></g> </g></svg></div>`,
|
||||
error: '<span class="data-error-icon">X</span>',
|
||||
};
|
||||
|
||||
if (status === 'error') {
|
||||
return `
|
||||
<div class="data-audio">
|
||||
<div class="data-audio-content data-audio-error">
|
||||
<div class="data-audio-center">
|
||||
<div class="data-audio-name">${escape(name)}</div>
|
||||
<div class="data-audio-message">
|
||||
${icons.error}
|
||||
${message || locales['loadError']}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const fileSize: string = size ? getFileSize(size) : '';
|
||||
|
||||
if (status === 'uploading') {
|
||||
return `
|
||||
<div class="data-audio">
|
||||
<div class="data-audio-content data-audio-uploading">
|
||||
<div class="data-audio-center">
|
||||
${icons.audio}
|
||||
<div class="data-audio-name">
|
||||
${escape(name)} (${escape(fileSize)})
|
||||
</div>
|
||||
<div class="data-audio-progress">
|
||||
${icons.spin}
|
||||
<span class="percent">${percent || 0}%<span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
const isLoading = typeof status === 'undefined';
|
||||
if (status === 'transcoding' || isLoading) {
|
||||
return `
|
||||
<div class="data-audio">
|
||||
<div class="data-audio-content data-audio-uploaded">
|
||||
<div class="data-audio-center">
|
||||
${icons.audio}
|
||||
<div class="data-audio-name">
|
||||
${escape(name)} (${escape(fileSize)})
|
||||
</div>
|
||||
<div class="data-audio-transcoding">
|
||||
${icons.spin}
|
||||
<span class="transcoding">${isLoading ? locales['loading'] : locales['transcoding']}%<span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="data-audio">
|
||||
<div class="data-audio-content data-audio-done"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
onBeforeRender = (action: 'query' | 'download' | 'cover', url: string) => {
|
||||
const audioPlugin = this.editor.plugin.components['audio'] as any;
|
||||
if (audioPlugin) {
|
||||
const { onBeforeRender } = audioPlugin['options'] || {};
|
||||
if (onBeforeRender) return onBeforeRender(action, url);
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
initPlayer() {
|
||||
const value = this.getValue();
|
||||
if (!value) return;
|
||||
|
||||
const url = sanitizeUrl(this.onBeforeRender('query', value.url));
|
||||
const audio = document.createElement('audio');
|
||||
audio.preload = 'none';
|
||||
audio.setAttribute('src', url);
|
||||
audio.setAttribute('webkit-playsinline', 'webkit-playsinline');
|
||||
audio.setAttribute('playsinline', 'playsinline');
|
||||
|
||||
this.container?.find('.data-audio-content').append(audio);
|
||||
|
||||
audio.oncontextmenu = function () {
|
||||
return false;
|
||||
};
|
||||
// 一次渲染时序开启 controls 会触发一次内容为空的 window.onerror,疑似 chrome bug
|
||||
setTimeout(() => {
|
||||
audio.controls = true;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
downloadFile = () => {
|
||||
const value = this.getValue();
|
||||
if (!value?.download) return;
|
||||
window.open(sanitizeUrl(this.onBeforeRender('download', value.url)));
|
||||
};
|
||||
|
||||
toolbar() {
|
||||
const items: Array<CardToolbarItemOptions | ToolbarItemOptions> = [];
|
||||
const value = this.getValue();
|
||||
if (!value) return items;
|
||||
const { status, download } = value;
|
||||
const locale = this.getLocales();
|
||||
|
||||
if (status === 'done') {
|
||||
if (download) {
|
||||
items.push({
|
||||
type: 'button',
|
||||
content: '<span class="data-icon data-icon-download" />',
|
||||
title: locale.download,
|
||||
onClick: this.downloadFile,
|
||||
});
|
||||
}
|
||||
|
||||
if (isEngine(this.editor) && !this.editor.readonly) {
|
||||
items.push({
|
||||
type: 'copy',
|
||||
});
|
||||
items.push({
|
||||
type: 'separator',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isEngine(this.editor) && !this.editor.readonly) {
|
||||
items.push({
|
||||
type: 'delete',
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
setProgressPercent(percent: number) {
|
||||
this.container?.find('.percent').html(`${percent}%`);
|
||||
}
|
||||
|
||||
onActivate(activated: boolean) {
|
||||
if (activated) this.container?.addClass('data-audio-active');
|
||||
else this.container?.removeClass('data-audio-active');
|
||||
}
|
||||
|
||||
checker(audio_id: string, success: (data?: { url: string; name?: string; cover?: string; download?: string; status?: string }) => void, failed: (message: string) => void) {
|
||||
const { command } = this.editor;
|
||||
const handle = () => {
|
||||
command.executeMethod(
|
||||
'audio-uploader',
|
||||
'query',
|
||||
audio_id,
|
||||
(data?: { url: string; name?: string; cover?: string; download?: string; status?: string }) => {
|
||||
if (data && data.status !== 'done') setTimeout(handle, 3000);
|
||||
else success(data);
|
||||
},
|
||||
(message: string) => {
|
||||
failed(message);
|
||||
}
|
||||
);
|
||||
};
|
||||
handle();
|
||||
}
|
||||
|
||||
render(): string | void | NodeInterface {
|
||||
const value = this.getValue();
|
||||
if (!value) return;
|
||||
const center = this.getCenter();
|
||||
//先清空卡片内容容器
|
||||
center.empty();
|
||||
const { command, plugin } = this.editor;
|
||||
const { audio_id, status } = value;
|
||||
const locales = this.getLocales();
|
||||
//阅读模式
|
||||
if (!isEngine(this.editor)) {
|
||||
if (status === 'done') {
|
||||
//设置为加载状态
|
||||
this.container = $(this.renderTemplate({ ...value, status: undefined }));
|
||||
const updateValue = (data?: { url: string; name?: string; cover?: string; download?: string }) => {
|
||||
const newValue: AudioValue = {
|
||||
...value,
|
||||
url: data?.url ? data.url : value.url,
|
||||
name: data?.name ? data.name : value.name,
|
||||
download: data?.download ? data.download : value.download,
|
||||
};
|
||||
this.container = $(this.renderTemplate(newValue));
|
||||
center.empty();
|
||||
center.append(this.container);
|
||||
this.initPlayer();
|
||||
};
|
||||
if (plugin.components['audio-uploader']) {
|
||||
command.executeMethod(
|
||||
'audio-uploader',
|
||||
'query',
|
||||
audio_id,
|
||||
(data?: { url: string; name?: string; cover?: string; download?: string }) => {
|
||||
updateValue(data);
|
||||
},
|
||||
(error: string) => {
|
||||
this.container = $(
|
||||
this.renderTemplate({
|
||||
...value,
|
||||
status: 'error',
|
||||
message: error || locales['loadError'],
|
||||
})
|
||||
);
|
||||
center.empty();
|
||||
center.append(this.container);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
updateValue();
|
||||
}
|
||||
return this.container;
|
||||
} else if (status === 'error') {
|
||||
return $(
|
||||
this.renderTemplate({
|
||||
...value,
|
||||
message: value.message || locales['loadError'],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
//转换中
|
||||
else if (status === 'transcoding') {
|
||||
this.container = $(this.renderTemplate(value));
|
||||
if (!audio_id) throw 'audio id is undefined';
|
||||
this.checker(
|
||||
audio_id,
|
||||
(data?: { url: string; name?: string; cover?: string; download?: string; status?: string }) => {
|
||||
const newValue: V = {
|
||||
...value,
|
||||
url: data?.url ? data.url : value.url,
|
||||
name: data?.name ? data.name : value.name,
|
||||
download: data?.download ? data.download : value.download,
|
||||
status: 'done',
|
||||
};
|
||||
this.setValue(newValue);
|
||||
this.container = $(this.renderTemplate(newValue));
|
||||
center.empty();
|
||||
center.append(this.container);
|
||||
this.initPlayer();
|
||||
},
|
||||
(error: string) => {
|
||||
const newValue: V = {
|
||||
...value,
|
||||
status: 'error',
|
||||
message: error || locales['loadError'],
|
||||
};
|
||||
this.setValue(newValue);
|
||||
this.container = $(this.renderTemplate(newValue));
|
||||
center.empty();
|
||||
center.append(this.container);
|
||||
}
|
||||
);
|
||||
return this.container;
|
||||
}
|
||||
//已完成
|
||||
else if (status === 'done') {
|
||||
//设置为加载状态
|
||||
this.container = $(this.renderTemplate({ ...value, status: undefined }));
|
||||
command.executeMethod(
|
||||
'audio-uploader',
|
||||
'query',
|
||||
audio_id,
|
||||
(data?: { url: string; name?: string; cover?: string; download?: string }) => {
|
||||
const newValue: AudioValue = {
|
||||
...value,
|
||||
url: data?.url ? data.url : value.url,
|
||||
name: data?.name ? data.name : value.name,
|
||||
download: data?.download ? data.download : value.download,
|
||||
};
|
||||
this.container = $(this.renderTemplate(newValue));
|
||||
center.empty();
|
||||
center.append(this.container);
|
||||
this.initPlayer();
|
||||
},
|
||||
(error: string) => {
|
||||
this.container = $(
|
||||
this.renderTemplate({
|
||||
...value,
|
||||
status: 'error',
|
||||
message: error || locales['loadError'],
|
||||
})
|
||||
);
|
||||
center.empty();
|
||||
center.append(this.container);
|
||||
}
|
||||
);
|
||||
return this.container;
|
||||
} else {
|
||||
return $(this.renderTemplate(value));
|
||||
}
|
||||
}
|
||||
|
||||
didRender() {
|
||||
super.didRender();
|
||||
this.container?.on(isMobile ? 'touchstart' : 'click', () => {
|
||||
if (isEngine(this.editor) && !this.activated) {
|
||||
this.editor.card.activate(this.root);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default AudioComponent;
|
||||
138
editor/src/plugins/audio/index.ts
Normal file
138
editor/src/plugins/audio/index.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { $, CardEntry, CardInterface, CARD_KEY, decodeCardValue, encodeCardValue, isEngine, NodeInterface, Plugin, PluginEntry, PluginOptions, sanitizeUrl, SchemaInterface } from '@aomao/engine';
|
||||
import AudioComponent, { AudioValue } from './component';
|
||||
import AudioUploader from './uploader';
|
||||
import locales from './locales';
|
||||
|
||||
export interface AudioOptions extends PluginOptions {
|
||||
onBeforeRender?: (action: 'download' | 'query' | 'cover', url: string) => string;
|
||||
}
|
||||
export default class AudioPlugin<T extends AudioOptions = AudioOptions> extends Plugin<T> {
|
||||
static get pluginName() {
|
||||
return 'audio';
|
||||
}
|
||||
|
||||
init() {
|
||||
this.editor.language.add(locales);
|
||||
if (!isEngine(this.editor)) return;
|
||||
this.editor.on('parse:html', node => this.parseHtml(node));
|
||||
this.editor.on('paste:each', child => this.pasteHtml(child));
|
||||
this.editor.on('paste:schema', (schema: SchemaInterface) => this.pasteSchema(schema));
|
||||
}
|
||||
|
||||
execute(status: 'uploading' | 'transcoding' | 'done' | 'error', url: string, name?: string, audio_id?: string, size?: number, download?: string): void {
|
||||
const value: AudioValue = {
|
||||
status,
|
||||
audio_id,
|
||||
url,
|
||||
name: name || url,
|
||||
size,
|
||||
download,
|
||||
};
|
||||
if (status === 'error') {
|
||||
value.url = '';
|
||||
value.message = url;
|
||||
}
|
||||
this.editor.card.insert('audio', value);
|
||||
}
|
||||
|
||||
async waiting(callback?: (name: string, card?: CardInterface, ...args: any) => boolean | number | void): Promise<void> {
|
||||
const { card } = this.editor;
|
||||
// 检测单个组件
|
||||
const check = (component: CardInterface) => {
|
||||
return component.root.inEditor() && (component.constructor as CardEntry).cardName === AudioComponent.cardName && (component as AudioComponent<AudioValue>).getValue()?.status === 'uploading';
|
||||
};
|
||||
// 找到不合格的组件
|
||||
const find = (): CardInterface | undefined => {
|
||||
return card.components.find(check);
|
||||
};
|
||||
const waitCheck = (component: CardInterface): Promise<void> => {
|
||||
let time = 60000;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (callback) {
|
||||
const result = callback((this.constructor as PluginEntry).pluginName, component);
|
||||
if (result === false) {
|
||||
return reject({
|
||||
name: (this.constructor as PluginEntry).pluginName,
|
||||
card: component,
|
||||
});
|
||||
} else if (typeof result === 'number') {
|
||||
time = result;
|
||||
}
|
||||
}
|
||||
const beginTime = new Date().getTime();
|
||||
const now = new Date().getTime();
|
||||
const timeout = () => {
|
||||
if (now - beginTime >= time) return resolve();
|
||||
setTimeout(() => {
|
||||
if (check(component)) timeout();
|
||||
else resolve();
|
||||
}, 10);
|
||||
};
|
||||
timeout();
|
||||
});
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const component = find();
|
||||
const wait = (component: CardInterface) => {
|
||||
waitCheck(component)
|
||||
.then(() => {
|
||||
const next = find();
|
||||
if (next) wait(next);
|
||||
else resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
};
|
||||
if (component) wait(component);
|
||||
else resolve();
|
||||
});
|
||||
}
|
||||
|
||||
pasteSchema(schema: SchemaInterface) {
|
||||
schema.add({
|
||||
type: 'block',
|
||||
name: 'div',
|
||||
attributes: {
|
||||
'data-value': '*',
|
||||
'data-type': {
|
||||
required: true,
|
||||
value: AudioComponent.cardName,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
pasteHtml(node: NodeInterface) {
|
||||
if (!isEngine(this.editor)) return;
|
||||
if (node.isElement()) {
|
||||
const type = node.attributes('data-type');
|
||||
if (type === AudioComponent.cardName) {
|
||||
const value = node.attributes('data-value');
|
||||
const cardValue = decodeCardValue(value) as AudioValue;
|
||||
if (!cardValue.url) return;
|
||||
this.editor.card.replaceNode(node, AudioComponent.cardName, cardValue);
|
||||
node.remove();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
parseHtml(root: NodeInterface) {
|
||||
root.find(`[${CARD_KEY}=${AudioComponent.cardName}`).each(cardNode => {
|
||||
const node = $(cardNode);
|
||||
const card = this.editor.card.find<AudioValue>(node);
|
||||
const value = card?.getValue();
|
||||
if (value?.url && value.status === 'done') {
|
||||
const { onBeforeRender } = this.options;
|
||||
const { url } = value;
|
||||
const html = `<div data-type="${AudioComponent.cardName}" data-value="${encodeCardValue(value)}"><audio controls src="${sanitizeUrl(
|
||||
onBeforeRender ? onBeforeRender('query', url) : url
|
||||
)}" webkit-playsinline="webkit-playsinline" playsinline="playsinline" style="outline:none;" /></div>`;
|
||||
node.empty();
|
||||
node.replaceWith($(html));
|
||||
} else node.remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { AudioComponent, AudioUploader };
|
||||
12
editor/src/plugins/audio/locales/en-US.ts
Normal file
12
editor/src/plugins/audio/locales/en-US.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export default {
|
||||
audio: {
|
||||
errorMessageCopy: 'Copy error message',
|
||||
loadError: 'The audio failed to load!',
|
||||
uploadError: 'The audio failed to upload!',
|
||||
uploadLimitError: 'Upload audio size is limited to $size',
|
||||
download: 'Download',
|
||||
preview: 'Preview',
|
||||
loading: 'Loading...',
|
||||
transcoding: 'Transcoding...',
|
||||
},
|
||||
};
|
||||
7
editor/src/plugins/audio/locales/index.ts
Normal file
7
editor/src/plugins/audio/locales/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import en from './en-US';
|
||||
import cn from './zh-CN';
|
||||
|
||||
export default {
|
||||
'en-US': en,
|
||||
'zh-CN': cn,
|
||||
};
|
||||
12
editor/src/plugins/audio/locales/zh-CN.ts
Normal file
12
editor/src/plugins/audio/locales/zh-CN.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export default {
|
||||
audio: {
|
||||
errorMessageCopy: '复制错误信息',
|
||||
loadError: '音频加载失败!',
|
||||
uploadError: '上传音频失败!',
|
||||
uploadLimitError: '上传音频大小限制为 $size',
|
||||
download: '下载',
|
||||
preview: '预览',
|
||||
loading: '加载中...',
|
||||
transcoding: '转码中...',
|
||||
},
|
||||
};
|
||||
376
editor/src/plugins/audio/uploader.ts
Normal file
376
editor/src/plugins/audio/uploader.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import {
|
||||
File,
|
||||
isAndroid,
|
||||
isEngine,
|
||||
NodeInterface,
|
||||
Plugin,
|
||||
READY_CARD_KEY,
|
||||
getExtensionName,
|
||||
PluginOptions,
|
||||
CARD_VALUE_KEY,
|
||||
decodeCardValue,
|
||||
encodeCardValue,
|
||||
RequestData,
|
||||
RequestHeaders,
|
||||
} from '@aomao/engine';
|
||||
|
||||
import AudioComponent, { AudioValue } from './component';
|
||||
|
||||
export interface Options extends PluginOptions {
|
||||
/**
|
||||
* 音频上传地址
|
||||
*/
|
||||
action: string;
|
||||
/**
|
||||
* 数据返回类型,默认 json
|
||||
*/
|
||||
type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js';
|
||||
/**
|
||||
* 音频文件上传时 FormData 的名称,默认 file
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* 额外携带数据上传
|
||||
*/
|
||||
data?: RequestData;
|
||||
/**
|
||||
* 请求类型,默认 multipart/form-data;
|
||||
*/
|
||||
contentType?: string;
|
||||
/**
|
||||
* 是否跨域
|
||||
*/
|
||||
crossOrigin?: boolean;
|
||||
/**
|
||||
* 请求头
|
||||
*/
|
||||
headers?: RequestHeaders;
|
||||
/**
|
||||
* 文件接收的格式,默认 "*"
|
||||
*/
|
||||
accept?: string | Array<string>;
|
||||
/**
|
||||
* 文件选择限制数量
|
||||
*/
|
||||
multiple?: boolean | number;
|
||||
/**
|
||||
* 上传大小限制,默认 1024 * 1024 * 5 就是5M
|
||||
*/
|
||||
limitSize?: number;
|
||||
/**
|
||||
* 解析上传后的Respone,返回 result:是否成功,data:成功:{id:音频唯一标识,url:音频地址},失败:错误信息
|
||||
*/
|
||||
parse?: (response: any) => {
|
||||
result: boolean;
|
||||
data:
|
||||
| {
|
||||
url: string;
|
||||
id?: string;
|
||||
status?: 'uploading' | 'transcoding' | 'done' | 'error';
|
||||
}
|
||||
| string;
|
||||
};
|
||||
/**
|
||||
* 查询地址
|
||||
*/
|
||||
query?: {
|
||||
/**
|
||||
* 查询地址
|
||||
*/
|
||||
action: string;
|
||||
/**
|
||||
* 数据返回类型,默认 json
|
||||
*/
|
||||
type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js';
|
||||
/**
|
||||
* 额外携带数据上传
|
||||
*/
|
||||
data?: RequestData;
|
||||
/**
|
||||
* 请求类型,默认 multipart/form-data;
|
||||
*/
|
||||
contentType?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default class extends Plugin<Options> {
|
||||
private cardComponents: { [key: string]: AudioComponent } = {};
|
||||
|
||||
static get pluginName() {
|
||||
return 'audio-uploader';
|
||||
}
|
||||
|
||||
extensionNames = ['mp3'];
|
||||
|
||||
init() {
|
||||
if (isEngine(this.editor)) {
|
||||
this.editor.on('drop:files', files => this.dropFiles(files));
|
||||
this.editor.on('paste:event', ({ files }) => this.pasteFiles(files));
|
||||
this.editor.on('paste:each', node => this.pasteEach(node));
|
||||
}
|
||||
let { accept } = this.options;
|
||||
const names: Array<string> = [];
|
||||
if (typeof accept === 'string') accept = accept.split(',');
|
||||
|
||||
(accept || []).forEach(name => {
|
||||
name = name.trim();
|
||||
const newName = name.split('.').pop();
|
||||
if (newName) names.push(newName);
|
||||
});
|
||||
if (names.length > 0) this.extensionNames = names;
|
||||
}
|
||||
|
||||
isAudio(file: File) {
|
||||
const name = getExtensionName(file);
|
||||
return this.extensionNames.indexOf(name) >= 0;
|
||||
}
|
||||
|
||||
async execute(files?: Array<File> | MouseEvent | string, ...args: any) {
|
||||
if (typeof files === 'string') {
|
||||
switch (files) {
|
||||
case 'query':
|
||||
return this.query(args[0], args[1], args[2]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { request, card, language } = this.editor;
|
||||
const { action, data, type, contentType, multiple, crossOrigin, headers, name } = this.options;
|
||||
const { parse } = this.options;
|
||||
const limitSize = this.options.limitSize || 5 * 1024 * 1024;
|
||||
if (!Array.isArray(files)) {
|
||||
files = await request.getFiles({
|
||||
event: files,
|
||||
accept: isAndroid ? 'audio/*' : this.extensionNames.length > 0 ? '.' + this.extensionNames.join(',.') : '',
|
||||
multiple,
|
||||
});
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
request.upload(
|
||||
{
|
||||
url: action,
|
||||
data,
|
||||
type,
|
||||
contentType,
|
||||
crossOrigin,
|
||||
headers,
|
||||
onBefore: file => {
|
||||
if (file.size > limitSize) {
|
||||
this.editor.messageError(
|
||||
'upload-limit',
|
||||
language
|
||||
.get('audio', 'uploadLimitError')
|
||||
.toString()
|
||||
.replace('$size', (limitSize / 1024 / 1024).toFixed(0) + 'M')
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
onReady: fileInfo => {
|
||||
if (!isEngine(this.editor) || this.cardComponents[fileInfo.uid]) return;
|
||||
const component = card.insert<AudioValue>('audio', {
|
||||
status: 'uploading',
|
||||
name: fileInfo.name,
|
||||
size: fileInfo.size,
|
||||
}) as AudioComponent;
|
||||
this.cardComponents[fileInfo.uid] = component;
|
||||
},
|
||||
onUploading: (file, { percent }) => {
|
||||
const component = this.cardComponents[file.uid || ''];
|
||||
if (!component) return;
|
||||
component.setProgressPercent(percent);
|
||||
},
|
||||
onSuccess: (response, file) => {
|
||||
const component = this.cardComponents[file.uid || ''];
|
||||
if (!component) return;
|
||||
const id: string = response.id || (response.data && response.data.id);
|
||||
const url: string = response.url || (response.data && response.data.url);
|
||||
const cover: string = response.cover || (response.data && response.data.cover);
|
||||
const download: string = response.download || (response.data && response.data.download);
|
||||
let status: 'uploading' | 'transcoding' | 'done' | 'error' = response.status || (response.data && response.data.status);
|
||||
status = status === 'transcoding' ? 'transcoding' : 'done';
|
||||
let result: {
|
||||
result: boolean;
|
||||
data:
|
||||
| {
|
||||
url: string;
|
||||
audio_id?: string;
|
||||
cover?: string;
|
||||
download?: string;
|
||||
status?: 'uploading' | 'transcoding' | 'done' | 'error';
|
||||
}
|
||||
| string;
|
||||
} = {
|
||||
result: true,
|
||||
data: {
|
||||
audio_id: id,
|
||||
url,
|
||||
cover,
|
||||
download,
|
||||
status,
|
||||
},
|
||||
};
|
||||
if (parse) {
|
||||
const customizeResult = parse(response);
|
||||
if (customizeResult.result) {
|
||||
let data = result.data as {
|
||||
url: string;
|
||||
audio_id?: string;
|
||||
cover?: string;
|
||||
download?: string;
|
||||
status?: 'uploading' | 'transcoding' | 'done' | 'error';
|
||||
};
|
||||
if (typeof customizeResult.data === 'string')
|
||||
result.data = {
|
||||
...data,
|
||||
url: customizeResult.data,
|
||||
};
|
||||
else {
|
||||
data.url = customizeResult.data.url;
|
||||
if (customizeResult.data.status !== undefined)
|
||||
data = {
|
||||
...data,
|
||||
status: customizeResult.data.status,
|
||||
};
|
||||
if (customizeResult.data.id !== undefined)
|
||||
data = {
|
||||
...data,
|
||||
audio_id: customizeResult.data.id,
|
||||
};
|
||||
result.data = { ...data };
|
||||
}
|
||||
} else {
|
||||
result = {
|
||||
result: false,
|
||||
data: customizeResult.data.toString(),
|
||||
};
|
||||
}
|
||||
} else if (!url) {
|
||||
result = { result: false, data: response.data };
|
||||
}
|
||||
//失败
|
||||
if (!result.result) {
|
||||
card.update<AudioValue>(component.id, {
|
||||
status: 'error',
|
||||
message: (result.data as string) || this.editor.language.get<string>('audio', 'uploadError'),
|
||||
});
|
||||
}
|
||||
//成功
|
||||
else {
|
||||
this.editor.card.update<AudioValue>(
|
||||
component.id,
|
||||
typeof result.data === 'string'
|
||||
? { url: result.data }
|
||||
: {
|
||||
...result.data,
|
||||
}
|
||||
);
|
||||
}
|
||||
delete this.cardComponents[file.uid || ''];
|
||||
},
|
||||
onError: (error, file) => {
|
||||
const component = this.cardComponents[file.uid || ''];
|
||||
if (!component) return;
|
||||
card.update<AudioValue>(component.id, {
|
||||
status: 'error',
|
||||
message: error.message || this.editor.language.get<string>('audio', 'uploadError'),
|
||||
});
|
||||
delete this.cardComponents[file.uid || ''];
|
||||
},
|
||||
},
|
||||
files,
|
||||
name
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
query(
|
||||
audio_id: string,
|
||||
success: (data?: { url: string; name?: string; cover?: string; download?: string; status?: string }) => void,
|
||||
failed: (message: string) => void = () => {
|
||||
return;
|
||||
}
|
||||
) {
|
||||
const { request } = this.editor;
|
||||
|
||||
const { query, parse } = this.options;
|
||||
if (!query || !audio_id) return success();
|
||||
|
||||
const { action, type, contentType, data } = query;
|
||||
request.ajax({
|
||||
url: action,
|
||||
contentType: contentType || '',
|
||||
type: type === undefined ? 'json' : type,
|
||||
data:
|
||||
typeof data === 'function'
|
||||
? async () => {
|
||||
const newData = data();
|
||||
return {
|
||||
...newData,
|
||||
id: audio_id,
|
||||
};
|
||||
}
|
||||
: {
|
||||
...data,
|
||||
id: audio_id,
|
||||
},
|
||||
success: (response: any) => {
|
||||
const { result, data } = response;
|
||||
if (!result) {
|
||||
failed(data);
|
||||
} else {
|
||||
const result = parse ? parse(response) : response;
|
||||
if (result.result === false) {
|
||||
failed(result.data || this.editor.language.get('audio', 'loadError'));
|
||||
} else
|
||||
success({
|
||||
...result.data,
|
||||
status: result.data.status !== 'transcoding' ? 'done' : 'transcoding',
|
||||
});
|
||||
}
|
||||
},
|
||||
error: error => {
|
||||
failed(error.message || this.editor.language.get('audio', 'loadError'));
|
||||
},
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
dropFiles(files: Array<File>) {
|
||||
if (!isEngine(this.editor)) return;
|
||||
files = files.filter(file => this.isAudio(file));
|
||||
if (files.length === 0) return;
|
||||
this.editor.command.execute('audio-uploader', files);
|
||||
return false;
|
||||
}
|
||||
|
||||
pasteFiles(files: Array<File>) {
|
||||
if (!isEngine(this.editor)) return;
|
||||
files = files.filter(file => this.isAudio(file));
|
||||
if (files.length === 0) return;
|
||||
this.editor.command.execute(
|
||||
'audio-uploader',
|
||||
files.filter(file => this.isAudio(file)),
|
||||
files
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
pasteEach(node: NodeInterface) {
|
||||
//是卡片,并且还没渲染
|
||||
if (node.isCard() && node.attributes(READY_CARD_KEY)) {
|
||||
if (node.attributes(READY_CARD_KEY) !== 'audio') return;
|
||||
const value = decodeCardValue(node.attributes(CARD_VALUE_KEY));
|
||||
if (!value || !value.url) {
|
||||
node.remove();
|
||||
return;
|
||||
}
|
||||
if (value.status === 'uploading') {
|
||||
//如果是上传状态,设置为正常状态
|
||||
value.percent = 0;
|
||||
node.attributes(CARD_VALUE_KEY, encodeCardValue({ ...value, status: 'done' }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
editor/src/plugins/draw/component/constant.ts
Normal file
15
editor/src/plugins/draw/component/constant.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const XML_DATA = `<mxGraphModel dx="1186" dy="670" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="9v9bDfxMJK_rrW6hIOUh-4" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="1" source="9v9bDfxMJK_rrW6hIOUh-1" target="9v9bDfxMJK_rrW6hIOUh-3">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="9v9bDfxMJK_rrW6hIOUh-1" value="Start" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="200" y="140" width="120" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="9v9bDfxMJK_rrW6hIOUh-3" value="End" style="whiteSpace=wrap;html=1;rounded=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="400" y="140" width="120" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>`;
|
||||
11
editor/src/plugins/draw/component/diagram-loader.ts
Normal file
11
editor/src/plugins/draw/component/diagram-loader.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type * as Diagram from 'embed-drawio';
|
||||
|
||||
let instance: typeof Diagram | null = null;
|
||||
|
||||
export const diagramLoader = (): Promise<typeof Diagram> => {
|
||||
if (instance) return Promise.resolve(instance);
|
||||
return import('embed-drawio').then(res => {
|
||||
instance = res;
|
||||
return res;
|
||||
});
|
||||
};
|
||||
75
editor/src/plugins/draw/component/draw-edit.vue
Normal file
75
editor/src/plugins/draw/component/draw-edit.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div id="draw-edit">
|
||||
<div v-if="loading">资源加载中...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import 'embed-drawio/dist/index.css';
|
||||
import { defineComponent, onMounted, ref, PropType } from 'vue';
|
||||
import { XML_DATA } from './constant';
|
||||
import { stringToXml, xmlToString } from './utils';
|
||||
import { diagramLoader } from './diagram-loader';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'draw-edit',
|
||||
components: {},
|
||||
props: {
|
||||
value: { type: String, default: XML_DATA },
|
||||
change: {
|
||||
type: Function as PropType<(val: string) => void>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { value, change } = { ...props };
|
||||
let diagramEditor: any = null;
|
||||
const loading = ref(true);
|
||||
const xmlExample = ref(value);
|
||||
|
||||
onMounted(() => {
|
||||
diagramLoader().then(diagram => {
|
||||
setTimeout(() => {
|
||||
loading.value = false;
|
||||
editXML();
|
||||
}, 400);
|
||||
});
|
||||
});
|
||||
// 在线编辑
|
||||
const editXML = () => {
|
||||
diagramLoader().then(diagram => {
|
||||
const renderExit = (el: HTMLDivElement) => {
|
||||
const div = document.createElement('div');
|
||||
div.innerText = '保存';
|
||||
div.style.marginRight = '20px';
|
||||
div.onclick = diagramEditorExit;
|
||||
el.appendChild(div);
|
||||
};
|
||||
const draw = document.body as HTMLDivElement;
|
||||
diagramEditor = new diagram.DiagramEditor(draw, renderExit);
|
||||
diagram.getLanguage('zh').then(res => {
|
||||
diagramEditor.start(res, stringToXml(xmlExample.value), (xml: Node) => {
|
||||
const xmlString = xmlToString(xml);
|
||||
xmlExample.value = xmlString || '';
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const diagramEditorExit = () => {
|
||||
if (diagramEditor) {
|
||||
diagramEditor.exit();
|
||||
}
|
||||
|
||||
change && change(xmlExample.value);
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
xmlExample,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
58
editor/src/plugins/draw/component/draw-view.vue
Normal file
58
editor/src/plugins/draw/component/draw-view.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div id="draw-view">
|
||||
<Spin :spinning="loading" tip="资源加载中..."></Spin>
|
||||
<div v-if="!loading" class="draw-view" ref="xmlContainer"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import 'embed-drawio/dist/index.css';
|
||||
import { defineComponent, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { Spin } from 'ant-design-vue';
|
||||
import { XML_DATA } from './constant';
|
||||
import { stringToXml } from './utils';
|
||||
import { diagramLoader } from './diagram-loader';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'draw-view',
|
||||
components: { Spin },
|
||||
props: {
|
||||
value: { type: String, default: XML_DATA },
|
||||
},
|
||||
setup(props) {
|
||||
const { value } = { ...props };
|
||||
const loading = ref(true);
|
||||
const xmlExample = ref(value);
|
||||
const xmlContainer = ref<HTMLDivElement | null>(null);
|
||||
|
||||
watch(xmlExample, () => convertXML());
|
||||
|
||||
onMounted(() => {
|
||||
diagramLoader().then(() => {
|
||||
setTimeout(() => {
|
||||
loading.value = false;
|
||||
nextTick(() => convertXML());
|
||||
}, 400);
|
||||
});
|
||||
});
|
||||
// 显示图像
|
||||
const convertXML = (xml: string = xmlExample.value) => {
|
||||
const div = xmlContainer.value;
|
||||
if (div) {
|
||||
diagramLoader().then(diagram => {
|
||||
const diagramViewer = new diagram.DiagramViewer(stringToXml(xml));
|
||||
const svg = diagramViewer.renderSVG(null, 1, 1);
|
||||
svg && div.appendChild(svg);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
xmlContainer,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
166
editor/src/plugins/draw/component/index.css
Normal file
166
editor/src/plugins/draw/component/index.css
Normal file
@@ -0,0 +1,166 @@
|
||||
.data-draw .data-draw-mask {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 100%;
|
||||
z-index: 3;
|
||||
display: block;
|
||||
transition: all .3s cubic-bezier(.3, 1.2, .2, 1)
|
||||
}
|
||||
|
||||
.data-draw .data-draw-mask:hover {
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.data-draw-body {
|
||||
border: 1px solid #e8e8e8;
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.data-draw-body .data-draw-content {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.draw-icon-edit {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.draw-icon-edit:hover path{
|
||||
fill: #40a9ff;
|
||||
}
|
||||
|
||||
.data-draw-body .data-draw-maximize {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.65);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
line-height: 20px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0px 2px 8px 0px rgba(0, 0, 0, 0.08);
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-draw-body:hover .data-draw-maximize {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 自定义Drawio样式,覆盖原有样式 */
|
||||
body {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.example {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.example .buttonGroup {
|
||||
margin: 30px;
|
||||
}
|
||||
|
||||
.example .buttonGroup>button {
|
||||
display: block;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
:global .diagram-container .diagram-exit-btn {
|
||||
cursor: pointer;
|
||||
margin-right: 50px;
|
||||
}
|
||||
|
||||
#draw-view {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#draw-view,
|
||||
.draw-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.diagram-container,
|
||||
.diagram-container *,
|
||||
.diagram-container *::before,
|
||||
.diagram-container *::after {
|
||||
box-sizing: content-box !important;
|
||||
}
|
||||
|
||||
.geSidebar,
|
||||
.geSidebar input {
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.draw-edit {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.geEditor .geSidebarContainer,
|
||||
.geEditor .geDiagramContainer,
|
||||
.geEditor .geHsplit {
|
||||
bottom: 0 !important;
|
||||
}
|
||||
|
||||
.geEditor .geFooterContainer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.draw-full-modal .ant-modal {
|
||||
max-width: 100%;
|
||||
top: 0;
|
||||
padding-bottom: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.draw-full-modal .ant-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.draw-full-modal .ant-modal-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.draw-full-modal .ant-modal-confirm .ant-modal-body {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.draw-full-modal .ant-modal-confirm-btns {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
margin: 0;
|
||||
display: none;
|
||||
}
|
||||
143
editor/src/plugins/draw/component/index.ts
Normal file
143
editor/src/plugins/draw/component/index.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { EmbedOptions, EmbedValue, EmbedRenderBeforeEvent } from '../types';
|
||||
import { $, Card, CardType, NodeInterface, CardToolbarItemOptions, ToolbarItemOptions, isEngine } from '@aomao/engine';
|
||||
import { App, createApp, createVNode } from 'vue';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
import DrawView from './draw-view.vue';
|
||||
import DrawEdit from './draw-edit.vue';
|
||||
import './index.css';
|
||||
|
||||
export const editIcon = `<span class="draw-icon-edit">
|
||||
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1777" width="16" height="16">
|
||||
<path d="M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 000-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 009.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3-362.7 362.6-88.9 15.7 15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z" fill="#595959"></path>
|
||||
</svg>
|
||||
</span>`;
|
||||
|
||||
class EmbedComponent<V extends EmbedValue = EmbedValue> extends Card<V> {
|
||||
renderBefore?: EmbedRenderBeforeEvent;
|
||||
|
||||
static get cardName() {
|
||||
return 'draw';
|
||||
}
|
||||
|
||||
static get cardType() {
|
||||
return CardType.BLOCK;
|
||||
}
|
||||
|
||||
static get lazyRender() {
|
||||
return true;
|
||||
}
|
||||
|
||||
static get singleSelectable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
static get autoSelected() {
|
||||
return false;
|
||||
}
|
||||
|
||||
#drawContent?: NodeInterface;
|
||||
private vm?: App;
|
||||
|
||||
resize = () => {
|
||||
const value = this.getValue();
|
||||
if (!value?.isResize) return;
|
||||
return this.#drawContent?.parent();
|
||||
};
|
||||
|
||||
toolbar(): Array<ToolbarItemOptions | CardToolbarItemOptions> {
|
||||
const getItems = () => {
|
||||
if (isEngine(this.editor) && !this.editor.readonly) {
|
||||
const items: Array<CardToolbarItemOptions | ToolbarItemOptions> = [
|
||||
{ type: 'dnd' },
|
||||
{
|
||||
type: 'node',
|
||||
node: $(editIcon),
|
||||
didMount: node => {
|
||||
if (node?.get()) {
|
||||
node.on('click', () => {
|
||||
const value = this.getValue();
|
||||
createApp(DrawEdit, {
|
||||
value: value.xml,
|
||||
change: (xml: string) => {
|
||||
this.setValue({
|
||||
...value,
|
||||
xml,
|
||||
});
|
||||
this.didRender();
|
||||
},
|
||||
}).mount(node.get() as Element);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'copy' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'delete' },
|
||||
];
|
||||
return items;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
return getItems();
|
||||
}
|
||||
|
||||
renderContainer() {
|
||||
const value = this.getValue();
|
||||
const height = value?.height || 'auto';
|
||||
|
||||
const container = $(`
|
||||
<div class="data-draw data-draw-container">
|
||||
<div
|
||||
class="data-draw-body"
|
||||
style="height:${height === 'auto' ? '' : height + 'px'}"
|
||||
>
|
||||
<div class="data-draw-content">
|
||||
</div>
|
||||
<span class="data-draw-maximize">
|
||||
<span class="data-icon data-icon-maximize"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
const drawContent = container.find('.data-draw-content');
|
||||
const maximize = container.find('.data-draw-maximize');
|
||||
maximize.on('click', () => {
|
||||
const value = this.getValue();
|
||||
Modal.confirm({
|
||||
icon: null,
|
||||
content: createVNode(DrawView, { value: value.xml }),
|
||||
wrapClassName: 'draw-full-modal',
|
||||
width: '100%',
|
||||
cancelText: '关 闭',
|
||||
closable: true,
|
||||
});
|
||||
});
|
||||
if (value?.height) {
|
||||
drawContent.attributes('data-height', value.height);
|
||||
}
|
||||
this.#drawContent = drawContent;
|
||||
return container;
|
||||
}
|
||||
|
||||
render(renderBefore?: EmbedRenderBeforeEvent): string | void | NodeInterface {
|
||||
this.renderBefore = renderBefore;
|
||||
const center = this.getCenter();
|
||||
center.empty();
|
||||
center.append(this.renderContainer());
|
||||
}
|
||||
|
||||
didRender() {
|
||||
super.didRender();
|
||||
const value = this.getValue();
|
||||
this.vm = createApp(DrawView, { value: value.xml });
|
||||
this.vm.mount(this.#drawContent?.get<HTMLElement>());
|
||||
}
|
||||
|
||||
destroy() {
|
||||
super.destroy();
|
||||
this.vm?.unmount();
|
||||
this.vm = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export default EmbedComponent;
|
||||
59
editor/src/plugins/draw/component/utils.ts
Normal file
59
editor/src/plugins/draw/component/utils.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export const clearElement = (element: HTMLElement | null): void => {
|
||||
element && element.childNodes.forEach(node => element.removeChild(node));
|
||||
};
|
||||
|
||||
export const getDrawIOSvgString = (xml: XMLDocument) => {
|
||||
return xmlToString(xml.documentElement.firstChild?.firstChild || null);
|
||||
};
|
||||
|
||||
export const xmlToString = (xml: Node | null): string | null => {
|
||||
if (!xml) return null;
|
||||
try {
|
||||
const serialize = new XMLSerializer();
|
||||
return serialize.serializeToString(xml);
|
||||
} catch (error) {
|
||||
console.log('XmlToString Error: ', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const stringToXml = (str: string): XMLDocument | null => {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
return parser.parseFromString(str, 'text/xml') as XMLDocument;
|
||||
} catch (error) {
|
||||
console.log('StringToXml Error: ', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const svgToString = (svg: Node | null): string | null => {
|
||||
if (!svg) return null;
|
||||
try {
|
||||
const serialize = new XMLSerializer();
|
||||
return serialize.serializeToString(svg);
|
||||
} catch (error) {
|
||||
console.log('SvgToString Error: ', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const stringToSvg = (str: string): SVGElement | null => {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
return parser.parseFromString(str, 'image/svg+xml').firstChild as SVGElement;
|
||||
} catch (error) {
|
||||
console.log('StringToSvg Error: ', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const base64ToSvgString = (base64: string): string | null => {
|
||||
try {
|
||||
const svg = atob(base64.replace('data:image/svg+xml;base64,', ''));
|
||||
return svg;
|
||||
} catch (error) {
|
||||
console.log('base64ToSvgString Error: ', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
73
editor/src/plugins/draw/index.ts
Normal file
73
editor/src/plugins/draw/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { $, Plugin, NodeInterface, CARD_KEY, isEngine, SchemaInterface, PluginOptions, decodeCardValue, encodeCardValue } from '@aomao/engine';
|
||||
import DrawComponent from './component';
|
||||
|
||||
export interface Options extends PluginOptions {
|
||||
hotkey?: string | Array<string>;
|
||||
}
|
||||
export default class extends Plugin<Options> {
|
||||
static get pluginName() {
|
||||
return 'draw';
|
||||
}
|
||||
// 插件初始化
|
||||
init() {
|
||||
// 监听解析成html的事件
|
||||
this.editor.on('paser:html', node => this.parseHtml(node));
|
||||
// 监听粘贴时候设置schema规则的入口
|
||||
this.editor.on('paste:schema', schema => this.pasteSchema(schema));
|
||||
// 监听粘贴时候的节点循环
|
||||
this.editor.on('paste:each', child => this.pasteHtml(child));
|
||||
}
|
||||
// 执行方法
|
||||
execute() {
|
||||
if (!isEngine(this.editor)) return;
|
||||
const { card } = this.editor;
|
||||
card.insert(DrawComponent.cardName);
|
||||
}
|
||||
// 快捷键
|
||||
hotkey() {
|
||||
return this.options.hotkey || 'mod+shift+0';
|
||||
}
|
||||
// 粘贴的时候添加需要的 schema
|
||||
pasteSchema(schema: SchemaInterface) {
|
||||
schema.add({
|
||||
type: 'block',
|
||||
name: 'div',
|
||||
attributes: {
|
||||
'data-type': {
|
||||
required: true,
|
||||
value: DrawComponent.cardName,
|
||||
},
|
||||
'data-value': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
// 解析粘贴过来的html
|
||||
pasteHtml(node: NodeInterface) {
|
||||
if (!isEngine(this.editor)) return;
|
||||
if (node.isElement()) {
|
||||
const type = node.attributes('data-type');
|
||||
if (type === DrawComponent.cardName) {
|
||||
const value = node.attributes('data-value');
|
||||
const cardValue = decodeCardValue(value);
|
||||
this.editor.card.replaceNode(node, DrawComponent.cardName, cardValue);
|
||||
node.remove();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// 解析成html
|
||||
parseHtml(root: NodeInterface) {
|
||||
root.find(`[${CARD_KEY}=${DrawComponent.cardName}`).each(cardNode => {
|
||||
const node = $(cardNode);
|
||||
const card = this.editor.card.find(node) as DrawComponent;
|
||||
const value = card?.getValue();
|
||||
if (value) {
|
||||
node.empty();
|
||||
const div = $(`<div data-type="${DrawComponent.cardName}" data-value="${encodeCardValue(value)}"></div>`);
|
||||
node.replaceWith(div);
|
||||
} else node.remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
export { DrawComponent };
|
||||
15
editor/src/plugins/draw/types.ts
Normal file
15
editor/src/plugins/draw/types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { CardToolbarItemOptions, CardValue, EditorInterface, PluginOptions, ToolbarItemOptions } from '@aomao/engine';
|
||||
|
||||
export interface EmbedValue extends CardValue {
|
||||
height?: number;
|
||||
collapsed?: boolean;
|
||||
xml?: string;
|
||||
isResize?: boolean;
|
||||
}
|
||||
|
||||
export type EmbedRenderBeforeEvent = (url: string) => EmbedValue;
|
||||
|
||||
export interface EmbedOptions extends PluginOptions {
|
||||
renderBefore?: EmbedRenderBeforeEvent;
|
||||
cardToolbars?: (items: (ToolbarItemOptions | CardToolbarItemOptions)[], editor: EditorInterface) => (ToolbarItemOptions | CardToolbarItemOptions)[];
|
||||
}
|
||||
189
editor/src/plugins/image/component/image/index.css
Normal file
189
editor/src/plugins/image/component/image/index.css
Normal file
@@ -0,0 +1,189 @@
|
||||
.am-engine [data-card-key="image"].card-selected [data-card-element="center"].data-card-border-selected {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.am-engine [data-card-key="image"].card-selected [data-card-element="center"].data-card-border-selected .data-image-disable-resize {
|
||||
outline: 2px solid #1890FF;
|
||||
}
|
||||
|
||||
.data-image {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
border-radius: 3px 3px;
|
||||
line-height: 24px;
|
||||
text-indent: 0;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.data-image-blcok {
|
||||
display: flex;
|
||||
margin: 0 auto;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.data-image-blcok.data-image-disable-resize {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.data-image-detail {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.data-image-blcok .data-image-detail {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.data-image-meta {
|
||||
position: relative;
|
||||
color: #595959;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.data-image-progress {
|
||||
padding-left: 8px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.data-image-progress .data-anticon {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
line-height: 24px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.data-image-maximize {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.65);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
line-height: 20px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0px 2px 8px 0px rgba(0, 0, 0, 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-image-maximize .data-icon-maximize {
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.data-image-loading , .data-image-loaded {
|
||||
display: inline-block;
|
||||
border-radius: 0 0;
|
||||
padding: 0 0;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.data-image-blcok .data-image-loading, .data-image-blcok .data-image-loaded {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.data-image-loading .data-image-meta, .data-image-loaded .data-image-meta {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.data-image-blcok .data-image-loading .data-image-meta, .data-image-blcok .data-image-loaded .data-image-meta {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.data-image-loading .data-image-meta img,.data-image-loaded .data-image-meta img {
|
||||
border-radius: 2px 2px;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
opacity: 0.6;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.data-image-blcok .data-image-loading .data-image-meta img, .data-image-blcok .data-image-loaded .data-image-meta img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.data-image-loading .data-image-meta img , .data-image-blcok .data-image-loading .data-image-meta img {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-image-loading .data-image-meta .data-image-progress,.data-image-loaded .data-image-meta .data-image-progress {
|
||||
font-family: 'Lucida Console', Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 0px 6px;
|
||||
border-radius: 2px 2px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-image-loaded .data-image-meta img {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.data-image-error {
|
||||
padding: 2px 4px;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
border-radius: 4px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.data-image-error .data-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.data-image-error .data-icon-error {
|
||||
color: red;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.data-image-error .data-icon-copy {
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.am-engine [data-card-key="image"].card-selected [data-card-element=center].data-card-background-selected {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.data-image-bg {
|
||||
display: none;
|
||||
border-radius: 2px;
|
||||
opacity: 0.6;
|
||||
background-size:100% 100%;
|
||||
background-color: #FAFAFA;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjhweCIgaGVpZ2h0PSIyMnB4IiB2aWV3Qm94PSIwIDAgMjggMjIiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDU1LjIgKDc4MTgxKSAtIGh0dHBzOi8vc2tldGNoYXBwLmNvbSAtLT4KICAgIDx0aXRsZT5pbWFnZS1maWxs5aSH5Lu9PC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGcgaWQ9Iuafpeeci+WbvueJh+S8mOWMljQuMCIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgaWQ9IuWKoOi9veWbvueJhyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTU3Mi4wMDAwMDAsIC01MDYuMDAwMDAwKSI+CiAgICAgICAgICAgIDxnIGlkPSJpbWFnZS1maWxs5aSH5Lu9IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg1NzAuMDAwMDAwLCA1MDEuMDAwMDAwKSI+CiAgICAgICAgICAgICAgICA8cmVjdCBpZD0iUmVjdGFuZ2xlIiBmaWxsPSIjMDAwMDAwIiBvcGFjaXR5PSIwIiB4PSIwIiB5PSIwIiB3aWR0aD0iMzIiIGhlaWdodD0iMzIiPjwvcmVjdD4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0yOSw1IEwzLDUgQzIuNDQ2ODc1LDUgMiw1LjQ0Njg3NSAyLDYgTDIsMjYgQzIsMjYuNTUzMTI1IDIuNDQ2ODc1LDI3IDMsMjcgTDI5LDI3IEMyOS41NTMxMjUsMjcgMzAsMjYuNTUzMTI1IDMwLDI2IEwzMCw2IEMzMCw1LjQ0Njg3NSAyOS41NTMxMjUsNSAyOSw1IFogTTEwLjU2MjUsOS41IEMxMS42NjU2MjUsOS41IDEyLjU2MjUsMTAuMzk2ODc1IDEyLjU2MjUsMTEuNSBDMTIuNTYyNSwxMi42MDMxMjUgMTEuNjY1NjI1LDEzLjUgMTAuNTYyNSwxMy41IEM5LjQ1OTM3NSwxMy41IDguNTYyNSwxMi42MDMxMjUgOC41NjI1LDExLjUgQzguNTYyNSwxMC4zOTY4NzUgOS40NTkzNzUsOS41IDEwLjU2MjUsOS41IFogTTI2LjYyMTg3NSwyMy4xNTkzNzUgQzI2LjU3ODEyNSwyMy4xOTY4NzUgMjYuNTE4NzUsMjMuMjE4NzUgMjYuNDU5Mzc1LDIzLjIxODc1IEw1LjUzNzUsMjMuMjE4NzUgQzUuNCwyMy4yMTg3NSA1LjI4NzUsMjMuMTA2MjUgNS4yODc1LDIyLjk2ODc1IEM1LjI4NzUsMjIuOTA5Mzc1IDUuMzA5Mzc1LDIyLjg1MzEyNSA1LjM0Njg3NSwyMi44MDYyNSBMMTAuNjY4NzUsMTYuNDkzNzUgQzEwLjc1NjI1LDE2LjM4NzUgMTAuOTE1NjI1LDE2LjM3NSAxMS4wMjE4NzUsMTYuNDYyNSBDMTEuMDMxMjUsMTYuNDcxODc1IDExLjA0Mzc1LDE2LjQ4MTI1IDExLjA1MzEyNSwxNi40OTM3NSBMMTQuMTU5Mzc1LDIwLjE4MTI1IEwxOS4xLDE0LjMyMTg3NSBDMTkuMTg3NSwxNC4yMTU2MjUgMTkuMzQ2ODc1LDE0LjIwMzEyNSAxOS40NTMxMjUsMTQuMjkwNjI1IEMxOS40NjI1LDE0LjMgMTkuNDc1LDE0LjMwOTM3NSAxOS40ODQzNzUsMTQuMzIxODc1IEwyNi42NTkzNzUsMjIuODA5Mzc1IEMyNi43NDA2MjUsMjIuOTEyNSAyNi43MjgxMjUsMjMuMDcxODc1IDI2LjYyMTg3NSwyMy4xNTkzNzUgWiIgaWQ9IlNoYXBlIiBmaWxsPSIjRThFOEU4Ij48L3BhdGg+CiAgICAgICAgICAgIDwvZz4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPg==);
|
||||
}
|
||||
|
||||
.data-image-loading .data-image-bg {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.data-image-blcok .data-image-loading .data-image-bg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.data-image-loading .data-image-maximize {
|
||||
display: none;
|
||||
}
|
||||
720
editor/src/plugins/image/component/image/index.ts
Normal file
720
editor/src/plugins/image/component/image/index.ts
Normal file
@@ -0,0 +1,720 @@
|
||||
import type { ImageOptions, PswpInterface } from '../../types';
|
||||
import type { EditorInterface, NodeInterface } from '@aomao/engine';
|
||||
import { $, File, isEngine, escape, random, getExtensionName, sanitizeUrl, Tooltip, isMobile, Resizer, CardType } from '@aomao/engine';
|
||||
import 'cropperjs/dist/cropper.css';
|
||||
import Cropper from 'cropperjs';
|
||||
import PhotoSwipe from 'photoswipe';
|
||||
import { ImageValue } from '..';
|
||||
import Pswp from '../pswp';
|
||||
import './index.css';
|
||||
|
||||
export type Status = 'uploading' | 'done' | 'error';
|
||||
|
||||
export type Size = {
|
||||
width: number;
|
||||
height: number;
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
};
|
||||
|
||||
export type Options = {
|
||||
/**
|
||||
* 卡片根节点
|
||||
*/
|
||||
root: NodeInterface;
|
||||
/**
|
||||
* 容器
|
||||
*/
|
||||
container: NodeInterface;
|
||||
/**
|
||||
* 状态
|
||||
* uploading 上传中
|
||||
* done 上传完成
|
||||
* error 错误
|
||||
*/
|
||||
status: Status;
|
||||
/**
|
||||
* 图标链接
|
||||
*/
|
||||
src: string;
|
||||
/**
|
||||
* 标题
|
||||
*/
|
||||
alt?: string;
|
||||
/**
|
||||
* 链接
|
||||
*/
|
||||
link?: {
|
||||
href: string;
|
||||
target?: string;
|
||||
};
|
||||
display?: CardType;
|
||||
/**
|
||||
* 错误消息
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* 样式名称,多个以空格隔空
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* 图片大小
|
||||
*/
|
||||
size?: Size;
|
||||
/**
|
||||
* 上传进度
|
||||
*/
|
||||
percent?: number;
|
||||
/**
|
||||
* 图片渲染前调用
|
||||
* @param status 状态
|
||||
* @param src 图片地址
|
||||
* @returns 图片地址
|
||||
*/
|
||||
onBeforeRender?: (status: 'uploading' | 'done', src: string, editor: EditorInterface) => string;
|
||||
onChangeSrc?: (url?: string, loaded?: boolean) => void;
|
||||
onChange?: (size?: Size, loaded?: boolean) => void;
|
||||
onError?: () => void;
|
||||
onLoad?: () => void;
|
||||
enableResizer?: boolean;
|
||||
maxHeight?: number | undefined;
|
||||
};
|
||||
|
||||
export const winPixelRatio = window.devicePixelRatio;
|
||||
let pswp: PswpInterface | undefined = undefined;
|
||||
class Image {
|
||||
private editor: EditorInterface;
|
||||
options: Options;
|
||||
root: NodeInterface;
|
||||
private progress: NodeInterface;
|
||||
private image: NodeInterface;
|
||||
private detail: NodeInterface;
|
||||
private meta: NodeInterface;
|
||||
private maximize: NodeInterface;
|
||||
private bg: NodeInterface;
|
||||
resizer?: Resizer;
|
||||
private pswp: PswpInterface;
|
||||
src: string;
|
||||
status: Status;
|
||||
size: Size;
|
||||
maxWidth: number;
|
||||
maxHeight: number | undefined;
|
||||
rate = 1;
|
||||
isLoad = false;
|
||||
message: string | undefined;
|
||||
cropper: Cropper | undefined;
|
||||
|
||||
constructor(editor: EditorInterface, options: Options) {
|
||||
this.editor = editor;
|
||||
this.options = options;
|
||||
this.src = this.options.src;
|
||||
this.size = this.options.size || {
|
||||
width: 0,
|
||||
height: 0,
|
||||
naturalHeight: 0,
|
||||
naturalWidth: 0,
|
||||
};
|
||||
this.maxHeight = this.options.maxHeight;
|
||||
this.status = this.options.status;
|
||||
this.root = $(this.renderTemplate());
|
||||
this.progress = this.root.find('.data-image-progress');
|
||||
this.image = this.root.find('img');
|
||||
this.detail = this.root.find('.data-image-detail');
|
||||
this.meta = this.root.find('.data-image-meta');
|
||||
this.maximize = this.root.find('.data-image-maximize');
|
||||
this.bg = this.root.find('.data-image-bg');
|
||||
this.maxWidth = this.getMaxWidth();
|
||||
this.pswp = pswp || new Pswp(editor);
|
||||
this.message = this.options.message;
|
||||
|
||||
pswp = this.pswp;
|
||||
}
|
||||
|
||||
renderTemplate(message?: string) {
|
||||
const { link, percent, className, onBeforeRender } = this.options;
|
||||
|
||||
if (this.status === 'error') {
|
||||
return `<span class="data-image-error" style="max-width:${this.maxWidth}px">
|
||||
<span class="data-icon data-icon-error"></span>
|
||||
${message || this.options.message}
|
||||
<span class="data-icon data-icon-copy"></span>
|
||||
</span>`;
|
||||
}
|
||||
const src = onBeforeRender ? onBeforeRender(this.status, this.options.src, this.editor) : this.options.src;
|
||||
const progress = `<span class="data-image-progress">
|
||||
<i class="data-anticon">
|
||||
<svg viewBox="0 0 1024 1024" class="data-anticon-spin" data-icon="loading" width="1em" height="1em" fill="currentColor" aria-hidden="true">
|
||||
<path d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 0 0-94.3-139.9 437.71 437.71 0 0 0-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z"></path>
|
||||
</svg>
|
||||
</i>
|
||||
<span class="percent">${percent || 0}%</span>
|
||||
</span>`;
|
||||
|
||||
const alt = escape(this.options.alt || '');
|
||||
const attr = alt ? ` alt="${alt}" title="${alt}" ` : '';
|
||||
//加上 data-drag-image 样式可以拖动图片
|
||||
let img = `<img src="${sanitizeUrl(src)}" class="${className || ''} data-drag-image" ${attr}/>`;
|
||||
//只读渲染加载链接
|
||||
if (link && !isEngine(this.editor)) {
|
||||
const target = link.target || '_blank';
|
||||
img = `<a href="${sanitizeUrl(link.href)}" target="${target}">${img}</a>`;
|
||||
}
|
||||
//全屏图标
|
||||
const maximize = `<span class="data-image-maximize" style="display: none;"><span class="data-icon data-icon-maximize"></span></span>`;
|
||||
|
||||
return `
|
||||
<span class="data-image">
|
||||
<span class="data-image-content data-image-loading">
|
||||
<span class="data-image-detail">
|
||||
<span class="data-image-meta">
|
||||
${img}
|
||||
${progress}
|
||||
<span class="data-image-bg"></span>
|
||||
${maximize}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
bindErrorEvent(node: NodeInterface) {
|
||||
const editor = this.editor;
|
||||
const copyNode = node.find('.data-icon-copy');
|
||||
copyNode.on('mouseenter', () => {
|
||||
Tooltip.show(copyNode, editor.language.get('image', 'errorMessageCopy').toString());
|
||||
});
|
||||
copyNode.on('mouseleave', () => {
|
||||
Tooltip.hide();
|
||||
});
|
||||
copyNode.on('click', (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
Tooltip.hide();
|
||||
editor.clipboard.copy(this.message || this.options.message || 'Error message');
|
||||
editor.messageSuccess('copy', editor.language.get('copy', 'success').toString());
|
||||
});
|
||||
}
|
||||
|
||||
setProgressPercent(percent: number) {
|
||||
this.progress.find('.percent').html(`${percent}%`);
|
||||
}
|
||||
|
||||
imageLoadCallback() {
|
||||
const editor = this.editor;
|
||||
const root = editor.card.closest(this.root);
|
||||
if (!root || this.status === 'uploading') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.status === 'done') {
|
||||
const contentNode = this.root.find('.data-image-content');
|
||||
contentNode.addClass('data-image-loaded');
|
||||
contentNode.removeClass('data-image-loading');
|
||||
}
|
||||
|
||||
const img = this.image.get<HTMLImageElement>();
|
||||
if (!img) return;
|
||||
const { naturalWidth, naturalHeight } = img;
|
||||
this.rate = naturalHeight / naturalWidth;
|
||||
|
||||
this.size.naturalWidth = naturalWidth;
|
||||
this.size.naturalHeight = naturalHeight;
|
||||
|
||||
if (!this.size.width) this.size.width = naturalWidth;
|
||||
if (!this.size.height) this.size.height = naturalHeight;
|
||||
|
||||
this.resetSize();
|
||||
|
||||
this.image.css('visibility', 'visible');
|
||||
this.detail.css('height', '');
|
||||
this.detail.css('width', '');
|
||||
const { onChange } = this.options;
|
||||
if (isEngine(editor) && onChange) {
|
||||
onChange(this.size, true);
|
||||
}
|
||||
window.removeEventListener('resize', this.onWindowResize);
|
||||
window.addEventListener('resize', this.onWindowResize);
|
||||
editor.off('editor:resize', this.onWindowResize);
|
||||
editor.on('editor:resize', this.onWindowResize);
|
||||
// 重新调整拖动层尺寸
|
||||
if (this.resizer) {
|
||||
this.resizer.setSize(img.clientWidth, img.clientHeight);
|
||||
}
|
||||
this.isLoad = true;
|
||||
if (this.options.onLoad) {
|
||||
this.options.onLoad();
|
||||
}
|
||||
}
|
||||
|
||||
onWindowResize = () => {
|
||||
if (!isEngine(this.editor)) return;
|
||||
this.maxWidth = this.getMaxWidth();
|
||||
this.resetSize();
|
||||
const image = this.image.get<HTMLElement>();
|
||||
if (!image) return;
|
||||
const { clientWidth, clientHeight } = image;
|
||||
if (this.resizer) {
|
||||
this.resizer.maxWidth = this.maxWidth;
|
||||
this.resizer.setSize(clientWidth, clientHeight);
|
||||
}
|
||||
};
|
||||
|
||||
imageLoadError() {
|
||||
if (this.status === 'uploading') return;
|
||||
this.status = 'error';
|
||||
const { container } = this.options;
|
||||
container.empty();
|
||||
container.append(this.renderTemplate(this.editor.language.get('image', 'loadError').toString()));
|
||||
this.detail.css('width', '');
|
||||
this.detail.css('height', '');
|
||||
this.bindErrorEvent(container);
|
||||
const { onError } = this.options;
|
||||
if (onError) onError();
|
||||
this.isLoad = true;
|
||||
}
|
||||
|
||||
getMaxWidth(node: NodeInterface = this.options.root) {
|
||||
const block = this.editor.block.closest(node).get<HTMLElement>();
|
||||
if (!block) return 0;
|
||||
return block.clientWidth - 6;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置大小
|
||||
*/
|
||||
resetSize() {
|
||||
this.meta.css({
|
||||
'background-color': '',
|
||||
width: '',
|
||||
//height: "",
|
||||
});
|
||||
|
||||
this.image.css({
|
||||
width: '',
|
||||
//height: "",
|
||||
});
|
||||
|
||||
const img = this.image.get<HTMLImageElement>();
|
||||
if (!img) return;
|
||||
|
||||
let { width, height } = this.size;
|
||||
|
||||
if (!height) {
|
||||
height = Math.round(this.rate * width);
|
||||
} else if (!width) {
|
||||
width = Math.round(height / this.rate);
|
||||
} else if (width && height) {
|
||||
// 修正非正常的比例
|
||||
height = Math.round(this.rate * width);
|
||||
this.size.height = height;
|
||||
} else {
|
||||
const { clientWidth, clientHeight } = img;
|
||||
width = clientWidth;
|
||||
height = clientHeight;
|
||||
const { naturalWidth, naturalHeight } = this.size;
|
||||
// fix:svg 图片宽度 300px 问题
|
||||
if (this.isSvg() && naturalWidth && naturalHeight) {
|
||||
width = naturalWidth;
|
||||
height = naturalHeight;
|
||||
}
|
||||
}
|
||||
|
||||
if (width > this.maxWidth) {
|
||||
width = this.maxWidth;
|
||||
height = Math.round(width * this.rate);
|
||||
}
|
||||
if (this.options.enableResizer === false) {
|
||||
this.image.css('width', '');
|
||||
} else {
|
||||
this.image.css('width', `${width}px`);
|
||||
//this.image.css("height", `${height}px`);
|
||||
}
|
||||
}
|
||||
|
||||
changeSize(width: number, height: number) {
|
||||
if (width < 24) {
|
||||
width = 24;
|
||||
height = width * this.rate;
|
||||
}
|
||||
|
||||
if (width > this.maxWidth) {
|
||||
width = this.maxWidth;
|
||||
height = width * this.rate;
|
||||
}
|
||||
|
||||
if (height < 24) {
|
||||
height = 24;
|
||||
width = height / this.rate;
|
||||
}
|
||||
|
||||
width = Math.round(width);
|
||||
height = Math.round(height);
|
||||
this.size.width = width;
|
||||
this.size.height = height;
|
||||
this.image.css({
|
||||
width: `${width}px`,
|
||||
//height: `${height}px`,
|
||||
});
|
||||
const { onChange } = this.options;
|
||||
if (onChange) onChange(this.size);
|
||||
|
||||
this.destroyEditor();
|
||||
this.renderEditor();
|
||||
}
|
||||
|
||||
changeUrl(url: string) {
|
||||
if (this.src !== url) {
|
||||
this.src = url;
|
||||
this.isLoad = false;
|
||||
this.image.attributes('src', this.getSrc());
|
||||
}
|
||||
}
|
||||
|
||||
getSrc = () => {
|
||||
const { onBeforeRender } = this.options;
|
||||
return onBeforeRender && this.status !== 'error' ? onBeforeRender(this.status, this.src, this.editor) : this.src;
|
||||
};
|
||||
|
||||
isSvg() {
|
||||
return this.src.split('?')[0].endsWith('.svg') || this.src.startsWith('data:image/svg+xml');
|
||||
}
|
||||
|
||||
openZoom = (event: MouseEvent | TouchEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const editor = this.editor;
|
||||
const imageArray: PhotoSwipe.Item[] = [];
|
||||
const cardRoot = editor.card.closest(this.root);
|
||||
let rootIndex = 0;
|
||||
|
||||
editor.container
|
||||
.find(`[data-card-key="image"]`)
|
||||
.toArray()
|
||||
.filter(image => {
|
||||
return image.find('img').length > 0;
|
||||
})
|
||||
.forEach((imageNode, index) => {
|
||||
const card = editor.card.find<ImageValue>(imageNode);
|
||||
const value = card?.getValue();
|
||||
if (!card || !value) return;
|
||||
const image = card.getCenter().find('img');
|
||||
const imageWidth = parseInt(image.css('width'));
|
||||
const imageHeight = parseInt(image.css('height'));
|
||||
const size = value.size;
|
||||
const naturalWidth = size ? size.naturalWidth || this.size.naturalWidth : imageWidth * winPixelRatio;
|
||||
const naturalHeight = size ? size.naturalHeight || this.size.naturalHeight : imageHeight * winPixelRatio;
|
||||
let src = value['src'];
|
||||
const { onBeforeRender } = this.options;
|
||||
if (onBeforeRender) src = onBeforeRender('done', src, this.editor);
|
||||
const msrc = image.attributes('src');
|
||||
imageArray.push({
|
||||
src,
|
||||
msrc,
|
||||
w: naturalWidth,
|
||||
h: naturalHeight,
|
||||
});
|
||||
if (cardRoot?.equal(imageNode)) {
|
||||
rootIndex = index;
|
||||
}
|
||||
});
|
||||
this.pswp.open(imageArray, rootIndex);
|
||||
};
|
||||
|
||||
closeZoom() {
|
||||
this.pswp?.close();
|
||||
}
|
||||
|
||||
cropImage() {
|
||||
if (this.status === 'done') {
|
||||
this.destroyEditor();
|
||||
}
|
||||
const img = this.image.get<HTMLElement>();
|
||||
this.cropper = new Cropper(img as HTMLImageElement, { autoCropArea: 1 });
|
||||
}
|
||||
|
||||
async cropImageSave() {
|
||||
if (this.cropper) {
|
||||
const canvas = this.cropper.getCroppedCanvas();
|
||||
const base64url = canvas?.toDataURL();
|
||||
/* 直接使用 base64赋值
|
||||
this.changeUrl(base64url);
|
||||
onChangeSrc && onChangeSrc(base64url);
|
||||
*/
|
||||
// 重新上传用后台返回的url 展示
|
||||
const fileBlob = this.dataURIToFile(base64url);
|
||||
const ext = getExtensionName(fileBlob);
|
||||
const name = ext ? 'image.'.concat(ext) : 'image';
|
||||
const file: File = new globalThis.File([fileBlob], name, { type: 'image/jpeg' });
|
||||
file.uid = new Date().getTime() + '-' + random();
|
||||
const src = await this.uploadImage([file]);
|
||||
const { onChangeSrc } = this.options;
|
||||
this.changeUrl(src);
|
||||
onChangeSrc && onChangeSrc(src);
|
||||
this.size.naturalWidth = canvas?.width;
|
||||
this.size.naturalHeight = canvas?.height;
|
||||
const { width, height } = this.cropper?.getCropBoxData();
|
||||
this.changeSize(width, height);
|
||||
this.cropper.destroy();
|
||||
this.cropper = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async rotateImage() {
|
||||
console.log("rotateImage", this.editor)
|
||||
const canvas = document.createElement('canvas');
|
||||
// 默认旋转90° 将图片的初始宽高交换赋值给画布的宽高
|
||||
canvas.width = this.size.naturalHeight;
|
||||
canvas.height = this.size.naturalWidth;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx?.save();
|
||||
// 从坐标 0,0 渲染canvas.width, canvas.height的画布
|
||||
ctx?.fillRect(0, 0, canvas.width, canvas.height);
|
||||
// 旋转90°
|
||||
ctx?.rotate(90 * Math.PI / 180);
|
||||
// 将图片渲染到画布
|
||||
const img = this.image.get<HTMLElement>();
|
||||
ctx?.drawImage(img as HTMLImageElement, 0, -canvas.width);
|
||||
// 生成base64url
|
||||
const base64url = canvas.toDataURL('image/png');
|
||||
/* 直接使用 base64赋值
|
||||
this.changeUrl(base64url);
|
||||
onChangeSrc && onChangeSrc(base64url);
|
||||
*/
|
||||
// 重新上传用后台返回的url 展示
|
||||
const fileBlob = this.dataURIToFile(base64url);
|
||||
const ext = getExtensionName(fileBlob);
|
||||
const name = ext ? 'image.'.concat(ext) : 'image';
|
||||
const file: File = new globalThis.File([fileBlob], name, { type: 'image/jpeg' });
|
||||
file.uid = new Date().getTime() + '-' + random();
|
||||
const src = await this.uploadImage([file]);
|
||||
// 修改图片地址
|
||||
const { onChangeSrc } = this.options;
|
||||
this.changeUrl(src);
|
||||
onChangeSrc && onChangeSrc(src);
|
||||
// 计算旋转后的宽高
|
||||
const size = {
|
||||
width: this.size.height,
|
||||
height: this.size.width,
|
||||
naturalWidth: this.size.naturalHeight,
|
||||
naturalHeight: this.size.naturalWidth
|
||||
};
|
||||
this.size.naturalWidth = size.naturalWidth;
|
||||
this.size.naturalHeight = size.naturalHeight;
|
||||
// 修改图片宽高
|
||||
this.changeSize(size.width, size.height);
|
||||
}
|
||||
|
||||
dataURIToFile(dataURI: string) {
|
||||
let byteString;
|
||||
|
||||
if (dataURI.split(',')[0].indexOf('base64') >= 0) {
|
||||
byteString = atob(dataURI.split(',')[1]);
|
||||
} else {
|
||||
byteString = unescape(dataURI.split(',')[1]);
|
||||
}
|
||||
// separate out the mime component
|
||||
const mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]; // write the bytes of the string to a typed array
|
||||
|
||||
const ia = new Uint8Array(byteString.length);
|
||||
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new Blob([ia], {
|
||||
type: mimeString,
|
||||
});
|
||||
}
|
||||
|
||||
uploadImage(files: Array<File>): Promise<string> {
|
||||
const { request, card } = this.editor;
|
||||
const imageUploaderPlugin = this.editor.plugin.findPlugin<ImageOptions>('image-uploader');
|
||||
const { action, crossOrigin, headers } = imageUploaderPlugin?.options.file;
|
||||
const parse = imageUploaderPlugin?.options.parse;
|
||||
return new Promise((resolve, reject) => {
|
||||
return request.upload(
|
||||
{
|
||||
url: action,
|
||||
crossOrigin,
|
||||
headers,
|
||||
onSuccess: (response, file) => {
|
||||
const result = parse(response);
|
||||
return resolve(result.data);
|
||||
},
|
||||
onError: (error, file) => {
|
||||
return reject(error);
|
||||
},
|
||||
},
|
||||
files,
|
||||
'file'
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
renderEditor() {
|
||||
const img = this.image.get<HTMLElement>();
|
||||
if (!img) return;
|
||||
const { clientWidth, clientHeight } = img;
|
||||
|
||||
if (!clientWidth || !clientHeight) {
|
||||
return;
|
||||
}
|
||||
const editor = this.editor;
|
||||
this.maxWidth = this.getMaxWidth();
|
||||
this.rate = clientHeight / clientWidth;
|
||||
if (isMobile || !isEngine(editor) || editor.readonly) return;
|
||||
if (this.options.enableResizer === false) {
|
||||
return;
|
||||
}
|
||||
// 拖动调整图片大小
|
||||
const resizer = new Resizer({
|
||||
imgUrl: this.getSrc(),
|
||||
width: clientWidth,
|
||||
height: clientHeight,
|
||||
rate: this.rate,
|
||||
maxWidth: this.maxWidth,
|
||||
onChange: ({ width, height }) => this.changeSize(width, height),
|
||||
});
|
||||
const resizerNode = resizer.render();
|
||||
this.root.find('.data-image-detail').append(resizerNode);
|
||||
this.resizer = resizer;
|
||||
this.resizer.on('dblclick', this.openZoom);
|
||||
}
|
||||
|
||||
destroyEditor() {
|
||||
this.resizer?.off('dblclick', this.openZoom);
|
||||
this.resizer?.destroy();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener('resize', this.onWindowResize);
|
||||
this.editor.off('editor:resize', this.onWindowResize);
|
||||
this.destroyEditor();
|
||||
this.image.off('click', this.openZoom);
|
||||
this.image.off('dblclick', this.openZoom);
|
||||
this.maximize.off('click', this.openZoom);
|
||||
}
|
||||
|
||||
focus = () => {
|
||||
if (!isEngine(this.editor)) {
|
||||
return;
|
||||
}
|
||||
this.root.addClass('data-image-active');
|
||||
if (this.status === 'done') {
|
||||
this.destroyEditor();
|
||||
this.renderEditor();
|
||||
}
|
||||
};
|
||||
|
||||
blur = () => {
|
||||
if (!isEngine(this.editor)) {
|
||||
return;
|
||||
}
|
||||
this.root.removeClass('data-image-active');
|
||||
if (this.status === 'done') {
|
||||
this.destroyEditor();
|
||||
this.cropImageSave();
|
||||
}
|
||||
};
|
||||
|
||||
render(loadingBg?: string) {
|
||||
// 阅读模式不展示错误
|
||||
const { container, display, enableResizer } = this.options;
|
||||
if (display === CardType.BLOCK) {
|
||||
this.root.addClass('data-image-blcok');
|
||||
}
|
||||
const editor = this.editor;
|
||||
if (enableResizer === false) {
|
||||
this.root.addClass('data-image-disable-resize');
|
||||
}
|
||||
if (this.status === 'error' && isEngine(editor)) {
|
||||
this.root = $(this.renderTemplate(this.message || editor.language.get<string>('image', 'uploadError')));
|
||||
this.bindErrorEvent(this.root);
|
||||
container.empty().append(this.root);
|
||||
this.progress.remove();
|
||||
return;
|
||||
}
|
||||
if (this.status === 'uploading') {
|
||||
this.progress.show();
|
||||
container.empty().append(this.root);
|
||||
} else {
|
||||
this.progress.remove();
|
||||
}
|
||||
if (this.status === 'done' && this.isLoad) {
|
||||
const contentNode = this.root.find('.data-image-content');
|
||||
contentNode.addClass('data-image-loaded');
|
||||
contentNode.removeClass('data-image-loading');
|
||||
}
|
||||
if (this.status === 'done' && !this.isLoad) {
|
||||
if (!this.root.inEditor()) container.empty().append(this.root);
|
||||
}
|
||||
this.maxWidth = this.getMaxWidth();
|
||||
let { width, height } = this.size;
|
||||
if ((width && height) || !this.src) {
|
||||
if (width > this.maxWidth) {
|
||||
width = this.maxWidth;
|
||||
height = Math.round((width * height) / this.size.width);
|
||||
} else if (!this.src && !width && !height) {
|
||||
width = this.maxWidth;
|
||||
height = this.maxWidth / 2;
|
||||
}
|
||||
if (this.src) {
|
||||
if (this.options.enableResizer === false) {
|
||||
this.image.css({
|
||||
width: '100%',
|
||||
});
|
||||
} else {
|
||||
this.image.css({
|
||||
width: width + 'px',
|
||||
//height: height + "px",
|
||||
});
|
||||
}
|
||||
|
||||
const { onChange } = this.options;
|
||||
if (width > 0 && height > 0) {
|
||||
this.size = { ...this.size, width, height };
|
||||
if (onChange) onChange(this.size);
|
||||
}
|
||||
}
|
||||
if (this.options.enableResizer === false) {
|
||||
this.bg.css({
|
||||
width: '100%',
|
||||
});
|
||||
} else {
|
||||
this.bg.css({
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
});
|
||||
}
|
||||
|
||||
if (loadingBg) {
|
||||
this.bg.css('background-image', `url(${loadingBg})`);
|
||||
}
|
||||
}
|
||||
|
||||
this.image.on('load', () => this.imageLoadCallback());
|
||||
this.image.on('error', () => this.imageLoadError());
|
||||
if (!isMobile) {
|
||||
this.root.on('mouseenter', () => {
|
||||
this.maximize.show();
|
||||
});
|
||||
this.root.on('mouseleave', () => {
|
||||
this.maximize.hide();
|
||||
});
|
||||
}
|
||||
|
||||
if (!isEngine(editor) || editor.readonly) {
|
||||
const link = this.image.closest('a');
|
||||
// 无链接
|
||||
if (link.length === 0) {
|
||||
this.image.on('click', this.openZoom);
|
||||
}
|
||||
}
|
||||
this.maximize.on('click', this.openZoom);
|
||||
if (isEngine(editor) || !this.root.inEditor()) {
|
||||
this.image.on('dblclick', this.openZoom);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Image;
|
||||
354
editor/src/plugins/image/component/index.ts
Normal file
354
editor/src/plugins/image/component/index.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import type { ImageOptions } from '../types';
|
||||
import { Card, CardToolbarItemOptions, CardType, CardValue, isEngine, isMobile, NodeInterface, ToolbarItemOptions } from '@aomao/engine';
|
||||
import Image, { Size } from './image';
|
||||
|
||||
export interface ImageValue extends CardValue {
|
||||
/**
|
||||
* 图片地址
|
||||
*/
|
||||
src: string;
|
||||
/**
|
||||
* 位置
|
||||
*/
|
||||
align?: string;
|
||||
/**
|
||||
* 状态
|
||||
* uploading 上传中
|
||||
* done 上传成功
|
||||
*/
|
||||
status?: 'uploading' | 'done' | 'error';
|
||||
/**
|
||||
* 标题
|
||||
*/
|
||||
alt?: string;
|
||||
/**
|
||||
* 链接
|
||||
*/
|
||||
link?: {
|
||||
href: string;
|
||||
target?: string;
|
||||
};
|
||||
/**
|
||||
* 上传进度
|
||||
*/
|
||||
percent?: number;
|
||||
/**
|
||||
* 错误状态下的错误信息
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* 图片大小
|
||||
*/
|
||||
size?: {
|
||||
/**
|
||||
* 图片展示宽度
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* 图片展示高度
|
||||
*/
|
||||
height: number;
|
||||
/**
|
||||
* 图片真实宽度
|
||||
*/
|
||||
naturalWidth: number;
|
||||
/**
|
||||
* 图片真实高度
|
||||
*/
|
||||
naturalHeight: number;
|
||||
};
|
||||
}
|
||||
|
||||
class ImageComponent<T extends ImageValue = ImageValue> extends Card<T> {
|
||||
protected image?: Image;
|
||||
protected widthInput?: NodeInterface;
|
||||
protected heightInput?: NodeInterface;
|
||||
protected isLocalError?: boolean;
|
||||
|
||||
static get cardName() {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
static get cardType() {
|
||||
return CardType.INLINE;
|
||||
}
|
||||
|
||||
static get collab() {
|
||||
return false;
|
||||
}
|
||||
// static get autoSelected() {
|
||||
// return false;
|
||||
// }
|
||||
|
||||
/**
|
||||
* 设置上传进度
|
||||
* @param percent 进度百分比
|
||||
*/
|
||||
setProgressPercent(percent: number) {
|
||||
this.image?.setProgressPercent(percent);
|
||||
this.setValue({
|
||||
percent,
|
||||
} as T);
|
||||
}
|
||||
|
||||
setSize(size: Size, loaded?: boolean) {
|
||||
if (!size.width || !size.height) return;
|
||||
const value = this.getValue();
|
||||
if (!loaded || !value.size || !value.size.height || !value.size.width || !value.size.naturalWidth || !value.size.naturalHeight) this.setValue({ size } as T);
|
||||
if (this.widthInput) {
|
||||
this.widthInput.get<HTMLInputElement>()!.value = size.width.toString();
|
||||
}
|
||||
if (this.heightInput) {
|
||||
this.heightInput.get<HTMLInputElement>()!.value = size.height.toString();
|
||||
}
|
||||
}
|
||||
|
||||
onInputChange(width: string | number, height: string | number) {
|
||||
const value = this.getValue();
|
||||
if (typeof width === 'string') {
|
||||
if (!/^[1-9]+(\d+)?$/.test(width) && this.widthInput) {
|
||||
width = value?.size?.width || value?.size?.naturalWidth || 0;
|
||||
this.widthInput.get<HTMLInputElement>()!.value = width.toString();
|
||||
}
|
||||
width = parseInt(width.toString(), 10);
|
||||
}
|
||||
if (typeof height === 'string') {
|
||||
if (!/^[1-9]+(\d+)?$/.test(height) && this.heightInput) {
|
||||
height = value?.size?.height || value?.size?.naturalHeight || 0;
|
||||
this.heightInput.get<HTMLInputElement>()!.value = height.toString();
|
||||
}
|
||||
height = parseInt(height.toString(), 10);
|
||||
}
|
||||
this.image?.changeSize(parseInt(width.toString(), 10), height);
|
||||
}
|
||||
|
||||
toolbar(): Array<CardToolbarItemOptions | ToolbarItemOptions> {
|
||||
const editor = this.editor;
|
||||
const getItems = (): Array<CardToolbarItemOptions | ToolbarItemOptions> => {
|
||||
if (!isEngine(editor) || editor.readonly) return [];
|
||||
const { language } = editor;
|
||||
let value = this.getValue();
|
||||
if (this.isLocalError === true || value?.status !== 'done')
|
||||
return [
|
||||
{
|
||||
key: 'delete',
|
||||
type: 'delete',
|
||||
},
|
||||
];
|
||||
|
||||
const items: Array<CardToolbarItemOptions | ToolbarItemOptions> = [
|
||||
{
|
||||
key: 'copy',
|
||||
type: 'copy',
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
type: 'delete',
|
||||
},
|
||||
];
|
||||
if (isMobile) return items;
|
||||
const rotateItems: (CardToolbarItemOptions | ToolbarItemOptions)[] = [
|
||||
{
|
||||
key: 'button',
|
||||
type: 'button',
|
||||
content: `<span class="data-icon data-icon-rotate-right"></span>`,
|
||||
title: '旋转',
|
||||
onClick: () => {
|
||||
this.image?.rotateImage();
|
||||
},
|
||||
},
|
||||
];
|
||||
const cropperItems: (CardToolbarItemOptions | ToolbarItemOptions)[] = [
|
||||
{
|
||||
key: 'button',
|
||||
type: 'button',
|
||||
content: `<span class="data-icon data-icon-cut"></span>`,
|
||||
title: '裁剪',
|
||||
onClick: () => {
|
||||
this.image?.cropImage();
|
||||
},
|
||||
},
|
||||
];
|
||||
const resizerItems: (CardToolbarItemOptions | ToolbarItemOptions)[] = [
|
||||
{
|
||||
key: 'width',
|
||||
type: 'input',
|
||||
placeholder: language.get('image', 'toolbbarWidthTitle').toString(),
|
||||
prefix: '宽:',
|
||||
value: value?.size?.width || 0,
|
||||
didMount: node => {
|
||||
this.widthInput = node.find('input[type=input]');
|
||||
},
|
||||
onChange: value => {
|
||||
const height = Math.round(parseInt(value, 10) * (this.image?.rate || 1));
|
||||
this.onInputChange(value, height);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'height',
|
||||
type: 'input',
|
||||
placeholder: language.get('image', 'toolbbarHeightTitle').toString(),
|
||||
prefix: '高:',
|
||||
value: value?.size?.height || 0,
|
||||
didMount: node => {
|
||||
this.heightInput = node.find('input[type=input]');
|
||||
},
|
||||
onChange: value => {
|
||||
const width = Math.round(parseInt(value, 10) / (this.image?.rate || 1));
|
||||
this.onInputChange(width, value);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'resize',
|
||||
type: 'button',
|
||||
content: `<span class="data-icon data-icon-huanyuan"></span>`,
|
||||
title: language.get<string>('image', 'toolbarReductionTitle'),
|
||||
onClick: () => {
|
||||
value = this.getValue();
|
||||
this.onInputChange(value?.size?.naturalWidth || 0, value?.size?.naturalHeight || 0);
|
||||
},
|
||||
},
|
||||
];
|
||||
const typeItems: (CardToolbarItemOptions | ToolbarItemOptions)[] = [
|
||||
{
|
||||
key: 'block',
|
||||
type: 'button',
|
||||
content: `<span class="data-icon data-icon-block-image"></span>`,
|
||||
title: language.get<string>('image', 'displayBlockTitle'),
|
||||
onClick: () => {
|
||||
this.type = CardType.BLOCK;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'inline',
|
||||
type: 'button',
|
||||
content: `<span class="data-icon data-icon-inline-image"></span>`,
|
||||
title: language.get<string>('image', 'displayInlineTitle'),
|
||||
onClick: () => {
|
||||
this.type = CardType.INLINE;
|
||||
},
|
||||
},
|
||||
];
|
||||
const imagePlugin = editor.plugin.findPlugin<ImageOptions>('image');
|
||||
return items.concat([
|
||||
...(imagePlugin?.options?.enableRotate === false ? [] : rotateItems),
|
||||
...(imagePlugin?.options?.enableCropper === false ? [] : cropperItems),
|
||||
...(imagePlugin?.options?.enableResizer === false ? [] : resizerItems),
|
||||
...(imagePlugin?.options?.enableTypeSwitch === false ? [] : typeItems),
|
||||
]);
|
||||
};
|
||||
const options = editor.plugin.findPlugin<ImageOptions>('image')?.options;
|
||||
if (options?.cardToolbars) {
|
||||
return options.cardToolbars(getItems(), this.editor);
|
||||
}
|
||||
return getItems();
|
||||
}
|
||||
|
||||
onActivate(activated: boolean) {
|
||||
super.onActivate(activated);
|
||||
if (activated && !this.selectedByOther) this.image?.focus();
|
||||
else this.image?.blur();
|
||||
}
|
||||
|
||||
onSelectByOther(
|
||||
selected: boolean,
|
||||
value?: {
|
||||
color: string;
|
||||
rgb: string;
|
||||
}
|
||||
): NodeInterface | void {
|
||||
this.image?.root?.css('outline', selected ? '2px solid ' + value!.color : '');
|
||||
const className = 'card-selected-other';
|
||||
if (selected) this.root.addClass(className);
|
||||
else this.root.removeClass(className);
|
||||
return this.image?.root;
|
||||
}
|
||||
|
||||
writeHistoryOnValueChange() {
|
||||
if (this.loading) return false;
|
||||
return;
|
||||
}
|
||||
|
||||
render(loadingBg?: string): string | void | NodeInterface {
|
||||
const value = this.getValue();
|
||||
if (!value) return;
|
||||
const editor = this.editor;
|
||||
if (!this.image || this.image.root.length === 0) {
|
||||
const imagePlugin = editor.plugin.findPlugin<ImageOptions>('image');
|
||||
this.image = new Image(editor, {
|
||||
root: this.root,
|
||||
container: this.getCenter(),
|
||||
status: value.status || 'done',
|
||||
src: value.src,
|
||||
size: value.size,
|
||||
alt: value.alt,
|
||||
link: value.link,
|
||||
display: this.type,
|
||||
percent: value.percent,
|
||||
message: value.message,
|
||||
enableResizer: imagePlugin?.options?.enableResizer,
|
||||
onBeforeRender: (status, src) => {
|
||||
const imagePlugin = editor.plugin.findPlugin<ImageOptions>('image');
|
||||
if (imagePlugin) {
|
||||
const { onBeforeRender } = imagePlugin.options || {};
|
||||
if (onBeforeRender) return onBeforeRender(status, src, this.editor);
|
||||
}
|
||||
return src;
|
||||
},
|
||||
onChangeSrc: src => {
|
||||
if (isEngine(editor) && !editor.readonly && src) {
|
||||
const value = this.getValue();
|
||||
console.log("value", value);
|
||||
this.setValue({ ...value, src } as T);
|
||||
}
|
||||
},
|
||||
onChange: (size, loaded) => {
|
||||
if (isEngine(editor) && !editor.readonly && size) this.setSize(size, loaded);
|
||||
},
|
||||
onError: () => {
|
||||
this.isLocalError = true;
|
||||
this.didUpdate();
|
||||
},
|
||||
onLoad: () => {
|
||||
if (this.image?.size && (!value.size?.naturalHeight || !value.size?.naturalWidth)) {
|
||||
const { naturalHeight, naturalWidth } = this.image.size;
|
||||
this.setSize(
|
||||
{
|
||||
...value.size,
|
||||
naturalHeight,
|
||||
naturalWidth,
|
||||
} as Size,
|
||||
true
|
||||
);
|
||||
}
|
||||
if (this.activated) this.image?.focus();
|
||||
},
|
||||
maxHeight: imagePlugin?.options?.maxHeight,
|
||||
});
|
||||
} else {
|
||||
this.image.changeUrl(value.src);
|
||||
this.image.status = value.status || 'done';
|
||||
this.image.message = value.message;
|
||||
this.image.size.width = value.size?.width || 0;
|
||||
this.image.size.height = value.size?.height || 0;
|
||||
if (value.percent) this.image.setProgressPercent(value.percent);
|
||||
this.image.resizer?.destroy();
|
||||
}
|
||||
this.image.render(loadingBg);
|
||||
}
|
||||
|
||||
didUpdate() {
|
||||
super.didUpdate();
|
||||
this.toolbarModel?.getContainer()?.remove();
|
||||
this.toolbarModel?.create();
|
||||
this.toolbarModel?.setDefaultAlign('top');
|
||||
}
|
||||
|
||||
didRender() {
|
||||
const value = this.getValue();
|
||||
if (value.status === 'done') super.didRender();
|
||||
this.toolbarModel?.setDefaultAlign('top');
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageComponent;
|
||||
194
editor/src/plugins/image/component/pswp/index.css
Normal file
194
editor/src/plugins/image/component/pswp/index.css
Normal file
@@ -0,0 +1,194 @@
|
||||
.pswp .data-pswp-tool-bar {
|
||||
position: absolute;
|
||||
top: initial;
|
||||
margin: 0 auto;
|
||||
bottom: 20px;
|
||||
vertical-align: middle;
|
||||
height: 54px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .separation {
|
||||
width: 1px;
|
||||
margin: 0px 10px;
|
||||
display: inline-block;
|
||||
height: 20px;
|
||||
border: 0.5px solid #383838;
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .btn {
|
||||
color: #f8f9fa;
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
margin: 0 6px;
|
||||
border: 1px solid #383838;
|
||||
border-radius: 2px;
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .btn::before {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 0 auto;
|
||||
content: ' ';
|
||||
display: inline-block;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0.65;
|
||||
background-position: -1px -1px;
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .data-pswp-arrow-left {
|
||||
padding: 8px;
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .data-pswp-arrow-right {
|
||||
padding: 8px;
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .data-pswp-arrow-left::before {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-size: 16px 16px;
|
||||
background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjBweCIgaGVpZ2h0PSIyMHB4IiB2aWV3Qm94PSIwIDAgMjAgMjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDU1LjIgKDc4MTgxKSAtIGh0dHBzOi8vc2tldGNoYXBwLmNvbSAtLT4KICAgIDx0aXRsZT5sZWZ0LWNpcmNsZTwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4KICAgIDxnIGlkPSJsZWZ0LWNpcmNsZSIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgaWQ9ImxlZnQiIGZpbGwtcnVsZT0ibm9uemVybyI+CiAgICAgICAgICAgIDxyZWN0IGlkPSLnn6nlvaIiIGZpbGw9IiMwMDAwMDAiIG9wYWNpdHk9IjAiIHg9IjAiIHk9IjAiIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCI+PC9yZWN0PgogICAgICAgICAgICA8cGF0aCBkPSJNMTQuMTQwNjI1LDQuMjYzNjcxODggTDE0LjE0MDYyNSwyLjc1MzkwNjI1IEMxNC4xNDA2MjUsMi42MjMwNDY4OCAxMy45OTAyMzQ0LDIuNTUwNzgxMjUgMTMuODg4NjcxOSwyLjYzMDg1OTM3IEw1LjA4Mzk4NDM4LDkuNTA3ODEyNSBDNC43NjM2NzE4OCw5Ljc1NzgxMjUgNC43NjM2NzE4OCwxMC4yNDAyMzQ0IDUuMDgzOTg0MzgsMTAuNDkwMjM0NCBMMTMuODg4NjcxOSwxNy4zNjcxODc1IEMxMy45OTIxODc1LDE3LjQ0NzI2NTYgMTQuMTQwNjI1LDE3LjM3NSAxNC4xNDA2MjUsMTcuMjQ0MTQwNiBMMTQuMTQwNjI1LDE1LjczNDM3NSBDMTQuMTQwNjI1LDE1LjYzODY3MTkgMTQuMDk1NzAzMSwxNS41NDY4NzUgMTQuMDIxNDg0NCwxNS40ODgyODEzIEw2Ljk5MDIzNDM4LDEwIEwxNC4wMjE0ODQ0LDQuNTA5NzY1NjMgQzE0LjA5NTcwMzEsNC40NTExNzE4OCAxNC4xNDA2MjUsNC4zNTkzNzUgMTQuMTQwNjI1LDQuMjYzNjcxODggWiIgaWQ9Iui3r+W+hCIgZmlsbD0iI0ZGRkZGRiI+PC9wYXRoPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+');
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .data-pswp-arrow-right::before {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-size: 16px 16px;
|
||||
background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjBweCIgaGVpZ2h0PSIyMHB4IiB2aWV3Qm94PSIwIDAgMjAgMjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDU1LjIgKDc4MTgxKSAtIGh0dHBzOi8vc2tldGNoYXBwLmNvbSAtLT4KICAgIDx0aXRsZT5yaWdodC1jaXJjbGU8L3RpdGxlPgogICAgPGRlc2M+Q3JlYXRlZCB3aXRoIFNrZXRjaC48L2Rlc2M+CiAgICA8ZyBpZD0icmlnaHQtY2lyY2xlIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj4KICAgICAgICA8ZyBpZD0icmlnaHQiIGZpbGwtcnVsZT0ibm9uemVybyI+CiAgICAgICAgICAgIDxyZWN0IGlkPSLnn6nlvaIiIGZpbGw9IiMwMDAwMDAiIG9wYWNpdHk9IjAiIHg9IjAiIHk9IjAiIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCI+PC9yZWN0PgogICAgICAgICAgICA8cGF0aCBkPSJNMTQuOTU1MDc4MSw5LjUwNzgxMjUgTDYuMTUwMzkwNjIsMi42MzA4NTkzNyBDNi4wNDY4NzUsMi41NTA3ODEyNSA1Ljg5ODQzNzUsMi42MjMwNDY4NyA1Ljg5ODQzNzUsMi43NTM5MDYyNSBMNS44OTg0Mzc1LDQuMjYzNjcxODggQzUuODk4NDM3NSw0LjM1OTM3NSA1Ljk0MzM1OTM4LDQuNDUxMTcxODggNi4wMTc1NzgxMiw0LjUwOTc2NTYyIEwxMy4wNDg4MjgxLDEwIEw2LjAxNzU3ODEyLDE1LjQ5MDIzNDQgQzUuOTQxNDA2MjUsMTUuNTQ4ODI4MSA1Ljg5ODQzNzUsMTUuNjQwNjI1IDUuODk4NDM3NSwxNS43MzYzMjgxIEw1Ljg5ODQzNzUsMTcuMjQ2MDkzOCBDNS44OTg0Mzc1LDE3LjM3Njk1MzEgNi4wNDg4MjgxMiwxNy40NDkyMTg4IDYuMTUwMzkwNjIsMTcuMzY5MTQwNiBMMTQuOTU1MDc4MSwxMC40OTIxODc1IEMxNS4yNzUzOTA2LDEwLjI0MjE4NzUgMTUuMjc1MzkwNiw5Ljc1NzgxMjUgMTQuOTU1MDc4MSw5LjUwNzgxMjUgWiIgaWQ9Iui3r+W+hCIgZmlsbD0iI0ZGRkZGRiI+PC9wYXRoPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+');
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .data-pswp-zoom-in::before {
|
||||
background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjBweCIgaGVpZ2h0PSIyMHB4IiB2aWV3Qm94PSIwIDAgMjAgMjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDU1LjEgKDc4MTM2KSAtIGh0dHBzOi8vc2tldGNoYXBwLmNvbSAtLT4KICAgIDx0aXRsZT56b29tIGluPC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGcgaWQ9Inpvb20taW4iIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxnIGZpbGwtcnVsZT0ibm9uemVybyI+CiAgICAgICAgICAgIDxyZWN0IGlkPSLnn6nlvaIiIGZpbGw9IiMwMDAwMDAiIG9wYWNpdHk9IjAiIHg9IjAiIHk9IjAiIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCI+PC9yZWN0PgogICAgICAgICAgICA8cGF0aCBkPSJNMTIuNDQxNDA2Myw4LjY1MjM0Mzc1IEwxMC4xMzY3MTg4LDguNjUyMzQzNzUgTDEwLjEzNjcxODgsNi4wMzUxNTYyNSBDMTAuMTM2NzE4OCw1Ljk0OTIxODc1IDEwLjA2NjQwNjMsNS44Nzg5MDYyNSA5Ljk4MDQ2ODc1LDUuODc4OTA2MjUgTDguODA4NTkzNzUsNS44Nzg5MDYyNSBDOC43MjI2NTYyNSw1Ljg3ODkwNjI1IDguNjUyMzQzNzUsNS45NDkyMTg3NSA4LjY1MjM0Mzc1LDYuMDM1MTU2MjUgTDguNjUyMzQzNzUsOC42NTIzNDM3NSBMNi4zNDc2NTYyNSw4LjY1MjM0Mzc1IEM2LjI2MTcxODc1LDguNjUyMzQzNzUgNi4xOTE0MDYyNSw4LjcyMjY1NjI1IDYuMTkxNDA2MjUsOC44MDg1OTM3NSBMNi4xOTE0MDYyNSw5Ljk4MDQ2ODc1IEM2LjE5MTQwNjI1LDEwLjA2NjQwNjMgNi4yNjE3MTg3NSwxMC4xMzY3MTg4IDYuMzQ3NjU2MjUsMTAuMTM2NzE4OCBMOC42NTIzNDM3NSwxMC4xMzY3MTg4IEw4LjY1MjM0Mzc1LDEyLjc1MzkwNjMgQzguNjUyMzQzNzUsMTIuODM5ODQzNyA4LjcyMjY1NjI1LDEyLjkxMDE1NjMgOC44MDg1OTM3NSwxMi45MTAxNTYzIEw5Ljk4MDQ2ODc1LDEyLjkxMDE1NjMgQzEwLjA2NjQwNjMsMTIuOTEwMTU2MyAxMC4xMzY3MTg4LDEyLjgzOTg0MzcgMTAuMTM2NzE4OCwxMi43NTM5MDYzIEwxMC4xMzY3MTg4LDEwLjEzNjcxODggTDEyLjQ0MTQwNjMsMTAuMTM2NzE4OCBDMTIuNTI3MzQzNywxMC4xMzY3MTg4IDEyLjU5NzY1NjMsMTAuMDY2NDA2MyAxMi41OTc2NTYzLDkuOTgwNDY4NzUgTDEyLjU5NzY1NjMsOC44MDg1OTM3NSBDMTIuNTk3NjU2Myw4LjcyMjY1NjI1IDEyLjUyNzM0MzcsOC42NTIzNDM3NSAxMi40NDE0MDYzLDguNjUyMzQzNzUgWiBNMTcuOTg4MjgxMywxNi45MzM1OTM4IEwxNS4xMzY3MTg4LDE0LjA4MjAzMTMgQzE3LjUyMTQ4NDQsMTEuMTczODI4MSAxNy4zNTU0Njg4LDYuODY1MjM0MzggMTQuNjI4OTA2Myw0LjE0MDYyNSBDMTEuNzM4MjgxMywxLjI0ODA0Njg4IDcuMDQyOTY4NzUsMS4yNDgwNDY4OCA0LjE0MDYyNSw0LjE0MDYyNSBDMS4yNDgwNDY4OCw3LjA0Mjk2ODc1IDEuMjQ4MDQ2ODgsMTEuNzM4MjgxMyA0LjE0MDYyNSwxNC42Mjg5MDYzIEM2Ljg2NTIzNDM4LDE3LjM1NTQ2ODggMTEuMTczODI4MSwxNy41MjE0ODQ0IDE0LjA4MjAzMTMsMTUuMTM2NzE4OCBMMTYuOTMzNTkzOCwxNy45ODgyODEzIEMxNi45OTYwOTM4LDE4LjA0Mjk2ODggMTcuMDk1NzAzMSwxOC4wNDI5Njg4IDE3LjE0ODQzNzUsMTcuOTg4MjgxMyBMMTcuOTg4MjgxMywxNy4xNDg0Mzc1IEMxOC4wNDI5Njg4LDE3LjA5NTcwMzEgMTguMDQyOTY4OCwxNi45OTYwOTM4IDE3Ljk4ODI4MTMsMTYuOTMzNTkzOCBaIE0xMy41OTM3NSwxMy41OTM3NSBDMTEuMjczNDM3NSwxNS45MTIxMDk0IDcuNTE1NjI1LDE1LjkxMjEwOTQgNS4xOTUzMTI1LDEzLjU5Mzc1IEMyLjg3Njk1MzEzLDExLjI3MzQzNzUgMi44NzY5NTMxMyw3LjUxNTYyNSA1LjE5NTMxMjUsNS4xOTUzMTI1IEM3LjUxNTYyNSwyLjg3Njk1MzEzIDExLjI3MzQzNzUsMi44NzY5NTMxMyAxMy41OTM3NSw1LjE5NTMxMjUgQzE1LjkxMjEwOTQsNy41MTU2MjUgMTUuOTEyMTA5NCwxMS4yNzM0Mzc1IDEzLjU5Mzc1LDEzLjU5Mzc1IFoiIGlkPSLlvaLnirYiIGZpbGw9IiNGRkZGRkYiPjwvcGF0aD4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPg==');
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .data-pswp-zoom-out::before {
|
||||
background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjBweCIgaGVpZ2h0PSIyMHB4IiB2aWV3Qm94PSIwIDAgMjAgMjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDU1LjEgKDc4MTM2KSAtIGh0dHBzOi8vc2tldGNoYXBwLmNvbSAtLT4KICAgIDx0aXRsZT56b29tIG91dDwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4KICAgIDxnIGlkPSJ6b29tLW91dCIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgZmlsbC1ydWxlPSJub256ZXJvIj4KICAgICAgICAgICAgPHJlY3QgaWQ9IuefqeW9oiIgZmlsbD0iIzAwMDAwMCIgb3BhY2l0eT0iMCIgeD0iMCIgeT0iMCIgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIj48L3JlY3Q+CiAgICAgICAgICAgIDxwYXRoIGQ9Ik0xMi40NDE0MDYzLDguNjUyMzQzNzUgTDYuMzQ3NjU2MjUsOC42NTIzNDM3NSBDNi4yNjE3MTg3NSw4LjY1MjM0Mzc1IDYuMTkxNDA2MjUsOC43MjI2NTYyNSA2LjE5MTQwNjI1LDguODA4NTkzNzUgTDYuMTkxNDA2MjUsOS45ODA0Njg3NSBDNi4xOTE0MDYyNSwxMC4wNjY0MDYzIDYuMjYxNzE4NzUsMTAuMTM2NzE4OCA2LjM0NzY1NjI1LDEwLjEzNjcxODggTDEyLjQ0MTQwNjMsMTAuMTM2NzE4OCBDMTIuNTI3MzQzNywxMC4xMzY3MTg4IDEyLjU5NzY1NjMsMTAuMDY2NDA2MyAxMi41OTc2NTYzLDkuOTgwNDY4NzUgTDEyLjU5NzY1NjMsOC44MDg1OTM3NSBDMTIuNTk3NjU2Myw4LjcyMjY1NjI1IDEyLjUyNzM0MzcsOC42NTIzNDM3NSAxMi40NDE0MDYzLDguNjUyMzQzNzUgWiBNMTcuOTg4MjgxMywxNi45MzM1OTM4IEwxNS4xMzY3MTg4LDE0LjA4MjAzMTMgQzE3LjUyMTQ4NDQsMTEuMTczODI4MSAxNy4zNTU0Njg4LDYuODY1MjM0MzggMTQuNjI4OTA2Myw0LjE0MDYyNSBDMTEuNzM4MjgxMywxLjI0ODA0Njg4IDcuMDQyOTY4NzUsMS4yNDgwNDY4OCA0LjE0MDYyNSw0LjE0MDYyNSBDMS4yNDgwNDY4OCw3LjA0Mjk2ODc1IDEuMjQ4MDQ2ODgsMTEuNzM4MjgxMyA0LjE0MDYyNSwxNC42Mjg5MDYzIEM2Ljg2NTIzNDM4LDE3LjM1NTQ2ODggMTEuMTczODI4MSwxNy41MjE0ODQ0IDE0LjA4MjAzMTMsMTUuMTM2NzE4OCBMMTYuOTMzNTkzOCwxNy45ODgyODEzIEMxNi45OTYwOTM4LDE4LjA0Mjk2ODggMTcuMDk1NzAzMSwxOC4wNDI5Njg4IDE3LjE0ODQzNzUsMTcuOTg4MjgxMyBMMTcuOTg4MjgxMywxNy4xNDg0Mzc1IEMxOC4wNDI5Njg4LDE3LjA5NTcwMzEgMTguMDQyOTY4OCwxNi45OTYwOTM4IDE3Ljk4ODI4MTMsMTYuOTMzNTkzOCBaIE0xMy41OTM3NSwxMy41OTM3NSBDMTEuMjczNDM3NSwxNS45MTIxMDk0IDcuNTE1NjI1LDE1LjkxMjEwOTQgNS4xOTUzMTI1LDEzLjU5Mzc1IEMyLjg3Njk1MzEzLDExLjI3MzQzNzUgMi44NzY5NTMxMyw3LjUxNTYyNSA1LjE5NTMxMjUsNS4xOTUzMTI1IEM3LjUxNTYyNSwyLjg3Njk1MzEzIDExLjI3MzQzNzUsMi44NzY5NTMxMyAxMy41OTM3NSw1LjE5NTMxMjUgQzE1LjkxMjEwOTQsNy41MTU2MjUgMTUuOTEyMTA5NCwxMS4yNzM0Mzc1IDEzLjU5Mzc1LDEzLjU5Mzc1IFoiIGlkPSLlvaLnirYiIGZpbGw9IiNGRkZGRkYiPjwvcGF0aD4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPg==');
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .data-pswp-origin-size::before {
|
||||
background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjBweCIgaGVpZ2h0PSIyMHB4IiB2aWV3Qm94PSIwIDAgMjAgMjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDU1LjEgKDc4MTM2KSAtIGh0dHBzOi8vc2tldGNoYXBwLmNvbSAtLT4KICAgIDx0aXRsZT5hY3R1YWwgc2l6ZTwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4KICAgIDxnIGlkPSJhY3R1YWwtc2l6ZSIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgaWQ9Iue8lue7hC0yIj4KICAgICAgICAgICAgPGcgaWQ9Iue8lue7hCI+CiAgICAgICAgICAgICAgICA8cmVjdCBpZD0i55+p5b2iIiB4PSIwIiB5PSIwIiB3aWR0aD0iMjAiIGhlaWdodD0iMjAiPjwvcmVjdD4KICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8cGF0aCBkPSJNNS45Mzc1LDE1LjM5MDYyNSBMNC4xMTY3NzYzMiwxNS4zOTA2MjUgTDQuMTE2Nzc2MzIsNy40NDU3MDk3NSBMMS44NzUsNy40NDU3MDk3NSBMMS44NzUsNi40MjA3NTQ5NCBDMi42NjcxNzc2LDYuNDE3MjcwNjUgMy4zMDI5MzEwOCw2LjI2Mzg2MzM2IDMuNzc5NjI2MzcsNS45NTM5MDU1MyBDNC4yMzAwNDk4Miw1LjY2MTAzMDIxIDQuNTI4ODg0MSw1LjIwODg0ODQxIDQuNjc2MDQ5NzgsNC42MDkzNzUgTDUuOTM3NSw0LjYwOTM3NSBMNS45Mzc1LDE1LjM5MDYyNSBaIE05LjY4NzUsNy41MzkwNjI1IEwxMS40MDYyNSw3LjUzOTA2MjUgTDExLjQwNjI1LDkuNTcwMzEyNSBMOS42ODc1LDkuNTcwMzEyNSBMOS42ODc1LDcuNTM5MDYyNSBaIE05LjY4NzUsMTMuMzU5Mzc1IEwxMS40MDYyNSwxMy4zNTkzNzUgTDExLjQwNjI1LDE1LjM5MDYyNSBMOS42ODc1LDE1LjM5MDYyNSBMOS42ODc1LDEzLjM1OTM3NSBaIE0xNy41LDE1LjM5MDYyNSBMMTcuNSw0LjYwOTM3NSBMMTYuMjM4NTQ5OCw0LjYwOTM3NSBDMTYuMDkxMzg0MSw1LjIwODg0ODQxIDE1Ljc5MjU0OTgsNS42NjEwMzAyMSAxNS4zNDIxMjY0LDUuOTUzOTA1NTMgQzE0Ljg2NTQzMTEsNi4yNjM4NjMzNiAxNC4yMjk2Nzc2LDYuNDE3MjcwNjUgMTMuNDM3NSw2LjQyMDc1NDk0IEwxMy40Mzc1LDcuNDQ1NzA5NzUgTDE1LjY3OTI3NjMsNy40NDU3MDk3NSBMMTUuNjc5Mjc2MywxNS4zOTA2MjUgTDE3LjUsMTUuMzkwNjI1IFoiIGlkPSLlvaLnirbnu5PlkIgiIGZpbGw9IiNGRkZGRkYiIGZpbGwtcnVsZT0ibm9uemVybyI+PC9wYXRoPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+');
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .data-pswp-best-size::before {
|
||||
background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjBweCIgaGVpZ2h0PSIyMHB4IiB2aWV3Qm94PSIwIDAgMjAgMjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDU1LjIgKDc4MTgxKSAtIGh0dHBzOi8vc2tldGNoYXBwLmNvbSAtLT4KICAgIDx0aXRsZT5maXQgc2NyZWVuPC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGcgaWQ9ImZpdC1zY3JlZW4iIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxnIGlkPSJhcnJhd3NhbHQiIGZpbGwtcnVsZT0ibm9uemVybyI+CiAgICAgICAgICAgIDxyZWN0IGlkPSLnn6nlvaIiIGZpbGw9IiMwMDAwMDAiIG9wYWNpdHk9IjAiIHg9IjAiIHk9IjAiIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCI+PC9yZWN0PgogICAgICAgICAgICA8cGF0aCBkPSJNMTYuNjk5MjE4OCwzLjEyNjk1MzEyIEwxMy4wMDM5MDYzLDMuNTg1OTM3NSBDMTIuODc1LDMuNjAxNTYyNSAxMi44MjIyNjU2LDMuNzU3ODEyNSAxMi45MTIxMDk0LDMuODQ5NjA5MzcgTDEzLjk4MDQ2ODgsNC45MTc5Njg3NSBMMTAuOTgyNDIxOSw3LjkxNjAxNTYyIEMxMC45MjE4NzUsNy45NzY1NjI1IDEwLjkyMTg3NSw4LjA3NjE3MTg3IDEwLjk4MjQyMTksOC4xMzY3MTg3NSBMMTEuODYzMjgxMyw5LjAxNzU3ODEyIEMxMS45MjM4MjgxLDkuMDc4MTI1IDEyLjAyMzQzNzUsOS4wNzgxMjUgMTIuMDgzOTg0NCw5LjAxNzU3ODEyIEwxNS4wODM5ODQ0LDYuMDE3NTc4MTIgTDE2LjE1MjM0MzgsNy4wODU5Mzc1IEMxNi4yNDQxNDA2LDcuMTc3NzM0MzcgMTYuNDAwMzkwNiw3LjEyMzA0Njg3IDE2LjQxNjAxNTYsNi45OTQxNDA2MiBMMTYuODczMDQ2OSwzLjMwMDc4MTI1IEMxNi44ODY3MTg4LDMuMTk5MjE4NzUgMTYuODAwNzgxMywzLjExMzI4MTI1IDE2LjY5OTIxODgsMy4xMjY5NTMxMiBaIE04LjEzNjcxODc1LDEwLjk4MjQyMTkgQzguMDc2MTcxODgsMTAuOTIxODc1IDcuOTc2NTYyNSwxMC45MjE4NzUgNy45MTYwMTU2MywxMC45ODI0MjE5IEw0LjkxNzk2ODc1LDEzLjk4MjQyMTkgTDMuODQ5NjA5MzgsMTIuOTE0MDYyNSBDMy43NTc4MTI1LDEyLjgyMjI2NTYgMy42MDE1NjI1LDEyLjg3Njk1MzEgMy41ODU5Mzc1LDEzLjAwNTg1OTQgTDMuMTI2OTUzMTMsMTYuNjk5MjE4OCBDMy4xMTUyMzQzOCwxNi44MDA3ODEyIDMuMTk5MjE4NzUsMTYuODg0NzY1NiAzLjMwMDc4MTI1LDE2Ljg3MzA0NjkgTDYuOTk2MDkzNzUsMTYuNDE0MDYyNSBDNy4xMjUsMTYuMzk4NDM3NSA3LjE3NzczNDM4LDE2LjI0MjE4NzUgNy4wODc4OTA2MywxNi4xNTAzOTA2IEw2LjAxOTUzMTI1LDE1LjA4MjAzMTIgTDkuMDE5NTMxMjUsMTIuMDgyMDMxMiBDOS4wODAwNzgxMywxMi4wMjE0ODQ0IDkuMDgwMDc4MTMsMTEuOTIxODc1IDkuMDE5NTMxMjUsMTEuODYxMzI4MSBMOC4xMzY3MTg3NSwxMC45ODI0MjE5IEw4LjEzNjcxODc1LDEwLjk4MjQyMTkgWiIgaWQ9IuW9oueKtiIgZmlsbD0iI0ZGRkZGRiI+PC9wYXRoPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+');
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .btn:not(.disable):hover {
|
||||
cursor: pointer;
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .btn:not(.disable):hover::before {
|
||||
opacity: 1;
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .btn.disable {
|
||||
background: none;
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .btn.activated {
|
||||
background: #454545;
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .btn.activated::before {
|
||||
opacity: 1;
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .btn.disable::before {
|
||||
opacity: 0.25;
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .disable:hover {
|
||||
cursor: not-allowed;
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .pswp-toolbar-content {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
background: #252525;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
|
||||
}
|
||||
|
||||
.pswp .data-pswp-tool-bar .pswp-toolbar-content .data-pswp-counter {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
vertical-align: top;
|
||||
line-height: 34px;
|
||||
color: #DEDEDE;
|
||||
margin: 0 2px;
|
||||
|
||||
}
|
||||
|
||||
.pswp.data-pswp-mobile .data-pswp-tool-bar {
|
||||
display: none;
|
||||
|
||||
}
|
||||
|
||||
.pswp__ui {
|
||||
-webkit-font-smoothing: auto;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
z-index: 1550;
|
||||
}
|
||||
|
||||
.pswp__button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
position: relative;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
overflow: visible;
|
||||
display: block;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
float: right;
|
||||
opacity: 0.75;
|
||||
-webkit-transition: opacity 0.2s;
|
||||
transition: opacity 0.2s;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pswp-fade-out {
|
||||
opacity: 0;
|
||||
animation: fadeOut 0.333s;
|
||||
}
|
||||
|
||||
.pswp .data-pswp-button-close {
|
||||
margin: 32px;
|
||||
margin-right: 0px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
z-index: 999999;
|
||||
position: relative;
|
||||
background: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iNDBweCIgaGVpZ2h0PSI0MHB4IiB2aWV3Qm94PSIwIDAgNDAgNDAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDU1LjIgKDc4MTgxKSAtIGh0dHBzOi8vc2tldGNoYXBwLmNvbSAtLT4KICAgIDx0aXRsZT5jbG9zZS1kZWZhdWx0PC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGcgaWQ9Iuafpeeci+WbvueJh+S8mOWMljQuMCIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgaWQ9IueUu+advyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTQ4LjAwMDAwMCwgLTczNS4wMDAwMDApIj4KICAgICAgICAgICAgPGcgaWQ9ImNsb3NlLWRlZmF1bHQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDQ4LjAwMDAwMCwgNzM1LjAwMDAwMCkiPgogICAgICAgICAgICAgICAgPHJlY3QgaWQ9IuefqeW9oiIgZmlsbD0iIzFBMUExQSIgb3BhY2l0eT0iMC4zNSIgeD0iMCIgeT0iMCIgd2lkdGg9IjQwIiBoZWlnaHQ9IjQwIiByeD0iNCI+PC9yZWN0PgogICAgICAgICAgICAgICAgPGcgaWQ9ImNsb3NlIiBvcGFjaXR5PSIwLjY1IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg4LjAwMDAwMCwgOC4wMDAwMDApIj4KICAgICAgICAgICAgICAgICAgICA8cmVjdCBpZD0i55+p5b2iIiBmaWxsPSIjMDAwMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIG9wYWNpdHk9IjAiIHg9IjAiIHk9IjAiIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PC9yZWN0PgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xMy4yMTQwNjI1LDEyIEwxOS4zNjY0MDYyLDQuNjY2NDA2MjUgQzE5LjQ2OTUzMTIsNC41NDQ1MzEyNSAxOS4zODI4MTI1LDQuMzU5Mzc1IDE5LjIyMzQzNzUsNC4zNTkzNzUgTDE3LjM1MzEyNSw0LjM1OTM3NSBDMTcuMjQyOTY4Nyw0LjM1OTM3NSAxNy4xMzc1LDQuNDA4NTkzNzUgMTcuMDY0ODQzNyw0LjQ5Mjk2ODc1IEwxMS45OTA2MjUsMTAuNTQyMTg3NSBMNi45MTY0MDYyNSw0LjQ5Mjk2ODc1IEM2Ljg0NjA5Mzc1LDQuNDA4NTkzNzUgNi43NDA2MjUsNC4zNTkzNzUgNi42MjgxMjUsNC4zNTkzNzUgTDQuNzU3ODEyNSw0LjM1OTM3NSBDNC41OTg0Mzc1LDQuMzU5Mzc1IDQuNTExNzE4NzUsNC41NDQ1MzEyNSA0LjYxNDg0Mzc1LDQuNjY2NDA2MjUgTDEwLjc2NzE4NzUsMTIgTDQuNjE0ODQzNzUsMTkuMzMzNTkzNyBDNC41MTE3MTg3NSwxOS40NTU0Njg4IDQuNTk4NDM3NSwxOS42NDA2MjUgNC43NTc4MTI1LDE5LjY0MDYyNSBMNi42MjgxMjUsMTkuNjQwNjI1IEM2LjczODI4MTI1LDE5LjY0MDYyNSA2Ljg0Mzc1LDE5LjU5MTQwNjIgNi45MTY0MDYyNSwxOS41MDcwMzEyIEwxMS45OTA2MjUsMTMuNDU3ODEyNSBMMTcuMDY0ODQzNywxOS41MDcwMzEyIEMxNy4xMzUxNTYyLDE5LjU5MTQwNjIgMTcuMjQwNjI1LDE5LjY0MDYyNSAxNy4zNTMxMjUsMTkuNjQwNjI1IEwxOS4yMjM0Mzc1LDE5LjY0MDYyNSBDMTkuMzgyODEyNSwxOS42NDA2MjUgMTkuNDY5NTMxMiwxOS40NTU0Njg4IDE5LjM2NjQwNjIsMTkuMzMzNTkzNyBMMTMuMjE0MDYyNSwxMiBaIiBpZD0i6Lev5b6EIiBmaWxsPSIjRkZGRkZGIj48L3BhdGg+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDwvZz4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPg==');
|
||||
}
|
||||
|
||||
338
editor/src/plugins/image/component/pswp/index.ts
Normal file
338
editor/src/plugins/image/component/pswp/index.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { EventEmitter2 } from 'eventemitter2';
|
||||
import PhotoSwipe from 'photoswipe';
|
||||
import PhotoSwipeUI from 'photoswipe/dist/photoswipe-ui-default';
|
||||
import { $, EditorInterface, isHotkey, isMobile, NodeInterface } from '@aomao/engine';
|
||||
import { PswpInterface } from '../../types';
|
||||
import Zoom from './zoom';
|
||||
import 'photoswipe/dist/photoswipe.css';
|
||||
import './index.css';
|
||||
|
||||
class Pswp extends EventEmitter2 implements PswpInterface {
|
||||
private editor: EditorInterface;
|
||||
private options: PhotoSwipeUI.Options;
|
||||
private timeouts: any = [];
|
||||
private pswpUI?: PhotoSwipe<PhotoSwipeUI.Options>;
|
||||
private zoom?: number;
|
||||
private isDestroy = true;
|
||||
private zoomUI: Zoom;
|
||||
root: NodeInterface;
|
||||
barUI: NodeInterface;
|
||||
closeUI: NodeInterface;
|
||||
|
||||
constructor(editor: EditorInterface, options?: PhotoSwipeUI.Options) {
|
||||
super();
|
||||
this.editor = editor;
|
||||
this.options = {
|
||||
shareEl: false,
|
||||
fullscreenEl: false,
|
||||
zoomEl: false,
|
||||
history: false,
|
||||
closeOnScroll: false,
|
||||
preloaderEl: false,
|
||||
captionEl: false,
|
||||
counterEl: false,
|
||||
clickToCloseNonZoomable: false,
|
||||
showAnimationDuration: 0,
|
||||
hideAnimationDuration: 0,
|
||||
closeOnVerticalDrag: isMobile,
|
||||
tapToClose: true,
|
||||
bgOpacity: 0.8,
|
||||
barsSize: {
|
||||
top: 44,
|
||||
bottom: 80,
|
||||
},
|
||||
...options,
|
||||
};
|
||||
|
||||
this.isDestroy = true;
|
||||
this.root = this.renderTemplate();
|
||||
this.barUI = this.root.find('.data-pswp-custom-top-bar');
|
||||
this.closeUI = this.root.find('.data-pswp-button-close');
|
||||
$(document.body).append(this.root);
|
||||
this.zoomUI = new Zoom(editor, this);
|
||||
this.zoomUI.render();
|
||||
this.reset();
|
||||
this.bindClickEvent();
|
||||
window.addEventListener('resize', this.reset);
|
||||
}
|
||||
|
||||
renderTemplate() {
|
||||
const root = $(`
|
||||
<div class="pswp" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="pswp__bg"></div>
|
||||
<div class="pswp__scroll-wrap">
|
||||
<div class="pswp__container">
|
||||
<div class="pswp__item"></div>
|
||||
<div class="pswp__item"></div>
|
||||
<div class="pswp__item"></div>
|
||||
</div>
|
||||
<div class="pswp__ui pswp__ui--hidden">
|
||||
<button type="button" class="pswp__button data-pswp-button-close" title="Close (Esc)"></button>
|
||||
<div class="data-pswp-custom-top-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
return root;
|
||||
}
|
||||
|
||||
reset = () => {
|
||||
this.root.removeClass(isMobile ? 'data-pswp-mobile' : 'data-pswp-pc');
|
||||
this.root.addClass(isMobile ? 'data-pswp-mobile' : 'data-pswp-pc');
|
||||
this.unbindKeyboardEvnet();
|
||||
this.unbindControllerFadeInAndOut();
|
||||
if (!isMobile) {
|
||||
this.bindKeyboardEvnet();
|
||||
this.bindControllerFadeInAndOut();
|
||||
}
|
||||
};
|
||||
|
||||
onBarMouseEnter = () => {
|
||||
this.removeFadeOut(this.barUI, 'barFadeInAndOut');
|
||||
this.removeFadeOut(this.closeUI, 'closeFadeInAndOut');
|
||||
};
|
||||
|
||||
onBarMouseLeave = () => {
|
||||
this.fadeOut(this.barUI, 'barFadeInAndOut');
|
||||
this.fadeOut(this.closeUI, 'closeFadeInAndOut');
|
||||
};
|
||||
|
||||
onCloseMouseEnter = () => {
|
||||
this.removeFadeOut(this.barUI, 'barFadeInAndOut');
|
||||
this.removeFadeOut(this.closeUI, 'closeFadeInAndOut');
|
||||
};
|
||||
|
||||
onCloseMouseLeave = () => {
|
||||
this.fadeOut(this.barUI, 'barFadeInAndOut');
|
||||
this.fadeOut(this.closeUI, 'closeFadeInAndOut');
|
||||
};
|
||||
|
||||
bindControllerFadeInAndOut() {
|
||||
this.barUI.on('mouseenter', this.onBarMouseEnter);
|
||||
this.barUI.on('mouseleave', this.onBarMouseLeave);
|
||||
this.closeUI.on('mouseenter', this.onCloseMouseEnter);
|
||||
this.closeUI.on('mouseleave', this.onCloseMouseLeave);
|
||||
}
|
||||
|
||||
unbindControllerFadeInAndOut() {
|
||||
this.barUI.off('mouseenter', this.onBarMouseEnter);
|
||||
this.barUI.off('mouseleave', this.onBarMouseLeave);
|
||||
this.closeUI.off('mouseenter', this.onCloseMouseEnter);
|
||||
this.closeUI.off('mouseleave', this.onCloseMouseLeave);
|
||||
}
|
||||
|
||||
removeFadeOut(node: NodeInterface, id: string) {
|
||||
if (this.timeouts[id]) {
|
||||
clearTimeout(this.timeouts[id]);
|
||||
}
|
||||
node.removeClass('pswp-fade-out');
|
||||
}
|
||||
|
||||
fadeOut(node: NodeInterface, id: string) {
|
||||
if (this.timeouts[id]) {
|
||||
clearTimeout(this.timeouts[id]);
|
||||
}
|
||||
this.timeouts[id] = setTimeout(() => {
|
||||
node.addClass('pswp-fade-out');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
bindClickEvent() {
|
||||
const onClick = (event: MouseEvent | TouchEvent) => {
|
||||
const node = window.TouchEvent && event instanceof TouchEvent ? $(event.touches[0].target) : $(event.target || []);
|
||||
if (node.hasClass('pswp__img')) {
|
||||
setTimeout(() => {
|
||||
this.zoom = undefined;
|
||||
this.afterZoom();
|
||||
}, 366);
|
||||
}
|
||||
if (node.hasClass('pswp__bg') || node.hasClass('data-pswp-tool-bar')) {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
this.root.on('click', onClick, { passive: true });
|
||||
this.closeUI.on('click', this.close);
|
||||
}
|
||||
|
||||
prev() {
|
||||
this.pswpUI?.prev();
|
||||
}
|
||||
|
||||
next() {
|
||||
this.pswpUI?.next();
|
||||
}
|
||||
|
||||
renderCounter() {
|
||||
this.barUI.find('.data-pswp-counter').html(`${(this.pswpUI?.getCurrentIndex() || 0) + 1} / ${this.pswpUI?.items.length || ''}`);
|
||||
}
|
||||
|
||||
getCurrentZoomLevel() {
|
||||
return (this.zoom && +this.zoom.toFixed(2)) || (this.pswpUI && +this.pswpUI.getZoomLevel().toFixed(2)) || 0;
|
||||
}
|
||||
|
||||
zoomTo(zoom: number) {
|
||||
if (!this.pswpUI) return;
|
||||
this.pswpUI.zoomTo(
|
||||
zoom,
|
||||
{
|
||||
x: this.pswpUI.viewportSize.x / 2,
|
||||
y: this.pswpUI.viewportSize.y / 2,
|
||||
},
|
||||
100
|
||||
);
|
||||
this.zoom = zoom;
|
||||
this.afterZoom();
|
||||
}
|
||||
|
||||
zoomIn() {
|
||||
const zoom = this.getCurrentZoomLevel();
|
||||
let newZoom = (zoom || 0) + 0.2;
|
||||
if (5 !== zoom) {
|
||||
if (newZoom > 5) newZoom = 5;
|
||||
this.zoomTo(newZoom);
|
||||
}
|
||||
}
|
||||
|
||||
zoomOut() {
|
||||
const zoom = this.getCurrentZoomLevel();
|
||||
if (0.05 !== zoom && zoom !== undefined) {
|
||||
let newZoom = zoom - 0.2;
|
||||
if (0.05 > newZoom) {
|
||||
newZoom = 0.05;
|
||||
}
|
||||
this.zoomTo(newZoom);
|
||||
}
|
||||
}
|
||||
|
||||
onKeyboardEvent = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && 187 === event.keyCode) {
|
||||
event.preventDefault();
|
||||
this.zoomIn();
|
||||
}
|
||||
if (isHotkey('mod+-', event)) {
|
||||
event.preventDefault();
|
||||
this.zoomOut();
|
||||
}
|
||||
};
|
||||
|
||||
bindKeyboardEvnet() {
|
||||
this.root.on('keydown', this.onKeyboardEvent);
|
||||
}
|
||||
|
||||
unbindKeyboardEvnet() {
|
||||
this.root.off('keydown', this.onKeyboardEvent);
|
||||
}
|
||||
|
||||
zoomToOriginSize() {
|
||||
this.zoomTo(1);
|
||||
}
|
||||
|
||||
zoomToBestSize() {
|
||||
const zoom = this.getInitialZoomLevel();
|
||||
if (!zoom) return;
|
||||
this.zoomTo(zoom);
|
||||
}
|
||||
|
||||
updateCursor() {
|
||||
const { root } = this;
|
||||
const currentZoomLevel = this.getCurrentZoomLevel();
|
||||
const initialZoomLevel = this.getInitialZoomLevel();
|
||||
if (currentZoomLevel === 1) {
|
||||
root.addClass('pswp--zoomed-in');
|
||||
} else if (initialZoomLevel === initialZoomLevel) {
|
||||
root.removeClass('pswp--zoomed-in');
|
||||
}
|
||||
}
|
||||
|
||||
getInitialZoomLevel() {
|
||||
if (!this.pswpUI) return 0;
|
||||
return +(this.pswpUI.currItem.initialZoomLevel?.toFixed(2) || 0);
|
||||
}
|
||||
|
||||
afterZoom() {
|
||||
this.updateCursor();
|
||||
this.emit('afterzoom');
|
||||
}
|
||||
|
||||
getCount() {
|
||||
return this.pswpUI?.items.length || 0;
|
||||
}
|
||||
|
||||
afterChange() {
|
||||
if (!isMobile) {
|
||||
const initialZoomLevel = this.getInitialZoomLevel();
|
||||
this.renderCounter();
|
||||
this.zoom = initialZoomLevel;
|
||||
setTimeout(() => {
|
||||
this.afterZoom();
|
||||
}, 100);
|
||||
this.emit('afterchange');
|
||||
this.zoom = this.getInitialZoomLevel();
|
||||
}
|
||||
this.setWhiteBackground();
|
||||
}
|
||||
|
||||
bindPswpEvent() {
|
||||
this.pswpUI?.listen('afterChange', () => {
|
||||
this.afterChange();
|
||||
});
|
||||
this.pswpUI?.listen('destroy', () => {
|
||||
this.isDestroy = true;
|
||||
});
|
||||
this.pswpUI?.listen('resize', () => {
|
||||
this.emit('resize');
|
||||
});
|
||||
this.pswpUI?.listen('imageLoadComplete', () => {
|
||||
this.setWhiteBackground();
|
||||
});
|
||||
}
|
||||
|
||||
setWhiteBackground() {
|
||||
this.root.find('.pswp__img').each(img => {
|
||||
const node = img as HTMLImageElement;
|
||||
if (node.complete) {
|
||||
node.style.background = 'white';
|
||||
node.style['boxShadow'] = '0 0 10px rgba(0, 0, 0, 0.5)';
|
||||
} else {
|
||||
node.onload = () => {
|
||||
node.style.background = 'white';
|
||||
node.style['boxShadow'] = '0 0 10px rgba(0, 0, 0, 0.5)';
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
open(items: Array<PhotoSwipe.Item>, index: number) {
|
||||
if (true === this.isDestroy) {
|
||||
const { root } = this;
|
||||
const pswp = new PhotoSwipe(this.root.get<HTMLElement>()!, PhotoSwipeUI, items, {
|
||||
index,
|
||||
...this.options,
|
||||
});
|
||||
pswp.items = items;
|
||||
pswp.init();
|
||||
this.pswpUI = pswp;
|
||||
this.isDestroy = false;
|
||||
if (!isMobile) {
|
||||
this.barUI.removeClass('pswp-fade-out');
|
||||
this.fadeOut(this.barUI, 'barFadeInAndOut');
|
||||
this.closeUI.removeClass('pswp-fade-out');
|
||||
this.fadeOut(this.closeUI, 'closeFadeInAndOut');
|
||||
}
|
||||
root.removeClass('pswp-fade-in');
|
||||
root.addClass('pswp-fade-in');
|
||||
this.afterChange();
|
||||
this.bindPswpEvent();
|
||||
}
|
||||
}
|
||||
|
||||
close = () => {
|
||||
this.pswpUI?.close();
|
||||
};
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener('resize', this.reset);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
export default Pswp;
|
||||
137
editor/src/plugins/image/component/pswp/zoom.ts
Normal file
137
editor/src/plugins/image/component/pswp/zoom.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { PswpInterface } from '../../types';
|
||||
import { $, EditorInterface, Tooltip } from '@aomao/engine';
|
||||
|
||||
class Zoom {
|
||||
private pswp: PswpInterface;
|
||||
private editor: EditorInterface;
|
||||
prevStatus = 'default';
|
||||
nextStatus = 'default';
|
||||
zoomInStatus = 'default';
|
||||
zoomOutStatus = 'default';
|
||||
originSizeStatus = 'default';
|
||||
bestSizeStatus = 'default';
|
||||
|
||||
constructor(editor: EditorInterface, pswp: PswpInterface) {
|
||||
this.editor = editor;
|
||||
this.pswp = pswp;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.pswp.on('afterzoom', () => {
|
||||
this.afterZoom();
|
||||
});
|
||||
|
||||
this.pswp.on('afterchange', () => {
|
||||
this.afterChange();
|
||||
});
|
||||
|
||||
this.pswp.on('resize', () => {
|
||||
setTimeout(() => {
|
||||
this.afterChange();
|
||||
this.afterZoom();
|
||||
}, 333);
|
||||
});
|
||||
this.render();
|
||||
}
|
||||
|
||||
renderTemplate() {
|
||||
const root = $(`
|
||||
<div class="data-pswp-tool-bar">
|
||||
<div class="pswp-toolbar-content"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const toolbarContent = root.find('.pswp-toolbar-content');
|
||||
|
||||
const lang: any = this.editor.language.get('image');
|
||||
|
||||
toolbarContent.append(
|
||||
this.renderBtn('arrow-left', lang['prev'], this.prevStatus, () => {
|
||||
if ('disable' !== this.prevStatus) this.pswp.prev();
|
||||
})
|
||||
);
|
||||
|
||||
toolbarContent.append(`<span class="data-pswp-counter"></span>`);
|
||||
|
||||
toolbarContent.append(
|
||||
this.renderBtn('arrow-right', lang['next'], this.nextStatus, () => {
|
||||
if ('disable' !== this.nextStatus) this.pswp.next();
|
||||
})
|
||||
);
|
||||
|
||||
toolbarContent.append(`<span class="separation"></span>`);
|
||||
|
||||
toolbarContent.append(
|
||||
this.renderBtn('zoom-in', lang['zoomIn'], this.zoomInStatus, () => {
|
||||
if ('disable' !== this.zoomInStatus) this.pswp.zoomIn();
|
||||
})
|
||||
);
|
||||
|
||||
toolbarContent.append(
|
||||
this.renderBtn('zoom-out', lang['zoomOut'], this.zoomOutStatus, () => {
|
||||
if ('disable' !== this.zoomOutStatus) this.pswp.zoomOut();
|
||||
})
|
||||
);
|
||||
|
||||
toolbarContent.append(
|
||||
this.renderBtn('origin-size', lang['originSize'], this.originSizeStatus, () => {
|
||||
if ('disable' !== this.originSizeStatus) this.pswp.zoomToOriginSize();
|
||||
})
|
||||
);
|
||||
|
||||
toolbarContent.append(
|
||||
this.renderBtn('best-size', lang['bestSize'], this.bestSizeStatus, () => {
|
||||
if ('disable' !== this.bestSizeStatus) this.pswp.zoomToBestSize();
|
||||
})
|
||||
);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
afterZoom() {
|
||||
const currentLevel = this.pswp.getCurrentZoomLevel();
|
||||
const initLevel = this.pswp.getInitialZoomLevel();
|
||||
let status = 'default';
|
||||
if (currentLevel === initLevel) {
|
||||
status = 'activated';
|
||||
}
|
||||
if (1 === initLevel) {
|
||||
status = 'disable';
|
||||
}
|
||||
this.zoomOutStatus = 0.05 === currentLevel ? 'disable' : 'default';
|
||||
this.zoomInStatus = 5 === currentLevel ? 'disable' : 'default';
|
||||
this.originSizeStatus = 1 === currentLevel ? 'activated' : 'default';
|
||||
this.bestSizeStatus = status;
|
||||
this.render();
|
||||
}
|
||||
|
||||
afterChange() {
|
||||
const count = this.pswp.getCount();
|
||||
this.nextStatus = 1 === count ? 'disable' : 'default';
|
||||
this.prevStatus = 1 === count ? 'disable' : 'default';
|
||||
this.render();
|
||||
}
|
||||
|
||||
renderBtn(zoomClass: string, title: string, status: string, onClick: () => void) {
|
||||
const btn = $(`<span class="data-pswp-${zoomClass} btn ${status}"></span>`);
|
||||
btn.on('mouseenter', () => {
|
||||
Tooltip.show(btn, title);
|
||||
});
|
||||
btn.on('mouseleave', () => {
|
||||
Tooltip.hide();
|
||||
});
|
||||
btn.on('mousedown', e => {
|
||||
e.stopPropagation();
|
||||
Tooltip.hide();
|
||||
});
|
||||
btn.on('click', onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.pswp.barUI.empty();
|
||||
this.pswp.barUI.append(this.renderTemplate());
|
||||
}
|
||||
}
|
||||
|
||||
export default Zoom;
|
||||
129
editor/src/plugins/image/index.ts
Normal file
129
editor/src/plugins/image/index.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { $, CardInterface, CardType, CARD_KEY, CARD_TYPE_KEY, CARD_VALUE_KEY, decodeCardValue, NodeInterface, Plugin, PluginEntry, READY_CARD_KEY } from '@aomao/engine';
|
||||
import ImageComponent, { ImageValue } from './component';
|
||||
import ImageUploader from './uploader';
|
||||
import { ImageUploaderOptions } from './uploader';
|
||||
import locales from './locales';
|
||||
import { ImageOptions } from './types';
|
||||
|
||||
const PARSE_HTML = 'parse:html';
|
||||
|
||||
export default class<T extends ImageOptions = ImageOptions> extends Plugin<T> {
|
||||
static get pluginName() {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
init() {
|
||||
const editor = this.editor;
|
||||
editor.language.add(locales);
|
||||
editor.on(PARSE_HTML, this.parseHtml);
|
||||
}
|
||||
|
||||
execute(status: 'uploading' | 'done' | 'error', src: string, alt?: string): void {
|
||||
const value: ImageValue = {
|
||||
status,
|
||||
src,
|
||||
alt,
|
||||
};
|
||||
if (status === 'error') {
|
||||
value.src = '';
|
||||
value.message = src;
|
||||
}
|
||||
this.editor.card.insert('image', value);
|
||||
}
|
||||
|
||||
async waiting(callback?: (name: string, card?: CardInterface, ...args: any) => boolean | number | void): Promise<void> {
|
||||
const { card } = this.editor;
|
||||
// 检测单个组件
|
||||
const check = (component: CardInterface) => {
|
||||
return component.root.inEditor() && component.name === ImageComponent.cardName && (component as ImageComponent<ImageValue>).getValue()?.status === 'uploading';
|
||||
};
|
||||
// 找到不合格的组件
|
||||
const find = (): CardInterface | undefined => {
|
||||
return card.components.find(check);
|
||||
};
|
||||
const waitCheck = (component: CardInterface): Promise<void> => {
|
||||
let time = 60000;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (callback) {
|
||||
const result = callback((this.constructor as PluginEntry).pluginName, component);
|
||||
if (result === false) {
|
||||
return reject({
|
||||
name: (this.constructor as PluginEntry).pluginName,
|
||||
card: component,
|
||||
});
|
||||
} else if (typeof result === 'number') {
|
||||
time = result;
|
||||
}
|
||||
}
|
||||
const beginTime = new Date().getTime();
|
||||
const now = new Date().getTime();
|
||||
const timeout = () => {
|
||||
if (now - beginTime >= time) return resolve();
|
||||
setTimeout(() => {
|
||||
if (check(component)) timeout();
|
||||
else resolve();
|
||||
}, 10);
|
||||
};
|
||||
timeout();
|
||||
});
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const component = find();
|
||||
const wait = (component: CardInterface) => {
|
||||
waitCheck(component)
|
||||
.then(() => {
|
||||
const next = find();
|
||||
if (next) wait(next);
|
||||
else resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
};
|
||||
if (component) wait(component);
|
||||
else resolve();
|
||||
});
|
||||
}
|
||||
|
||||
parseHtml = (root: NodeInterface, callback?: (node: NodeInterface, value: ImageValue) => NodeInterface) => {
|
||||
const results: NodeInterface[] = [];
|
||||
const editor = this.editor;
|
||||
root.find(`[${CARD_KEY}="${ImageComponent.cardName}"],[${READY_CARD_KEY}="${ImageComponent.cardName}"]`).each(cardNode => {
|
||||
const node = $(cardNode);
|
||||
const card = editor.card.find(node) as ImageComponent<ImageValue>;
|
||||
const value = card?.getValue() || decodeCardValue(node.attributes(CARD_VALUE_KEY));
|
||||
if (value?.src && value.status === 'done') {
|
||||
const currentImg = node.find('.data-image-meta img');
|
||||
let img = currentImg.length > 0 ? currentImg.clone(true) : $('<img />');
|
||||
node.empty();
|
||||
let src = value.src;
|
||||
const { onBeforeRender } = this.options;
|
||||
if (onBeforeRender) {
|
||||
src = onBeforeRender(value.status, value.src, this.editor);
|
||||
}
|
||||
const type = node.attributes(CARD_TYPE_KEY);
|
||||
img.attributes('src', src);
|
||||
img.css('visibility', 'visible');
|
||||
const size = value.size;
|
||||
if (size?.width) img.css('width', `${size.width}px`);
|
||||
if (size?.height) img.css('height', `${size.height}px`);
|
||||
img.removeAttributes('class');
|
||||
img.attributes('data-type', type);
|
||||
if (callback) {
|
||||
img = callback(img, value);
|
||||
}
|
||||
if (type === CardType.BLOCK) {
|
||||
img = editor.node.wrap(img, $(`<p style="text-align:center;"></p>`));
|
||||
}
|
||||
node.replaceWith(img);
|
||||
results.push(img);
|
||||
} else node.remove();
|
||||
});
|
||||
return results;
|
||||
};
|
||||
|
||||
destroy() {
|
||||
this.editor.off(PARSE_HTML, this.parseHtml);
|
||||
}
|
||||
}
|
||||
|
||||
export { ImageComponent, ImageUploader };
|
||||
export type { ImageValue, ImageOptions, ImageUploaderOptions };
|
||||
19
editor/src/plugins/image/locales/en-US.ts
Normal file
19
editor/src/plugins/image/locales/en-US.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export default {
|
||||
image: {
|
||||
next: 'Next',
|
||||
prev: 'Previous',
|
||||
zoomIn: 'Zoom In',
|
||||
zoomOut: 'Zoom Out',
|
||||
originSize: 'Origin Size',
|
||||
bestSize: 'Best Size',
|
||||
errorMessageCopy: 'Copy error message',
|
||||
loadError: 'The picture failed to load!',
|
||||
uploadError: 'The picture failed to upload!',
|
||||
uploadLimitError: 'Upload image size is limited to $size',
|
||||
toolbarReductionTitle: 'Reduction size',
|
||||
toolbarWidthTitle: 'Width',
|
||||
toolbarHeightTitle: 'Height',
|
||||
displayBlockTitle: 'Block',
|
||||
displayInlineTitle: 'In line',
|
||||
},
|
||||
};
|
||||
7
editor/src/plugins/image/locales/index.ts
Normal file
7
editor/src/plugins/image/locales/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import en from './en-US';
|
||||
import cn from './zh-CN';
|
||||
|
||||
export default {
|
||||
'en-US': en,
|
||||
'zh-CN': cn,
|
||||
};
|
||||
19
editor/src/plugins/image/locales/zh-CN.ts
Normal file
19
editor/src/plugins/image/locales/zh-CN.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export default {
|
||||
image: {
|
||||
next: '下一张',
|
||||
prev: '上一张',
|
||||
zoomIn: '放大',
|
||||
zoomOut: '缩小',
|
||||
originSize: '实际尺寸',
|
||||
bestSize: '适应屏幕',
|
||||
errorMessageCopy: '复制错误信息',
|
||||
loadError: '图片加载失败!',
|
||||
uploadError: '上传图片失败!',
|
||||
uploadLimitError: '上传图片大小限制为 $size',
|
||||
toolbarReductionTitle: '还原',
|
||||
toolbarWidthTitle: '宽度',
|
||||
toolbarHeightTitle: '宽度',
|
||||
displayBlockTitle: '独占一行',
|
||||
displayInlineTitle: '嵌入行内',
|
||||
},
|
||||
};
|
||||
56
editor/src/plugins/image/types.ts
Normal file
56
editor/src/plugins/image/types.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { CardToolbarItemOptions, CardType, EditorInterface, NodeInterface, PluginOptions, ToolbarItemOptions } from '@aomao/engine';
|
||||
import { EventEmitter2 } from 'eventemitter2';
|
||||
import PhotoSwipe from 'photoswipe';
|
||||
|
||||
export interface PswpInterface extends EventEmitter2 {
|
||||
root: NodeInterface;
|
||||
barUI: NodeInterface;
|
||||
closeUI: NodeInterface;
|
||||
bindControllerFadeInAndOut(): void;
|
||||
unbindControllerFadeInAndOut(): void;
|
||||
removeFadeOut(node: NodeInterface, id: string): void;
|
||||
fadeOut(node: NodeInterface, id: string): void;
|
||||
prev(): void;
|
||||
next(): void;
|
||||
renderCounter(): void;
|
||||
getCurrentZoomLevel(): number;
|
||||
zoomTo(zoom: number): void;
|
||||
zoomIn(): void;
|
||||
zoomOut(): void;
|
||||
zoomToOriginSize(): void;
|
||||
zoomToBestSize(): void;
|
||||
updateCursor(): void;
|
||||
getInitialZoomLevel(): number;
|
||||
afterZoom(): void;
|
||||
getCount(): number;
|
||||
afterChange(): void;
|
||||
setWhiteBackground(): void;
|
||||
open(items: PhotoSwipe.Item[], index: number): void;
|
||||
reset: () => void;
|
||||
close(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface ImageOptions extends PluginOptions {
|
||||
/**
|
||||
* 图片渲染前调用,可以在这里修改图片链接
|
||||
*/
|
||||
onBeforeRender?: (status: 'uploading' | 'done', src: string, editor: EditorInterface) => string;
|
||||
/**
|
||||
* 是否启用大小拖动,默认为 true
|
||||
*/
|
||||
enableResizer?: boolean;
|
||||
/**
|
||||
* 是否启用block、inline切换
|
||||
*/
|
||||
enableTypeSwitch?: boolean;
|
||||
/**
|
||||
* 默认使用的卡片类型
|
||||
*/
|
||||
defaultType?: CardType;
|
||||
/**
|
||||
* 最高高度,设置后默认按最高高度缩放
|
||||
*/
|
||||
maxHeight?: number;
|
||||
cardToolbars?: (items: (ToolbarItemOptions | CardToolbarItemOptions)[], editor: EditorInterface) => (ToolbarItemOptions | CardToolbarItemOptions)[];
|
||||
}
|
||||
630
editor/src/plugins/image/uploader.ts
Normal file
630
editor/src/plugins/image/uploader.ts
Normal file
@@ -0,0 +1,630 @@
|
||||
import {
|
||||
$,
|
||||
File,
|
||||
isAndroid,
|
||||
isEngine,
|
||||
NodeInterface,
|
||||
Plugin,
|
||||
random,
|
||||
READY_CARD_KEY,
|
||||
getExtensionName,
|
||||
SchemaInterface,
|
||||
PluginOptions,
|
||||
CARD_VALUE_KEY,
|
||||
decodeCardValue,
|
||||
encodeCardValue,
|
||||
removeUnit,
|
||||
CardType,
|
||||
} from '@aomao/engine';
|
||||
import type MarkdownIt from 'markdown-it';
|
||||
import type { RequestData, RequestHeaders } from '@aomao/engine';
|
||||
import { ImageOptions } from '.';
|
||||
import ImageComponent, { ImageValue } from './component';
|
||||
export interface ImageUploaderOptions extends PluginOptions {
|
||||
/**
|
||||
* 文件上传配置
|
||||
*/
|
||||
file: {
|
||||
/**
|
||||
* 文件上传地址
|
||||
*/
|
||||
action: string;
|
||||
/**
|
||||
* 数据返回类型,默认 json
|
||||
*/
|
||||
type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js';
|
||||
/**
|
||||
* 图片文件上传时 FormData 的名称,默认 file
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* 额外携带数据上传
|
||||
*/
|
||||
data?: RequestData;
|
||||
/**
|
||||
* 请求类型,默认 multipart/form-data;
|
||||
*/
|
||||
contentType?: string;
|
||||
/**
|
||||
* 图片接收的格式,默认 "svg","png","bmp","jpg","jpeg","gif","tif","tiff","emf","webp"
|
||||
*/
|
||||
accept?: string | string[] | Record<string, string>;
|
||||
/**
|
||||
* 是否跨域
|
||||
*/
|
||||
crossOrigin?: boolean;
|
||||
/**
|
||||
* https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest/withCredentials
|
||||
*/
|
||||
withCredentials?: boolean;
|
||||
/**
|
||||
* 请求头
|
||||
*/
|
||||
headers?: RequestHeaders;
|
||||
/**
|
||||
* 文件选择限制数量
|
||||
*/
|
||||
multiple?: boolean | number;
|
||||
/**
|
||||
* 上传大小限制,默认 1024 * 1024 * 5 就是5M
|
||||
*/
|
||||
limitSize?: number;
|
||||
};
|
||||
remote: {
|
||||
/**
|
||||
* 是否跨域
|
||||
*/
|
||||
crossOrigin?: boolean;
|
||||
/**
|
||||
* https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest/withCredentials
|
||||
*/
|
||||
withCredentials?: boolean;
|
||||
/**
|
||||
* 请求头
|
||||
*/
|
||||
headers?: RequestHeaders;
|
||||
/**
|
||||
* 上传地址
|
||||
*/
|
||||
action: string;
|
||||
/**
|
||||
* 数据返回类型,默认 json
|
||||
*/
|
||||
type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js';
|
||||
/**
|
||||
* 额外携带数据上传
|
||||
*/
|
||||
data?: RequestData;
|
||||
/**
|
||||
* 图片地址上传时请求参数的名称,默认 url
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* 请求类型,默认 multipart/form-data;
|
||||
*/
|
||||
contentType?: string;
|
||||
};
|
||||
/**
|
||||
* Markdown
|
||||
*/
|
||||
markdown?: boolean;
|
||||
/**
|
||||
* 解析上传后的Respone,返回 result:是否成功,data:成功:图片地址,失败:错误信息
|
||||
*/
|
||||
parse?: (response: any) => {
|
||||
result: boolean;
|
||||
data: string;
|
||||
};
|
||||
/**
|
||||
* 是否是第三方图片地址,如果是,那么地址将上传服务器下载图片
|
||||
*/
|
||||
isRemote?: (src: string) => boolean;
|
||||
}
|
||||
|
||||
const DROP_FILES = 'drop:files';
|
||||
const PASTE_EVENT = 'paste:event';
|
||||
const PASTE_SCHEMA = 'paste:schema';
|
||||
const PASTE_EACH = 'paste:each';
|
||||
const PASTE_AFTER = 'paste:after';
|
||||
const MARKDOWN_IT = 'markdown-it';
|
||||
|
||||
export default class<T extends ImageUploaderOptions = ImageUploaderOptions> extends Plugin<T> {
|
||||
private cardComponents: { [key: string]: ImageComponent<ImageValue> } = {};
|
||||
private loadCounts: { [key: string]: number } = {};
|
||||
|
||||
static get pluginName() {
|
||||
return 'image-uploader';
|
||||
}
|
||||
|
||||
extensionNames: Record<string, string> | string[] = {
|
||||
svg: 'image/svg+xml',
|
||||
png: 'image/png',
|
||||
bmp: 'image/bmp',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
tif: 'image/tiff',
|
||||
tiff: 'image/tiff',
|
||||
emf: 'image/emf',
|
||||
webp: 'image/webp',
|
||||
};
|
||||
|
||||
init() {
|
||||
const editor = this.editor;
|
||||
if (isEngine(this.editor)) {
|
||||
editor.on(DROP_FILES, this.dropFiles);
|
||||
editor.on(PASTE_EVENT, this.pasteFiles);
|
||||
editor.on(PASTE_SCHEMA, this.pasteSchema);
|
||||
editor.on(PASTE_EACH, this.pasteEach);
|
||||
editor.on(PASTE_AFTER, this.pasteAfter);
|
||||
editor.on(MARKDOWN_IT, this.markdownIt);
|
||||
}
|
||||
let { accept } = this.options.file || {};
|
||||
if (typeof accept === 'string') accept = accept.split(',');
|
||||
if (Array.isArray(accept)) {
|
||||
const names: string[] = [];
|
||||
(accept || []).forEach(name => {
|
||||
name = name.trim();
|
||||
const newName = name.split('.').pop();
|
||||
if (newName) names.push(newName);
|
||||
});
|
||||
if (names.length > 0) this.extensionNames = names;
|
||||
} else if (typeof accept === 'object') {
|
||||
this.extensionNames = accept;
|
||||
}
|
||||
}
|
||||
|
||||
isImage(file: File) {
|
||||
const name = getExtensionName(file);
|
||||
const names = Array.isArray(this.extensionNames) ? this.extensionNames : Object.keys(this.extensionNames);
|
||||
return names.indexOf('*') >= 0 || names.indexOf(name) >= 0;
|
||||
}
|
||||
|
||||
dataURIToFile(dataURI: string) {
|
||||
// convert base64/URLEncoded data component to raw binary data held in a string
|
||||
let byteString;
|
||||
|
||||
if (dataURI.split(',')[0].indexOf('base64') >= 0) {
|
||||
byteString = atob(dataURI.split(',')[1]);
|
||||
} else {
|
||||
byteString = unescape(dataURI.split(',')[1]);
|
||||
}
|
||||
// separate out the mime component
|
||||
const mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]; // write the bytes of the string to a typed array
|
||||
|
||||
const ia = new Uint8Array(byteString.length);
|
||||
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new Blob([ia], {
|
||||
type: mimeString,
|
||||
});
|
||||
}
|
||||
|
||||
getUrl(value: ImageValue) {
|
||||
const imagePlugin = this.editor.plugin.components['image'];
|
||||
if (imagePlugin) {
|
||||
const { onBeforeRender } = (imagePlugin['options'] || {}) as any;
|
||||
if (onBeforeRender) return onBeforeRender(value.status, value.src, this.editor);
|
||||
}
|
||||
return value.src;
|
||||
}
|
||||
|
||||
loadImage(id: string, value: ImageValue) {
|
||||
if (!this.loadCounts[id]) this.loadCounts[id] = 1;
|
||||
const image = new Image();
|
||||
const editor = this.editor;
|
||||
image.src = this.getUrl(value);
|
||||
image.onload = () => {
|
||||
delete this.loadCounts[id];
|
||||
editor.card.update(id, value);
|
||||
};
|
||||
image.onerror = () => {
|
||||
if (this.loadCounts[id] <= 3) {
|
||||
setTimeout(() => {
|
||||
this.loadCounts[id]++;
|
||||
this.loadImage(id, value);
|
||||
}, 500);
|
||||
} else {
|
||||
delete this.loadCounts[id];
|
||||
value.status = 'error';
|
||||
(value.message = editor.language.get<string>('image', 'loadError')), editor.card.update(id, value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async execute(files?: Array<File> | string | MouseEvent) {
|
||||
const editor = this.editor;
|
||||
if (!isEngine(editor)) return;
|
||||
const { request, card, language } = editor;
|
||||
const { action, data, type, contentType, multiple, crossOrigin, withCredentials, headers, name } = this.options.file;
|
||||
const { parse } = this.options;
|
||||
const limitSize = this.options.file.limitSize || 5 * 1024 * 1024;
|
||||
|
||||
if (!Array.isArray(files) && typeof files !== 'string') {
|
||||
const accepts = Array.isArray(this.extensionNames) ? '.' + this.extensionNames.join(',.') : Object.values(this.extensionNames).join(',');
|
||||
files = await request.getFiles({
|
||||
event: files,
|
||||
accept: isAndroid ? 'image/*' : accepts.length > 0 ? accepts : '',
|
||||
multiple,
|
||||
});
|
||||
} else if (typeof files === 'string') {
|
||||
this.insertRemote(files);
|
||||
return;
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
request.upload(
|
||||
{
|
||||
url: action,
|
||||
crossOrigin,
|
||||
withCredentials,
|
||||
headers,
|
||||
data,
|
||||
type,
|
||||
contentType,
|
||||
onBefore: file => {
|
||||
if (file.size > limitSize) {
|
||||
editor.messageError(
|
||||
'upload-limit',
|
||||
language
|
||||
.get('image', 'uploadLimitError')
|
||||
.toString()
|
||||
.replace('$size', (limitSize / 1024 / 1024).toFixed(0) + 'M')
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
onReady: fileInfo => {
|
||||
if (!isEngine(editor) || !!this.cardComponents[fileInfo.uid]) return;
|
||||
const src = fileInfo.src || '';
|
||||
const base64String = typeof src !== 'string' ? window.btoa(String.fromCharCode(...new Uint8Array(src))) : src;
|
||||
const insertCard = (value: Partial<ImageValue>) => {
|
||||
const imagePlugin = editor.plugin.findPlugin<ImageOptions>('image');
|
||||
const component = card.insert<ImageValue, ImageComponent<ImageValue>>(
|
||||
'image',
|
||||
{
|
||||
...value,
|
||||
status: 'uploading',
|
||||
type: value.type || imagePlugin?.options?.defaultType,
|
||||
//fileInfo.src, 再协作中,如果大图片使用base64加载图片预览会造成很大资源浪费
|
||||
},
|
||||
base64String
|
||||
);
|
||||
this.cardComponents[fileInfo.uid] = component;
|
||||
};
|
||||
return new Promise<void>(resolve => {
|
||||
const image = new Image();
|
||||
image.src = base64String;
|
||||
const imagePlugin = editor.plugin.findPlugin<ImageOptions>('image');
|
||||
|
||||
image.onload = () => {
|
||||
const { naturalWidth, naturalHeight, height, width } = image;
|
||||
|
||||
let imageWidth: number = width;
|
||||
let imageHeight: number = height;
|
||||
const maxHeight: number | undefined = imagePlugin?.options?.maxHeight;
|
||||
|
||||
if (maxHeight && naturalHeight > naturalWidth && height > maxHeight) {
|
||||
imageHeight = maxHeight;
|
||||
imageWidth = naturalWidth * (maxHeight / naturalHeight);
|
||||
}
|
||||
|
||||
insertCard({
|
||||
src: '',
|
||||
size: {
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
naturalHeight: image.naturalHeight,
|
||||
naturalWidth: image.naturalWidth,
|
||||
},
|
||||
});
|
||||
resolve();
|
||||
};
|
||||
image.onerror = () => {
|
||||
insertCard({ src: '', status: 'error' });
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
},
|
||||
onUploading: (file, { percent }) => {
|
||||
const component = this.cardComponents[file.uid || ''];
|
||||
if (!component) return;
|
||||
component.setProgressPercent(percent);
|
||||
},
|
||||
onSuccess: (response, file) => {
|
||||
const component = this.cardComponents[file.uid || ''];
|
||||
if (!component) return;
|
||||
let src = response.url || (response.data && response.data.url) || response.src || (response.data && response.data.src);
|
||||
const result = parse ? parse(response) : src ? { result: true, data: src } : { result: false };
|
||||
if (!result.result) {
|
||||
card.update<ImageValue>(component.id, {
|
||||
status: 'error',
|
||||
message: result.data || language.get<string>('image', 'uploadError'),
|
||||
});
|
||||
} else {
|
||||
src = result.data;
|
||||
}
|
||||
const value: any = {
|
||||
status: 'done',
|
||||
};
|
||||
if (src) {
|
||||
value.src = src;
|
||||
this.loadImage(component.id, value);
|
||||
}
|
||||
delete this.cardComponents[file.uid || ''];
|
||||
},
|
||||
onError: (error, file) => {
|
||||
const component = this.cardComponents[file.uid || ''];
|
||||
if (!component) return;
|
||||
card.update<ImageValue>(component.id, {
|
||||
status: 'error',
|
||||
message: error.message || language.get<string>('image', 'uploadError'),
|
||||
});
|
||||
delete this.cardComponents[file.uid || ''];
|
||||
},
|
||||
},
|
||||
files,
|
||||
name
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
dropFiles = (files: File[]) => {
|
||||
const editor = this.editor;
|
||||
if (!isEngine(editor)) return;
|
||||
files = files.filter(file => this.isImage(file));
|
||||
if (files.length === 0) return;
|
||||
editor.command.execute('image-uploader', files);
|
||||
return false;
|
||||
};
|
||||
|
||||
pasteSchema = (schema: SchemaInterface) => {
|
||||
schema.add({
|
||||
type: 'inline',
|
||||
name: 'img',
|
||||
isVoid: true,
|
||||
attributes: {
|
||||
src: {
|
||||
required: true,
|
||||
value: '@url',
|
||||
},
|
||||
width: '@number',
|
||||
height: '@number',
|
||||
style: {
|
||||
'max-width': '@length',
|
||||
'max-height': '@length',
|
||||
width: '@length',
|
||||
height: '@length',
|
||||
},
|
||||
alt: '*',
|
||||
title: '*',
|
||||
'data-type': '*',
|
||||
'data-size': '@number',
|
||||
'data-width': '@number',
|
||||
'data-height': '@number',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
pasteFiles = ({ files }: Record<'files', File[]>) => {
|
||||
const editor = this.editor;
|
||||
if (!isEngine(editor)) return;
|
||||
files = files.filter(file => this.isImage(file));
|
||||
if (files.length === 0) return;
|
||||
editor.command.execute('image-uploader', files);
|
||||
return false;
|
||||
};
|
||||
|
||||
pasteEach = (node: NodeInterface) => {
|
||||
const editor = this.editor;
|
||||
const { isRemote } = this.options;
|
||||
//是卡片,并且还没渲染
|
||||
if (node.isCard() && node.attributes(READY_CARD_KEY)) {
|
||||
if (node.attributes(READY_CARD_KEY) !== 'image') return;
|
||||
const value = decodeCardValue(node.attributes(CARD_VALUE_KEY));
|
||||
if (!value || !value.src) {
|
||||
node.remove();
|
||||
return;
|
||||
}
|
||||
//第三方图片,设置上传状态
|
||||
if (isRemote && isRemote(value.src)) {
|
||||
value.status = 'uploading';
|
||||
value.percent = 0;
|
||||
editor.card.replaceNode(node, 'image', value);
|
||||
} else if (value.status === 'uploading') {
|
||||
//如果是上传状态,设置为正常状态
|
||||
value.percent = 0;
|
||||
node.attributes(CARD_VALUE_KEY, encodeCardValue({ ...value, status: 'done' }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
//图片带链接
|
||||
/**
|
||||
if(node.name === "a" && node.find("img").length > 0){
|
||||
const img = node.find("img")
|
||||
const href = node.attributes("href")
|
||||
const target = node.attributes("target")
|
||||
const src = img.attributes("src") || img.attributes("data-src")
|
||||
const alt = img.attributes("alt")
|
||||
if(!src) {
|
||||
node.remove()
|
||||
return
|
||||
}
|
||||
this.editor.card.replaceNode(node,"image",{
|
||||
src,
|
||||
status:isRemote && isRemote(src) || /^data:image\//i.test(src) ? "uploading" : "done",
|
||||
alt,
|
||||
link:{
|
||||
href,
|
||||
target
|
||||
},
|
||||
percent:0
|
||||
})
|
||||
} */
|
||||
//图片
|
||||
if (node.name === 'img') {
|
||||
const attributes = node.attributes();
|
||||
const src = attributes['src'] || attributes['data-src'];
|
||||
const alt = attributes['alt'];
|
||||
if (!src) {
|
||||
node.remove();
|
||||
return;
|
||||
}
|
||||
const imagePlugin = editor.plugin.findPlugin<ImageOptions>('image');
|
||||
const attrWidth = attributes['width'];
|
||||
const attrHeight = attributes['height'];
|
||||
const width = attrWidth ? attrWidth : node.css('width');
|
||||
const height = attrHeight ? attrHeight : node.css('height');
|
||||
const dataTypeValue = attributes['data-type'] || imagePlugin?.options.defaultType;
|
||||
let type = CardType.INLINE;
|
||||
if (dataTypeValue === 'block') {
|
||||
const parent = node.parent();
|
||||
// 移除转换为html的时候加载的额外p标签
|
||||
if (parent && parent.name === 'p') {
|
||||
editor.node.unwrap(parent);
|
||||
}
|
||||
type = CardType.BLOCK;
|
||||
}
|
||||
|
||||
editor.card.replaceNode(node, 'image', {
|
||||
type,
|
||||
src,
|
||||
status: (isRemote && isRemote(src)) || /^data:image\//i.test(src) ? 'uploading' : 'done',
|
||||
alt,
|
||||
percent: 0,
|
||||
size: {
|
||||
width: removeUnit(width),
|
||||
height: removeUnit(height),
|
||||
},
|
||||
});
|
||||
node.remove();
|
||||
}
|
||||
};
|
||||
|
||||
async uploadAddress(src: string, component: ImageComponent) {
|
||||
const editor = this.editor;
|
||||
if (!isEngine(editor)) return;
|
||||
const { action, type, contentType, crossOrigin, withCredentials, headers, name, data } = this.options.remote;
|
||||
const { parse } = this.options;
|
||||
const addressName = name || 'url';
|
||||
editor.request.ajax({
|
||||
url: action,
|
||||
method: 'POST',
|
||||
contentType: contentType || 'application/json',
|
||||
type: type === undefined ? 'json' : type,
|
||||
crossOrigin,
|
||||
withCredentials,
|
||||
headers,
|
||||
data:
|
||||
typeof data === 'function'
|
||||
? async () => {
|
||||
const newData = await data();
|
||||
return { ...newData, [addressName]: src };
|
||||
}
|
||||
: {
|
||||
...data,
|
||||
[addressName]: src,
|
||||
},
|
||||
success: response => {
|
||||
let src = response.url || (response.data && response.data.url) || response.src || (response.data && response.data.src);
|
||||
|
||||
const result = parse ? parse(response) : src ? { result: true, data: src } : { result: false };
|
||||
if (!result.result) {
|
||||
editor.card.update<ImageValue>(component.id, {
|
||||
status: 'error',
|
||||
message: result.data || editor.language.get<string>('image', 'uploadError'),
|
||||
});
|
||||
} else {
|
||||
src = result.data;
|
||||
}
|
||||
|
||||
const value: any = {
|
||||
status: 'done',
|
||||
};
|
||||
if (src) {
|
||||
value.src = src;
|
||||
this.loadImage(component.id, value);
|
||||
}
|
||||
},
|
||||
error: error => {
|
||||
editor.card.update<ImageValue>(component.id, {
|
||||
status: 'error',
|
||||
message: error.message || editor.language.get<string>('image', 'uploadError'),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
insertRemote(src: string, alt?: string) {
|
||||
const editor = this.editor;
|
||||
const imagePlugin = editor.plugin.findPlugin<ImageOptions>('image');
|
||||
const value: ImageValue = {
|
||||
src,
|
||||
alt,
|
||||
status: 'uploading',
|
||||
type: imagePlugin?.options.defaultType || CardType.INLINE,
|
||||
};
|
||||
const { isRemote } = this.options;
|
||||
//上传第三方图片
|
||||
if (isRemote && isRemote(src)) {
|
||||
const component = editor.card.insert<ImageValue, ImageComponent<ImageValue>>('image', value);
|
||||
this.uploadAddress(src, component);
|
||||
return;
|
||||
}
|
||||
//当前图片
|
||||
value.status = 'done';
|
||||
editor.card.insert('image', value);
|
||||
}
|
||||
|
||||
pasteAfter = () => {
|
||||
const editor = this.editor;
|
||||
editor.container.find('[data-card-key=image]').each((node, key) => {
|
||||
const component = editor.card.find(node) as ImageComponent;
|
||||
if (!component || !isEngine(editor)) return;
|
||||
const value = component.getValue();
|
||||
//不是上传状态,或者当前卡片正在执行上传跳过
|
||||
if (value?.status !== 'uploading' || Object.keys(this.cardComponents).find(key => this.cardComponents[key].id === component.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { src } = value;
|
||||
// 转存 base64 图片
|
||||
if (/^data:image\//i.test(src)) {
|
||||
const fileBlob = this.dataURIToFile(src);
|
||||
const ext = getExtensionName(fileBlob);
|
||||
const name = ext ? 'image.'.concat(ext) : 'image';
|
||||
const file: File = new globalThis.File([fileBlob], name);
|
||||
file.uid = new Date().getTime() + '-' + random();
|
||||
editor.command.execute('image-uploader', [file]);
|
||||
this.cardComponents[file.uid] = component;
|
||||
return;
|
||||
}
|
||||
const { isRemote } = this.options;
|
||||
if (isRemote && isRemote(src)) {
|
||||
this.uploadAddress(src, component);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
markdownIt = (mardown: MarkdownIt) => {
|
||||
if (this.options.markdown !== false) {
|
||||
mardown.enable('image');
|
||||
mardown.enable('reference');
|
||||
}
|
||||
};
|
||||
|
||||
destroy() {
|
||||
const editor = this.editor;
|
||||
if (isEngine(editor)) {
|
||||
editor.off(DROP_FILES, this.dropFiles);
|
||||
editor.off(PASTE_EVENT, this.pasteFiles);
|
||||
editor.off(PASTE_SCHEMA, this.pasteSchema);
|
||||
editor.off(PASTE_EACH, this.pasteEach);
|
||||
editor.off(PASTE_AFTER, this.pasteAfter);
|
||||
editor.off(MARKDOWN_IT, this.markdownIt);
|
||||
}
|
||||
}
|
||||
}
|
||||
180
editor/src/plugins/lightblock/component/index.tsx
Normal file
180
editor/src/plugins/lightblock/component/index.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { $, Card, CardToolbarItemOptions, CardType, isEngine, NodeInterface, Parser, ToolbarItemOptions } from '@aomao/engine';
|
||||
import { createApp } from 'vue';
|
||||
import type { LightblockValue, IChangeParam } from './types';
|
||||
import LightblockTheme from './lightblock-theme.vue';
|
||||
import './style.css';
|
||||
|
||||
export const themeIcon = `<span class="lightblock-icon-theme">
|
||||
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1777" width="16" height="16">
|
||||
<path d="M515.100444 911.758222c-161.393778 0-260.494222-23.495111-302.990222-71.793778-22.442667-25.486222-21.333333-49.521778-20.48-56.291555 0.455111-6.030222 15.530667-189.923556 28.216889-275.655111-4.608-3.128889-14.392889-7.054222-20.053333-9.329778a229.831111 229.831111 0 0 1-13.880889-5.888c-18.716444-8.789333-57.571556-28.046222-57.571556-28.046222-48.298667-22.186667-76.942222-50.403556-83.996444-83.399111-5.660444-26.424889 3.982222-49.180444 14.876444-61.696 1.308444-1.479111 2.702222-2.872889 4.181334-4.124445l227.072-193.735111a39.907556 39.907556 0 0 1 63.800889 17.607111c24.888889 73.699556 97.024 84.736 137.841777 84.736 10.865778 0 17.777778-0.853333 17.834667-0.881778 3.441778-0.426667 7.111111-0.426667 10.552889 0.028445 0 0 6.769778 0.824889 17.265778 0.824889 40.021333 0 110.791111-10.979556 135.253333-84.565334a39.964444 39.964444 0 0 1 64.085333-17.607111l223.800889 193.735111c1.479111 1.28 2.872889 2.673778 4.124445 4.152889 10.780444 12.515556 20.280889 35.299556 14.592 61.639111-7.054222 32.824889-35.299556 60.984889-83.939556 83.626667 0 0.028444-37.489778 18.887111-55.694222 27.562667-4.096 1.934222-8.760889 3.868444-13.710222 5.888-5.518222 2.247111-15.104 6.144-19.598223 9.244444 12.572444 86.471111 27.392 269.880889 28.017778 277.703111 0.682667 5.006222 1.735111 29.184-20.878222 54.812445-42.325333 48.099556-140.032 71.452444-298.723556 71.452444z m-243.541333-125.354666c6.712889 7.936 48.952889 45.425778 243.541333 45.425777 192.227556 0 233.187556-37.575111 239.530667-45.312-2.986667-35.925333-16.384-195.242667-27.249778-268.686222-9.102222-61.582222 47.530667-84.593778 68.835556-93.240889 3.413333-1.393778 6.656-2.673778 9.472-4.010666 17.92-8.533333 55.096889-27.249778 55.153778-27.278223 24.462222-11.434667 34.474667-20.565333 38.4-25.287111l-175.36-151.779555c-52.792889 78.193778-144.355556 87.808-186.083556 87.808-9.927111 0-17.834667-0.540444-22.698667-0.967111a255.601778 255.601778 0 0 1-22.983111 0.967111c-42.325333 0-135.253333-9.671111-188.643555-88.291556l-178.488889 152.291556c4.067556 4.750222 14.108444 13.710222 37.831111 24.604444 1.137778 0.540444 38.855111 19.285333 57.059556 27.818667 2.872889 1.336889 6.172444 2.645333 9.642666 4.039111 21.589333 8.647111 78.904889 31.601778 69.660445 93.411555-11.178667 74.24-24.945778 236.344889-27.619556 268.487112z" fill="#595959" p-id="1778"></path>
|
||||
</svg>
|
||||
</span>`;
|
||||
|
||||
class Lightblock extends Card<LightblockValue> {
|
||||
static get cardName() {
|
||||
return 'lightblock';
|
||||
}
|
||||
|
||||
static get cardType() {
|
||||
return CardType.BLOCK;
|
||||
}
|
||||
|
||||
static get autoSelected() {
|
||||
return false;
|
||||
}
|
||||
|
||||
static get singleSelectable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
contenteditable = ['div.lightblock-editor-container'];
|
||||
|
||||
#container?: NodeInterface;
|
||||
|
||||
#changeTimeout?: NodeJS.Timeout;
|
||||
|
||||
toolbar(): Array<ToolbarItemOptions | CardToolbarItemOptions> {
|
||||
if (!isEngine(this.editor) || this.editor.readonly) return [];
|
||||
|
||||
const value = this.getValue();
|
||||
|
||||
return [
|
||||
{ type: 'dnd' },
|
||||
{ type: 'copy' },
|
||||
{
|
||||
type: 'node',
|
||||
title: '主题',
|
||||
node: $(themeIcon),
|
||||
didMount: node => {
|
||||
console.log('node?.get', value, node?.get());
|
||||
if (node?.get()) {
|
||||
createApp(LightblockTheme, {
|
||||
value,
|
||||
change: (data: IChangeParam) => {
|
||||
this.setValue({
|
||||
...value,
|
||||
backgroundColor: data.background,
|
||||
borderColor: data.border,
|
||||
});
|
||||
this.updateColor();
|
||||
},
|
||||
}).mount(node.get() as Element);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ type: 'delete' },
|
||||
];
|
||||
}
|
||||
|
||||
getValue() {
|
||||
const value = super.getValue();
|
||||
const editorContainer = this.#container?.find(this.contenteditable.join(','));
|
||||
if (!editorContainer) return value;
|
||||
const editor = this.editor;
|
||||
const { schema, conversion } = editor;
|
||||
const container = $('<div></div>');
|
||||
|
||||
container.append(editorContainer.clone(true).children());
|
||||
const parser = new Parser(container, editor);
|
||||
const html = parser.toValue(schema, conversion, false, false);
|
||||
if (!isEngine(editor)) return { ...value, html };
|
||||
return {
|
||||
...value,
|
||||
html,
|
||||
} as LightblockValue;
|
||||
}
|
||||
|
||||
updateColor = (value = this.getValue()) => {
|
||||
this.#container?.css({
|
||||
borderColor: value.borderColor,
|
||||
backgroundColor: value.backgroundColor,
|
||||
});
|
||||
};
|
||||
|
||||
onChange = (trigger: 'remote' | 'local' = 'local') => {
|
||||
const editor = this.editor;
|
||||
if (isEngine(editor) && trigger === 'local' && editor.model.mutation.isStopped) return;
|
||||
|
||||
if (this.#changeTimeout) clearTimeout(this.#changeTimeout);
|
||||
this.#changeTimeout = setTimeout(() => {
|
||||
const value = this.getValue();
|
||||
this.updateColor(value);
|
||||
if (trigger === 'local' && isEngine(editor)) {
|
||||
if (value) this.setValue(value);
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
render(isFoucs?: boolean) {
|
||||
const value = this.getValue();
|
||||
const { borderColor, backgroundColor } = value;
|
||||
const childValue = value.html ? new Parser(value.html, this.editor).toValue() : '<br />';
|
||||
this.#container = $(
|
||||
`<div class="lightblock-container" style="border-color: ${borderColor};background-color:${backgroundColor};">
|
||||
<div class="lightblock-icon">
|
||||
<svg
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="13148"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path
|
||||
d="M833.5 330.5C833.5 153 689.6 7.6 512 7.6S190.5 153 190.5 330.5c0 70.3 37.3 161 97.2 227.9 59.4 66.3 103.5 177.9 103.5 266.9v34.9h241.6v-34.6c0-89 44.1-200.4 103.2-266.9 60.1-67.6 97.5-166.4 97.5-228.2z"
|
||||
fill="#FFC807"
|
||||
p-id="13149"
|
||||
></path>
|
||||
<path
|
||||
d="M636.5 790.6l-193.9-268L596.4 202l199.2 266.4c-17.4 36.4-39.3 67.4-63.9 95.2C685.3 621 664.1 671.1 644 741l-7.5 49.6z"
|
||||
fill="#FFB300"
|
||||
p-id="13150"
|
||||
></path>
|
||||
<path
|
||||
d="M499.5 378.3h-82.7c-12.6 0-21.4-12.7-16.8-24.5l59.2-153c2.7-6.9 9.4-11.5 16.8-11.5h105c13.6 0 22.3 14.5 15.9 26.5l-81.5 153c-3.2 5.8-9.3 9.5-15.9 9.5z"
|
||||
fill="#FFF8E1"
|
||||
p-id="13151"
|
||||
></path>
|
||||
<path
|
||||
d="M466.2 518.3l160-171c12-12.8 2.9-33.7-14.6-33.7h-105c-8.7 0-16.4 5.6-19.1 13.9l-55 171c-6.6 20.4 19.1 35.5 33.7 19.8z"
|
||||
fill="#FFF8E1"
|
||||
p-id="13152"
|
||||
></path>
|
||||
<path
|
||||
d="M593.6 1016.4H430.4c-5.7 0-10.9-3.7-14-9.8l-22.2-44.2h235.6l-22.2 44.2c-3.1 6.1-8.3 9.8-14 9.8z"
|
||||
fill="#455A64"
|
||||
p-id="13153"
|
||||
></path>
|
||||
<path
|
||||
d="M625.7 980.4H398.3c-22.1 0-40.1-17.9-40.1-40.1V776.7c0-22.1 17.9-40.1 40.1-40.1h227.5c22.1 0 40.1 17.9 40.1 40.1v163.7c-0.1 22.1-18 40-40.2 40z"
|
||||
fill="#ECEFF1"
|
||||
p-id="13154"
|
||||
></path>
|
||||
<path
|
||||
d="M539.8 808.6H359v-36h180.8c9.9 0 18 8.1 18 18 0 10-8.1 18-18 18zM359 840.1h306v36H359zM665 943.6H494.8c-9.9 0-18-8.1-18-18s8.1-18 18-18H665v36z"
|
||||
fill="#CFD8DC"
|
||||
p-id="13155"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="lightblock-editor-container">${childValue}</div>
|
||||
</div>`
|
||||
);
|
||||
|
||||
if (isFoucs) {
|
||||
setTimeout(() => {
|
||||
this.#container?.find('.lightblock-editor-container')?.get<HTMLElement>()?.focus?.();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return this.#container;
|
||||
}
|
||||
|
||||
didRender() {
|
||||
super.didRender();
|
||||
this.updateColor();
|
||||
}
|
||||
}
|
||||
export default Lightblock;
|
||||
export type { LightblockValue };
|
||||
99
editor/src/plugins/lightblock/component/lightblock-theme.vue
Normal file
99
editor/src/plugins/lightblock/component/lightblock-theme.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<span class="lightblock-icon-theme">
|
||||
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1777" width="16" height="16">
|
||||
<path
|
||||
d="M515.100444 911.758222c-161.393778 0-260.494222-23.495111-302.990222-71.793778-22.442667-25.486222-21.333333-49.521778-20.48-56.291555 0.455111-6.030222 15.530667-189.923556 28.216889-275.655111-4.608-3.128889-14.392889-7.054222-20.053333-9.329778a229.831111 229.831111 0 0 1-13.880889-5.888c-18.716444-8.789333-57.571556-28.046222-57.571556-28.046222-48.298667-22.186667-76.942222-50.403556-83.996444-83.399111-5.660444-26.424889 3.982222-49.180444 14.876444-61.696 1.308444-1.479111 2.702222-2.872889 4.181334-4.124445l227.072-193.735111a39.907556 39.907556 0 0 1 63.800889 17.607111c24.888889 73.699556 97.024 84.736 137.841777 84.736 10.865778 0 17.777778-0.853333 17.834667-0.881778 3.441778-0.426667 7.111111-0.426667 10.552889 0.028445 0 0 6.769778 0.824889 17.265778 0.824889 40.021333 0 110.791111-10.979556 135.253333-84.565334a39.964444 39.964444 0 0 1 64.085333-17.607111l223.800889 193.735111c1.479111 1.28 2.872889 2.673778 4.124445 4.152889 10.780444 12.515556 20.280889 35.299556 14.592 61.639111-7.054222 32.824889-35.299556 60.984889-83.939556 83.626667 0 0.028444-37.489778 18.887111-55.694222 27.562667-4.096 1.934222-8.760889 3.868444-13.710222 5.888-5.518222 2.247111-15.104 6.144-19.598223 9.244444 12.572444 86.471111 27.392 269.880889 28.017778 277.703111 0.682667 5.006222 1.735111 29.184-20.878222 54.812445-42.325333 48.099556-140.032 71.452444-298.723556 71.452444z m-243.541333-125.354666c6.712889 7.936 48.952889 45.425778 243.541333 45.425777 192.227556 0 233.187556-37.575111 239.530667-45.312-2.986667-35.925333-16.384-195.242667-27.249778-268.686222-9.102222-61.582222 47.530667-84.593778 68.835556-93.240889 3.413333-1.393778 6.656-2.673778 9.472-4.010666 17.92-8.533333 55.096889-27.249778 55.153778-27.278223 24.462222-11.434667 34.474667-20.565333 38.4-25.287111l-175.36-151.779555c-52.792889 78.193778-144.355556 87.808-186.083556 87.808-9.927111 0-17.834667-0.540444-22.698667-0.967111a255.601778 255.601778 0 0 1-22.983111 0.967111c-42.325333 0-135.253333-9.671111-188.643555-88.291556l-178.488889 152.291556c4.067556 4.750222 14.108444 13.710222 37.831111 24.604444 1.137778 0.540444 38.855111 19.285333 57.059556 27.818667 2.872889 1.336889 6.172444 2.645333 9.642666 4.039111 21.589333 8.647111 78.904889 31.601778 69.660445 93.411555-11.178667 74.24-24.945778 236.344889-27.619556 268.487112z"
|
||||
fill="#595959"
|
||||
p-id="1778"
|
||||
></path>
|
||||
</svg>
|
||||
<div class="lightblock-theme-contain">
|
||||
<div class="lightblock-theme-content" ref="themeContent">
|
||||
<div class="lightblock-theme-random" @click="randomColor">
|
||||
<span class="data-icon icon-reload"></span>
|
||||
随机
|
||||
</div>
|
||||
<div class="lightblock-theme-title">边框颜色</div>
|
||||
<div class="lightblock-theme-box">
|
||||
<span v-for="(color, i) in border" :key="color" :class="`lightblock-theme-box-item ${bdColor === color ? 'active' : ''}`" @click="() => changeColor('border', color, i)">
|
||||
<span :style="{ background: color }" />
|
||||
</span>
|
||||
</div>
|
||||
<div :style="{ height: '8px' }" />
|
||||
<div class="lightblock-theme-title">背景颜色</div>
|
||||
<div class="lightblock-theme-box">
|
||||
<span v-for="color in background" :key="color" :class="`lightblock-theme-box-item ${bgColor === color ? 'active' : ''}`" @click="() => changeColor('background', color)">
|
||||
<span :style="{ background: color }" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
|
||||
import type { LightblockValue, IChangeParam } from './types';
|
||||
|
||||
const colorMatch = {
|
||||
border: ['#eff0f1', '#fbbfbc', '#fed4a4', '#fff67a', '#b7edb1', '#bacefd', '#cdb2fa'],
|
||||
background: ['#f2f3f5', '#fef1f1', '#fff5eb', '#fefff0', '#f0fbef', '#f0f4ff', '#f6f1fe'],
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'lightblock-theme',
|
||||
props: {
|
||||
value: { type: Object as PropType<LightblockValue>, default: () => ({}) },
|
||||
change: {
|
||||
type: Function as PropType<(val: IChangeParam) => void>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { value, change: onChange } = { ...props };
|
||||
const themeContent = ref<HTMLDivElement | null>(null);
|
||||
const { border, background } = colorMatch;
|
||||
const { borderColor, backgroundColor } = value;
|
||||
const bdColor = ref<string>(borderColor);
|
||||
const bgColor = ref<string>(backgroundColor);
|
||||
|
||||
// onMounted(() => {});
|
||||
|
||||
// onUnmounted(() => {});
|
||||
|
||||
const randomColor = () => {
|
||||
const index = Math.floor(Math.random() * border.length);
|
||||
const randomBdColor = border[index];
|
||||
const randomBgColor = background[index];
|
||||
bdColor.value = randomBdColor;
|
||||
bgColor.value = randomBgColor;
|
||||
onChange && onChange({ background: randomBgColor, border: randomBdColor });
|
||||
};
|
||||
|
||||
const changeColor = (type: 'border' | 'background', color: string, key?: number) => {
|
||||
if (type === 'border' && key !== undefined) {
|
||||
bdColor.value = color;
|
||||
bgColor.value = background[key];
|
||||
}
|
||||
if (type === 'background') {
|
||||
bgColor.value = color;
|
||||
}
|
||||
|
||||
onChange && onChange({ border: bdColor.value, background: bgColor.value });
|
||||
};
|
||||
|
||||
return {
|
||||
themeContent,
|
||||
border,
|
||||
background,
|
||||
bdColor,
|
||||
bgColor,
|
||||
randomColor,
|
||||
changeColor,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less"></style>
|
||||
21
editor/src/plugins/lightblock/component/markdown.ts
Normal file
21
editor/src/plugins/lightblock/component/markdown.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import container from 'markdown-it-container';
|
||||
import type MarkdownIt from 'markdown-it';
|
||||
import { encodeCardValue } from '@aomao/engine';
|
||||
|
||||
export default function mk_lightblock(md: MarkdownIt) {
|
||||
const defaultValue = {
|
||||
borderColor: '#fed4a4',
|
||||
backgroundColor: '#fff5eb',
|
||||
text: 'light-block',
|
||||
};
|
||||
|
||||
md.use(container, 'tip', {
|
||||
render(tokens: any, idx: number) {
|
||||
if (tokens[idx].nesting === 1) {
|
||||
return `<div data-type="lightblock" data-value="${encodeCardValue(defaultValue)}">`;
|
||||
} else {
|
||||
return '</div>';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
93
editor/src/plugins/lightblock/component/style.css
Normal file
93
editor/src/plugins/lightblock/component/style.css
Normal file
@@ -0,0 +1,93 @@
|
||||
.lightblock-container {
|
||||
display: flex;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.lightblock-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 14px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.lightblock-editor-container {
|
||||
flex: 1;
|
||||
max-width: calc(100% - 36px);
|
||||
}
|
||||
|
||||
.lightblock-icon-theme {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
.lightblock-icon-theme:hover path{
|
||||
fill: #40a9ff;
|
||||
}
|
||||
.lightblock-theme-contain {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 100%);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.lightblock-theme-content {
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 10px rgb(0 0 0 / 12%);
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.lightblock-icon-theme:hover .lightblock-theme-contain {
|
||||
display: block;
|
||||
}
|
||||
.lightblock-theme-random {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
color:#3370ff;
|
||||
font-size: 12px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lightblock-theme-random span {
|
||||
font-size: 12px;
|
||||
}
|
||||
.lightblock-theme-title {
|
||||
padding-bottom: 4px;
|
||||
color: #888;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.lightblock-theme-box {
|
||||
display: flex;
|
||||
}
|
||||
.lightblock-theme-box-item {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 8px 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lightblock-theme-box-item.active {
|
||||
border: 2px solid #3370ff;
|
||||
}
|
||||
.lightblock-theme-box-item:hover {
|
||||
border: 2px solid rgb(51, 112, 255, 0.5);
|
||||
}
|
||||
.lightblock-theme-box-item span {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 2px;
|
||||
filter: brightness(1)
|
||||
}
|
||||
23
editor/src/plugins/lightblock/component/types.ts
Normal file
23
editor/src/plugins/lightblock/component/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { CardValue } from '@aomao/engine';
|
||||
|
||||
export interface LightblockValue extends CardValue {
|
||||
borderColor: string;
|
||||
backgroundColor: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
}
|
||||
|
||||
export interface ILightblockProp {
|
||||
value: LightblockValue;
|
||||
}
|
||||
|
||||
export interface IChangeParam {
|
||||
border: string;
|
||||
background: string;
|
||||
}
|
||||
|
||||
export interface IThemeProp {
|
||||
language: { [key: string]: string };
|
||||
value: LightblockValue;
|
||||
onChange?: (val: IChangeParam) => void;
|
||||
}
|
||||
109
editor/src/plugins/lightblock/index.ts
Normal file
109
editor/src/plugins/lightblock/index.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { $, Plugin, NodeInterface, CARD_KEY, isEngine, SchemaInterface, PluginOptions, decodeCardValue, encodeCardValue, READY_CARD_KEY, Parser } from '@aomao/engine';
|
||||
import type MarkdownIt from 'markdown-it';
|
||||
import LightblockComponent from './component';
|
||||
import type { LightblockValue } from './component';
|
||||
import lightblockMk from './component/markdown';
|
||||
|
||||
export type LightblockOptions = PluginOptions;
|
||||
|
||||
export default class extends Plugin<LightblockOptions> {
|
||||
static get pluginName() {
|
||||
return 'lightblock';
|
||||
}
|
||||
init() {
|
||||
const editor = this.editor;
|
||||
|
||||
editor.on('parse:html', this.parseHtml);
|
||||
editor.on('paste:schema', this.pasteSchema);
|
||||
editor.on('paste:each', this.pasteHtml);
|
||||
if (isEngine(editor)) {
|
||||
editor.on('markdown-it', this.markdownIt);
|
||||
}
|
||||
}
|
||||
|
||||
execute() {
|
||||
const editor = this.editor;
|
||||
|
||||
if (!isEngine(editor) || editor.readonly) return;
|
||||
const { card } = editor;
|
||||
|
||||
card.insert<LightblockValue>(
|
||||
LightblockComponent.cardName,
|
||||
{
|
||||
borderColor: '#fed4a4',
|
||||
backgroundColor: '#fff5eb',
|
||||
text: 'light-block',
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
markdownIt = (markdown: MarkdownIt) => {
|
||||
if (this.options.markdown !== false) {
|
||||
lightblockMk(markdown);
|
||||
}
|
||||
};
|
||||
|
||||
pasteSchema = (schema: SchemaInterface) => {
|
||||
schema.add({
|
||||
type: 'block',
|
||||
name: 'div',
|
||||
attributes: {
|
||||
'data-type': {
|
||||
required: true,
|
||||
value: LightblockComponent.cardName,
|
||||
},
|
||||
'data-value': '*',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
pasteHtml = (node: NodeInterface) => {
|
||||
const editor = this.editor;
|
||||
const cardName = LightblockComponent.cardName;
|
||||
|
||||
if (!isEngine(editor) || editor.readonly) return;
|
||||
if (node.isElement()) {
|
||||
const type = node.attributes('data-type');
|
||||
if (type === cardName) {
|
||||
const value = node.attributes('data-value');
|
||||
const cardValue = decodeCardValue(value);
|
||||
editor.card.replaceNode(node, cardName, cardValue);
|
||||
node.remove();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
parseHtml = (root: NodeInterface) => {
|
||||
const cardName = LightblockComponent.cardName;
|
||||
|
||||
root.find(`[${CARD_KEY}="${cardName}"],[${READY_CARD_KEY}="${cardName}"]`).each(cardNode => {
|
||||
const node = $(cardNode);
|
||||
const card = this.editor.card.find<LightblockValue, LightblockComponent>(node);
|
||||
const value = card?.getValue();
|
||||
if (value) {
|
||||
node.empty();
|
||||
const div = this.renderHtml(value, cardName);
|
||||
node.replaceWith(div);
|
||||
} else node.remove();
|
||||
});
|
||||
};
|
||||
|
||||
renderHtml = (value: LightblockValue, cardName: string) => {
|
||||
const htmlstring = new Parser(value.html || value.text, this.editor).toHTML();
|
||||
|
||||
return $(`<div data-type="${cardName}" data-value="${encodeCardValue(value)}">${htmlstring}</div>`);
|
||||
};
|
||||
|
||||
destroy() {
|
||||
const editor = this.editor;
|
||||
|
||||
editor.off('parse:html', this.parseHtml);
|
||||
editor.off('paste:schema', this.pasteSchema);
|
||||
editor.off('paste:each', this.pasteHtml);
|
||||
}
|
||||
}
|
||||
export { LightblockComponent };
|
||||
export type { LightblockValue };
|
||||
307
editor/src/plugins/math/component/constant.ts
Normal file
307
editor/src/plugins/math/component/constant.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
export const mathList = [
|
||||
{
|
||||
name: '积分',
|
||||
children: [
|
||||
'a^2',
|
||||
'a_2',
|
||||
'a^{2+2}',
|
||||
'a_{i,j}',
|
||||
'{}_1^2\\!X_3^4',
|
||||
'\\overset{\\frown} {AB}',
|
||||
'\\overline{hij}',
|
||||
`\\underline{klm}`,
|
||||
'\\overbrace{1+2+\\cdots+100}',
|
||||
'\\begin{matrix} 5050 \\\\ \\overbrace{ 1+2+\\cdots+100 }\\end{matrix}',
|
||||
'\\underbrace{a+b+\\cdots+z}',
|
||||
'\\sum_{k=1}^N k^2',
|
||||
'\\begin{matrix} \\sum_{k=1}^N k^2 \\end{matrix}',
|
||||
'\\prod_{i=1}^N x_i',
|
||||
'\\begin{matrix} \\prod_{i=1}^N x_i \\end{matrix}',
|
||||
'\\coprod_{i=1}^N x_i',
|
||||
'\\begin{matrix} \\coprod_{i=1}^N x_i \\end{matrix}',
|
||||
'\\lim_{n \\to \\infty}x_n',
|
||||
'\\begin{matrix} \\ lim_{n \\to \\infty}x_n \\end{matrix}',
|
||||
'\\int_{-N}^{N} e^x\\, \\mathrm{d}x',
|
||||
'\\begin{matrix} \\int_{_N}^{N} e^x\\, \\mathrm{d}x \\end{matrix}',
|
||||
'\\iint_{D}^{W} \\, \\mathrm{d}x\\,\\mathrm{d}y',
|
||||
'\\iiint_{E}^{V} \\,\\mathrm{d}x\\,\\mathrm{d}y,\\mathrm{d}z',
|
||||
'\\oint_{C} x^3\\, \\mathrm{d}x + 4y^2\\, \\mathrm{d}y',
|
||||
'\\bigcap_1^{n} p',
|
||||
'\\bigcup_1^{k} p',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '分隔符',
|
||||
children: [
|
||||
'\\left(\\frac{a}{b} \\right)',
|
||||
'\\left[\\frac{a}{b} \\right]',
|
||||
'\\left\\{\\frac{a}{b} \\right\\}',
|
||||
'\\left \\langle \\frac{a}{b} \\right \\rangle',
|
||||
'\\left|\\frac{a}{b} \\right|',
|
||||
'\\left \\lceil \\frac{c}{d} \\right \\rceil',
|
||||
'\\left / \\frac{a}{b} \\right \\backslash',
|
||||
'\\left \\Uparrow \\frac{a}{b} \\right \\Downarrow',
|
||||
'\\left \\updownarrow \\frac{a}{b} \\right \\Updownarrow',
|
||||
'\\left [ 0,1 \\right ) \\left \\langle \\psi \\right |',
|
||||
'\\left \\{ \\frac{a}{b} \\right.',
|
||||
'\\left . \\frac{a}{b} \\right \\}',
|
||||
'\\langle',
|
||||
'\\rangle',
|
||||
'\\lceil',
|
||||
'\\rceil',
|
||||
'\\lfloor',
|
||||
'\\rfloor',
|
||||
'\\lbrace',
|
||||
'\\rbrace',
|
||||
'\\lvert',
|
||||
'\\rvert',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '函数',
|
||||
children: [
|
||||
'\\sin\\theta',
|
||||
'\\cos\\theta',
|
||||
'\\tan\\theta',
|
||||
'\\arcsin\\frac{L}{r}',
|
||||
'\\arccos\\frac{T}{r}',
|
||||
'\\arctan\\frac{L}{T}',
|
||||
'\\sinh g',
|
||||
'\\cosh h',
|
||||
'\\tanh i',
|
||||
'\\coth j',
|
||||
'\\operatorname{sh}j',
|
||||
'\\operatorname{ch}h',
|
||||
'\\operatorname{th}i',
|
||||
'\\operatorname{argsh}k',
|
||||
'\\operatorname{argch}l',
|
||||
'\\operatorname{argth}m',
|
||||
'\\limsup S',
|
||||
'\\liminf I',
|
||||
'\\max H',
|
||||
'\\min L',
|
||||
'\\inf s',
|
||||
'\\sup t',
|
||||
'\\exp\\!t',
|
||||
'\\ln X',
|
||||
'\\lg X',
|
||||
'\\log X',
|
||||
'\\log_\\alpha X',
|
||||
'\\ker x',
|
||||
'\\deg x',
|
||||
'\\gcd(T,U,V,W,X)',
|
||||
'\\Pr x',
|
||||
'\\det x',
|
||||
'\\hom x',
|
||||
'\\arg x',
|
||||
'\\dim x',
|
||||
'\\lim_{t\\to n}T',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '微分导数',
|
||||
children: ['\\nabla\\psi', '\\partial x', '\\mathrm{d}x', '\\dot x', '\\ddot y', 'X^\\prime', '\\backprime', 'f^{(3)}'],
|
||||
},
|
||||
{
|
||||
name: '运算符',
|
||||
children: [
|
||||
'\\pm',
|
||||
'\\times',
|
||||
'\\div',
|
||||
'\\mid',
|
||||
'\\nmid',
|
||||
'\\cdot',
|
||||
'\\circ',
|
||||
'\\ast',
|
||||
'\\bigodot',
|
||||
'\\bigoplus',
|
||||
'\\leq',
|
||||
'\\geq',
|
||||
'\\leqq',
|
||||
'\\geqq',
|
||||
'=',
|
||||
'\\neq',
|
||||
'\\approx',
|
||||
'\\equiv',
|
||||
'\\not\\equiv',
|
||||
'\\sum',
|
||||
'\\prod',
|
||||
'\\coprod',
|
||||
'\\backslash',
|
||||
'\\sim',
|
||||
'\\backsim',
|
||||
'\\simeq',
|
||||
'\\cong',
|
||||
'\\dot=',
|
||||
'\\ggg',
|
||||
'\\gg',
|
||||
'>',
|
||||
'<',
|
||||
'\\ll',
|
||||
'\\lll',
|
||||
'\\propto',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '逻辑符号',
|
||||
children: [
|
||||
'、emptyset',
|
||||
'\\varnothing',
|
||||
'\\in',
|
||||
'\\not\\in',
|
||||
'\\subset',
|
||||
'\\supset',
|
||||
'\\subseteq',
|
||||
'\\sqsupseteq',
|
||||
'\\cap',
|
||||
'\\cup',
|
||||
'\\bigcup',
|
||||
'\\sqcap',
|
||||
'\\sqcup',
|
||||
'\\uplus',
|
||||
'\\biguplus',
|
||||
'\\bigsqcup',
|
||||
'\\top',
|
||||
'\\bot',
|
||||
'\\complement',
|
||||
'\\vee',
|
||||
'\\wedge',
|
||||
'\\bigvee',
|
||||
'\\bigwedge',
|
||||
'\\forall',
|
||||
'\\exists',
|
||||
'\\not\\subset',
|
||||
'\\not=',
|
||||
'\\not<',
|
||||
'\\not>',
|
||||
'\\because',
|
||||
'\\therefore',
|
||||
'\\neg',
|
||||
'\\bar{q} \\to p',
|
||||
'\\setminus',
|
||||
'\\smallsetminus',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '几何符号',
|
||||
children: ['\\Diamond', '\\Box', '\\triangle', '\\perp', '\\angle\\Alpha\\Beta\\Gamma', '60^\\circ'],
|
||||
},
|
||||
{
|
||||
name: '戴帽符号',
|
||||
children: [
|
||||
'\\vec{c}',
|
||||
'\\overleftarrow{ab}',
|
||||
'\\overrightarrow{cd}',
|
||||
'\\overleftrightarrow{ab}',
|
||||
'\\widehat{efg}',
|
||||
'\\overset{\\frown} {AB}',
|
||||
'\\hat{xyz}',
|
||||
'\\tilde{xy}',
|
||||
'\\bar{y}',
|
||||
'\\widetilde{xyz}',
|
||||
'\\acute{y}',
|
||||
'\\breve{y}',
|
||||
'\\check{y}',
|
||||
'\\grave{y}',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '箭头符号',
|
||||
children: [
|
||||
'\\to',
|
||||
'\\mapsto',
|
||||
'\\underrightarrow{1^circ/min}',
|
||||
'\\implies',
|
||||
'\\impliedby',
|
||||
'\\iff',
|
||||
'\\downarrow',
|
||||
'\\Uparrow',
|
||||
'\\Downarrow',
|
||||
'\\leftarrow',
|
||||
'\\rightarrow',
|
||||
'\\leftrightarrow',
|
||||
'\\Leftarrow',
|
||||
'\\Rightarrow',
|
||||
'\\Leftrightarrow',
|
||||
'\\longleftarrow',
|
||||
'\\longrightarrow',
|
||||
'\\longleftrightarrow',
|
||||
'\\Longleftarrow',
|
||||
'\\Longrightarrow',
|
||||
'\\Longleftrightarrow',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '特殊符号',
|
||||
children: ['\\eth', '\\%', '\\dagger', '\\ddagger', '\\star', '*', '\\ldots', '\\smile', '\\frown', '\\wr'],
|
||||
},
|
||||
{
|
||||
name: '分数多行',
|
||||
children: [
|
||||
'\\frac{2}{4}=0.5',
|
||||
'{2 \\over 3}',
|
||||
'{{a+b} \\over {a-b}}',
|
||||
'\\tfrac{2}{4} = 0.5',
|
||||
'\\cfrac{2}{c + \\cfrac{2}{d + \\cfrac{2}{4}}} = a',
|
||||
'\\begin{matrix}x & y \\\\z & v\\end{matrix}',
|
||||
'\\begin{Vmatrix}x & y \\\\z & v\\end{Vmatrix}',
|
||||
'\\begin{bmatrix}0& \\cdots & 0\\\\\\vdots & \\ddots & \\vdots \\\\0& \\cdots & 0\\end{bmatrix}',
|
||||
'\\begin{Bmatrix}x & y \\\\z & v\\end{Bmatrix}',
|
||||
'\\begin{pmatrix}x & y \\\\z & v\\end{pmatrix}',
|
||||
'\\begin{cases}3x + 5y + z \\\\7x - 2y + 4z \\\\-6x + 3y + 2z\\end{cases}',
|
||||
'\\begin{array}{|c|c||c|} a & b & S \\\\\\hline0&0&1\\\\0&1&1\\\\1&0&1\\\\1&1&0\\\\\\end{array}',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '希腊字母',
|
||||
children: [
|
||||
'\\alpha',
|
||||
'\\beta',
|
||||
'\\gamma',
|
||||
'\\delta',
|
||||
'\\epsilon',
|
||||
'\\epsilon',
|
||||
'\\zeta',
|
||||
'\\eta',
|
||||
'\\theta',
|
||||
'\\iota',
|
||||
'\\kappa',
|
||||
'\\lambda',
|
||||
'\\mu',
|
||||
'\\nu',
|
||||
'\\xi',
|
||||
'o',
|
||||
'\\pi',
|
||||
'\\rho',
|
||||
'\\sigma',
|
||||
'\\tau',
|
||||
'\\upsilon',
|
||||
'\\phi',
|
||||
],
|
||||
},
|
||||
];
|
||||
export const katexOption = {
|
||||
delimiters: [
|
||||
{
|
||||
left: '$$',
|
||||
right: '$$',
|
||||
display: true,
|
||||
},
|
||||
{
|
||||
left: '$',
|
||||
right: '$',
|
||||
display: false,
|
||||
},
|
||||
{
|
||||
left: '\\(',
|
||||
right: '\\)',
|
||||
display: false,
|
||||
},
|
||||
{
|
||||
left: '\\[',
|
||||
right: '\\]',
|
||||
display: true,
|
||||
},
|
||||
],
|
||||
throwOnError: false,
|
||||
};
|
||||
80
editor/src/plugins/math/component/index.ts
Normal file
80
editor/src/plugins/math/component/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { $, Card, CardToolbarItemOptions, CardType, isEngine, NodeInterface, ToolbarItemOptions } from '@aomao/engine';
|
||||
import { App, createApp } from 'vue';
|
||||
import { MathValue } from './type';
|
||||
import MathFormula from './math-formula.vue';
|
||||
|
||||
export const editIcon = `<span class="draw-icon-edit">
|
||||
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1777" width="16" height="16">
|
||||
<path d="M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 000-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 009.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3-362.7 362.6-88.9 15.7 15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z" fill="#595959"></path>
|
||||
</svg>
|
||||
</span>`;
|
||||
|
||||
class Math<V extends MathValue = MathValue> extends Card<V> {
|
||||
static get cardName() {
|
||||
return 'math';
|
||||
}
|
||||
|
||||
static get cardType() {
|
||||
return CardType.INLINE;
|
||||
}
|
||||
|
||||
#container?: NodeInterface;
|
||||
private vm?: App;
|
||||
|
||||
toolbar(): Array<ToolbarItemOptions | CardToolbarItemOptions> {
|
||||
if (!isEngine(this.editor) || this.editor.readonly) return [];
|
||||
return [
|
||||
// {
|
||||
// type: "dnd",
|
||||
// },
|
||||
{
|
||||
type: 'node',
|
||||
node: $(editIcon),
|
||||
didMount: node => {
|
||||
node.on('click', () => {
|
||||
this.defaultVisible = true;
|
||||
this.didRender();
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'copy',
|
||||
},
|
||||
{
|
||||
type: 'delete',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
defaultVisible = false;
|
||||
|
||||
render(visible?: boolean) {
|
||||
this.#container = $('<div>Loading</div>');
|
||||
this.defaultVisible = visible ?? false;
|
||||
return this.#container;
|
||||
}
|
||||
|
||||
didRender() {
|
||||
super.didRender();
|
||||
const value = this.getValue();
|
||||
setTimeout(() => {
|
||||
this.vm = createApp(MathFormula, {
|
||||
value,
|
||||
defaultVisible: this.defaultVisible,
|
||||
change: (item: any) => {
|
||||
this.setValue({
|
||||
...value,
|
||||
...item,
|
||||
});
|
||||
},
|
||||
});
|
||||
this.vm.mount(this.#container?.get<HTMLElement>());
|
||||
}, 20);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
super.destroy();
|
||||
this.vm?.unmount();
|
||||
}
|
||||
}
|
||||
export default Math;
|
||||
225
editor/src/plugins/math/component/math-formula.vue
Normal file
225
editor/src/plugins/math/component/math-formula.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div class="math-formula">
|
||||
<div :class="`math-formula-content ${id}`">
|
||||
<img v-if="mathTextareaValue" :src="imageUrl" :width="width" :height="height" />
|
||||
<span v-else>添加公式</span>
|
||||
</div>
|
||||
<Modal v-model:visible="visible" title="公式编辑器" width="1000px" wrap-class-name="math-formula-modal" cancel-text="取消" ok-text="保存" @ok="handleOk">
|
||||
<Tabs :activeKey="activeKey" @change="changePanel">
|
||||
<TabPane v-for="(mt, i) in mathTypeList" :key="i" :tab="mt.name" />
|
||||
</Tabs>
|
||||
<div v-if="visible" class="math-modal-content">
|
||||
<div class="math-modal-content-left">
|
||||
<div class="math-model-node" v-for="mc in mathTypeList[activeKey].children" @click="addTextareaValue(mc)" :key="mc">$${{ mc }}$$</div>
|
||||
</div>
|
||||
<div class="math-modal-content-right">
|
||||
<Textarea
|
||||
class="content-editor-input"
|
||||
v-model:value="mathTextareaValue"
|
||||
placeholder="请输入latex公式"
|
||||
@focus="setSelection"
|
||||
@blur="setSelection"
|
||||
@click="setSelection"
|
||||
@input="renderPreview"
|
||||
:auto-size="{ minRows: 5, maxRows: 6 }"
|
||||
allow-clear
|
||||
/>
|
||||
<div class="content-editor-preview">
|
||||
{{ mathPreviewValue }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import 'katex/dist/katex.css';
|
||||
import renderMathInElement from 'katex/contrib/auto-render/auto-render';
|
||||
import { Modal, Tabs, TabPane, Textarea } from 'ant-design-vue';
|
||||
import { PropType, defineComponent, ref, reactive, nextTick, onMounted } from 'vue';
|
||||
import { katexOption, mathList } from './constant';
|
||||
import { MathValue } from './type';
|
||||
import html2canvas from 'html2canvas';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'math-comp',
|
||||
components: { Modal, Tabs, TabPane, Textarea },
|
||||
props: {
|
||||
value: { type: Object as PropType<MathValue>, required: true },
|
||||
defaultVisible: { type: Boolean, required: false },
|
||||
change: { type: Function as PropType<(item: any) => void> },
|
||||
},
|
||||
setup(props) {
|
||||
const { defaultVisible, value, change } = { ...props };
|
||||
const id = ref<string>(value.id);
|
||||
const width = ref<number>(value.width);
|
||||
const height = ref<number>(value.height);
|
||||
const imageUrl = ref<string>(value.imageUrl);
|
||||
const visible = ref<boolean>(defaultVisible);
|
||||
const activeKey = ref<number>(0);
|
||||
let mathTypeList = ref<any>(mathList);
|
||||
const mathTextareaValue = ref<string>(value.code || '');
|
||||
const mathPreviewValue = ref<string>('');
|
||||
const textareaSelectionInfo = reactive({
|
||||
start: -1,
|
||||
end: -1,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
changePanel(0);
|
||||
renderMath();
|
||||
});
|
||||
|
||||
const handleOk = (e: MouseEvent) => {
|
||||
console.log('mathTextareaValue', mathTextareaValue.value);
|
||||
nextTick(() => {
|
||||
const mathPreview = document.querySelector('.content-editor-preview span.katex') as HTMLDivElement;
|
||||
html2canvas(mathPreview, { scale: 2 }).then((canvas: any) => {
|
||||
let url = canvas.toDataURL('image/svg+xml');
|
||||
imageUrl.value = url;
|
||||
width.value = mathPreview.offsetWidth * 1.2;
|
||||
height.value = mathPreview.offsetHeight * 1.2;
|
||||
visible.value = false;
|
||||
if (change) {
|
||||
change({
|
||||
code: mathTextareaValue.value,
|
||||
width: width.value,
|
||||
height: height.value,
|
||||
imageUrl: imageUrl.value,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const changePanel = (index: any) => {
|
||||
activeKey.value = index;
|
||||
nextTick(() => {
|
||||
let nodeList = document.getElementsByClassName('math-model-node');
|
||||
for (let k = 0; k < nodeList.length; k++) {
|
||||
renderMathInElement(nodeList[k], katexOption);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const addTextareaValue = (tex: any) => {
|
||||
let textareaValue = mathTextareaValue.value;
|
||||
let start = textareaSelectionInfo.start;
|
||||
let end = textareaSelectionInfo.end;
|
||||
if (start === -1 && end === -1) {
|
||||
start = textareaValue.length;
|
||||
end = textareaValue.length;
|
||||
}
|
||||
const startStr = textareaValue.substring(0, start);
|
||||
const endStr = textareaValue.substring(end);
|
||||
mathTextareaValue.value = `${startStr}${tex}${endStr}`;
|
||||
console.log('addTextareaValue', start, end);
|
||||
renderMath();
|
||||
};
|
||||
|
||||
const setSelection = () => {
|
||||
const textarea = document.querySelector('textarea.ant-input.content-editor-input') as HTMLTextAreaElement;
|
||||
if (!textarea) return;
|
||||
console.log('setSelection', textarea.selectionStart, textarea.selectionEnd);
|
||||
let textareaValue = mathTextareaValue.value;
|
||||
let start = textareaSelectionInfo.start;
|
||||
let end = textareaSelectionInfo.end;
|
||||
if (textareaValue.length === start && textareaValue.length === end) {
|
||||
textareaSelectionInfo.start = -1;
|
||||
textareaSelectionInfo.end = -1;
|
||||
} else {
|
||||
textareaSelectionInfo.start = textarea.selectionStart;
|
||||
textareaSelectionInfo.end = textarea.selectionEnd;
|
||||
}
|
||||
};
|
||||
|
||||
const renderMath = () => {
|
||||
if (mathTextareaValue.value) {
|
||||
const mathPreview = document.querySelector('.content-editor-preview') as HTMLDivElement;
|
||||
if (!mathPreview) return;
|
||||
mathPreview.innerHTML = `$$${mathTextareaValue.value}$$`;
|
||||
nextTick(() => {
|
||||
renderMathInElement(mathPreview, katexOption);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderPreview = () => {
|
||||
setSelection();
|
||||
renderMath();
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
width,
|
||||
height,
|
||||
imageUrl,
|
||||
visible,
|
||||
activeKey,
|
||||
mathTypeList,
|
||||
mathTextareaValue,
|
||||
mathPreviewValue,
|
||||
changePanel,
|
||||
addTextareaValue,
|
||||
setSelection,
|
||||
renderPreview,
|
||||
handleOk,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.math-formula-content {
|
||||
max-width: 690px;
|
||||
overflow: auto;
|
||||
}
|
||||
.math-formula-modal {
|
||||
.ant-modal {
|
||||
overflow-y: hidden;
|
||||
.ant-modal-body {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
.math-modal-content {
|
||||
display: flex;
|
||||
.math-modal-content-left {
|
||||
width: 240px;
|
||||
height: 320px;
|
||||
overflow: auto;
|
||||
.math-model-node {
|
||||
cursor: pointer;
|
||||
border: 1px solid #ccc;
|
||||
margin: 0 5px 10px 5px;
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s;
|
||||
&:hover {
|
||||
border: 1px solid #8f8d8d;
|
||||
}
|
||||
}
|
||||
}
|
||||
.math-modal-content-right {
|
||||
flex: 1;
|
||||
margin-left: 10px;
|
||||
overflow: auto;
|
||||
.content-editor-input {
|
||||
max-height: 180px;
|
||||
textarea {
|
||||
height: 180px !important;
|
||||
max-height: 180px !important;
|
||||
}
|
||||
}
|
||||
.content-editor-preview {
|
||||
height: 130px;
|
||||
margin-top: 10px;
|
||||
border: 1px solid #ccc;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
9
editor/src/plugins/math/component/type.ts
Normal file
9
editor/src/plugins/math/component/type.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { CardValue } from '@aomao/engine';
|
||||
|
||||
export interface MathValue extends CardValue {
|
||||
id: string;
|
||||
code: string;
|
||||
width: number;
|
||||
height: number;
|
||||
imageUrl: string;
|
||||
}
|
||||
79
editor/src/plugins/math/index.ts
Normal file
79
editor/src/plugins/math/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { $, Plugin, NodeInterface, CARD_KEY, isEngine, SchemaInterface, PluginOptions, decodeCardValue, encodeCardValue } from '@aomao/engine';
|
||||
import MathComponent from './component';
|
||||
import { MathValue } from './component/type';
|
||||
|
||||
export interface Options extends PluginOptions {
|
||||
hotkey?: string | Array<string>;
|
||||
}
|
||||
export default class extends Plugin<Options> {
|
||||
static get pluginName() {
|
||||
return 'math';
|
||||
}
|
||||
// 插件初始化
|
||||
init() {
|
||||
// 监听解析成html的事件
|
||||
this.editor.on('paser:html', node => this.parseHtml(node));
|
||||
// 监听粘贴时候设置schema规则的入口
|
||||
this.editor.on('paste:schema', schema => this.pasteSchema(schema));
|
||||
// 监听粘贴时候的节点循环
|
||||
this.editor.on('paste:each', child => this.pasteHtml(child));
|
||||
}
|
||||
// 执行方法
|
||||
execute() {
|
||||
if (!isEngine(this.editor)) return;
|
||||
const { card } = this.editor;
|
||||
card.insert<MathValue>(MathComponent.cardName, { code: '' }, true);
|
||||
}
|
||||
// 快捷键
|
||||
hotkey() {
|
||||
return this.options.hotkey || 'mod+shift+0';
|
||||
}
|
||||
// 粘贴的时候添加需要的 schema
|
||||
pasteSchema(schema: SchemaInterface) {
|
||||
schema.add({
|
||||
type: 'block',
|
||||
name: 'div',
|
||||
attributes: {
|
||||
'data-type': {
|
||||
required: true,
|
||||
value: MathComponent.cardName,
|
||||
},
|
||||
'data-value': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
// 解析粘贴过来的html
|
||||
pasteHtml(node: NodeInterface) {
|
||||
if (!isEngine(this.editor)) return;
|
||||
if (node.isElement()) {
|
||||
const type = node.attributes('data-type');
|
||||
if (type === MathComponent.cardName) {
|
||||
const value = node.attributes('data-value');
|
||||
const cardValue = decodeCardValue(value);
|
||||
this.editor.card.replaceNode(node, MathComponent.cardName, cardValue);
|
||||
node.remove();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// 解析成html
|
||||
parseHtml(root: NodeInterface) {
|
||||
root.find(`[${CARD_KEY}=${MathComponent.cardName}`).each(cardNode => {
|
||||
const node = $(cardNode);
|
||||
const card = this.editor.card.find(node) as MathComponent;
|
||||
const value = card?.getValue();
|
||||
if (value) {
|
||||
node.empty();
|
||||
const div = $(
|
||||
`<div
|
||||
data-type="${MathComponent.cardName}"
|
||||
data-value="${encodeCardValue(value)}"
|
||||
></div>`
|
||||
);
|
||||
node.replaceWith(div);
|
||||
} else node.remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
export { MathComponent };
|
||||
66
editor/src/plugins/tag/component/index.tsx
Normal file
66
editor/src/plugins/tag/component/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { $, Card, CardType, NodeInterface, SelectStyleType } from '@aomao/engine';
|
||||
import { App, createApp } from 'vue';
|
||||
import TagComponent from './tag';
|
||||
import type { TagValue } from './type';
|
||||
|
||||
class Tag extends Card<TagValue> {
|
||||
static get cardName() {
|
||||
return 'tag';
|
||||
}
|
||||
|
||||
static get cardType() {
|
||||
return CardType.INLINE;
|
||||
}
|
||||
|
||||
static get autoSelected() {
|
||||
return false;
|
||||
}
|
||||
|
||||
static get singleSelectable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
static get selectStyleType() {
|
||||
return SelectStyleType.BACKGROUND;
|
||||
}
|
||||
|
||||
#container?: NodeInterface;
|
||||
private vm?: App;
|
||||
|
||||
defaultVisible = false;
|
||||
|
||||
render(visible?: boolean) {
|
||||
this.#container = $('<div>Loading</div>');
|
||||
this.defaultVisible = visible ?? false;
|
||||
return this.#container;
|
||||
}
|
||||
|
||||
didRender() {
|
||||
super.didRender();
|
||||
const value = this.getValue();
|
||||
const { editor } = this;
|
||||
setTimeout(() => {
|
||||
this.vm = createApp(TagComponent, {
|
||||
value,
|
||||
editor,
|
||||
defaultVisible: this.defaultVisible,
|
||||
change: (item: any) => {
|
||||
this.setValue({
|
||||
tagType: item.type,
|
||||
tagValue: item.text,
|
||||
isCustom: item.isCustom,
|
||||
});
|
||||
},
|
||||
});
|
||||
this.vm.mount(this.#container?.get<HTMLElement>());
|
||||
}, 20);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
super.destroy();
|
||||
this.vm?.unmount();
|
||||
this.vm = undefined;
|
||||
}
|
||||
}
|
||||
export default Tag;
|
||||
export type { TagValue };
|
||||
91
editor/src/plugins/tag/component/style.css
Normal file
91
editor/src/plugins/tag/component/style.css
Normal file
@@ -0,0 +1,91 @@
|
||||
.tag-plugin-contain {
|
||||
background: #ffe8e6;
|
||||
color: #820014;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
|
||||
.tag-plugin-tooltip .ant-popover-inner-content {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.tag-plugin-tooltip-contain .tag-plugin-tooltip-title {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tag-plugin-tooltip-contain .tag-plugin-tooltip-default {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tag-plugin-tooltip-default > span {
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-plugin-tooltip-default > span + span {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.tag-plugin-tooltip-custom .ant-input-group-addon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tag-plugin-tooltip-custom input {
|
||||
font-size: 12px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.tag-plugin-tooltip-custom .tag-plugin-tooltip-custom-theme {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tag-plugin-tooltip-custom-theme > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-plugin-tooltip-custom-theme > span svg {
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
.tag-plugin-tooltip-custom-history {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tag-plugin-tooltip-custom-history > span {
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-plugin-tooltip-custom-history > span + span {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.tag-plugin-tooltip-custom-history-title {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.tag-plugin-tooltip-custom-history-active {
|
||||
border-width: 1px !important;
|
||||
}
|
||||
201
editor/src/plugins/tag/component/tag.tsx
Normal file
201
editor/src/plugins/tag/component/tag.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import type { PropType } from 'vue';
|
||||
import { defineComponent, ref, reactive, toRaw } from 'vue';
|
||||
import { TagValue, IType, ValueItem } from './type';
|
||||
import type { ChangeEvent } from 'ant-design-vue/lib/_util/EventInterface';
|
||||
import { Popover, Input } from 'ant-design-vue';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { EditorInterface } from '@aomao/engine';
|
||||
|
||||
type TagVauleItem = ValueItem & { isCustom: boolean };
|
||||
|
||||
export const defaultValue: ValueItem[] = [
|
||||
{
|
||||
type: 'abandon',
|
||||
background: '#FFE8E6',
|
||||
color: '#820014',
|
||||
text: '废弃',
|
||||
},
|
||||
{
|
||||
type: 'must',
|
||||
background: '#ebf3ff',
|
||||
color: '#338aff',
|
||||
text: '必填',
|
||||
},
|
||||
{
|
||||
type: 'add',
|
||||
background: '#d8eecd',
|
||||
color: '#5ca537',
|
||||
text: '新增',
|
||||
},
|
||||
{
|
||||
type: 'delete',
|
||||
background: '#ffe9bc',
|
||||
color: '#dc9300',
|
||||
text: '删除',
|
||||
},
|
||||
];
|
||||
|
||||
const getInitData = (list: ValueItem[], value: TagValue, local: Record<string, any>) => {
|
||||
const { tagType, tagValue, isCustom } = value;
|
||||
const data = list.find(item => item.type === tagType) || list[0];
|
||||
|
||||
if (isCustom) {
|
||||
return {
|
||||
...data,
|
||||
text: tagValue || local['addTag'],
|
||||
isCustom: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
text: tagType ? data.type : local['addTag'],
|
||||
isCustom: false,
|
||||
};
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'tag-comp',
|
||||
props: {
|
||||
value: { type: Object as PropType<TagValue>, required: true },
|
||||
editor: { type: Object as PropType<EditorInterface>, required: true },
|
||||
defaultVisible: { type: Boolean, required: false },
|
||||
change: { type: Function as PropType<(item: any) => void> },
|
||||
},
|
||||
setup(props) {
|
||||
const { value, editor, defaultVisible, change } = toRaw(props);
|
||||
const { tagType, tagValue, isCustom } = value;
|
||||
const local: any = editor.language.get('tag');
|
||||
const initData: ValueItem[] = defaultValue.map(item => ({
|
||||
...item,
|
||||
text: local[item.type],
|
||||
}));
|
||||
const data = getInitData(initData, value, local);
|
||||
const description = isCustom ? tagValue : '';
|
||||
|
||||
const type = ref(tagType);
|
||||
let activeValue = reactive(data);
|
||||
const customText = ref(description);
|
||||
const visible = ref(defaultVisible);
|
||||
|
||||
const hide = () => {
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
const handleVisibleChange = (newVisible: boolean) => {
|
||||
visible.value = newVisible;
|
||||
};
|
||||
|
||||
const onClick = (item: ValueItem) => {
|
||||
type.value = item.type;
|
||||
customText.value = '';
|
||||
activeValue = reactive({ ...item, isCustom: false });
|
||||
if (change) {
|
||||
change(activeValue);
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const onCustomClick = (item: ValueItem) => {
|
||||
type.value = item.type;
|
||||
activeValue = reactive({
|
||||
...item,
|
||||
text: customText.value,
|
||||
isCustom: true,
|
||||
});
|
||||
|
||||
if (change) {
|
||||
change(activeValue);
|
||||
}
|
||||
};
|
||||
|
||||
const onInputChange = (e: ChangeEvent) => {
|
||||
customText.value = e.target.value || '';
|
||||
activeValue.isCustom = true;
|
||||
};
|
||||
|
||||
const onPressEnter = () => {
|
||||
type.value = activeValue.type || 'abandon';
|
||||
activeValue = reactive({ ...activeValue, text: customText.value });
|
||||
if (change) {
|
||||
change(activeValue);
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
return (
|
||||
<Popover
|
||||
visible={visible.value}
|
||||
trigger={'click'}
|
||||
placement="bottomLeft"
|
||||
overlayClassName="tag-plugin-tooltip"
|
||||
onVisibleChange={handleVisibleChange}
|
||||
overlayStyle={{
|
||||
padding: 0,
|
||||
boxShadow: '0px 2px 4px 0px rgba(225 225 225, .5)',
|
||||
}}
|
||||
content={
|
||||
<div class="tag-plugin-tooltip-contain">
|
||||
<div class="tag-plugin-tooltip-default">
|
||||
<div class="tag-plugin-tooltip-title">{local['defaultTag']}</div>
|
||||
{initData.map(item => (
|
||||
<span
|
||||
key={item.type}
|
||||
style={{
|
||||
minWidth: 22,
|
||||
color: item.color,
|
||||
background: item.background,
|
||||
}}
|
||||
onClick={() => onClick(item)}
|
||||
>
|
||||
{type.value === item.type && !activeValue.isCustom && (
|
||||
<svg width={12} height={12} fill={item.color} viewBox="0 0 18 18" style={{ marginRight: 2 }}>
|
||||
<path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z" />
|
||||
</svg>
|
||||
)}
|
||||
{item.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div class="tag-plugin-tooltip-custom">
|
||||
<div class="tag-plugin-tooltip-title">{local['customTag']}</div>
|
||||
<div class="tag-plugin-tooltip-custom-theme">
|
||||
{initData.map(item => (
|
||||
<span
|
||||
key={item.type}
|
||||
style={{
|
||||
color: item.color,
|
||||
background: item.background,
|
||||
}}
|
||||
onClick={() => {
|
||||
onCustomClick(item);
|
||||
}}
|
||||
>
|
||||
{type.value === item.type && activeValue.isCustom && (
|
||||
<svg width={12} height={12} fill={item.color} viewBox="0 0 18 18" style={{ marginRight: 2 }}>
|
||||
<path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Input size="small" value={customText.value} placeholder={local['placeholder']} onChange={onInputChange} onPressEnter={onPressEnter} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="tag-plugin-contain"
|
||||
style={{
|
||||
color: activeValue.color,
|
||||
background: activeValue.background,
|
||||
}}
|
||||
>
|
||||
{activeValue.text || local['addTag']}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
16
editor/src/plugins/tag/component/type.ts
Normal file
16
editor/src/plugins/tag/component/type.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { CardValue } from '@aomao/engine';
|
||||
|
||||
export type IType = 'abandon' | 'must' | 'add' | 'delete' | '';
|
||||
|
||||
export interface TagValue extends CardValue {
|
||||
tagType: IType;
|
||||
tagValue: string;
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
export interface ValueItem {
|
||||
type: IType;
|
||||
background: string;
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
114
editor/src/plugins/tag/index.ts
Normal file
114
editor/src/plugins/tag/index.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { $, CARD_KEY, READY_CARD_KEY, isEngine, Plugin, SchemaInterface, NodeInterface, PluginOptions, decodeCardValue, encodeCardValue } from '@aomao/engine';
|
||||
import locales from './local';
|
||||
import { TagValue } from './component/type';
|
||||
import TagComponent from './component/index';
|
||||
import { defaultValue } from './component/tag';
|
||||
import './component/style.css';
|
||||
|
||||
export interface Options extends PluginOptions {
|
||||
hotkey?: string | Array<string>;
|
||||
}
|
||||
|
||||
export default class extends Plugin<Options> {
|
||||
static get pluginName() {
|
||||
return 'tag';
|
||||
}
|
||||
|
||||
init() {
|
||||
const { editor } = this;
|
||||
editor.language.add(locales);
|
||||
editor.on('parse:html', this.parseHtml);
|
||||
editor.on('paste:schema', this.pasteSchema);
|
||||
editor.on('paste:each', this.pasteHtml);
|
||||
}
|
||||
|
||||
execute() {
|
||||
const { editor } = this;
|
||||
if (!isEngine(editor) || editor.readonly) {
|
||||
return;
|
||||
}
|
||||
const { card } = editor;
|
||||
card.insert<TagValue>(
|
||||
TagComponent.cardName,
|
||||
{
|
||||
tagType: '',
|
||||
tagValue: '',
|
||||
isCustom: false,
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
hotkey() {
|
||||
return this.options.hotkey || '';
|
||||
}
|
||||
|
||||
pasteSchema = (schema: SchemaInterface) => {
|
||||
schema.add({
|
||||
type: 'inline',
|
||||
name: 'span',
|
||||
attributes: {
|
||||
'data-type': {
|
||||
required: true,
|
||||
value: TagComponent.cardName,
|
||||
},
|
||||
'data-value': '*',
|
||||
},
|
||||
});
|
||||
};
|
||||
pasteHtml = (node: NodeInterface) => {
|
||||
const { editor } = this;
|
||||
|
||||
if (!isEngine(editor) || editor.readonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.isElement()) {
|
||||
const type = node.attributes('data-type');
|
||||
if (type === TagComponent.cardName) {
|
||||
const value = node.attributes('data-value');
|
||||
const cardValue = decodeCardValue(value);
|
||||
editor.card.replaceNode(node, TagComponent.cardName, cardValue);
|
||||
node.remove();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
parseHtml = (root: NodeInterface) => {
|
||||
const name = TagComponent.cardName;
|
||||
|
||||
root.find(`[${CARD_KEY}="${name}"],[${READY_CARD_KEY}="${name}"]`).each(cardNode => {
|
||||
const node = $(cardNode);
|
||||
const card = this.editor.card.find<TagValue, TagComponent>(node);
|
||||
const value = card?.getValue();
|
||||
if (value) {
|
||||
node.empty();
|
||||
|
||||
const hideClass = value.tagValue ? '' : 'qz-tag-hide';
|
||||
const data = defaultValue.find(item => item.type === value.tagType) || defaultValue[0];
|
||||
|
||||
const span = $(
|
||||
`<span data-type="${name}"` +
|
||||
`data-value="${encodeCardValue(value)}"` +
|
||||
`class="${hideClass} qz-tag-view qz-tag-type-${value.tagType}" ` +
|
||||
`style="color:${data.color};background:${data.background};"` +
|
||||
`>${value.isCustom ? value.tagValue || '请添加标签' : data.text}</div>`
|
||||
);
|
||||
node.replaceWith(span);
|
||||
} else {
|
||||
node.remove();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
destroy() {
|
||||
const { editor } = this;
|
||||
editor.off('parse:html', this.parseHtml);
|
||||
editor.off('paste:schema', this.pasteSchema);
|
||||
editor.off('paste:each', this.pasteHtml);
|
||||
}
|
||||
}
|
||||
|
||||
export { TagComponent };
|
||||
export type { TagValue };
|
||||
28
editor/src/plugins/tag/local/index.ts
Normal file
28
editor/src/plugins/tag/local/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export default {
|
||||
'en-US': {
|
||||
tag: {
|
||||
defaultTag: 'Default Tag',
|
||||
customTag: 'Custom Tag',
|
||||
historyTag: 'History Tag',
|
||||
placeholder: 'Press Enter to confirm',
|
||||
addTag: 'Add Tag',
|
||||
abandon: 'Abandon',
|
||||
must: 'Require',
|
||||
add: 'Add',
|
||||
delete: 'Delete',
|
||||
},
|
||||
},
|
||||
'zh-CN': {
|
||||
tag: {
|
||||
defaultTag: '默认标签',
|
||||
customTag: '自定义标签',
|
||||
historyTag: '历史标签',
|
||||
placeholder: '输入标签内容, 回车确认',
|
||||
addTag: '请添加标签',
|
||||
abandon: '废弃',
|
||||
must: '必填',
|
||||
add: '添加',
|
||||
delete: '删除',
|
||||
},
|
||||
},
|
||||
};
|
||||
57
editor/src/plugins/test/component/index.ts
Normal file
57
editor/src/plugins/test/component/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { $, Card, CardToolbarItemOptions, CardType, isEngine, NodeInterface, ToolbarItemOptions } from '@aomao/engine';
|
||||
import { App, createApp } from 'vue';
|
||||
import TestVue from './test-com.vue';
|
||||
|
||||
class Test extends Card {
|
||||
static get cardName() {
|
||||
return 'test';
|
||||
}
|
||||
|
||||
static get cardType() {
|
||||
return CardType.BLOCK;
|
||||
}
|
||||
|
||||
#container?: NodeInterface;
|
||||
#vm?: App;
|
||||
|
||||
toolbar(): Array<ToolbarItemOptions | CardToolbarItemOptions> {
|
||||
if (!isEngine(this.editor) || this.editor.readonly) return [];
|
||||
return [
|
||||
{
|
||||
type: 'dnd',
|
||||
},
|
||||
{
|
||||
type: 'copy',
|
||||
},
|
||||
{
|
||||
type: 'delete',
|
||||
},
|
||||
{
|
||||
type: 'node',
|
||||
node: $('<span>测试按钮</span>'),
|
||||
didMount: node => {
|
||||
node.on('click', () => {
|
||||
alert('test button');
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
this.#container = $('<div>Loading</div>');
|
||||
return this.#container;
|
||||
}
|
||||
|
||||
didRender() {
|
||||
super.didRender();
|
||||
this.#vm = createApp(TestVue, {});
|
||||
this.#vm.mount(this.#container?.get<HTMLElement>());
|
||||
}
|
||||
|
||||
destroy() {
|
||||
super.destroy();
|
||||
this.#vm?.unmount();
|
||||
}
|
||||
}
|
||||
export default Test;
|
||||
7
editor/src/plugins/test/component/test-com.vue
Normal file
7
editor/src/plugins/test/component/test-com.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div>This is test plugin</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less"></style>
|
||||
73
editor/src/plugins/test/index.ts
Normal file
73
editor/src/plugins/test/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { $, Plugin, NodeInterface, CARD_KEY, isEngine, SchemaInterface, PluginOptions, decodeCardValue, encodeCardValue } from '@aomao/engine';
|
||||
import TestComponent from './component';
|
||||
|
||||
export interface Options extends PluginOptions {
|
||||
hotkey?: string | Array<string>;
|
||||
}
|
||||
export default class extends Plugin<Options> {
|
||||
static get pluginName() {
|
||||
return 'test';
|
||||
}
|
||||
// 插件初始化
|
||||
init() {
|
||||
// 监听解析成html的事件
|
||||
this.editor.on('paser:html', node => this.parseHtml(node));
|
||||
// 监听粘贴时候设置schema规则的入口
|
||||
this.editor.on('paste:schema', schema => this.pasteSchema(schema));
|
||||
// 监听粘贴时候的节点循环
|
||||
this.editor.on('paste:each', child => this.pasteHtml(child));
|
||||
}
|
||||
// 执行方法
|
||||
execute() {
|
||||
if (!isEngine(this.editor)) return;
|
||||
const { card } = this.editor;
|
||||
card.insert(TestComponent.cardName);
|
||||
}
|
||||
// 快捷键
|
||||
hotkey() {
|
||||
return this.options.hotkey || 'mod+shift+0';
|
||||
}
|
||||
// 粘贴的时候添加需要的 schema
|
||||
pasteSchema(schema: SchemaInterface) {
|
||||
schema.add({
|
||||
type: 'block',
|
||||
name: 'div',
|
||||
attributes: {
|
||||
'data-type': {
|
||||
required: true,
|
||||
value: TestComponent.cardName,
|
||||
},
|
||||
'data-value': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
// 解析粘贴过来的html
|
||||
pasteHtml(node: NodeInterface) {
|
||||
if (!isEngine(this.editor)) return;
|
||||
if (node.isElement()) {
|
||||
const type = node.attributes('data-type');
|
||||
if (type === TestComponent.cardName) {
|
||||
const value = node.attributes('data-value');
|
||||
const cardValue = decodeCardValue(value);
|
||||
this.editor.card.replaceNode(node, TestComponent.cardName, cardValue);
|
||||
node.remove();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// 解析成html
|
||||
parseHtml(root: NodeInterface) {
|
||||
root.find(`[${CARD_KEY}=${TestComponent.cardName}`).each(cardNode => {
|
||||
const node = $(cardNode);
|
||||
const card = this.editor.card.find(node) as TestComponent;
|
||||
const value = card?.getValue();
|
||||
if (value) {
|
||||
node.empty();
|
||||
const div = $(`<div data-type="${TestComponent.cardName}" data-value="${encodeCardValue(value)}"></div>`);
|
||||
node.replaceWith(div);
|
||||
} else node.remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
export { TestComponent };
|
||||
9
editor/src/publice-path.ts
Normal file
9
editor/src/publice-path.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
interface Window {
|
||||
__POWERED_BY_QIANKUN__: any,
|
||||
__INJECTED_PUBLIC_PATH_BY_QIANKUN__: any
|
||||
}
|
||||
// 判断是否为乾坤模式运行并调整打包环境
|
||||
if (window.__POWERED_BY_QIANKUN__) {
|
||||
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
|
||||
}
|
||||
75
editor/src/router/index.ts
Normal file
75
editor/src/router/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createRouter, createWebHashHistory, createWebHistory, RouteRecordRaw } from 'vue-router';
|
||||
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: 'manual',
|
||||
},
|
||||
{
|
||||
path: '/manual',
|
||||
name: 'manual',
|
||||
component: () => import("../views/manual/index.vue"),
|
||||
},
|
||||
{
|
||||
path: '/manual/edit',
|
||||
name: 'manual-edit',
|
||||
component: () => import("../views/manual/edit.vue"),
|
||||
},
|
||||
{
|
||||
path: '/manual/view',
|
||||
name: 'manual-view',
|
||||
component: () => import("../views/manual/view.vue"),
|
||||
},
|
||||
|
||||
|
||||
// <Route path='/user/new' element={<UserForm />} />
|
||||
// <Route path='/user/:id/edit' element={<UserForm />} />
|
||||
// <Route path='/user/:id' element={<UserForm />} />
|
||||
|
||||
{
|
||||
path: '/editor',
|
||||
name: 'editor',
|
||||
component: () => import(/* webpackChunkName: "editor" */ "../views/editor/index.vue"),
|
||||
},
|
||||
{
|
||||
path: '/editor/edit',
|
||||
name: 'editor-edit',
|
||||
component: () => import(/* webpackChunkName: "editor-edit" */ "../views/editor/edit.vue"),
|
||||
},
|
||||
{
|
||||
path: '/editor/view',
|
||||
name: 'editor-view',
|
||||
component: () => import(/* webpackChunkName: "editor-view" */ "../views/editor/view.vue"),
|
||||
},
|
||||
];
|
||||
// if (window.__POWERED_BY_QIANKUN__) {
|
||||
// routes.forEach(item => {
|
||||
// item.path = '/app1' + item.path
|
||||
// })
|
||||
// }
|
||||
|
||||
const router = createRouter({
|
||||
// history: createWebHistory('/app1'),
|
||||
history: createWebHashHistory('/app1'),
|
||||
routes,
|
||||
});
|
||||
router.beforeEach((to, from, next) => {
|
||||
// if (window.history.state === null) {
|
||||
// history.replaceState({
|
||||
// back: from.path,
|
||||
// current: to.path,
|
||||
// forward: null,
|
||||
// position: NaN,
|
||||
// replaced: false,
|
||||
// scroll: null
|
||||
// }, 'http://192.168.2.99:5173/' + to.path);
|
||||
// } else {
|
||||
if(to.fullPath==='/undefined'){
|
||||
next('/manual');
|
||||
}else{
|
||||
next();
|
||||
}
|
||||
|
||||
// }
|
||||
})
|
||||
export default router;
|
||||
9
editor/src/shims-vue.d.ts
vendored
Normal file
9
editor/src/shims-vue.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/* eslint-disable */
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
declare module 'katex/contrib/auto-render/auto-render';
|
||||
declare module 'html2canvas';
|
||||
9
editor/src/store/index.ts
Normal file
9
editor/src/store/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createStore } from 'vuex';
|
||||
|
||||
export default createStore({
|
||||
state: {},
|
||||
getters: {},
|
||||
mutations: {},
|
||||
actions: {},
|
||||
modules: {},
|
||||
});
|
||||
35
editor/src/utils/index.ts
Normal file
35
editor/src/utils/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export const getCurrentKey = () => {
|
||||
return localStorage.getItem('demo-key') || 'default';
|
||||
};
|
||||
|
||||
export const setCurrentKey = (key = 'default') => {
|
||||
localStorage.setItem('demo-key', key);
|
||||
};
|
||||
|
||||
export const setDocValue = (value: string, key: string = getCurrentKey()) => {
|
||||
localStorage.setItem(`${key}-demo-value`, value);
|
||||
};
|
||||
|
||||
export const getDocValue = (key: string = getCurrentKey()) => {
|
||||
return localStorage.getItem(`${key}-demo-value`);
|
||||
};
|
||||
|
||||
export const listToTree = (list: Array<any> = [], parentKey = 'pid') => {
|
||||
const map: any = {};
|
||||
const treeData = [];
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
const item = list[i];
|
||||
map[item.id] = i;
|
||||
item.children = [];
|
||||
}
|
||||
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
const node = list[i];
|
||||
if (node[parentKey] && list[map[node[parentKey]]]) {
|
||||
list[map[node[parentKey]]].children.push(node);
|
||||
} else {
|
||||
treeData.push(node);
|
||||
}
|
||||
}
|
||||
return treeData;
|
||||
};
|
||||
120
editor/src/utils/request.ts
Normal file
120
editor/src/utils/request.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import axios, {
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
InternalAxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
} from 'axios';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
interface ResponseData {
|
||||
code: number;
|
||||
data: any;
|
||||
message: string;
|
||||
}
|
||||
|
||||
class HttpRequest {
|
||||
private baseUrl: string;
|
||||
private timeout: number;
|
||||
private headers: any;
|
||||
private axiosInstance: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = process.env.VUE_APP_BASE_URL || "";
|
||||
this.timeout = 5000;
|
||||
this.headers = {
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
};
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
timeout: this.timeout,
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
this.axiosInstance.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = JSON.parse(localStorage.getItem("token") || '{}')
|
||||
if (token) {
|
||||
config.headers.Authorization = token;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
this.axiosInstance.interceptors.response.use(
|
||||
(response: AxiosResponse<ResponseData>) => {
|
||||
if (response.data.code === 0) {
|
||||
return response.data.data;
|
||||
} else {
|
||||
message.error(response.data.message);
|
||||
return Promise.reject(response.data.message);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
message.error(error.message);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public request(config: AxiosRequestConfig): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.axiosInstance
|
||||
.request(config)
|
||||
.then((res) => {
|
||||
resolve(res);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public get(url: string, params?: any): Promise<any> {
|
||||
return this.request({
|
||||
method: "GET",
|
||||
url,
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
public post(url: string, data?: any): Promise<any> {
|
||||
return this.request({
|
||||
method: "POST",
|
||||
url,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
public patch(url: string, data?: any): Promise<any> {
|
||||
return this.request({
|
||||
method: "PATCH",
|
||||
url,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
public put(url: string, data?: any): Promise<any> {
|
||||
return this.request({
|
||||
method: "PUT",
|
||||
url,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
public delete(url: string, params?: any): Promise<any> {
|
||||
return this.request({
|
||||
method: "DELETE",
|
||||
url,
|
||||
params,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const httpRequest = new HttpRequest();
|
||||
|
||||
export default httpRequest;
|
||||
245
editor/src/views/editor/edit.vue
Normal file
245
editor/src/views/editor/edit.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<am-loading :loading="loading">
|
||||
<am-toolbar v-if="engine" :engine="engine" :items="items" />
|
||||
<div :class="['editor-wrapper', { 'editor-mobile': isMobile }]">
|
||||
<div class="editor-container">
|
||||
<div class="editor-content">
|
||||
<div ref="container"></div>
|
||||
<am-outline v-if="!isMobile && engine" :engine="engine" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</am-loading>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted, onUnmounted, ref, reactive } from 'vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import Engine, { $, EngineInterface, isMobile, removeUnit } from '@aomao/engine';
|
||||
import AmToolbar from '@aomao/toolbar-vue';
|
||||
import AmLoading from '@/components/editor/loading.vue';
|
||||
import AmOutline from '@/components/editor/outline.vue';
|
||||
import { cards, plugins, pluginConfig, onLoad } from '@/components/editor/config';
|
||||
import { getDocValue, setDocValue } from '@/utils';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'engine-edit',
|
||||
components: {
|
||||
AmLoading,
|
||||
AmToolbar,
|
||||
AmOutline,
|
||||
},
|
||||
data() {
|
||||
// toolbar 配置项
|
||||
return {
|
||||
items: [
|
||||
[
|
||||
{
|
||||
type: 'collapse',
|
||||
groups: [
|
||||
{
|
||||
items: [
|
||||
'image-uploader',
|
||||
'codeblock',
|
||||
'table',
|
||||
'file-uploader',
|
||||
'video-uploader',
|
||||
'math',
|
||||
'status',
|
||||
{
|
||||
name: 'tag',
|
||||
icon: '<span style="width:23px;height:23px;display: inline-block;border:1px solid #E8E8E8;"><svg width="24" height="24" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><rect stroke="#E8E8E8" fill="#FFF" x=".5" y=".5" width="23" height="23" rx="2"></rect><g transform="scale(0.3, 0.3) translate(16 16)"><path d="M8 44L8 6C8 4.89543 8.89543 4 10 4H38C39.1046 4 40 4.89543 40 6V44L24 35.7273L8 44Z" fill="none" stroke="#595959" stroke-width="2" stroke-linejoin="round"></path><path d="M16 18H32" stroke="#595959" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></g></g></svg><span>',
|
||||
title: '标签',
|
||||
search: '标签,tag,biaoqian,bq',
|
||||
},
|
||||
{
|
||||
name: 'embed',
|
||||
icon: '<span style="width:23px;height:23px;display: inline-block;border:1px solid #E8E8E8;"><svg width="24" height="24" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><rect stroke="#E8E8E8" fill="#FFF" x=".5" y=".5" width="23" height="23" rx="2"></rect><g transform="scale(0.3, 0.3) translate(16 16)"><path d="M8 44L8 6C8 4.89543 8.89543 4 10 4H38C39.1046 4 40 4.89543 40 6V44L24 35.7273L8 44Z" fill="none" stroke="#595959" stroke-width="2" stroke-linejoin="round"></path><path d="M16 18H32" stroke="#595959" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></g></g></svg><span>',
|
||||
title: '嵌入网址',
|
||||
search: '嵌入网址,embed,qianruwangzhi,qrwz',
|
||||
},
|
||||
{
|
||||
name: 'lightblock',
|
||||
icon: '<span style="width:23px;height:23px;display: inline-block;border:1px solid #E8E8E8;"><svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="24"><g><rect stroke="#E8E8E8" fill="#FFF" x=".5" y=".5" width="23" height="23" rx="2"></rect><g transform="scale(0.02, 0.02) translate(90, 66)"><path d="M334.381005 532.396498c-43.065755-49.294608-63.309781-112.604389-57.006228-178.291306 10.574825-110.073758 97.919974-198.776832 207.71744-210.893776 68.155127-7.538682 133.543239 13.271232 184.12721 58.571883 49.904497 44.705089 78.529384 108.733229 78.529384 175.681881 0 58.288428-21.461758 114.226326-60.43532 157.530511-33.148915 36.840996-52.83217 85.053971-56.389176 137.225087H528.321701V438.869569c0-9.007123-7.311508-16.319655-16.323748-16.319655-9.014286 0-16.312492 7.312531-16.312491 16.324771v233.34507H393.113547c-3.619427-51.119159-24.146908-100.241852-58.732542-139.823257z m267.534684 349.898389H422.088404c-15.65553 0-28.397714-12.72888-28.397714-28.38441v-13.222113h236.617596v13.222113c0.001023 15.648367-12.737067 28.384411-28.392597 28.38441z m28.393621-176.619226v40.79095h-236.61862V704.913299l236.61862 0.762362z m0 102.380557h-236.61862v-28.945182h236.617596v28.945182h0.001024z m-269.255882 45.853236c0 33.645217 27.378503 61.036 61.035999 61.035999h179.827286c33.65238 0 61.036-27.390782 61.035999-61.035999V689.406148c0-50.646392 17.267234-97.71736 48.62639-132.56803 44.377631-49.313027 68.815158-113.009617 68.815158-179.372938 0-76.212623-32.576888-149.107695-89.390734-199.987401-57.609977-51.586809-132.021585-75.230251-209.499013-66.725571-125.072327 13.823816-224.583539 114.852588-236.613503 240.230883-7.177455 74.713483 15.876564 146.765352 64.907159 202.899725 33.056817 37.817228 51.255258 85.643394 51.255259 134.65557v165.371068z"></path></g></g></svg><span>',
|
||||
title: '高亮块',
|
||||
search: '高亮块,lightblock',
|
||||
},
|
||||
{
|
||||
name: 'audio-uploader',
|
||||
icon: '<span style="width:23px;height:23px;display: inline-block;border:1px solid #E8E8E8;"><svg style="top: 2px;position: relative;" t="1636128560405" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="28042" width="16" height="16"><path d="M877.854 269.225l-56.805-56.806-121.726-123.079c-24.345-21.64-41.928-27.050-68.978-27.050h-451.737c-31.108 0-55.453 24.345-55.453 55.453v789.865c0 29.755 24.345 54.1 55.453 54.1h666.787c31.108 0 55.453-24.345 55.453-54.1v-584.284c0-24.345-8.115-35.165-22.993-54.1v0zM830.516 289.513h-156.891v-156.891l156.891 156.891zM856.213 907.609c0 5.409-4.057 10.821-10.821 10.821h-666.787c-6.762 0-12.172-5.409-12.172-10.821v-789.865c0-6.762 5.409-12.172 12.172-12.172 0 0 451.737 0 451.737 0v205.582c0 12.173 9.468 21.64 21.64 21.64h204.229v574.816zM723.668 413.943c-117.668-1.353-246.157 22.993-363.825 59.511-9.468 4.058-10.821 5.409-10.821 14.877v210.991c-12.172-5.409-27.050-6.762-41.927-5.409-45.985 1.353-82.503 29.755-82.503 60.862 0 31.108 36.517 55.453 82.503 52.748 45.985-2.706 82.503-29.755 82.503-60.863v-193.409c109.553-25.698 209.638-43.28 312.429-51.395v150.128c-12.173-5.409-25.698-6.762-40.576-6.762-45.985 2.706-82.503 29.755-82.503 62.215 0 31.108 36.517 55.453 82.503 52.748 44.632-2.706 82.503-29.755 82.503-60.863v-267.797c0-13.525-6.762-16.23-20.287-17.583z" p-id="28043"></path></svg><span>',
|
||||
title: '音频',
|
||||
search: '音频,audio,yinpin,YP',
|
||||
},
|
||||
{
|
||||
name: 'draw',
|
||||
icon: '<span style="width:23px;height:23px;display: inline-block;border:1px solid #E8E8E8;"><svg style="top: 2px;position: relative;" t="1636128560405" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="28042" width="16" height="16"><path d="M877.854 269.225l-56.805-56.806-121.726-123.079c-24.345-21.64-41.928-27.050-68.978-27.050h-451.737c-31.108 0-55.453 24.345-55.453 55.453v789.865c0 29.755 24.345 54.1 55.453 54.1h666.787c31.108 0 55.453-24.345 55.453-54.1v-584.284c0-24.345-8.115-35.165-22.993-54.1v0zM830.516 289.513h-156.891v-156.891l156.891 156.891zM856.213 907.609c0 5.409-4.057 10.821-10.821 10.821h-666.787c-6.762 0-12.172-5.409-12.172-10.821v-789.865c0-6.762 5.409-12.172 12.172-12.172 0 0 451.737 0 451.737 0v205.582c0 12.173 9.468 21.64 21.64 21.64h204.229v574.816zM723.668 413.943c-117.668-1.353-246.157 22.993-363.825 59.511-9.468 4.058-10.821 5.409-10.821 14.877v210.991c-12.172-5.409-27.050-6.762-41.927-5.409-45.985 1.353-82.503 29.755-82.503 60.862 0 31.108 36.517 55.453 82.503 52.748 45.985-2.706 82.503-29.755 82.503-60.863v-193.409c109.553-25.698 209.638-43.28 312.429-51.395v150.128c-12.173-5.409-25.698-6.762-40.576-6.762-45.985 2.706-82.503 29.755-82.503 62.215 0 31.108 36.517 55.453 82.503 52.748 44.632-2.706 82.503-29.755 82.503-60.863v-267.797c0-13.525-6.762-16.23-20.287-17.583z" p-id="28043"></path></svg><span>',
|
||||
title: '绘图',
|
||||
search: '绘图,draw',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
['undo', 'redo', 'paintformat', 'removeformat'],
|
||||
['heading', 'fontfamily', 'fontsize'],
|
||||
['bold', 'italic', 'strikethrough', 'underline', 'moremark'],
|
||||
['fontcolor', 'backcolor'],
|
||||
['alignment'],
|
||||
['unorderedlist', 'orderedlist', 'tasklist', 'indent', 'line-height'],
|
||||
['link', 'quote', 'hr'],
|
||||
] as any,
|
||||
};
|
||||
},
|
||||
setup() {
|
||||
// 编辑器容器
|
||||
const container = ref<HTMLElement | null>(null);
|
||||
// 编辑器引擎
|
||||
const engine = ref<any>(null); // EngineInterface | null
|
||||
// 当前所有协作用户
|
||||
const members = ref([]);
|
||||
// 默认设置为当前在加载中
|
||||
const loading = ref(true);
|
||||
onMounted(() => {
|
||||
// 容器加载后实例化编辑器引擎
|
||||
if (container.value) {
|
||||
//实例化引擎
|
||||
const engineInstance = new Engine(container.value, {
|
||||
// 启用的插件
|
||||
plugins,
|
||||
// 启用的卡片
|
||||
cards,
|
||||
// 所有的卡片配置
|
||||
config: pluginConfig,
|
||||
});
|
||||
onLoad(engineInstance);
|
||||
// 设置显示成功消息UI,默认使用 console.log
|
||||
engineInstance.messageSuccess = (msg: string) => {
|
||||
message.success(msg);
|
||||
};
|
||||
// 设置显示错误消息UI,默认使用 console.error
|
||||
engineInstance.messageError = (error: string) => {
|
||||
message.error(error);
|
||||
};
|
||||
// 设置显示确认消息UI,默认无
|
||||
engineInstance.messageConfirm = (msg: string) => {
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
Modal.confirm({
|
||||
content: msg,
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => reject(),
|
||||
});
|
||||
});
|
||||
};
|
||||
//卡片最大化时设置编辑页面样式
|
||||
engineInstance.on('card:maximize', () => {
|
||||
$('.editor-toolbar').css('z-index', '9999').css('top', '55px');
|
||||
});
|
||||
engineInstance.on('card:minimize', () => {
|
||||
$('.editor-toolbar').css('z-index', '').css('top', '');
|
||||
});
|
||||
// 增加首行缩进
|
||||
const addIndent = () => {
|
||||
const children = engineInstance.container.children();
|
||||
children.each((_, index) => {
|
||||
const child = children.eq(index);
|
||||
// 节点没有缩进,并且不是空行就设置一个2em的缩进
|
||||
if (child && !removeUnit(child.css('text-indent')) && !child.isCard() && !engineInstance.node.isEmpty(child)) {
|
||||
engineInstance.command.executeMethod('indent', 'addPadding', child, 2, 10);
|
||||
}
|
||||
});
|
||||
};
|
||||
// engineInstance.on("afterSetValue", addIndent);
|
||||
// engineInstance.on("paste:after", addIndent);
|
||||
// 默认编辑器值,为了演示,这里初始化值写死,正式环境可以请求api加载
|
||||
const value = getDocValue() || '<strong>Hello</strong>,This is demo';
|
||||
// 非协同编辑,设置编辑器值,异步渲染后回调
|
||||
engineInstance.setValue(value, () => {
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
// 监听编辑器值改变事件
|
||||
engineInstance.on('change', () => {
|
||||
const { value, paths } = engineInstance.command.executeMethod('mark-range', 'action', 'mark', 'filter', engineInstance.getValue());
|
||||
setDocValue(value);
|
||||
// console.log('value', value);
|
||||
// console.log('html:', engineInstance.getHtml());
|
||||
});
|
||||
|
||||
engine.value = engineInstance;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (engine.value) engine.value.destroy();
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
isMobile,
|
||||
container,
|
||||
engine,
|
||||
members,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.editor-toolbar {
|
||||
width: 100%;
|
||||
/* height: 38px; */
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
.editor-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: calc(100vh - 38px);
|
||||
min-width: 1580px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.editor-wrapper.editor-mobile {
|
||||
min-width: auto;
|
||||
height: calc(100vh - 82px);
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
background: #fafafa;
|
||||
background-color: #fafafa;
|
||||
padding: 24px 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-mobile .editor-container {
|
||||
padding: 12px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
position: relative;
|
||||
width: 812px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
// min-height: 400px;
|
||||
min-height: calc(100vh - 86px);
|
||||
}
|
||||
|
||||
.editor-mobile .editor-content {
|
||||
width: auto;
|
||||
border: 0 none;
|
||||
}
|
||||
|
||||
.editor-content .am-engine {
|
||||
padding: 40px 60px 60px;
|
||||
}
|
||||
|
||||
.editor-mobile .editor-content .am-engine {
|
||||
padding: 18px 0 0 0;
|
||||
}
|
||||
</style>
|
||||
38
editor/src/views/editor/index.vue
Normal file
38
editor/src/views/editor/index.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="editor">
|
||||
<Button type="primary" @click="handleClick('/editor/edit')">编辑</Button>
|
||||
<Button type="primary" @click="handleClick('/editor/view')">阅读</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { Button } from 'ant-design-vue';
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'editor',
|
||||
components: { Button },
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
|
||||
const handleClick = (url: string) => {
|
||||
router.push(url);
|
||||
};
|
||||
|
||||
return { handleClick };
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.editor {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.ant-btn {
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
128
editor/src/views/editor/view.vue
Normal file
128
editor/src/views/editor/view.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<am-loading :loading="loading">
|
||||
<div class="editor-wrapper editor-wrapper-view">
|
||||
<div class="editor-container">
|
||||
<div class="editor-content">
|
||||
<div ref="container" class="am-engine-view"></div>
|
||||
<am-outline v-if="!isMobile && engine" :engine="engine" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</am-loading>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { $, View, isMobile } from '@aomao/engine';
|
||||
import AmLoading from '@/components/editor/loading.vue';
|
||||
import AmOutline from '@/components/editor/outline.vue';
|
||||
import { cards, plugins, onLoad } from '@/components/editor/config';
|
||||
import { getDocValue } from '@/utils';
|
||||
|
||||
const viewPlugins = plugins.filter(
|
||||
(plugin) =>
|
||||
plugin.pluginName.indexOf('uploader') < 0 &&
|
||||
['mark-range'].indexOf(plugin.pluginName) < 0,
|
||||
);
|
||||
|
||||
export default defineComponent({
|
||||
name: 'engine-view',
|
||||
components: {
|
||||
AmLoading,
|
||||
AmOutline,
|
||||
},
|
||||
setup() {
|
||||
const container = ref<HTMLElement | null>(null);
|
||||
const engine = ref<any>(null); // EngineInterface | null
|
||||
const members = ref([]);
|
||||
const loading = ref(true);
|
||||
onMounted(() => {
|
||||
if (container.value) {
|
||||
const view = new View(container.value, {
|
||||
plugins: viewPlugins,
|
||||
cards,
|
||||
config: {
|
||||
table: {
|
||||
overflow: {
|
||||
maxLeftWidth: () => {
|
||||
return 0;
|
||||
},
|
||||
maxRightWidth: () => {
|
||||
if (isMobile) return 0;
|
||||
const wrapper = $('.editor-wrapper-view');
|
||||
const view = $('.am-engine-view');
|
||||
const wRect = wrapper.get<HTMLElement>()?.getBoundingClientRect();
|
||||
const vRect = view.get<HTMLElement>()?.getBoundingClientRect();
|
||||
const width = (wRect?.width || 0) - (vRect?.width || 0);
|
||||
return width <= 0 ? 100 : width;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// 设置显示成功消息UI,默认使用 console.log
|
||||
view.messageSuccess = (msg: string) => {
|
||||
message.success(msg);
|
||||
};
|
||||
// 设置显示错误消息UI,默认使用 console.error
|
||||
view.messageError = (error: string) => {
|
||||
message.error(error);
|
||||
};
|
||||
// 默认编辑器值,为了演示,这里初始化值写死,正式环境可以请求api加载
|
||||
const value = getDocValue() || '<strong>Hello</strong>,This is demo';
|
||||
// 非协同编辑,设置编辑器值,异步渲染后回调
|
||||
view.render(value);
|
||||
loading.value = false;
|
||||
|
||||
engine.value = view;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (engine.value) engine.value.destroy();
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
isMobile,
|
||||
container,
|
||||
engine,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.editor-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
min-width: 1580px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
background: #fafafa;
|
||||
background-color: #fafafa;
|
||||
padding: 24px 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
position: relative;
|
||||
width: 812px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
// min-height: 400px;
|
||||
min-height: calc(100vh - 86px);
|
||||
}
|
||||
|
||||
.editor-content .am-engine-view {
|
||||
padding: 40px 60px 60px;
|
||||
}
|
||||
</style>
|
||||
210
editor/src/views/manual/components/manual-classify.vue
Normal file
210
editor/src/views/manual/components/manual-classify.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div class="manual-classify">
|
||||
<h2>手册分类</h2>
|
||||
<InputSearch
|
||||
v-model:value="searchValue"
|
||||
placeholder="请输入搜索内容"
|
||||
enter-button
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<div class="classify-toolbar">
|
||||
<Button type="primary" size="small" shape="circle" ghost @click="addClassify">
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
<template v-if="selectedKeys && selectedKeys.length > 0">
|
||||
<Button type="primary" size="small" shape="circle" ghost @click="editClassify">
|
||||
<EditOutlined />
|
||||
</Button>
|
||||
<Button size="small" shape="circle" ghost danger @click="deleteClassify">
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="classify-tree">
|
||||
<Tree
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
:tree-data="treeData"
|
||||
@select="selectClassify"
|
||||
:fieldNames="{
|
||||
title: 'name',
|
||||
key: 'id',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal v-model:visible="visible" :title="modalTitle" @ok="handleOk">
|
||||
<Form>
|
||||
<FormItem label="" v-bind="validateInfos.name">
|
||||
<Input v-model:value="modelRef.name" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { createVNode, PropType, toRefs } from "vue";
|
||||
import { defineComponent, ref, reactive, onMounted, toRaw } from 'vue';
|
||||
import { Button, Divider, Input, InputSearch, Tree, Modal, Form, FormItem, message } from 'ant-design-vue';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons-vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { TreeProps } from 'ant-design-vue';
|
||||
import { getManualClassifyListApi, addManualClassifyApi, editManualClassifyApi, deleteManualClassifyApi } from '@/apis/manual';
|
||||
import { listToTree } from "@/utils";
|
||||
|
||||
export default defineComponent({
|
||||
name: 'manual-classify',
|
||||
components: { Button, Divider, Input, InputSearch, Tree, Modal, Form, FormItem, PlusOutlined, EditOutlined, DeleteOutlined, ExclamationCircleOutlined },
|
||||
emits: ['change'],
|
||||
setup(_, { emit }) {
|
||||
const router = useRouter();
|
||||
const searchValue = ref<string>('');
|
||||
const listData = ref<any>([]);
|
||||
const treeData = ref<TreeProps['treeData']>([]);
|
||||
const selectedKeys = ref<number[]>([]);
|
||||
const modalTitle = ref('');
|
||||
const visible = ref<boolean>(false);
|
||||
let modelRef = reactive({
|
||||
id: undefined,
|
||||
name: '',
|
||||
pid: 0
|
||||
});
|
||||
const rulesRef = reactive({
|
||||
name: [{required: true, message: '请输入分类名称'}]
|
||||
});
|
||||
const { resetFields, validate, validateInfos } = Form.useForm(modelRef, rulesRef, {
|
||||
onValidate: (...args) => console.log(...args),
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
getManualClassifyList();
|
||||
});
|
||||
|
||||
const getManualClassifyList = async () => {
|
||||
const data = await getManualClassifyListApi();
|
||||
listData.value = data;
|
||||
treeData.value = listToTree(data);
|
||||
};
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
const data = toRaw(listData.value).filter((item: any) => {
|
||||
return item.name.toLowerCase().indexOf(value.toLowerCase()) >= 0;
|
||||
});
|
||||
treeData.value = listToTree(data);
|
||||
};
|
||||
|
||||
const addClassify = () => {
|
||||
modalTitle.value = '新增分类';
|
||||
const data = {
|
||||
id: undefined,
|
||||
name: '',
|
||||
pid: 0
|
||||
};
|
||||
resetFields();
|
||||
modelRef = Object.assign(modelRef, data);
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
const editClassify = () => {
|
||||
const data = listData.value.find((item: any) => {
|
||||
return selectedKeys.value[0] === item.id;
|
||||
});
|
||||
resetFields();
|
||||
modelRef = Object.assign(modelRef, {...data});
|
||||
modalTitle.value = '修改分类';
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
const deleteClassify = () => {
|
||||
Modal.confirm({
|
||||
title: '确定删除当前手册分类吗?',
|
||||
icon: createVNode(ExclamationCircleOutlined),
|
||||
okText: '确定',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const data = listData.value.find((item: any) => {
|
||||
return selectedKeys.value[0] === item.id;
|
||||
});
|
||||
await deleteManualClassifyApi(data.id);
|
||||
message.success('手册分类删除成功');
|
||||
selectedKeys.value = [];
|
||||
selectClassify([]);
|
||||
getManualClassifyList();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOk = () => {
|
||||
validate()
|
||||
.then(async () => {
|
||||
const params = { ...modelRef }
|
||||
if (!params.id) {
|
||||
params.pid = selectedKeys.value && selectedKeys.value[0] ? selectedKeys.value[0] : 0
|
||||
await addManualClassifyApi(params);
|
||||
message.success('手册分类创建成功');
|
||||
} else {
|
||||
await editManualClassifyApi(params);
|
||||
message.success('手册分类修改成功');
|
||||
}
|
||||
getManualClassifyList();
|
||||
visible.value = false;
|
||||
})
|
||||
.catch(err => {
|
||||
console.log('error', err);
|
||||
});
|
||||
};
|
||||
|
||||
const selectClassify = (keys: any) => {
|
||||
emit('change', keys[0]);
|
||||
};
|
||||
|
||||
return {
|
||||
searchValue,
|
||||
treeData,
|
||||
selectedKeys,
|
||||
modalTitle,
|
||||
visible,
|
||||
validateInfos,
|
||||
resetFields,
|
||||
modelRef,
|
||||
handleSearch,
|
||||
addClassify,
|
||||
editClassify,
|
||||
deleteClassify,
|
||||
selectClassify,
|
||||
handleOk
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.manual-classify {
|
||||
width: 240px;
|
||||
.classify-toolbar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #d8d8d8;
|
||||
button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
.classify-tree {
|
||||
width: 100%;
|
||||
height: calc(100vh - 176px);
|
||||
padding: 10px 0;
|
||||
overflow: auto;
|
||||
/deep/ .ant-tree {
|
||||
.ant-tree-treenode {
|
||||
width: 100%;
|
||||
.ant-tree-node-content-wrapper {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
442
editor/src/views/manual/components/manual-contents.vue
Normal file
442
editor/src/views/manual/components/manual-contents.vue
Normal file
@@ -0,0 +1,442 @@
|
||||
<template>
|
||||
<div class="manual-contents">
|
||||
<div class="manual-contents-actionbar">
|
||||
<div class="manual-contents-actionbar-left">
|
||||
<div class="actionbar-item">
|
||||
<Checkbox
|
||||
v-model:checked="checkedState.checkAll"
|
||||
:indeterminate="checkedState.indeterminate"
|
||||
@change="onCheckAllChange"
|
||||
/>
|
||||
<span v-if="checkedState.indeterminate"> 已选择{{ checkedKeys.length }}个</span>
|
||||
<span v-else> 所有文档</span>
|
||||
</div>
|
||||
<div class="actionbar-item" @click="onCollapseAll">
|
||||
<PicCenterOutlined /> 全部折叠
|
||||
</div>
|
||||
<div class="actionbar-item" @click="onExpandAll">
|
||||
<PicLeftOutlined /> 全部展开
|
||||
</div>
|
||||
<div class="actionbar-item" @click="onAdd(0, 'atricle')">
|
||||
<PlusCircleOutlined /> 添加文档
|
||||
</div>
|
||||
<div class="actionbar-item" @click="onAdd(0, 'group')">
|
||||
<PlusSquareOutlined /> 添加分组
|
||||
</div>
|
||||
<div class="actionbar-item">
|
||||
<Select
|
||||
show-search
|
||||
size="small"
|
||||
placeholder="请输入搜索内容"
|
||||
style="width: 200px"
|
||||
v-model:value="searchArticle"
|
||||
@change="changeSelect"
|
||||
>
|
||||
<SelectOption v-for="opt in listData" :key="opt.id">
|
||||
{{ opt.title }}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="manual-contents-actionbar-right"
|
||||
v-if="checkedKeys.length"
|
||||
>
|
||||
<Button
|
||||
type="danger"
|
||||
size="small"
|
||||
>删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Tree
|
||||
class="draggable-tree"
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
v-model:checkedKeys="checkedKeys"
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
draggable
|
||||
block-node
|
||||
checkable
|
||||
:tree-data="treeData"
|
||||
@dragenter="onDragEnter"
|
||||
@drop="onDrop"
|
||||
@dragend="onDragend"
|
||||
@dragstart="onDragstart"
|
||||
:fieldNames="{
|
||||
key: 'id',
|
||||
}"
|
||||
>
|
||||
<template #title="node">
|
||||
<div class="tree-node-title">
|
||||
<div class="tree-title-left">
|
||||
<Input v-if="showTreeInput.id === node.id" v-model:value="showTreeInput.value" v-focus size="small" placeholder="small size" @blur="onBlur" />
|
||||
<span v-else @click.stop @dblclick="onEdit(node)">{{ node.title }}</span>
|
||||
</div>
|
||||
<div class="tree-title-right">
|
||||
<span class="article-time">{{ dateTimeFormat(node.updateTime) }}</span>
|
||||
<span class="article-operate">
|
||||
<EyeOutlined @click.stop="onView(node)" />
|
||||
<Dropdown>
|
||||
<PlusSquareOutlined />
|
||||
<template #overlay>
|
||||
<Menu>
|
||||
<MenuItem key="1">
|
||||
<template #icon>
|
||||
<ContainerOutlined />
|
||||
</template>
|
||||
<span>新建文档</span>
|
||||
</MenuItem>
|
||||
<MenuItem key="2">
|
||||
<template #icon>
|
||||
<DatabaseOutlined />
|
||||
</template>
|
||||
<span>新建分组</span>
|
||||
</MenuItem>
|
||||
<MenuItem key="3">
|
||||
<template #icon>
|
||||
<EditOutlined />
|
||||
</template>
|
||||
<span>编辑文档</span>
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuItem key="4">
|
||||
<template #icon>
|
||||
<HighlightOutlined />
|
||||
</template>
|
||||
<span>重命名</span>
|
||||
</MenuItem>
|
||||
<MenuItem key="5">
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
<span>删除</span>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Tree>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, reactive, toRaw, watch, onMounted } from 'vue';
|
||||
import { Button, Input, Tree, Checkbox, Select, SelectOption, Dropdown, Menu, MenuItem, MenuDivider, message } from 'ant-design-vue';
|
||||
import { EyeOutlined, PlusSquareOutlined, PlusCircleOutlined, PicLeftOutlined, PicCenterOutlined, ContainerOutlined, DatabaseOutlined, EditOutlined, HighlightOutlined, DeleteOutlined } from '@ant-design/icons-vue';
|
||||
import type { AntTreeNodeDragEnterEvent, AntTreeNodeDropEvent, TreeProps } from 'ant-design-vue/es/tree';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { addManualArticleApi, getManualArticleListApi } from '@/apis/manual';
|
||||
import { listToTree } from "@/utils";
|
||||
import moment from "moment";
|
||||
|
||||
export default defineComponent({
|
||||
name: 'manual-contents',
|
||||
components: { Button, Input, Tree, Checkbox, Select, SelectOption, Dropdown, Menu, MenuItem, MenuDivider, EyeOutlined, PlusSquareOutlined, PlusCircleOutlined, PicLeftOutlined, PicCenterOutlined, ContainerOutlined, DatabaseOutlined, EditOutlined, HighlightOutlined, DeleteOutlined },
|
||||
props: {
|
||||
formId: { type: Number },
|
||||
},
|
||||
directives: {
|
||||
// 在模板中启用 v-focus
|
||||
focus: {
|
||||
mounted: (el) => el.focus()
|
||||
}
|
||||
},
|
||||
setup(props, ctx) {
|
||||
const route = useRoute();
|
||||
const checkedState = reactive({
|
||||
indeterminate: false,
|
||||
checkAll: false,
|
||||
});
|
||||
const searchArticle = ref();
|
||||
const listData = ref<any>([]);
|
||||
const treeData = ref<TreeProps['treeData']>([]);
|
||||
const selectedKeys = ref<string[]>([]);
|
||||
const checkedKeys = ref<string[]>([]);
|
||||
const expandedKeys = ref<string[]>([]);
|
||||
const showTreeInput = reactive({
|
||||
id: undefined,
|
||||
value: ''
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
//
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => checkedKeys.value, () => listData.value],
|
||||
() => {
|
||||
checkedState.indeterminate = !!checkedKeys.value.length && checkedKeys.value.length < listData.value.length;
|
||||
checkedState.checkAll = checkedKeys.value.length === listData.value.length;
|
||||
},
|
||||
);
|
||||
|
||||
const dateTimeFormat = (dateTime: string) => {
|
||||
return dateTime ? moment(dateTime).format('YYYY-MM-DD HH:mm:ss') : '';
|
||||
};
|
||||
|
||||
const getManualArticleList = async () => {
|
||||
const id = toRaw(props.formId) as number;
|
||||
const data = await getManualArticleListApi(id);
|
||||
listData.value = data;
|
||||
treeData.value = listToTree(data, 'parentId');
|
||||
};
|
||||
|
||||
watch(() => props.formId, () => getManualArticleList(), { immediate: true });
|
||||
|
||||
const onCheckAllChange = () => {
|
||||
let arr = [];
|
||||
if (checkedState.checkAll) {
|
||||
arr = listData.value.map((item: any) => item.id);
|
||||
}
|
||||
checkedKeys.value = arr;
|
||||
checkedState.indeterminate = false;
|
||||
};
|
||||
|
||||
const onCollapseAll = () => {
|
||||
expandedKeys.value = [];
|
||||
};
|
||||
|
||||
const onExpandAll = () => {
|
||||
const keys = listData.value.filter((item: any) => item.children && item.children.length > 0).map((item: any) => item.id);
|
||||
expandedKeys.value = keys;
|
||||
};
|
||||
|
||||
const onAdd = async (parentId: number, type: string) => {
|
||||
const title = type == 'atricle' ? '新增文档' : '新增分组';
|
||||
const formData = {
|
||||
title,// 文章名称
|
||||
explain: '',// 说明
|
||||
manuals: props.formId,// 属于哪个手册
|
||||
content: '',// 内容
|
||||
type,// 类型 分组|文章
|
||||
parentId,// 父级id
|
||||
authorityUser: [],// 具有权限的用户
|
||||
authorityRole: [],// 具有权限的角色
|
||||
resourceAuthority: false,// 资源是否可下载
|
||||
index: 0,// 排名
|
||||
// 资源量
|
||||
video: 0,
|
||||
audio: 0,
|
||||
image: 0,
|
||||
model: 0,
|
||||
};
|
||||
const data = await addManualArticleApi(formData);
|
||||
message.success(`${title}创建成功`);
|
||||
await getManualArticleList();
|
||||
selectedKeys.value = [data.id];
|
||||
};
|
||||
|
||||
const onEdit = (node: any) => {
|
||||
//
|
||||
console.log('onEdit', node);
|
||||
showTreeInput.id = node.id;
|
||||
showTreeInput.value = node.title;
|
||||
selectedKeys.value = [node.id];
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
//
|
||||
};
|
||||
|
||||
const onView = (node: any) => {
|
||||
console.log('onView', node);
|
||||
};
|
||||
|
||||
const getPathRelations: any = (id: any, arr: any = []) => {
|
||||
for (let i = 0; i < listData.value.length; i++) {
|
||||
const item = listData.value[i];
|
||||
if (item.id === id && item.parentId) {
|
||||
const index = arr.indexOf(item.parentId)
|
||||
if (index === -1) {
|
||||
arr.push(item.parentId);
|
||||
return getPathRelations(item.parentId, arr);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const changeSelect = (key: any) => {
|
||||
getPathRelations(key, expandedKeys.value);
|
||||
selectedKeys.value = [key];
|
||||
};
|
||||
|
||||
const onDragEnter = (info: AntTreeNodeDragEnterEvent) => {
|
||||
console.log(info);
|
||||
// expandedKeys 需要展开时
|
||||
// expandedKeys.value = info.expandedKeys;
|
||||
};
|
||||
|
||||
const onDrop = (info: AntTreeNodeDropEvent) => {
|
||||
console.log(info);
|
||||
// const dropKey = info.node.key;
|
||||
// const dragKey = info.dragNode.key;
|
||||
// const dropPos = info.node.pos.split('-');
|
||||
// const dropPosition = info.dropPosition - Number(dropPos[dropPos.length - 1]);
|
||||
// const loop = (data: TreeProps['treeData'], key: string | number, callback: any) => {
|
||||
// data.forEach((item, index) => {
|
||||
// if (item.key === key) {
|
||||
// return callback(item, index, data);
|
||||
// }
|
||||
// if (item.children) {
|
||||
// return loop(item.children, key, callback);
|
||||
// }
|
||||
// });
|
||||
// };
|
||||
// const data = [...treeData.value];
|
||||
|
||||
// // Find dragObject
|
||||
// let dragObj: TreeDataItem;
|
||||
// loop(data, dragKey, (item: TreeDataItem, index: number, arr: TreeProps['treeData']) => {
|
||||
// arr.splice(index, 1);
|
||||
// dragObj = item;
|
||||
// });
|
||||
// if (!info.dropToGap) {
|
||||
// // Drop on the content
|
||||
// loop(data, dropKey, (item: TreeDataItem) => {
|
||||
// item.children = item.children || [];
|
||||
// /// where to insert 示例添加到头部,可以是随意位置
|
||||
// item.children.unshift(dragObj);
|
||||
// });
|
||||
// } else if (
|
||||
// (info.node.children || []).length > 0 && // Has children
|
||||
// info.node.expanded && // Is expanded
|
||||
// dropPosition === 1 // On the bottom gap
|
||||
// ) {
|
||||
// loop(data, dropKey, (item: TreeDataItem) => {
|
||||
// item.children = item.children || [];
|
||||
// // where to insert 示例添加到头部,可以是随意位置
|
||||
// item.children.unshift(dragObj);
|
||||
// });
|
||||
// } else {
|
||||
// let ar: TreeProps['treeData'] = [];
|
||||
// let i = 0;
|
||||
// loop(data, dropKey, (_item: TreeDataItem, index: number, arr: TreeProps['treeData']) => {
|
||||
// ar = arr;
|
||||
// i = index;
|
||||
// });
|
||||
// if (dropPosition === -1) {
|
||||
// ar.splice(i, 0, dragObj);
|
||||
// } else {
|
||||
// ar.splice(i + 1, 0, dragObj);
|
||||
// }
|
||||
// }
|
||||
// treeData.value = data;
|
||||
};
|
||||
|
||||
const onDragend = () => {
|
||||
//
|
||||
};
|
||||
|
||||
const onDragstart = () => {
|
||||
//
|
||||
};
|
||||
|
||||
return {
|
||||
checkedState,
|
||||
searchArticle,
|
||||
listData,
|
||||
treeData,
|
||||
selectedKeys,
|
||||
checkedKeys,
|
||||
expandedKeys,
|
||||
showTreeInput,
|
||||
dateTimeFormat,
|
||||
onCollapseAll,
|
||||
onExpandAll,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onBlur,
|
||||
onView,
|
||||
onCheckAllChange,
|
||||
changeSelect,
|
||||
onDragEnter,
|
||||
onDrop,
|
||||
onDragend,
|
||||
onDragstart,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.manual-contents {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
background-color: #f4f4f4;
|
||||
overflow: hidden;
|
||||
&-actionbar {
|
||||
width: 100%;
|
||||
padding: 6px 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #27a1ac;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
&-left,
|
||||
&-right {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
&-left {
|
||||
.actionbar-item {
|
||||
margin: 0 10px;
|
||||
cursor: pointer;
|
||||
.anticon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/deep/ .draggable-tree {
|
||||
min-height: 200px;
|
||||
background-color: #f4f4f4;
|
||||
padding: 10px 10px 10px 0;
|
||||
.ant-tree-treenode {
|
||||
padding: 2px 0;
|
||||
line-height: 28px;
|
||||
&.ant-tree-treenode-selected {
|
||||
border: 2px solid #008cff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.ant-tree-node-content-wrapper {
|
||||
&.ant-tree-node-selected {
|
||||
background-color: transparent;
|
||||
}
|
||||
.ant-tree-title {
|
||||
.tree-node-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.tree-title-left {
|
||||
|
||||
}
|
||||
.tree-title-right {
|
||||
.article-time {
|
||||
display: inline-block;
|
||||
}
|
||||
.article-operate {
|
||||
display: none;
|
||||
> .anticon {
|
||||
font-size: 16px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.article-time {
|
||||
display: none !important;
|
||||
}
|
||||
.article-operate {
|
||||
display: inline-block !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
227
editor/src/views/manual/components/manual-form.vue
Normal file
227
editor/src/views/manual/components/manual-form.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="manual-form">
|
||||
<Form :label-col="labelCol" :wrapper-col="wrapperCol">
|
||||
<Row>
|
||||
<Col :span="12">
|
||||
<FormItem label="手册名称" v-bind="validateInfos.name">
|
||||
<Input v-model:value="modelRef.name" allow-clear placeholder="请输入手册名称" />
|
||||
</FormItem>
|
||||
<FormItem label="编著" v-bind="validateInfos.compile">
|
||||
<Input v-model:value="modelRef.compile" allow-clear placeholder="请输入编著名称" />
|
||||
</FormItem>
|
||||
<FormItem label="出版" v-bind="validateInfos.publish">
|
||||
<Input v-model:value="modelRef.publish" allow-clear placeholder="请输入出版信息" />
|
||||
</FormItem>
|
||||
<FormItem label="手册简介" v-bind="validateInfos.explain">
|
||||
<Textarea v-model:value="modelRef.explain" :rows="4" allow-clear placeholder="请输入简介信息" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<FormItem label="手册分类" v-bind="validateInfos.classify">
|
||||
<TreeSelect
|
||||
v-model:value="modelRef.classify"
|
||||
show-search
|
||||
style="width: 100%"
|
||||
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }"
|
||||
placeholder="请选择分类"
|
||||
allow-clear
|
||||
:tree-data="treeData"
|
||||
tree-node-filter-prop="label"
|
||||
:fieldNames="{
|
||||
label: 'name',
|
||||
value: 'id',
|
||||
}"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="手册分类" v-bind="validateInfos.cover">
|
||||
<Upload
|
||||
v-model:file-list="fileList"
|
||||
accept="image/*"
|
||||
name="file"
|
||||
:headers="{ Authorization: token }"
|
||||
list-type="picture-card"
|
||||
:action="`${baseUrl}/resource/upload`"
|
||||
:before-upload="beforeUpload"
|
||||
@change="handleChange"
|
||||
>
|
||||
<div v-if="fileList.length < 1">
|
||||
<LoadingOutlined v-if="loading" />
|
||||
<PlusOutlined v-else />
|
||||
<div class="ant-upload-text">上传</div>
|
||||
</div>
|
||||
</Upload>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
<FormItem :wrapper-col="{ span: 14, offset: 4 }" style="text-align: center;">
|
||||
<Button type="primary" shape="round" @click.prevent="handleSubmit">保存</Button>
|
||||
<Button type="primary" danger shape="round" style="margin-left: 10px" @click="handleResetForm">重置</Button>
|
||||
<Button shape="round" style="margin-left: 10px" @click="handleGoBack">返回</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, reactive, toRaw, onMounted, watch, getCurrentInstance } from 'vue';
|
||||
import { Form, FormItem, Row, Col, Input, Textarea, Button, TreeSelect, Upload, Checkbox, message } from 'ant-design-vue';
|
||||
import type { TreeProps, UploadChangeParam, UploadProps } from 'ant-design-vue';
|
||||
import { PlusOutlined, LoadingOutlined } from '@ant-design/icons-vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { listToTree } from '@/utils';
|
||||
import { getManualClassifyListApi, addManualApi, editManualApi } from '@/apis/manual';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'manual-form',
|
||||
components: { Form, FormItem, Row, Col, Input, Textarea, Button, TreeSelect, Upload, LoadingOutlined, PlusOutlined },
|
||||
props: ['formData'],
|
||||
emits: ['addDone'],
|
||||
setup(props, { emit }) {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const instance = getCurrentInstance();
|
||||
const token = instance?.appContext.config.globalProperties.$globalVariable.token;
|
||||
const baseUrl = instance?.appContext.config.globalProperties.$globalVariable.baseUrl;
|
||||
const resourceBaseUrl = instance?.appContext.config.globalProperties.$globalVariable.resourceBaseUrl;
|
||||
const manualClassify = ref<string>();
|
||||
const treeData = ref<any>([]);
|
||||
const fileList = ref<any>([]);
|
||||
const loading = ref<boolean>(false);
|
||||
let modelRef = reactive({
|
||||
id: undefined,
|
||||
name: '',
|
||||
compile: '',
|
||||
publish: '',
|
||||
explain: '',
|
||||
classify: 0,
|
||||
cover: '',
|
||||
stick: false,
|
||||
password: '',
|
||||
canEditor: [1],
|
||||
canView: [1],
|
||||
});
|
||||
const rulesRef = reactive({
|
||||
name: [{ required: true, message: '请输入手册名称' }],
|
||||
});
|
||||
|
||||
const { resetFields, validate, validateInfos } = Form.useForm(modelRef, rulesRef, {
|
||||
onValidate: (...args) => console.log(...args),
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
getManualClassifyList();
|
||||
});
|
||||
|
||||
const resetForm = (data: any = {
|
||||
id: undefined,
|
||||
name: '',
|
||||
compile: '',
|
||||
publish: '',
|
||||
explain: '',
|
||||
classify: 0,
|
||||
cover: '',
|
||||
stick: false,
|
||||
password: '',
|
||||
canEditor: [1],
|
||||
canView: [1],
|
||||
}) => {
|
||||
resetFields();
|
||||
modelRef = Object.assign(modelRef, data);
|
||||
if (modelRef.cover) {
|
||||
fileList.value = [{ url: `${resourceBaseUrl}/${modelRef.cover}` }]
|
||||
} else {
|
||||
fileList.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.formData, resetForm, { immediate: true });
|
||||
|
||||
const getManualClassifyList = async () => {
|
||||
const data = await getManualClassifyListApi();
|
||||
let arr = [{ id: 0, name: '无分类', pid: 0 }];
|
||||
arr = arr.concat(listToTree(data));
|
||||
treeData.value = arr;
|
||||
};
|
||||
|
||||
const handleChange = (info: UploadChangeParam) => {
|
||||
if (info.file.status === 'uploading') {
|
||||
loading.value = true;
|
||||
return;
|
||||
}
|
||||
if (info.file.status === 'done') {
|
||||
modelRef.cover = info.file.response.data.diskname;
|
||||
loading.value = false;
|
||||
}
|
||||
if (info.file.status === 'error') {
|
||||
loading.value = false;
|
||||
message.error('upload error');
|
||||
}
|
||||
};
|
||||
|
||||
const beforeUpload = (file: any) => {
|
||||
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
|
||||
if (!isJpgOrPng) {
|
||||
message.error('You can only upload JPG file!');
|
||||
}
|
||||
const isLt2M = file.size / 1024 / 1024 < 2;
|
||||
if (!isLt2M) {
|
||||
message.error('Image must smaller than 2MB!');
|
||||
}
|
||||
return isJpgOrPng && isLt2M;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
validate()
|
||||
.then(async () => {
|
||||
const params = { ...modelRef }
|
||||
if (!params.id) {
|
||||
const data = await addManualApi(params);
|
||||
message.success('手册创建成功');
|
||||
router.push({
|
||||
path: route.path,
|
||||
query: { id: data.id, classify: data.classify }
|
||||
});
|
||||
} else {
|
||||
await editManualApi(params.id, params);
|
||||
message.success('手册修改成功');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.log('error', err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
resetForm({
|
||||
id: props.formData.id,
|
||||
classify: props.formData.classify || 0
|
||||
});
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
router.push('/manual');
|
||||
};
|
||||
|
||||
return {
|
||||
labelCol: { span: 2 },
|
||||
wrapperCol: { span: 16 },
|
||||
token,
|
||||
baseUrl,
|
||||
resourceBaseUrl,
|
||||
manualClassify,
|
||||
treeData,
|
||||
fileList,
|
||||
loading,
|
||||
validateInfos,
|
||||
handleResetForm,
|
||||
modelRef,
|
||||
handleChange,
|
||||
beforeUpload,
|
||||
handleSubmit,
|
||||
handleGoBack,
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
</style>
|
||||
301
editor/src/views/manual/components/manual-list.vue
Normal file
301
editor/src/views/manual/components/manual-list.vue
Normal file
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<div class="manual-list">
|
||||
<div class="header-toolbar">
|
||||
<div class="header-toolbar-left">
|
||||
<InputSearch v-model:value="searchValue" placeholder="请输入需要搜索的手册名称" enter-button="查询" style="width: 300px" @search="getManualList" />
|
||||
<!-- <span class="iconfont icon-date-asce" />
|
||||
<span class="iconfont icon-date-desc" /> -->
|
||||
<span v-if="showTable" class="iconfont icon-list" @click="showTable = !showTable" />
|
||||
<span v-else class="iconfont icon-tubiao" @click="showTable = !showTable" />
|
||||
</div>
|
||||
<div class="header-toolbar-right">
|
||||
<Button type="primary" shape="round" @click="handleAdd"> 新增手册 </Button>
|
||||
<Button v-if="showTable" type="primary" danger shape="round" :disabled="selectedRowKeys.length <= 0" @click="handleDelect()"> 删除选中 </Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="manual-data">
|
||||
<Table bordered v-if="showTable" size="small" :columns="columns" :data-source="dataSource" :pagination="pagination" rowKey="id" :row-selection="rowSelection">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'updateTime'">
|
||||
<span>{{ dateTimeFormat(record.updateTime) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'operation'">
|
||||
<Button type="link" @click="handleView(record)">查看</Button>
|
||||
<Button type="link" @click="handleEdit(record)">编辑</Button>
|
||||
<Button type="link" danger @click="handleDelect(record)">删除</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<List v-else item-layout="horizontal" size="small" :pagination="pagination" :data-source="dataSource">
|
||||
<template #renderItem="{ item }">
|
||||
<ListItem>
|
||||
<div class="list-item-content">
|
||||
<img v-if="item.cover" width="118" height="118" :src="`${resourceBaseUrl}/${item.cover}`" alt="封面" />
|
||||
<span v-else class="cover-none">暂无封面</span>
|
||||
<div class="list-item-fields">
|
||||
<div class="list-item-header">
|
||||
<h3 class="field-name">{{ item.name }}</h3>
|
||||
<div class="list-item-header-right">
|
||||
<Button type="link" @click="handleView(item)">查看</Button>
|
||||
<Button type="link" @click="handleEdit(item)">编辑</Button>
|
||||
<Button type="link" danger @click="handleDelect(item)">删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-list">
|
||||
<div class="field-item">
|
||||
<span class="field-item-label"> <UserOutlined /> 编著: </span>
|
||||
<span class="field-item-value">{{ item.compile }}</span>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<span class="field-item-label"> <BookOutlined /> 出版社: </span>
|
||||
<span class="field-item-value">{{ item.publish }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-explain">
|
||||
<span class="field-explain-label">简介:</span>
|
||||
<span class="field-explain-value" :title="item.explain">{{ item.explain }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ListItem>
|
||||
</template>
|
||||
</List>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, reactive, getCurrentInstance, watch, computed, unref, createVNode } from 'vue';
|
||||
import { Row, Col, Divider, InputSearch, Button, Table, List, ListItem, message, Modal } from 'ant-design-vue';
|
||||
import { UserOutlined, BookOutlined, ExclamationCircleOutlined } from '@ant-design/icons-vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getManualListApi, deleteManualApi } from '@/apis/manual';
|
||||
import moment from 'moment';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '手册名称',
|
||||
dataIndex: 'name',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updateTime',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: '手册备注说明',
|
||||
dataIndex: 'explain',
|
||||
ellipsis: true,
|
||||
width: '40%',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'operation',
|
||||
width: '20%',
|
||||
},
|
||||
];
|
||||
|
||||
export default defineComponent({
|
||||
name: 'manual-list',
|
||||
components: { Row, Col, Divider, InputSearch, Button, Table, List, ListItem, UserOutlined, BookOutlined, ExclamationCircleOutlined },
|
||||
props: {
|
||||
manualClassify: { type: Number },
|
||||
},
|
||||
setup(props) {
|
||||
const router = useRouter();
|
||||
const searchValue = ref<string>('');
|
||||
const dataSource = ref<any>([]);
|
||||
const selectedRowKeys = ref<any>([]);
|
||||
const showTable = ref<boolean>(true);
|
||||
const pagination: any = {
|
||||
size: 'small',
|
||||
onChange: (page: number) => {
|
||||
console.log(page);
|
||||
},
|
||||
pageSize: 10,
|
||||
};
|
||||
const instance = getCurrentInstance();
|
||||
const resourceBaseUrl = instance?.appContext.config.globalProperties.$globalVariable.resourceBaseUrl;
|
||||
|
||||
const rowSelection = computed(() => {
|
||||
return {
|
||||
selectedRowKeys: unref(selectedRowKeys),
|
||||
onChange: (keys: any) => {
|
||||
selectedRowKeys.value = keys;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const getManualList = async () => {
|
||||
const classify = props.manualClassify;
|
||||
let data = await getManualListApi(classify);
|
||||
if (searchValue.value) {
|
||||
data = data.filter((item: any) => {
|
||||
return item.name.toLowerCase().indexOf(searchValue.value.toLowerCase()) >= 0;
|
||||
});
|
||||
}
|
||||
dataSource.value = data;
|
||||
};
|
||||
|
||||
watch(() => props.manualClassify, getManualList, { immediate: true });
|
||||
|
||||
const dateTimeFormat = (dateTime: string) => {
|
||||
return dateTime ? moment(dateTime).format('YYYY-MM-DD HH:mm:ss') : '';
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
router.push({
|
||||
path: '/manual/edit',
|
||||
query: { classify: props.manualClassify },
|
||||
});
|
||||
};
|
||||
|
||||
const handleView = (record: any) => {
|
||||
// window.history.pushState({ id: record.id }, '', 'app1#/manual/view');
|
||||
router.push({
|
||||
path: '/manual/view',
|
||||
query: { id: record.id }
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (record: any) => {
|
||||
console.log('handleEdit', record);
|
||||
router.push({
|
||||
path: '/manual/edit',
|
||||
query: { id: record.id },
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelect = async (record?: any) => {
|
||||
Modal.confirm({
|
||||
title: '确定删除当前手册吗?',
|
||||
icon: createVNode(ExclamationCircleOutlined),
|
||||
okText: '确定',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
await deleteManualApi({
|
||||
idArray: record ? [record.id] : selectedRowKeys.value,
|
||||
data: { delFlag: 1 },
|
||||
});
|
||||
message.success('手册删除成功');
|
||||
if (!record) selectedRowKeys.value = [];
|
||||
getManualList();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
searchValue,
|
||||
dataSource,
|
||||
columns,
|
||||
selectedRowKeys,
|
||||
rowSelection,
|
||||
showTable,
|
||||
pagination,
|
||||
resourceBaseUrl,
|
||||
getManualList,
|
||||
dateTimeFormat,
|
||||
handleAdd,
|
||||
handleView,
|
||||
handleEdit,
|
||||
handleDelect,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.manual-list {
|
||||
.header-toolbar {
|
||||
padding: 10px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
&-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.iconfont {
|
||||
display: inline-block;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
font-size: 24px;
|
||||
margin: 0 6px;
|
||||
}
|
||||
}
|
||||
&-right {
|
||||
.ant-btn {
|
||||
margin: 0 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.manual-data {
|
||||
/deep/ .ant-list {
|
||||
.ant-list-item {
|
||||
border: none;
|
||||
background-color: #f4f4f4;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
.list-item-content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
img {
|
||||
margin-right: 20px;
|
||||
}
|
||||
.cover-none {
|
||||
width: 118px;
|
||||
height: 118px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.list-item-fields {
|
||||
flex: 1;
|
||||
.list-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.field-name {
|
||||
line-height: 32px;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
.field-list {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
.field-item {
|
||||
display: flex;
|
||||
margin-right: 20px;
|
||||
color: #9b9b9b;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
}
|
||||
.field-explain {
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
line-height: 27px;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-all;
|
||||
&-label {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
58
editor/src/views/manual/edit.vue
Normal file
58
editor/src/views/manual/edit.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="manual-edit">
|
||||
<div class="group-title">手册信息</div>
|
||||
<ManualForm :form-data="formData" />
|
||||
<div class="group-title">手册目录</div>
|
||||
<ManualContents :form-id="formId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, watch, toRaw, onMounted } from 'vue';
|
||||
import ManualForm from "./components/manual-form.vue";
|
||||
import ManualContents from "./components/manual-contents.vue";
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { getManualInfoApi } from '@/apis/manual';
|
||||
|
||||
|
||||
export default defineComponent({
|
||||
name: 'manual-edit',
|
||||
components: { ManualForm, ManualContents, },
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const formId = ref();
|
||||
const formData = ref();
|
||||
|
||||
onMounted(() => {
|
||||
// getManualInfo();
|
||||
});
|
||||
|
||||
const getManualInfo = async () => {
|
||||
if (route.query && route.query.id) {
|
||||
formId.value = Number(route.query.id);
|
||||
const data = await getManualInfoApi(route.query.id as string);
|
||||
formData.value = data;
|
||||
} else if (route.query && route.query.classify) {
|
||||
formData.value = { classify: Number(route.query.classify) };
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => route.query, () => getManualInfo(), { immediate: true });
|
||||
|
||||
return { formId, formData };
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.manual-edit {
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
.group-title {
|
||||
font-weight: 700;
|
||||
padding-left: 6px;
|
||||
font-size: 15px;
|
||||
border-left: 6px solid #10a6b4;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
48
editor/src/views/manual/index.vue
Normal file
48
editor/src/views/manual/index.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="manual">
|
||||
<Row type="flex">
|
||||
<Col flex="240px">
|
||||
<ManualClassify @change="handleChange" />
|
||||
</Col>
|
||||
<Divider type="vertical" style="height: 100%; margin: 0 20px;" />
|
||||
<Col flex="1">
|
||||
<ManualList :manual-classify="manualClassify" />
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { Row, Col, Divider } from 'ant-design-vue';
|
||||
import ManualClassify from "./components/manual-classify.vue";
|
||||
import ManualList from "./components/manual-list.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: 'manual',
|
||||
components: { Row, Col, Divider, ManualClassify, ManualList },
|
||||
setup() {
|
||||
const manualClassify = ref<number>();
|
||||
|
||||
const handleChange = (val: number) => {
|
||||
manualClassify.value = val;
|
||||
};
|
||||
|
||||
return {
|
||||
manualClassify,
|
||||
handleChange
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.manual {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
.ant-row {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
116
editor/src/views/manual/view.vue
Normal file
116
editor/src/views/manual/view.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="manual-view">
|
||||
<am-loading :loading="loading">
|
||||
<div class="editor-wrapper editor-wrapper-view">
|
||||
<am-outline v-if="!isMobile && engine" :engine="engine" />
|
||||
<div ref="container" class="editor-container" />
|
||||
<am-outline v-if="!isMobile && engine" :engine="engine" />
|
||||
</div>
|
||||
</am-loading>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { $, View, isMobile } from '@aomao/engine';
|
||||
import AmLoading from '@/components/editor/loading.vue';
|
||||
import AmOutline from '@/components/editor/outline.vue';
|
||||
import { cards, plugins, onLoad } from '@/components/editor/config';
|
||||
import { getDocValue } from '@/utils';
|
||||
|
||||
const viewPlugins = plugins.filter(
|
||||
(plugin) =>
|
||||
plugin.pluginName.indexOf('uploader') < 0 &&
|
||||
['mark-range'].indexOf(plugin.pluginName) < 0,
|
||||
);
|
||||
|
||||
export default defineComponent({
|
||||
name: 'manual-view',
|
||||
components: {
|
||||
AmLoading,
|
||||
AmOutline,
|
||||
},
|
||||
setup() {
|
||||
const container = ref<HTMLElement | null>(null);
|
||||
const engine = ref<any>(null); // EngineInterface | null
|
||||
const members = ref([]);
|
||||
const loading = ref(true);
|
||||
onMounted(() => {
|
||||
if (container.value) {
|
||||
const view = new View(container.value, {
|
||||
plugins: viewPlugins,
|
||||
cards,
|
||||
config: {
|
||||
table: {
|
||||
overflow: {
|
||||
maxLeftWidth: () => {
|
||||
return 0;
|
||||
},
|
||||
maxRightWidth: () => {
|
||||
if (isMobile) return 0;
|
||||
const wrapper = $('.editor-wrapper-view');
|
||||
const view = $('.am-engine-view');
|
||||
const wRect = wrapper.get<HTMLElement>()?.getBoundingClientRect();
|
||||
const vRect = view.get<HTMLElement>()?.getBoundingClientRect();
|
||||
const width = (wRect?.width || 0) - (vRect?.width || 0);
|
||||
return width <= 0 ? 100 : width;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// 设置显示成功消息UI,默认使用 console.log
|
||||
view.messageSuccess = (msg: string) => {
|
||||
message.success(msg);
|
||||
};
|
||||
// 设置显示错误消息UI,默认使用 console.error
|
||||
view.messageError = (error: string) => {
|
||||
message.error(error);
|
||||
};
|
||||
// 默认编辑器值,为了演示,这里初始化值写死,正式环境可以请求api加载
|
||||
const value = getDocValue() || '<strong>Hello</strong>,This is demo';
|
||||
// 非协同编辑,设置编辑器值,异步渲染后回调
|
||||
view.render(value);
|
||||
loading.value = false;
|
||||
|
||||
engine.value = view;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (engine.value) engine.value.destroy();
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
isMobile,
|
||||
container,
|
||||
engine,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.manual-view {
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
overflow: auto;
|
||||
.editor-wrapper {
|
||||
width: 100%;
|
||||
min-width: 1460px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
border: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
min-width: 800px;
|
||||
min-height: calc(100vh - 40px);
|
||||
margin: 0 10px;
|
||||
position: relative;
|
||||
padding: 40px 60px 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
41
editor/tsconfig.json
Normal file
41
editor/tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"moduleResolution": "node",
|
||||
"experimentalDecorators": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"useDefineForClassFields": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"types": [
|
||||
"webpack-env"
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
},
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"scripthost"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"tests/**/*.ts",
|
||||
"tests/**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
46
editor/vue.config.js
Normal file
46
editor/vue.config.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const { defineConfig } = require('@vue/cli-service');
|
||||
let name = require('./package.json')
|
||||
module.exports = defineConfig({
|
||||
transpileDependencies: true,
|
||||
css: {
|
||||
loaderOptions: {
|
||||
// 向 CSS 相关的 loader 传递选项
|
||||
less: {
|
||||
javascriptEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
devServer: {
|
||||
port: '9099',
|
||||
proxy: {
|
||||
// '/api': {
|
||||
// target: 'http://localhost/:8000', // 代理目标的基础路径
|
||||
// changeOrigin: true, // 更改请求头中的origin为目标URL
|
||||
// rewrite: (path) => path.replace(/^\/api/, ''), // 重写请求路径,将/api替换为空字符串
|
||||
// }
|
||||
'/api/(latex|puml|graphviz|flowchart|mermaid)': {
|
||||
target: 'https://g.aomao.com/',
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/api': '' },
|
||||
},
|
||||
[process.env.VUE_APP_BASE_URL]: {
|
||||
// target: 'https://editor.aomao.com',
|
||||
target: 'http://127.0.0.1:3000',
|
||||
changeOrigin: true,
|
||||
pathRewrite: { ['^' + process.env.VUE_APP_BASE_URL]: '' },
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*' // 主应用获取子应用时跨域响应头
|
||||
}
|
||||
},
|
||||
configureWebpack: {
|
||||
output: {
|
||||
library: `${name}-[name]`,
|
||||
libraryTarget: 'umd', // 把微应用打包成 umd 库格式
|
||||
|
||||
|
||||
// libraryTarget: "system",
|
||||
},
|
||||
},
|
||||
});
|
||||
15441
editor/yarn.lock
Normal file
15441
editor/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
3
front/.env.development
Normal file
3
front/.env.development
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_APP_BASE_URL = /api
|
||||
VITE_APP_WS_URL = ws://localhost:3000
|
||||
VITE_APP_IFRAME_URL = http://192.168.2.99:8080
|
||||
3
front/.env.production
Normal file
3
front/.env.production
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_APP_BASE_URL = ''
|
||||
VITE_APP_WS_URL = ''
|
||||
VITE_APP_IFRAME_URL = /text-book
|
||||
3
front/.eslintignore
Normal file
3
front/.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
||||
*.d.ts
|
||||
dist
|
||||
public
|
||||
37
front/.eslintrc.cjs
Normal file
37
front/.eslintrc.cjs
Normal file
@@ -0,0 +1,37 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true
|
||||
},
|
||||
extends: [
|
||||
'plugin:vue/vue3-essential',
|
||||
'standard'
|
||||
],
|
||||
globals: {
|
||||
$fetch: 'readonly'
|
||||
},
|
||||
overrides: [
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
},
|
||||
plugins: [
|
||||
'vue'
|
||||
],
|
||||
rules: {
|
||||
'no-undef': 0,
|
||||
'vue/multi-word-component-names': 0,
|
||||
'vue/no-deprecated-slot-attribute': 0,
|
||||
'vue/no-deprecated-slot-scope-attribute': 0,
|
||||
'vue/no-deprecated-v-on-native-modifier': 0,
|
||||
'vue/no-v-text-v-html-on-component': 0,
|
||||
'vue/no-v-for-template-key-on-child': 0,
|
||||
'vue/no-deprecated-dollar-listeners-api': 0,
|
||||
'vue/no-deprecated-dollar-scopedslots-api': 0,
|
||||
'vue/no-deprecated-v-bind-sync': 0,
|
||||
'vue/no-deprecated-destroyed-lifecycle': 0,
|
||||
'vue/no-mutating-props': 0,
|
||||
'vue/no-deprecated-events-api': 0
|
||||
}
|
||||
}
|
||||
28
front/.gitignore
vendored
Normal file
28
front/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
start.bat
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
2
front/.stylelintignore
Normal file
2
front/.stylelintignore
Normal file
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
public
|
||||
6
front/.stylelintrc.js
Normal file
6
front/.stylelintrc.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
extends: ['stylelint-config-recommended', 'stylelint-config-standard'],
|
||||
rules: {
|
||||
indentation: 2
|
||||
}
|
||||
}
|
||||
13
front/.vscode/settings.json
vendored
Normal file
13
front/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"stylelint.validate": [
|
||||
"css",
|
||||
"scss",
|
||||
"postcss"
|
||||
],
|
||||
"editor.codeActionsOnSave":{
|
||||
"source.fixAll": true
|
||||
},
|
||||
"editor.suggest.snippetsPreventQuickSuggestions": false,
|
||||
"docwriter.style": "Auto-detect"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user