feat:初始化 -融骅

This commit is contained in:
2023-10-17 09:15:30 +08:00
parent c9ff84e6a2
commit 405e152b38
1190 changed files with 138344 additions and 455 deletions

4
editor/.browserslistrc Normal file
View File

@@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11

8
editor/.env Normal file
View File

@@ -0,0 +1,8 @@
# 页面标题
VUE_APP_TITLE = 'haoque'
# 开发环境配置
ENV = 'development'
# 开发环境
VUE_APP_BASE_URL = '/api'

35
editor/.eslintrc.js Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
module.exports = {
presets: ['@vue/cli-plugin-babel/preset'],
};

94
editor/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

17
editor/public/index.html Normal file
View 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
View 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
View 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);

View 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";
}

File diff suppressed because one or more lines are too long

View 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
}
]
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,5 @@
@primary-color: #11a6b4; // #1890ff; // 全局主色
@link-color: #11a6b4; // 链接色
@success-color: #34cb80; // #52c41a; // 成功色
@warning-color: #faad14; // 警告色
@error-color: #f05b59; // #f5222d; // 错误色

View File

@@ -0,0 +1,2 @@
@import '~ant-design-vue/dist/antd.less'; // 引入官方提供的 less 样式入口文件
@import 'custom-theme.less'; // 用于覆盖上面定义的变量

View 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;
},
},
};

View 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>

View 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>

View 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
View 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

View 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;
}

View 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;

View 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 };

View 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...',
},
};

View File

@@ -0,0 +1,7 @@
import en from './en-US';
import cn from './zh-CN';
export default {
'en-US': en,
'zh-CN': cn,
};

View File

@@ -0,0 +1,12 @@
export default {
audio: {
errorMessageCopy: '复制错误信息',
loadError: '音频加载失败!',
uploadError: '上传音频失败!',
uploadLimitError: '上传音频大小限制为 $size',
download: '下载',
preview: '预览',
loading: '加载中...',
transcoding: '转码中...',
},
};

View 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;
}
}
}

View 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>`;

View 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;
});
};

View 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>

View 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>

View 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;
}

View 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;

View 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;
}
};

View 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 };

View 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)[];
}

View 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;
}

View 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;
// fixsvg 图片宽度 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;

View 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;

View 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==');
}

View 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;

View 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;

View 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 };

View 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',
},
};

View File

@@ -0,0 +1,7 @@
import en from './en-US';
import cn from './zh-CN';
export default {
'en-US': en,
'zh-CN': cn,
};

View File

@@ -0,0 +1,19 @@
export default {
image: {
next: '下一张',
prev: '上一张',
zoomIn: '放大',
zoomOut: '缩小',
originSize: '实际尺寸',
bestSize: '适应屏幕',
errorMessageCopy: '复制错误信息',
loadError: '图片加载失败!',
uploadError: '上传图片失败!',
uploadLimitError: '上传图片大小限制为 $size',
toolbarReductionTitle: '还原',
toolbarWidthTitle: '宽度',
toolbarHeightTitle: '宽度',
displayBlockTitle: '独占一行',
displayInlineTitle: '嵌入行内',
},
};

View 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)[];
}

View 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);
}
}
}

View 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 };

View 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">&#xe77f;</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>

View 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>';
}
},
});
}

View 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)
}

View 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;
}

View 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 };

View 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,
};

View 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;

View 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>

View 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;
}

View 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 };

View 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 };

View 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;
}

View 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>
);
};
},
});

View 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;
}

View 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 };

View 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: '删除',
},
},
};

View 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;

View File

@@ -0,0 +1,7 @@
<template>
<div>
<div>This is test plugin</div>
</div>
</template>
<style lang="less"></style>

View 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 };

View 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__;
}

View 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
View 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';

View 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
View 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
View 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;

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

3
front/.env.development Normal file
View 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
View File

@@ -0,0 +1,3 @@
VITE_APP_BASE_URL = ''
VITE_APP_WS_URL = ''
VITE_APP_IFRAME_URL = /text-book

3
front/.eslintignore Normal file
View File

@@ -0,0 +1,3 @@
*.d.ts
dist
public

37
front/.eslintrc.cjs Normal file
View 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
View 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
View File

@@ -0,0 +1,2 @@
dist
public

6
front/.stylelintrc.js Normal file
View 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
View 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