diff --git a/editor/.browserslistrc b/editor/.browserslistrc new file mode 100644 index 0000000..dc3bc09 --- /dev/null +++ b/editor/.browserslistrc @@ -0,0 +1,4 @@ +> 1% +last 2 versions +not dead +not ie 11 diff --git a/editor/.env b/editor/.env new file mode 100644 index 0000000..7fbda51 --- /dev/null +++ b/editor/.env @@ -0,0 +1,8 @@ +# 页面标题 +VUE_APP_TITLE = 'haoque' + +# 开发环境配置 +ENV = 'development' + +# 开发环境 +VUE_APP_BASE_URL = '/api' diff --git a/editor/.eslintrc.js b/editor/.eslintrc.js new file mode 100644 index 0000000..244c371 --- /dev/null +++ b/editor/.eslintrc.js @@ -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', + }, +}; diff --git a/editor/.gitignore b/editor/.gitignore new file mode 100644 index 0000000..403adbc --- /dev/null +++ b/editor/.gitignore @@ -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? diff --git a/editor/.prettierrc.js b/editor/.prettierrc.js new file mode 100644 index 0000000..00bdd3b --- /dev/null +++ b/editor/.prettierrc.js @@ -0,0 +1,8 @@ +module.exports = { + printWidth: 200, + tabWidth: 2, + semi: true, + singleQuote: true, + bracketSpacing: true, + arrowParens: 'avoid', +}; diff --git a/editor/README.md b/editor/README.md new file mode 100644 index 0000000..1e77040 --- /dev/null +++ b/editor/README.md @@ -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/). diff --git a/editor/babel.config.js b/editor/babel.config.js new file mode 100644 index 0000000..078c005 --- /dev/null +++ b/editor/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['@vue/cli-plugin-babel/preset'], +}; diff --git a/editor/package.json b/editor/package.json new file mode 100644 index 0000000..900d41f --- /dev/null +++ b/editor/package.json @@ -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" + } +} diff --git a/editor/public/favicon.ico b/editor/public/favicon.ico new file mode 100644 index 0000000..df36fcf Binary files /dev/null and b/editor/public/favicon.ico differ diff --git a/editor/public/index.html b/editor/public/index.html new file mode 100644 index 0000000..3e5a139 --- /dev/null +++ b/editor/public/index.html @@ -0,0 +1,17 @@ + + + + + + + + <%= htmlWebpackPlugin.options.title %> + + + +
+ + + diff --git a/editor/src/App.vue b/editor/src/App.vue new file mode 100644 index 0000000..60a5b67 --- /dev/null +++ b/editor/src/App.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/editor/src/apis/manual.ts b/editor/src/apis/manual.ts new file mode 100644 index 0000000..bc8bb1c --- /dev/null +++ b/editor/src/apis/manual.ts @@ -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); diff --git a/editor/src/assets/iconfont/iconfont.css b/editor/src/assets/iconfont/iconfont.css new file mode 100644 index 0000000..b4a8b8c --- /dev/null +++ b/editor/src/assets/iconfont/iconfont.css @@ -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"; +} + diff --git a/editor/src/assets/iconfont/iconfont.js b/editor/src/assets/iconfont/iconfont.js new file mode 100644 index 0000000..93b9e33 --- /dev/null +++ b/editor/src/assets/iconfont/iconfont.js @@ -0,0 +1 @@ +window._iconfont_svg_string_4246922='',function(e){var t=(t=document.getElementsByTagName("script"))[t.length-1],a=t.getAttribute("data-injectcss"),t=t.getAttribute("data-disable-injectsvg");if(!t){var n,o,i,c,l,d=function(t,a){a.parentNode.insertBefore(t,a)};if(a&&!e.__iconfont__svg__cssinject__){e.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(t){console&&console.log(t)}}n=function(){var t,a=document.createElement("div");a.innerHTML=e._iconfont_svg_string_4246922,(a=a.getElementsByTagName("svg")[0])&&(a.setAttribute("aria-hidden","true"),a.style.position="absolute",a.style.width=0,a.style.height=0,a.style.overflow="hidden",a=a,(t=document.body).firstChild?d(a,t.firstChild):t.appendChild(a))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(n,0):(o=function(){document.removeEventListener("DOMContentLoaded",o,!1),n()},document.addEventListener("DOMContentLoaded",o,!1)):document.attachEvent&&(i=n,c=e.document,l=!1,s(),c.onreadystatechange=function(){"complete"==c.readyState&&(c.onreadystatechange=null,h())})}function h(){l||(l=!0,i())}function s(){try{c.documentElement.doScroll("left")}catch(t){return void setTimeout(s,50)}h()}}(window); \ No newline at end of file diff --git a/editor/src/assets/iconfont/iconfont.json b/editor/src/assets/iconfont/iconfont.json new file mode 100644 index 0000000..aa3ed53 --- /dev/null +++ b/editor/src/assets/iconfont/iconfont.json @@ -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 + } + ] +} diff --git a/editor/src/assets/iconfont/iconfont.ttf b/editor/src/assets/iconfont/iconfont.ttf new file mode 100644 index 0000000..0b64abf Binary files /dev/null and b/editor/src/assets/iconfont/iconfont.ttf differ diff --git a/editor/src/assets/iconfont/iconfont.woff b/editor/src/assets/iconfont/iconfont.woff new file mode 100644 index 0000000..fbeb116 Binary files /dev/null and b/editor/src/assets/iconfont/iconfont.woff differ diff --git a/editor/src/assets/iconfont/iconfont.woff2 b/editor/src/assets/iconfont/iconfont.woff2 new file mode 100644 index 0000000..3f18dbf Binary files /dev/null and b/editor/src/assets/iconfont/iconfont.woff2 differ diff --git a/editor/src/assets/styles/custom-theme.less b/editor/src/assets/styles/custom-theme.less new file mode 100644 index 0000000..3f4b0f4 --- /dev/null +++ b/editor/src/assets/styles/custom-theme.less @@ -0,0 +1,5 @@ +@primary-color: #11a6b4; // #1890ff; // 全局主色 +@link-color: #11a6b4; // 链接色 +@success-color: #34cb80; // #52c41a; // 成功色 +@warning-color: #faad14; // 警告色 +@error-color: #f05b59; // #f5222d; // 错误色 \ No newline at end of file diff --git a/editor/src/assets/styles/index.less b/editor/src/assets/styles/index.less new file mode 100644 index 0000000..229e3a7 --- /dev/null +++ b/editor/src/assets/styles/index.less @@ -0,0 +1,2 @@ +@import '~ant-design-vue/dist/antd.less'; // 引入官方提供的 less 样式入口文件 +@import 'custom-theme.less'; // 用于覆盖上面定义的变量 \ No newline at end of file diff --git a/editor/src/components/editor/config.ts b/editor/src/components/editor/config.ts new file mode 100644 index 0000000..0c02cc2 --- /dev/null +++ b/editor/src/components/editor/config.ts @@ -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 = [ + 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 = [ + 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 }, removeIds: { [key: string]: Array }) => { + // 新增的标记 + 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()!); + }, + onEmpty: (root: NodeInterface) => { + const vm = createApp(Empty); + vm.mount(root.get()!); + }, + 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()!); + }, + }, + [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; + }, + }, +}; diff --git a/editor/src/components/editor/loading.vue b/editor/src/components/editor/loading.vue new file mode 100644 index 0000000..49f9f9d --- /dev/null +++ b/editor/src/components/editor/loading.vue @@ -0,0 +1,34 @@ + + + + diff --git a/editor/src/components/editor/mention-popover.vue b/editor/src/components/editor/mention-popover.vue new file mode 100644 index 0000000..35d4cb9 --- /dev/null +++ b/editor/src/components/editor/mention-popover.vue @@ -0,0 +1,24 @@ + + + + diff --git a/editor/src/components/editor/outline.vue b/editor/src/components/editor/outline.vue new file mode 100644 index 0000000..3ac4a4f --- /dev/null +++ b/editor/src/components/editor/outline.vue @@ -0,0 +1,205 @@ + + + + diff --git a/editor/src/main.ts b/editor/src/main.ts new file mode 100644 index 0000000..8e92030 --- /dev/null +++ b/editor/src/main.ts @@ -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 diff --git a/editor/src/plugins/audio/component/index.css b/editor/src/plugins/audio/component/index.css new file mode 100644 index 0000000..7093e00 --- /dev/null +++ b/editor/src/plugins/audio/component/index.css @@ -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; + } \ No newline at end of file diff --git a/editor/src/plugins/audio/component/index.ts b/editor/src/plugins/audio/component/index.ts new file mode 100644 index 0000000..f3f9f2c --- /dev/null +++ b/editor/src/plugins/audio/component/index.ts @@ -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 extends Card { + 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: `
+
`, + spin: ``, + warn: `
`, + error: 'X', + }; + + if (status === 'error') { + return ` +
+
+
+
${escape(name)}
+
+ ${icons.error} + ${message || locales['loadError']} +
+
+
+
`; + } + + const fileSize: string = size ? getFileSize(size) : ''; + + if (status === 'uploading') { + return ` +
+
+
+ ${icons.audio} +
+ ${escape(name)} (${escape(fileSize)}) +
+
+ ${icons.spin} + ${percent || 0}% +
+
+
+
`; + } + const isLoading = typeof status === 'undefined'; + if (status === 'transcoding' || isLoading) { + return ` +
+
+
+ ${icons.audio} +
+ ${escape(name)} (${escape(fileSize)}) +
+
+ ${icons.spin} + ${isLoading ? locales['loading'] : locales['transcoding']}% +
+
+
+
+ `; + } + + return ` +
+
+
+ `; + } + + 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 = []; + 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: '', + 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; diff --git a/editor/src/plugins/audio/index.ts b/editor/src/plugins/audio/index.ts new file mode 100644 index 0000000..9b02944 --- /dev/null +++ b/editor/src/plugins/audio/index.ts @@ -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 extends Plugin { + 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 { + const { card } = this.editor; + // 检测单个组件 + const check = (component: CardInterface) => { + return component.root.inEditor() && (component.constructor as CardEntry).cardName === AudioComponent.cardName && (component as AudioComponent).getValue()?.status === 'uploading'; + }; + // 找到不合格的组件 + const find = (): CardInterface | undefined => { + return card.components.find(check); + }; + const waitCheck = (component: CardInterface): Promise => { + 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(node); + const value = card?.getValue(); + if (value?.url && value.status === 'done') { + const { onBeforeRender } = this.options; + const { url } = value; + const html = `
`; + node.empty(); + node.replaceWith($(html)); + } else node.remove(); + }); + } +} + +export { AudioComponent, AudioUploader }; diff --git a/editor/src/plugins/audio/locales/en-US.ts b/editor/src/plugins/audio/locales/en-US.ts new file mode 100644 index 0000000..9c3aae3 --- /dev/null +++ b/editor/src/plugins/audio/locales/en-US.ts @@ -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...', + }, +}; diff --git a/editor/src/plugins/audio/locales/index.ts b/editor/src/plugins/audio/locales/index.ts new file mode 100644 index 0000000..c32f63b --- /dev/null +++ b/editor/src/plugins/audio/locales/index.ts @@ -0,0 +1,7 @@ +import en from './en-US'; +import cn from './zh-CN'; + +export default { + 'en-US': en, + 'zh-CN': cn, +}; diff --git a/editor/src/plugins/audio/locales/zh-CN.ts b/editor/src/plugins/audio/locales/zh-CN.ts new file mode 100644 index 0000000..3464ab3 --- /dev/null +++ b/editor/src/plugins/audio/locales/zh-CN.ts @@ -0,0 +1,12 @@ +export default { + audio: { + errorMessageCopy: '复制错误信息', + loadError: '音频加载失败!', + uploadError: '上传音频失败!', + uploadLimitError: '上传音频大小限制为 $size', + download: '下载', + preview: '预览', + loading: '加载中...', + transcoding: '转码中...', + }, +}; diff --git a/editor/src/plugins/audio/uploader.ts b/editor/src/plugins/audio/uploader.ts new file mode 100644 index 0000000..96a385c --- /dev/null +++ b/editor/src/plugins/audio/uploader.ts @@ -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; + /** + * 文件选择限制数量 + */ + 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 { + 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 = []; + 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 | 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('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(component.id, { + status: 'error', + message: (result.data as string) || this.editor.language.get('audio', 'uploadError'), + }); + } + //成功 + else { + this.editor.card.update( + 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(component.id, { + status: 'error', + message: error.message || this.editor.language.get('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) { + 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) { + 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; + } + } +} diff --git a/editor/src/plugins/draw/component/constant.ts b/editor/src/plugins/draw/component/constant.ts new file mode 100644 index 0000000..e180335 --- /dev/null +++ b/editor/src/plugins/draw/component/constant.ts @@ -0,0 +1,15 @@ +export const XML_DATA = ` + + + + + + + + + + + + + +`; diff --git a/editor/src/plugins/draw/component/diagram-loader.ts b/editor/src/plugins/draw/component/diagram-loader.ts new file mode 100644 index 0000000..9fb8835 --- /dev/null +++ b/editor/src/plugins/draw/component/diagram-loader.ts @@ -0,0 +1,11 @@ +import type * as Diagram from 'embed-drawio'; + +let instance: typeof Diagram | null = null; + +export const diagramLoader = (): Promise => { + if (instance) return Promise.resolve(instance); + return import('embed-drawio').then(res => { + instance = res; + return res; + }); +}; diff --git a/editor/src/plugins/draw/component/draw-edit.vue b/editor/src/plugins/draw/component/draw-edit.vue new file mode 100644 index 0000000..2dd0d9e --- /dev/null +++ b/editor/src/plugins/draw/component/draw-edit.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/editor/src/plugins/draw/component/draw-view.vue b/editor/src/plugins/draw/component/draw-view.vue new file mode 100644 index 0000000..fa4c204 --- /dev/null +++ b/editor/src/plugins/draw/component/draw-view.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/editor/src/plugins/draw/component/index.css b/editor/src/plugins/draw/component/index.css new file mode 100644 index 0000000..93ee324 --- /dev/null +++ b/editor/src/plugins/draw/component/index.css @@ -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; +} \ No newline at end of file diff --git a/editor/src/plugins/draw/component/index.ts b/editor/src/plugins/draw/component/index.ts new file mode 100644 index 0000000..17569da --- /dev/null +++ b/editor/src/plugins/draw/component/index.ts @@ -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 = ` + + + +`; + +class EmbedComponent extends Card { + 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 { + const getItems = () => { + if (isEngine(this.editor) && !this.editor.readonly) { + const items: Array = [ + { 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 = $(` +
+
+
+
+ + + +
+
+ `); + 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()); + } + + destroy() { + super.destroy(); + this.vm?.unmount(); + this.vm = undefined; + } +} + +export default EmbedComponent; diff --git a/editor/src/plugins/draw/component/utils.ts b/editor/src/plugins/draw/component/utils.ts new file mode 100644 index 0000000..a4a66e3 --- /dev/null +++ b/editor/src/plugins/draw/component/utils.ts @@ -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; + } +}; diff --git a/editor/src/plugins/draw/index.ts b/editor/src/plugins/draw/index.ts new file mode 100644 index 0000000..8f5aa99 --- /dev/null +++ b/editor/src/plugins/draw/index.ts @@ -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; +} +export default class extends Plugin { + 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 = $(`
`); + node.replaceWith(div); + } else node.remove(); + }); + } +} +export { DrawComponent }; diff --git a/editor/src/plugins/draw/types.ts b/editor/src/plugins/draw/types.ts new file mode 100644 index 0000000..edef954 --- /dev/null +++ b/editor/src/plugins/draw/types.ts @@ -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)[]; +} diff --git a/editor/src/plugins/image/component/image/index.css b/editor/src/plugins/image/component/image/index.css new file mode 100644 index 0000000..9aedb44 --- /dev/null +++ b/editor/src/plugins/image/component/image/index.css @@ -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; +} \ No newline at end of file diff --git a/editor/src/plugins/image/component/image/index.ts b/editor/src/plugins/image/component/image/index.ts new file mode 100644 index 0000000..217eff4 --- /dev/null +++ b/editor/src/plugins/image/component/image/index.ts @@ -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 ` + + ${message || this.options.message} + + `; + } + const src = onBeforeRender ? onBeforeRender(this.status, this.options.src, this.editor) : this.options.src; + const progress = ` + + + + ${percent || 0}% + `; + + const alt = escape(this.options.alt || ''); + const attr = alt ? ` alt="${alt}" title="${alt}" ` : ''; + //加上 data-drag-image 样式可以拖动图片 + let img = ``; + //只读渲染加载链接 + if (link && !isEngine(this.editor)) { + const target = link.target || '_blank'; + img = `${img}`; + } + //全屏图标 + const maximize = ``; + + return ` + + + + + ${img} + ${progress} + + ${maximize} + + + + `; + } + + 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(); + 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(); + 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(); + 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(); + if (!img) return; + + let { width, height } = this.size; + + if (!height) { + height = Math.round(this.rate * width); + } else if (!width) { + width = Math.round(height / this.rate); + } else if (width && height) { + // 修正非正常的比例 + height = Math.round(this.rate * width); + this.size.height = height; + } else { + const { clientWidth, clientHeight } = img; + width = clientWidth; + height = clientHeight; + const { naturalWidth, naturalHeight } = this.size; + // fix:svg 图片宽度 300px 问题 + if (this.isSvg() && naturalWidth && naturalHeight) { + width = naturalWidth; + height = naturalHeight; + } + } + + if (width > this.maxWidth) { + width = this.maxWidth; + height = Math.round(width * this.rate); + } + if (this.options.enableResizer === false) { + this.image.css('width', ''); + } else { + this.image.css('width', `${width}px`); + //this.image.css("height", `${height}px`); + } + } + + changeSize(width: number, height: number) { + if (width < 24) { + width = 24; + height = width * this.rate; + } + + if (width > this.maxWidth) { + width = this.maxWidth; + height = width * this.rate; + } + + if (height < 24) { + height = 24; + width = height / this.rate; + } + + width = Math.round(width); + height = Math.round(height); + this.size.width = width; + this.size.height = height; + this.image.css({ + width: `${width}px`, + //height: `${height}px`, + }); + const { onChange } = this.options; + if (onChange) onChange(this.size); + + this.destroyEditor(); + this.renderEditor(); + } + + changeUrl(url: string) { + if (this.src !== url) { + this.src = url; + this.isLoad = false; + this.image.attributes('src', this.getSrc()); + } + } + + getSrc = () => { + const { onBeforeRender } = this.options; + return onBeforeRender && this.status !== 'error' ? onBeforeRender(this.status, this.src, this.editor) : this.src; + }; + + isSvg() { + return this.src.split('?')[0].endsWith('.svg') || this.src.startsWith('data:image/svg+xml'); + } + + openZoom = (event: MouseEvent | TouchEvent) => { + event.preventDefault(); + event.stopPropagation(); + const editor = this.editor; + const imageArray: PhotoSwipe.Item[] = []; + const cardRoot = editor.card.closest(this.root); + let rootIndex = 0; + + editor.container + .find(`[data-card-key="image"]`) + .toArray() + .filter(image => { + return image.find('img').length > 0; + }) + .forEach((imageNode, index) => { + const card = editor.card.find(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(); + 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(); + 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): Promise { + const { request, card } = this.editor; + const imageUploaderPlugin = this.editor.plugin.findPlugin('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(); + 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('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; diff --git a/editor/src/plugins/image/component/index.ts b/editor/src/plugins/image/component/index.ts new file mode 100644 index 0000000..8148ad1 --- /dev/null +++ b/editor/src/plugins/image/component/index.ts @@ -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 extends Card { + 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()!.value = size.width.toString(); + } + if (this.heightInput) { + this.heightInput.get()!.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()!.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()!.value = height.toString(); + } + height = parseInt(height.toString(), 10); + } + this.image?.changeSize(parseInt(width.toString(), 10), height); + } + + toolbar(): Array { + const editor = this.editor; + const getItems = (): Array => { + 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 = [ + { + key: 'copy', + type: 'copy', + }, + { + key: 'delete', + type: 'delete', + }, + ]; + if (isMobile) return items; + const rotateItems: (CardToolbarItemOptions | ToolbarItemOptions)[] = [ + { + key: 'button', + type: 'button', + content: ``, + title: '旋转', + onClick: () => { + this.image?.rotateImage(); + }, + }, + ]; + const cropperItems: (CardToolbarItemOptions | ToolbarItemOptions)[] = [ + { + key: 'button', + type: 'button', + content: ``, + 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: ``, + title: language.get('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: ``, + title: language.get('image', 'displayBlockTitle'), + onClick: () => { + this.type = CardType.BLOCK; + }, + }, + { + key: 'inline', + type: 'button', + content: ``, + title: language.get('image', 'displayInlineTitle'), + onClick: () => { + this.type = CardType.INLINE; + }, + }, + ]; + const imagePlugin = editor.plugin.findPlugin('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('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('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('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; diff --git a/editor/src/plugins/image/component/pswp/index.css b/editor/src/plugins/image/component/pswp/index.css new file mode 100644 index 0000000..53182c3 --- /dev/null +++ b/editor/src/plugins/image/component/pswp/index.css @@ -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=='); +} + diff --git a/editor/src/plugins/image/component/pswp/index.ts b/editor/src/plugins/image/component/pswp/index.ts new file mode 100644 index 0000000..885e201 --- /dev/null +++ b/editor/src/plugins/image/component/pswp/index.ts @@ -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; + 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 = $(` + `); + 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, index: number) { + if (true === this.isDestroy) { + const { root } = this; + const pswp = new PhotoSwipe(this.root.get()!, 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; diff --git a/editor/src/plugins/image/component/pswp/zoom.ts b/editor/src/plugins/image/component/pswp/zoom.ts new file mode 100644 index 0000000..ac3d9ea --- /dev/null +++ b/editor/src/plugins/image/component/pswp/zoom.ts @@ -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 = $(` +
+
+
+ `); + + 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(``); + + toolbarContent.append( + this.renderBtn('arrow-right', lang['next'], this.nextStatus, () => { + if ('disable' !== this.nextStatus) this.pswp.next(); + }) + ); + + toolbarContent.append(``); + + 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 = $(``); + 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; diff --git a/editor/src/plugins/image/index.ts b/editor/src/plugins/image/index.ts new file mode 100644 index 0000000..753f211 --- /dev/null +++ b/editor/src/plugins/image/index.ts @@ -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 extends Plugin { + 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 { + const { card } = this.editor; + // 检测单个组件 + const check = (component: CardInterface) => { + return component.root.inEditor() && component.name === ImageComponent.cardName && (component as ImageComponent).getValue()?.status === 'uploading'; + }; + // 找到不合格的组件 + const find = (): CardInterface | undefined => { + return card.components.find(check); + }; + const waitCheck = (component: CardInterface): Promise => { + 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; + 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) : $(''); + 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, $(`

`)); + } + 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 }; diff --git a/editor/src/plugins/image/locales/en-US.ts b/editor/src/plugins/image/locales/en-US.ts new file mode 100644 index 0000000..f432118 --- /dev/null +++ b/editor/src/plugins/image/locales/en-US.ts @@ -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', + }, +}; diff --git a/editor/src/plugins/image/locales/index.ts b/editor/src/plugins/image/locales/index.ts new file mode 100644 index 0000000..c32f63b --- /dev/null +++ b/editor/src/plugins/image/locales/index.ts @@ -0,0 +1,7 @@ +import en from './en-US'; +import cn from './zh-CN'; + +export default { + 'en-US': en, + 'zh-CN': cn, +}; diff --git a/editor/src/plugins/image/locales/zh-CN.ts b/editor/src/plugins/image/locales/zh-CN.ts new file mode 100644 index 0000000..58f7361 --- /dev/null +++ b/editor/src/plugins/image/locales/zh-CN.ts @@ -0,0 +1,19 @@ +export default { + image: { + next: '下一张', + prev: '上一张', + zoomIn: '放大', + zoomOut: '缩小', + originSize: '实际尺寸', + bestSize: '适应屏幕', + errorMessageCopy: '复制错误信息', + loadError: '图片加载失败!', + uploadError: '上传图片失败!', + uploadLimitError: '上传图片大小限制为 $size', + toolbarReductionTitle: '还原', + toolbarWidthTitle: '宽度', + toolbarHeightTitle: '宽度', + displayBlockTitle: '独占一行', + displayInlineTitle: '嵌入行内', + }, +}; diff --git a/editor/src/plugins/image/types.ts b/editor/src/plugins/image/types.ts new file mode 100644 index 0000000..8724484 --- /dev/null +++ b/editor/src/plugins/image/types.ts @@ -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)[]; +} diff --git a/editor/src/plugins/image/uploader.ts b/editor/src/plugins/image/uploader.ts new file mode 100644 index 0000000..ca194f7 --- /dev/null +++ b/editor/src/plugins/image/uploader.ts @@ -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; + /** + * 是否跨域 + */ + 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 extends Plugin { + private cardComponents: { [key: string]: ImageComponent } = {}; + private loadCounts: { [key: string]: number } = {}; + + static get pluginName() { + return 'image-uploader'; + } + + extensionNames: Record | 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('image', 'loadError')), editor.card.update(id, value); + } + }; + } + + async execute(files?: Array | 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) => { + const imagePlugin = editor.plugin.findPlugin('image'); + const component = card.insert>( + 'image', + { + ...value, + status: 'uploading', + type: value.type || imagePlugin?.options?.defaultType, + //fileInfo.src, 再协作中,如果大图片使用base64加载图片预览会造成很大资源浪费 + }, + base64String + ); + this.cardComponents[fileInfo.uid] = component; + }; + return new Promise(resolve => { + const image = new Image(); + image.src = base64String; + const imagePlugin = editor.plugin.findPlugin('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(component.id, { + status: 'error', + message: result.data || language.get('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(component.id, { + status: 'error', + message: error.message || language.get('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('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(component.id, { + status: 'error', + message: result.data || editor.language.get('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(component.id, { + status: 'error', + message: error.message || editor.language.get('image', 'uploadError'), + }); + }, + }); + } + + insertRemote(src: string, alt?: string) { + const editor = this.editor; + const imagePlugin = editor.plugin.findPlugin('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>('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); + } + } +} diff --git a/editor/src/plugins/lightblock/component/index.tsx b/editor/src/plugins/lightblock/component/index.tsx new file mode 100644 index 0000000..5f30860 --- /dev/null +++ b/editor/src/plugins/lightblock/component/index.tsx @@ -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 = ` + + + +`; + +class Lightblock extends Card { + 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 { + 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 = $('
'); + + 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() : '
'; + this.#container = $( + `
+
+ + + + + + + + + +
+
${childValue}
+
` + ); + + if (isFoucs) { + setTimeout(() => { + this.#container?.find('.lightblock-editor-container')?.get()?.focus?.(); + }, 0); + } + + return this.#container; + } + + didRender() { + super.didRender(); + this.updateColor(); + } +} +export default Lightblock; +export type { LightblockValue }; diff --git a/editor/src/plugins/lightblock/component/lightblock-theme.vue b/editor/src/plugins/lightblock/component/lightblock-theme.vue new file mode 100644 index 0000000..1234bf7 --- /dev/null +++ b/editor/src/plugins/lightblock/component/lightblock-theme.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/editor/src/plugins/lightblock/component/markdown.ts b/editor/src/plugins/lightblock/component/markdown.ts new file mode 100644 index 0000000..cc0eb83 --- /dev/null +++ b/editor/src/plugins/lightblock/component/markdown.ts @@ -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 `
`; + } else { + return '
'; + } + }, + }); +} diff --git a/editor/src/plugins/lightblock/component/style.css b/editor/src/plugins/lightblock/component/style.css new file mode 100644 index 0000000..cec6ce7 --- /dev/null +++ b/editor/src/plugins/lightblock/component/style.css @@ -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) +} diff --git a/editor/src/plugins/lightblock/component/types.ts b/editor/src/plugins/lightblock/component/types.ts new file mode 100644 index 0000000..a987847 --- /dev/null +++ b/editor/src/plugins/lightblock/component/types.ts @@ -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; +} diff --git a/editor/src/plugins/lightblock/index.ts b/editor/src/plugins/lightblock/index.ts new file mode 100644 index 0000000..f132e9b --- /dev/null +++ b/editor/src/plugins/lightblock/index.ts @@ -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 { + 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( + 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(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 $(`
${htmlstring}
`); + }; + + 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 }; diff --git a/editor/src/plugins/math/component/constant.ts b/editor/src/plugins/math/component/constant.ts new file mode 100644 index 0000000..e16835b --- /dev/null +++ b/editor/src/plugins/math/component/constant.ts @@ -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, +}; diff --git a/editor/src/plugins/math/component/index.ts b/editor/src/plugins/math/component/index.ts new file mode 100644 index 0000000..5532c78 --- /dev/null +++ b/editor/src/plugins/math/component/index.ts @@ -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 = ` + + + +`; + +class Math extends Card { + static get cardName() { + return 'math'; + } + + static get cardType() { + return CardType.INLINE; + } + + #container?: NodeInterface; + private vm?: App; + + toolbar(): Array { + 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 = $('
Loading
'); + 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()); + }, 20); + } + + destroy() { + super.destroy(); + this.vm?.unmount(); + } +} +export default Math; diff --git a/editor/src/plugins/math/component/math-formula.vue b/editor/src/plugins/math/component/math-formula.vue new file mode 100644 index 0000000..f4bdc11 --- /dev/null +++ b/editor/src/plugins/math/component/math-formula.vue @@ -0,0 +1,225 @@ +