From d1c3366ac5795655b9d226383e01f9b039f3136f Mon Sep 17 00:00:00 2001 From: yanmao Date: Wed, 3 Nov 2021 19:58:08 +0800 Subject: [PATCH] init --- .circleci/config.yml | 14 + .dumi/theme/components/LocaleSelect.less | 48 + .dumi/theme/components/LocaleSelect.tsx | 67 + .dumi/theme/components/NavRight.less | 17 + .dumi/theme/components/NavRight.tsx | 11 + .dumi/theme/components/Navbar.less | 173 + .dumi/theme/components/Navbar.tsx | 104 + .dumi/theme/components/SearchBar.less | 107 + .dumi/theme/components/SearchBar.tsx | 46 + .dumi/theme/components/SideMenu.less | 334 + .dumi/theme/components/SideMenu.tsx | 180 + .dumi/theme/components/SlugList.less | 18 + .dumi/theme/components/SlugList.tsx | 27 + .dumi/theme/layouts/index.tsx | 145 + .dumi/theme/layouts/layout.less | 327 + .dumi/theme/style/markdown.less | 196 + .dumi/theme/style/variables.less | 26 + .editorconfig | 16 + .fatherrc.ts | 5 + .github/ISSUE_TEMPLATE/bug_report.md | 40 + .github/ISSUE_TEMPLATE/feature_request.md | 19 + .gitignore | 25 + .prettierignore | 7 + .prettierrc | 11 + .umirc.ts | 360 + LICENSE | 21 + README.md | 331 + README.zh-CN.md | 335 + docs/api/clipboard.md | 68 + docs/api/clipboard.zh-CN.md | 68 + docs/api/command.md | 45 + docs/api/command.zh-CN.md | 45 + docs/api/constants.md | 111 + docs/api/constants.zh-CN.md | 111 + docs/api/editor-block.md | 320 + docs/api/editor-block.zh-CN.md | 320 + docs/api/editor-card-maximize.md | 35 + docs/api/editor-card-maximize.zh-CN.md | 35 + docs/api/editor-card-resize.md | 106 + docs/api/editor-card-resize.zh-CN.md | 106 + docs/api/editor-card-toolbar.md | 81 + docs/api/editor-card-toolbar.zh-CN.md | 81 + docs/api/editor-card.md | 335 + docs/api/editor-card.zh-CN.md | 331 + docs/api/editor-change-event.md | 112 + docs/api/editor-change-event.zh-CN.md | 112 + docs/api/editor-change.md | 295 + docs/api/editor-change.zh-CN.md | 295 + docs/api/editor-inline.md | 138 + docs/api/editor-inline.zh-CN.md | 138 + docs/api/editor-list.md | 331 + docs/api/editor-list.zh-CN.md | 331 + docs/api/editor-mark.md | 179 + docs/api/editor-mark.zh-CN.md | 179 + docs/api/editor-node.md | 362 + docs/api/editor-node.zh-CN.md | 362 + docs/api/editor.md | 201 + docs/api/editor.zh-CN.md | 201 + docs/api/engine.md | 184 + docs/api/engine.zh-CN.md | 184 + docs/api/history.md | 170 + docs/api/history.zh-CN.md | 174 + docs/api/hotkey.md | 45 + docs/api/hotkey.zh-CN.md | 45 + docs/api/language.md | 34 + docs/api/language.zh-CN.md | 34 + docs/api/node.md | 767 ++ docs/api/node.zh-CN.md | 861 ++ docs/api/parser.md | 99 + docs/api/parser.zh-CN.md | 99 + docs/api/range.md | 345 + docs/api/range.zh-CN.md | 345 + docs/api/schema.md | 287 + docs/api/schema.zh-CN.md | 287 + docs/api/selection.md | 89 + docs/api/selection.zh-CN.md | 89 + docs/api/utils.md | 185 + docs/api/utils.zh-CN.md | 185 + docs/api/view.md | 31 + docs/api/view.zh-CN.md | 31 + docs/config/index.md | 234 + docs/config/index.zh-CN.md | 234 + docs/config/ot.md | 74 + docs/config/ot.zh-CN.md | 74 + docs/config/toolbar.md | 509 + docs/config/toolbar.zh-CN.md | 513 + docs/config/upload.md | 428 + docs/config/upload.zh-CN.md | 429 + docs/config/view.md | 75 + docs/config/view.zh-CN.md | 75 + docs/docs/README.md | 31 + docs/docs/README.zh-CN.md | 33 + docs/docs/concepts-editor.md | 53 + docs/docs/concepts-editor.zh-CN.md | 53 + docs/docs/concepts-event.md | 504 + docs/docs/concepts-event.zh-CN.md | 504 + docs/docs/concepts-history.md | 5 + docs/docs/concepts-history.zh-CN.md | 5 + docs/docs/concepts-node.md | 118 + docs/docs/concepts-node.zh-CN.md | 118 + docs/docs/concepts-plugin.md | 48 + docs/docs/concepts-plugin.zh-CN.md | 48 + docs/docs/concepts-range.md | 52 + docs/docs/concepts-range.zh-CN.md | 52 + docs/docs/concepts-schema.md | 279 + docs/docs/concepts-schema.zh-CN.md | 279 + docs/docs/contributing.md | 77 + docs/docs/contributing.zh-CN.md | 77 + docs/docs/faq.md | 34 + docs/docs/faq.zh-CN.md | 34 + docs/docs/getting-started.md | 362 + docs/docs/getting-started.zh-CN.md | 362 + docs/docs/resources-icon.md | 3 + docs/docs/resources-icon.zh-CN.md | 3 + docs/index.md | 8 + docs/index.zh-CN.md | 8 + docs/plugin/plugin-alignment.md | 64 + docs/plugin/plugin-alignment.zh-CN.md | 64 + docs/plugin/plugin-backcolor.md | 51 + docs/plugin/plugin-backcolor.zh-CN.md | 51 + docs/plugin/plugin-bold.md | 66 + docs/plugin/plugin-bold.zh-CN.md | 66 + docs/plugin/plugin-code.md | 66 + docs/plugin/plugin-code.zh-CN.md | 66 + docs/plugin/plugin-codelock.md | 80 + docs/plugin/plugin-codelock.zh-CN.md | 80 + docs/plugin/plugin-file.md | 163 + docs/plugin/plugin-file.zh-CN.md | 163 + docs/plugin/plugin-fontcolor.md | 51 + docs/plugin/plugin-fontcolor.zh-CN.md | 51 + docs/plugin/plugin-fontfamily.md | 258 + docs/plugin/plugin-fontfamily.zh-CN.md | 260 + docs/plugin/plugin-fontsize.md | 80 + docs/plugin/plugin-fontsize.zh-CN.md | 80 + docs/plugin/plugin-heading.md | 253 + docs/plugin/plugin-heading.zh-CN.md | 251 + docs/plugin/plugin-hr.md | 64 + docs/plugin/plugin-hr.zh-CN.md | 64 + docs/plugin/plugin-image.md | 226 + docs/plugin/plugin-image.zh-CN.md | 228 + docs/plugin/plugin-indent.md | 67 + docs/plugin/plugin-indent.zh-CN.md | 67 + docs/plugin/plugin-italic.md | 66 + docs/plugin/plugin-italic.zh-CN.md | 66 + docs/plugin/plugin-line-height.md | 80 + docs/plugin/plugin-line-height.zh-CN.md | 80 + docs/plugin/plugin-link.md | 97 + docs/plugin/plugin-link.zh-CN.md | 97 + docs/plugin/plugin-mark-range.md | 203 + docs/plugin/plugin-mark-range.zh-CN.md | 203 + docs/plugin/plugin-mark.md | 201 + docs/plugin/plugin-mark.zh-CN.md | 201 + docs/plugin/plugin-math.md | 126 + docs/plugin/plugin-math.zh-CN.md | 126 + docs/plugin/plugin-mention.md | 126 + docs/plugin/plugin-mention.zh-CN.md | 126 + docs/plugin/plugin-orderedlist.md | 68 + docs/plugin/plugin-orderedlist.zh-CN.md | 68 + docs/plugin/plugin-paintformat.md | 53 + docs/plugin/plugin-paintformat.zh-CN.md | 53 + docs/plugin/plugin-quote.md | 66 + docs/plugin/plugin-quote.zh-CN.md | 66 + docs/plugin/plugin-redo.md | 47 + docs/plugin/plugin-redo.zh-CN.md | 47 + docs/plugin/plugin-removeformat.md | 49 + docs/plugin/plugin-removeformat.zh-CN.md | 49 + docs/plugin/plugin-selectall.md | 29 + docs/plugin/plugin-selectall.zh-CN.md | 29 + docs/plugin/plugin-status.md | 98 + docs/plugin/plugin-status.zh-CN.md | 98 + docs/plugin/plugin-strikethrough.md | 66 + docs/plugin/plugin-strikethrough.zh-CN.md | 66 + docs/plugin/plugin-sub.md | 66 + docs/plugin/plugin-sub.zh-CN.md | 66 + docs/plugin/plugin-sup.md | 66 + docs/plugin/plugin-sup.zh-CN.md | 66 + docs/plugin/plugin-table.md | 49 + docs/plugin/plugin-table.zh-CN.md | 49 + docs/plugin/plugin-tasklist.md | 68 + docs/plugin/plugin-tasklist.zh-CN.md | 68 + docs/plugin/plugin-underline.md | 47 + docs/plugin/plugin-underline.zh-CN.md | 47 + docs/plugin/plugin-undo.md | 47 + docs/plugin/plugin-undo.zh-CN.md | 47 + docs/plugin/plugin-unorderedlist.md | 66 + docs/plugin/plugin-unorderedlist.zh-CN.md | 66 + docs/plugin/plugin-video.md | 209 + docs/plugin/plugin-video.zh-CN.md | 209 + docs/plugin/tutorials-block.md | 154 + docs/plugin/tutorials-block.zh-CN.md | 154 + docs/plugin/tutorials-card.md | 1468 +++ docs/plugin/tutorials-card.zh-CN.md | 1468 +++ docs/plugin/tutorials-element.md | 276 + docs/plugin/tutorials-element.zh-CN.md | 276 + docs/plugin/tutorials-inline.md | 144 + docs/plugin/tutorials-inline.zh-CN.md | 144 + docs/plugin/tutorials-list.md | 132 + docs/plugin/tutorials-list.zh-CN.md | 132 + docs/plugin/tutorials-mark.md | 178 + docs/plugin/tutorials-mark.zh-CN.md | 178 + docs/plugin/tutorials.md | 230 + docs/plugin/tutorials.zh-CN.md | 230 + docs/view/index.md | 10 + docs/view/index.zh-CN.md | 10 + examples/react/components/comment/button.ts | 52 + examples/react/components/comment/edit.tsx | 59 + examples/react/components/comment/index.css | 165 + examples/react/components/comment/index.tsx | 627 ++ examples/react/components/comment/item.tsx | 129 + examples/react/components/comment/types.ts | 18 + examples/react/components/editor/config.tsx | 240 + examples/react/components/editor/index.less | 129 + examples/react/components/editor/index.tsx | 369 + examples/react/components/editor/ot/client.ts | 406 + examples/react/components/editor/ot/index.tsx | 47 + .../editor/plugins/test/component/index.tsx | 62 + .../editor/plugins/test/component/test.tsx | 3 + .../components/editor/plugins/test/index.ts | 91 + examples/react/components/editor/toolbar.tsx | 113 + examples/react/components/engine/index.tsx | 83 + examples/react/components/loading/index.less | 12 + examples/react/components/loading/index.tsx | 19 + examples/react/components/toc/index.css | 51 + examples/react/components/toc/index.tsx | 127 + examples/react/components/toc/utils.ts | 27 + examples/react/components/view/index.less | 11 + examples/react/components/view/index.tsx | 65 + examples/react/config.ts | 14 + examples/react/context.ts | 9 + examples/react/editor.css | 9 + examples/react/editor.tsx | 116 + examples/react/hooks/index.ts | 4 + examples/react/hooks/use-dispatch.ts | 46 + examples/react/hooks/use-selector.ts | 22 + examples/react/models/comment.ts | 33 + examples/react/models/doc.ts | 27 + examples/react/models/index.ts | 17 + examples/react/services/comment.ts | 51 + examples/react/services/doc.ts | 23 + examples/react/store.ts | 30 + examples/react/styles/variables.less | 26 + examples/react/types.ts | 31 + examples/react/view.tsx | 29 + examples/vue/.browserslistrc | 3 + examples/vue/.gitignore | 23 + examples/vue/babel.config.js | 3 + examples/vue/package.json | 67 + examples/vue/public/favicon.ico | Bin 0 -> 4286 bytes examples/vue/public/index.html | 17 + examples/vue/src/App.vue | 29 + examples/vue/src/assets/logo.png | Bin 0 -> 6849 bytes examples/vue/src/components/config.ts | 232 + examples/vue/src/components/demo.vue | 315 + examples/vue/src/components/loading.vue | 35 + examples/vue/src/components/mention.vue | 24 + examples/vue/src/components/ot-client.ts | 407 + examples/vue/src/main.ts | 5 + examples/vue/src/router/index.ts | 26 + examples/vue/src/shims-vue.d.ts | 6 + examples/vue/src/views/About.vue | 5 + examples/vue/src/views/Home.vue | 19 + examples/vue/tsconfig.json | 30 + examples/vue/tslint.json | 15 + examples/vue/vue.config.js | 11 + examples/vue/yarn.lock | 9173 +++++++++++++++++ lerna.json | 12 + locale/en-HK.json | 360 + locale/en-US.json | 362 + locale/ja-JP.json | 362 + locale/zh-HK.json | 362 + locale/zh-TW.json | 362 + locale/zh-cn.json | 362 + ot-server/.gitignore | 27 + ot-server/README.md | 14 + ot-server/config/dev.json | 8 + ot-server/nodemon.json | 4 + ot-server/package.json | 30 + ot-server/src/client.js | 143 + ot-server/src/doc.js | 110 + ot-server/src/index.js | 67 + package.json | 59 + packages/engine/README.md | 332 + packages/engine/package.json | 43 + packages/engine/src/@types/ot-json0.d.ts | 16 + packages/engine/src/block/index.ts | 1353 +++ packages/engine/src/block/typing/backspace.ts | 129 + packages/engine/src/block/typing/enter.ts | 91 + packages/engine/src/block/typing/index.ts | 4 + packages/engine/src/card/entry.ts | 371 + packages/engine/src/card/enum.ts | 13 + packages/engine/src/card/index.css | 12 + packages/engine/src/card/index.ts | 819 ++ packages/engine/src/card/maximize/index.css | 63 + packages/engine/src/card/maximize/index.ts | 68 + packages/engine/src/card/resize/index.css | 28 + packages/engine/src/card/resize/index.ts | 149 + packages/engine/src/card/toolbar/index.css | 59 + packages/engine/src/card/toolbar/index.ts | 362 + packages/engine/src/card/typing/backspace.ts | 122 + packages/engine/src/card/typing/default.ts | 45 + packages/engine/src/card/typing/down.ts | 43 + packages/engine/src/card/typing/enter.ts | 95 + packages/engine/src/card/typing/index.ts | 9 + packages/engine/src/card/typing/left.ts | 92 + packages/engine/src/card/typing/right.ts | 88 + packages/engine/src/card/typing/up.ts | 42 + packages/engine/src/change/dragover/index.css | 9 + packages/engine/src/change/dragover/index.ts | 189 + packages/engine/src/change/event.ts | 525 + packages/engine/src/change/index.ts | 886 ++ packages/engine/src/change/native-event.ts | 542 + packages/engine/src/change/paste.ts | 374 + packages/engine/src/change/range.ts | 342 + packages/engine/src/clipboard.ts | 378 + packages/engine/src/command.ts | 122 + packages/engine/src/constants/card.ts | 18 + packages/engine/src/constants/conversion.ts | 66 + packages/engine/src/constants/index.ts | 8 + packages/engine/src/constants/ot.ts | 14 + packages/engine/src/constants/root.ts | 16 + packages/engine/src/constants/schema.ts | 153 + packages/engine/src/constants/selection.ts | 14 + packages/engine/src/engine/container.ts | 187 + packages/engine/src/engine/index.css | 704 ++ packages/engine/src/engine/index.ts | 453 + packages/engine/src/history.ts | 466 + packages/engine/src/hotkey.ts | 105 + packages/engine/src/index.ts | 65 + packages/engine/src/inline/index.ts | 1054 ++ .../engine/src/inline/typing/backspace.ts | 176 + packages/engine/src/inline/typing/index.ts | 5 + packages/engine/src/inline/typing/left.ts | 122 + packages/engine/src/inline/typing/right.ts | 138 + packages/engine/src/language.ts | 35 + packages/engine/src/list/index.ts | 1278 +++ packages/engine/src/list/typing/backspace.ts | 151 + packages/engine/src/list/typing/enter.ts | 76 + packages/engine/src/list/typing/index.ts | 4 + packages/engine/src/locales/en-US.ts | 55 + packages/engine/src/locales/index.ts | 7 + packages/engine/src/locales/zh-cn.ts | 55 + packages/engine/src/mark/index.ts | 1604 +++ packages/engine/src/mark/typing/backspace.ts | 113 + packages/engine/src/mark/typing/index.ts | 3 + packages/engine/src/node/entry.ts | 1066 ++ packages/engine/src/node/event.ts | 67 + packages/engine/src/node/hash.ts | 72 + packages/engine/src/node/id.ts | 155 + packages/engine/src/node/index.ts | 999 ++ packages/engine/src/node/parse.ts | 90 + packages/engine/src/node/query.ts | 27 + packages/engine/src/node/utils.ts | 13 + packages/engine/src/ot/consumer.ts | 544 + packages/engine/src/ot/doc.ts | 42 + packages/engine/src/ot/index.css | 64 + packages/engine/src/ot/index.ts | 310 + packages/engine/src/ot/mutation.ts | 127 + packages/engine/src/ot/producer.ts | 463 + packages/engine/src/ot/range-coloring.ts | 730 ++ packages/engine/src/ot/selection.ts | 145 + packages/engine/src/ot/utils.ts | 364 + packages/engine/src/parser/conversion.ts | 125 + packages/engine/src/parser/index.ts | 558 + packages/engine/src/parser/text.ts | 23 + packages/engine/src/plugin/base.ts | 59 + packages/engine/src/plugin/block.ts | 54 + packages/engine/src/plugin/element.ts | 247 + packages/engine/src/plugin/index.ts | 75 + packages/engine/src/plugin/inline.ts | 183 + packages/engine/src/plugin/list/index.css | 110 + packages/engine/src/plugin/list/index.ts | 93 + packages/engine/src/plugin/mark.ts | 224 + packages/engine/src/position/index.ts | 119 + packages/engine/src/position/placements.ts | 81 + packages/engine/src/range.ts | 953 ++ packages/engine/src/request/ajax/constants.ts | 8 + packages/engine/src/request/ajax/index.ts | 470 + packages/engine/src/request/ajax/setup.ts | 18 + packages/engine/src/request/ajax/utils.ts | 101 + packages/engine/src/request/index.ts | 71 + packages/engine/src/request/uploader/index.ts | 154 + packages/engine/src/request/uploader/mime.ts | 78 + packages/engine/src/request/uploader/utils.ts | 46 + packages/engine/src/schema.ts | 494 + packages/engine/src/scrollbar/index.css | 84 + packages/engine/src/scrollbar/index.ts | 532 + packages/engine/src/selection.ts | 357 + packages/engine/src/toolbar/button.ts | 60 + .../engine/src/toolbar/dropdown/button.ts | 32 + packages/engine/src/toolbar/dropdown/index.ts | 116 + .../engine/src/toolbar/dropdown/switch.ts | 55 + packages/engine/src/toolbar/index.css | 234 + packages/engine/src/toolbar/index.ts | 105 + packages/engine/src/toolbar/input.ts | 77 + packages/engine/src/toolbar/tooltip/index.css | 141 + packages/engine/src/toolbar/tooltip/index.ts | 62 + packages/engine/src/types/block.ts | 196 + packages/engine/src/types/card.ts | 622 ++ packages/engine/src/types/change.ts | 305 + packages/engine/src/types/clipboard.ts | 36 + packages/engine/src/types/command.ts | 29 + packages/engine/src/types/conversion.ts | 83 + packages/engine/src/types/engine.ts | 1761 ++++ packages/engine/src/types/history.ts | 73 + packages/engine/src/types/hotkey.ts | 22 + packages/engine/src/types/index.ts | 23 + packages/engine/src/types/inline.ts | 108 + packages/engine/src/types/language.ts | 15 + packages/engine/src/types/list.ts | 220 + packages/engine/src/types/mark.ts | 173 + packages/engine/src/types/node.ts | 756 ++ packages/engine/src/types/ot.ts | 540 + packages/engine/src/types/parser.ts | 87 + packages/engine/src/types/plugin.ts | 174 + packages/engine/src/types/range.ts | 308 + packages/engine/src/types/request.ts | 186 + packages/engine/src/types/schema.ts | 206 + packages/engine/src/types/selection.ts | 37 + packages/engine/src/types/tiny-canvas.ts | 38 + packages/engine/src/types/toolbar.ts | 312 + packages/engine/src/types/typing.ts | 91 + packages/engine/src/types/view.ts | 55 + packages/engine/src/typing/index.ts | 128 + packages/engine/src/typing/keydown/all.ts | 6 + packages/engine/src/typing/keydown/at.ts | 8 + .../engine/src/typing/keydown/backspace.ts | 133 + packages/engine/src/typing/keydown/default.ts | 43 + packages/engine/src/typing/keydown/delete.ts | 162 + packages/engine/src/typing/keydown/down.ts | 8 + packages/engine/src/typing/keydown/enter.ts | 60 + packages/engine/src/typing/keydown/index.ts | 102 + packages/engine/src/typing/keydown/left.ts | 39 + packages/engine/src/typing/keydown/right.ts | 48 + .../engine/src/typing/keydown/shift-enter.ts | 66 + .../engine/src/typing/keydown/shift-tab.ts | 6 + packages/engine/src/typing/keydown/slash.ts | 10 + packages/engine/src/typing/keydown/space.ts | 8 + packages/engine/src/typing/keydown/tab.ts | 36 + packages/engine/src/typing/keydown/up.ts | 8 + packages/engine/src/typing/keyup/backspace.ts | 50 + packages/engine/src/typing/keyup/default.ts | 7 + packages/engine/src/typing/keyup/enter.ts | 6 + packages/engine/src/typing/keyup/index.ts | 39 + packages/engine/src/typing/keyup/space.ts | 6 + packages/engine/src/typing/keyup/tab.ts | 6 + packages/engine/src/utils/index.ts | 17 + packages/engine/src/utils/list.ts | 33 + packages/engine/src/utils/node.ts | 74 + packages/engine/src/utils/string.ts | 337 + packages/engine/src/utils/tiny-canvas.ts | 176 + packages/engine/src/utils/user-agent.ts | 48 + packages/engine/src/view.ts | 135 + packages/engine/tsconfig.json | 31 + packages/toolbar-vue/.browserslistrc | 3 + packages/toolbar-vue/.fatherrc.ts | 5 + packages/toolbar-vue/.gitignore | 23 + packages/toolbar-vue/package.json | 34 + .../toolbar-vue/src/components/button.vue | 142 + .../src/components/collapse/collapse.vue | 174 + .../src/components/collapse/group.vue | 40 + .../src/components/collapse/item.vue | 97 + .../src/components/color/color.vue | 189 + .../src/components/color/picker/group.vue | 25 + .../src/components/color/picker/item.vue | 116 + .../src/components/color/picker/palette.ts | 140 + .../src/components/color/picker/picker.vue | 161 + .../src/components/dropdown-list.vue | 88 + .../toolbar-vue/src/components/dropdown.vue | 251 + packages/toolbar-vue/src/components/group.vue | 74 + packages/toolbar-vue/src/components/table.vue | 92 + .../toolbar-vue/src/components/toolbar.vue | 304 + packages/toolbar-vue/src/config/fontfamily.ts | 126 + packages/toolbar-vue/src/config/index.css | 49 + packages/toolbar-vue/src/config/index.ts | 742 ++ packages/toolbar-vue/src/hooks/index.ts | 3 + packages/toolbar-vue/src/hooks/useRight.ts | 17 + packages/toolbar-vue/src/index.ts | 21 + packages/toolbar-vue/src/locales/en-US.ts | 232 + packages/toolbar-vue/src/locales/index.ts | 7 + packages/toolbar-vue/src/locales/zh-cn.ts | 232 + .../src/plugin/component/collapse.ts | 175 + .../src/plugin/component/index.css | 34 + .../toolbar-vue/src/plugin/component/index.ts | 256 + packages/toolbar-vue/src/plugin/index.ts | 107 + packages/toolbar-vue/src/shims-vue.d.ts | 6 + packages/toolbar-vue/src/types.ts | 361 + packages/toolbar-vue/src/utils.ts | 72 + packages/toolbar-vue/tsconfig.json | 36 + packages/toolbar-vue/tslint.json | 15 + packages/toolbar/package.json | 37 + packages/toolbar/src/button/index.css | 41 + packages/toolbar/src/button/index.tsx | 151 + packages/toolbar/src/collapse/group.tsx | 43 + packages/toolbar/src/collapse/index.css | 71 + packages/toolbar/src/collapse/index.tsx | 124 + packages/toolbar/src/collapse/item.tsx | 124 + packages/toolbar/src/color/index.css | 44 + packages/toolbar/src/color/index.tsx | 169 + packages/toolbar/src/color/picker/group.tsx | 38 + packages/toolbar/src/color/picker/index.css | 80 + packages/toolbar/src/color/picker/index.tsx | 78 + packages/toolbar/src/color/picker/item.tsx | 111 + packages/toolbar/src/color/picker/palette.ts | 140 + .../toolbar/src/config/toolbar/fontfamily.tsx | 129 + packages/toolbar/src/config/toolbar/index.css | 49 + packages/toolbar/src/config/toolbar/index.tsx | 893 ++ packages/toolbar/src/dropdown/index.css | 106 + packages/toolbar/src/dropdown/index.tsx | 167 + packages/toolbar/src/dropdown/list.tsx | 164 + packages/toolbar/src/group/index.css | 9 + packages/toolbar/src/group/index.tsx | 103 + packages/toolbar/src/hooks/index.ts | 3 + packages/toolbar/src/hooks/useRight.ts | 16 + packages/toolbar/src/index.css | 72 + packages/toolbar/src/index.tsx | 326 + packages/toolbar/src/locales/en-US.ts | 232 + packages/toolbar/src/locales/index.ts | 7 + packages/toolbar/src/locales/zh-cn.ts | 232 + .../toolbar/src/plugin/component/collapse.tsx | 175 + .../toolbar/src/plugin/component/index.css | 34 + .../toolbar/src/plugin/component/index.ts | 251 + packages/toolbar/src/plugin/index.ts | 110 + packages/toolbar/src/table/index.css | 19 + packages/toolbar/src/table/index.tsx | 87 + packages/toolbar/src/types.ts | 24 + packages/toolbar/src/utils.ts | 72 + packages/toolbar/tsconfig.json | 34 + plugins/alignment/README.md | 52 + plugins/alignment/package.json | 26 + plugins/alignment/src/index.ts | 127 + plugins/alignment/tsconfig.json | 34 + plugins/backcolor/README.md | 51 + plugins/backcolor/package.json | 26 + plugins/backcolor/src/index.ts | 31 + plugins/backcolor/tsconfig.json | 34 + plugins/bold/README.md | 66 + plugins/bold/package.json | 26 + plugins/bold/src/index.ts | 39 + plugins/bold/tsconfig.json | 34 + plugins/code/README.md | 66 + plugins/code/package.json | 26 + plugins/code/src/index.css | 10 + plugins/code/src/index.ts | 39 + plugins/code/tsconfig.json | 34 + plugins/codeblock-vue/.browserslistrc | 3 + plugins/codeblock-vue/.fatherrc.ts | 5 + plugins/codeblock-vue/.gitignore | 23 + plugins/codeblock-vue/README.md | 68 + plugins/codeblock-vue/package.json | 43 + plugins/codeblock-vue/src/component/editor.ts | 271 + plugins/codeblock-vue/src/component/index.css | 765 ++ plugins/codeblock-vue/src/component/index.ts | 146 + plugins/codeblock-vue/src/component/lang.ts | 32 + plugins/codeblock-vue/src/component/mode.ts | 241 + .../src/component/select/component.vue | 50 + .../src/component/select/index.ts | 20 + plugins/codeblock-vue/src/component/types.ts | 46 + plugins/codeblock-vue/src/index.ts | 359 + plugins/codeblock-vue/src/shims-vue.d.ts | 6 + plugins/codeblock-vue/tsconfig.json | 36 + plugins/codeblock-vue/tslint.json | 15 + plugins/codeblock/README.md | 74 + plugins/codeblock/package.json | 43 + plugins/codeblock/src/component/editor.ts | 271 + plugins/codeblock/src/component/index.css | 764 ++ plugins/codeblock/src/component/index.ts | 141 + plugins/codeblock/src/component/lang.ts | 32 + plugins/codeblock/src/component/mode.ts | 241 + plugins/codeblock/src/component/select.tsx | 72 + plugins/codeblock/src/component/types.ts | 46 + plugins/codeblock/src/index.ts | 360 + plugins/codeblock/tsconfig.json | 34 + plugins/file/README.md | 163 + plugins/file/package.json | 26 + plugins/file/src/component/index.css | 68 + plugins/file/src/component/index.ts | 280 + plugins/file/src/index.ts | 176 + plugins/file/src/locales/en-US.ts | 10 + plugins/file/src/locales/index.ts | 7 + plugins/file/src/locales/zh-cn.ts | 10 + plugins/file/src/uploader.ts | 328 + plugins/file/tsconfig.json | 34 + plugins/fontcolor/README.md | 51 + plugins/fontcolor/package.json | 26 + plugins/fontcolor/src/index.ts | 31 + plugins/fontcolor/tsconfig.json | 34 + plugins/fontfamily/README.md | 260 + plugins/fontfamily/package.json | 26 + plugins/fontfamily/src/index.ts | 93 + plugins/fontfamily/tsconfig.json | 34 + plugins/fontsize/README.md | 80 + plugins/fontsize/package.json | 26 + plugins/fontsize/src/index.ts | 87 + plugins/fontsize/tsconfig.json | 34 + plugins/heading/README.md | 131 + plugins/heading/package.json | 26 + plugins/heading/src/index.css | 96 + plugins/heading/src/index.ts | 445 + plugins/heading/src/outline.ts | 123 + plugins/heading/tsconfig.json | 34 + plugins/hr/README.md | 63 + plugins/hr/package.json | 26 + plugins/hr/src/component.ts | 69 + plugins/hr/src/index.css | 22 + plugins/hr/src/index.ts | 145 + plugins/hr/tsconfig.json | 34 + plugins/image/README.md | 226 + plugins/image/package.json | 35 + plugins/image/src/component/image/index.css | 156 + plugins/image/src/component/image/index.ts | 583 ++ plugins/image/src/component/index.ts | 297 + plugins/image/src/component/pswp/index.css | 194 + plugins/image/src/component/pswp/index.ts | 338 + plugins/image/src/component/pswp/zoom.ts | 161 + plugins/image/src/component/resizer/index.css | 98 + plugins/image/src/component/resizer/index.ts | 246 + plugins/image/src/index.ts | 148 + plugins/image/src/locales/en-US.ts | 19 + plugins/image/src/locales/index.ts | 7 + plugins/image/src/locales/zh-cn.ts | 19 + plugins/image/src/types.ts | 29 + plugins/image/src/uploader.ts | 716 ++ plugins/image/tsconfig.json | 34 + plugins/indent/README.md | 67 + plugins/indent/package.json | 26 + plugins/indent/src/index.ts | 327 + plugins/indent/tsconfig.json | 34 + plugins/italic/README.md | 66 + plugins/italic/package.json | 26 + plugins/italic/src/index.ts | 39 + plugins/italic/tsconfig.json | 34 + plugins/line-height/README.md | 80 + plugins/line-height/package.json | 26 + plugins/line-height/src/index.ts | 127 + plugins/line-height/tsconfig.json | 34 + plugins/link-vue/.browserslistrc | 3 + plugins/link-vue/.fatherrc.ts | 5 + plugins/link-vue/README.md | 72 + plugins/link-vue/package.json | 35 + plugins/link-vue/src/index.css | 79 + plugins/link-vue/src/index.ts | 195 + plugins/link-vue/src/locales/en-US.ts | 12 + plugins/link-vue/src/locales/index.ts | 7 + plugins/link-vue/src/locales/zh-cn.ts | 12 + plugins/link-vue/src/shims-vue.d.ts | 6 + plugins/link-vue/src/toolbar/editor.vue | 103 + plugins/link-vue/src/toolbar/index.ts | 224 + plugins/link-vue/src/toolbar/preview.vue | 60 + plugins/link-vue/tsconfig.json | 36 + plugins/link-vue/tslint.json | 15 + plugins/link/README.md | 78 + plugins/link/package.json | 34 + plugins/link/src/index.css | 79 + plugins/link/src/index.ts | 204 + plugins/link/src/locales/en-US.ts | 12 + plugins/link/src/locales/index.ts | 7 + plugins/link/src/locales/zh-cn.ts | 12 + plugins/link/src/toolbar/editor.tsx | 83 + plugins/link/src/toolbar/index.tsx | 207 + plugins/link/src/toolbar/preview.tsx | 47 + plugins/link/tsconfig.json | 34 + plugins/mark-range/README.md | 201 + plugins/mark-range/package.json | 31 + plugins/mark-range/src/index.ts | 732 ++ plugins/mark-range/tsconfig.json | 34 + plugins/mark/README.md | 66 + plugins/mark/package.json | 26 + plugins/mark/src/index.css | 5 + plugins/mark/src/index.ts | 21 + plugins/mark/tsconfig.json | 34 + plugins/math/README.md | 126 + plugins/math/package.json | 30 + plugins/math/src/component/editor.ts | 104 + plugins/math/src/component/index.css | 98 + plugins/math/src/component/index.ts | 213 + plugins/math/src/index.ts | 284 + plugins/math/src/locales/en-US.ts | 15 + plugins/math/src/locales/index.ts | 7 + plugins/math/src/locales/zh-cn.ts | 15 + plugins/math/src/utils.ts | 7 + plugins/math/tsconfig.json | 34 + plugins/mention/README.md | 126 + plugins/mention/package.json | 31 + plugins/mention/src/component/collapse.ts | 277 + plugins/mention/src/component/index.css | 74 + plugins/mention/src/component/index.ts | 356 + plugins/mention/src/index.ts | 271 + plugins/mention/src/locales/en-US.ts | 5 + plugins/mention/src/locales/index.ts | 7 + plugins/mention/src/locales/zh-cn.ts | 5 + plugins/mention/src/types.ts | 1 + plugins/mention/tsconfig.json | 34 + plugins/mind/README.md | 11 + plugins/mind/package.json | 33 + plugins/mind/src/@types/hierarchy.d.ts | 8 + .../mind/src/component/editor/hot-areas.ts | 207 + .../mind/src/component/editor/html-node.ts | 167 + plugins/mind/src/component/editor/index.css | 41 + plugins/mind/src/component/editor/index.ts | 232 + .../src/component/editor/shape/edges/base.ts | 3 + plugins/mind/src/component/editor/utils.ts | 33 + plugins/mind/src/component/index.ts | 80 + plugins/mind/src/index.ts | 18 + plugins/mind/src/types.ts | 19 + plugins/mind/tsconfig.json | 34 + plugins/orderedlist/README.md | 68 + plugins/orderedlist/package.json | 26 + plugins/orderedlist/src/index.ts | 176 + plugins/orderedlist/tsconfig.json | 34 + plugins/paintformat/README.md | 53 + plugins/paintformat/package.json | 26 + plugins/paintformat/src/index.css | 5 + plugins/paintformat/src/index.ts | 196 + plugins/paintformat/tsconfig.json | 34 + plugins/quote/README.md | 66 + plugins/quote/package.json | 26 + plugins/quote/src/index.css | 22 + plugins/quote/src/index.ts | 244 + plugins/quote/tsconfig.json | 34 + plugins/redo/README.md | 47 + plugins/redo/package.json | 26 + plugins/redo/src/index.ts | 24 + plugins/redo/tsconfig.json | 34 + plugins/removeformat/README.md | 49 + plugins/removeformat/package.json | 26 + plugins/removeformat/src/index.ts | 41 + plugins/removeformat/tsconfig.json | 34 + plugins/selectall/README.md | 29 + plugins/selectall/package.json | 26 + plugins/selectall/src/index.ts | 39 + plugins/selectall/tsconfig.json | 34 + plugins/status/README.md | 98 + plugins/status/package.json | 26 + plugins/status/src/components/editor.ts | 141 + plugins/status/src/components/index.css | 123 + plugins/status/src/components/index.ts | 182 + plugins/status/src/index.ts | 117 + plugins/status/src/locales/en-US.ts | 5 + plugins/status/src/locales/index.ts | 7 + plugins/status/src/locales/zh-cn.ts | 5 + plugins/status/tsconfig.json | 34 + plugins/strikethrough/README.md | 66 + plugins/strikethrough/package.json | 26 + plugins/strikethrough/src/index.ts | 43 + plugins/strikethrough/tsconfig.json | 34 + plugins/sub/README.md | 66 + plugins/sub/package.json | 26 + plugins/sub/src/index.ts | 20 + plugins/sub/tsconfig.json | 34 + plugins/sup/README.md | 66 + plugins/sup/package.json | 26 + plugins/sup/src/index.ts | 20 + plugins/sup/tsconfig.json | 34 + plugins/table/README.md | 49 + plugins/table/package.json | 29 + plugins/table/src/component/command.ts | 746 ++ plugins/table/src/component/controllbar.ts | 1565 +++ plugins/table/src/component/helper.ts | 613 ++ plugins/table/src/component/index.ts | 480 + plugins/table/src/component/menu.ts | 86 + plugins/table/src/component/selection.ts | 1139 ++ plugins/table/src/component/template.ts | 205 + plugins/table/src/component/toolbar/color.ts | 236 + plugins/table/src/component/toolbar/index.css | 162 + plugins/table/src/component/toolbar/index.ts | 5 + .../table/src/component/toolbar/palette.ts | 140 + plugins/table/src/index.css | 722 ++ plugins/table/src/index.ts | 503 + plugins/table/src/locale/en-US.ts | 30 + plugins/table/src/locale/index.ts | 7 + plugins/table/src/locale/zh-cn.ts | 30 + plugins/table/src/types.ts | 375 + plugins/table/tsconfig.json | 34 + plugins/tasklist/README.md | 68 + plugins/tasklist/package.json | 26 + plugins/tasklist/src/checkbox/index.css | 123 + plugins/tasklist/src/checkbox/index.ts | 101 + plugins/tasklist/src/index.css | 18 + plugins/tasklist/src/index.ts | 283 + plugins/tasklist/tsconfig.json | 34 + plugins/underline/README.md | 47 + plugins/underline/package.json | 26 + plugins/underline/src/index.ts | 31 + plugins/underline/tsconfig.json | 34 + plugins/undo/README.md | 47 + plugins/undo/package.json | 26 + plugins/undo/src/index.ts | 24 + plugins/undo/tsconfig.json | 34 + plugins/unorderedlist/README.md | 66 + plugins/unorderedlist/package.json | 26 + plugins/unorderedlist/src/index.ts | 171 + plugins/unorderedlist/tsconfig.json | 34 + plugins/video/README.md | 209 + plugins/video/package.json | 26 + plugins/video/src/component/index.css | 83 + plugins/video/src/component/index.ts | 453 + plugins/video/src/index.ts | 203 + plugins/video/src/locales/en-US.ts | 12 + plugins/video/src/locales/index.ts | 7 + plugins/video/src/locales/zh-cn.ts | 12 + plugins/video/src/uploader.ts | 423 + plugins/video/tsconfig.json | 34 + scripts/build.js | 65 + scripts/rollup-build.js | 279 + scripts/rollup.js | 62 + site-ssr/.env | 2 + site-ssr/.gitignore | 26 + site-ssr/README.md | 32 + site-ssr/app/controller/comment.js | 134 + site-ssr/app/controller/doc.js | 53 + site-ssr/app/controller/home.js | 66 + site-ssr/app/controller/upload.js | 188 + site-ssr/app/controller/user.js | 36 + site-ssr/app/data/comment.json | 54 + site-ssr/app/data/doc.json | 7 + site-ssr/app/data/user.json | 32 + site-ssr/app/extend/helper.js | 36 + site-ssr/app/router.js | 22 + site-ssr/app/view/dev.html | 21 + site-ssr/config/config.default.js | 68 + site-ssr/config/config.local.js | 40 + site-ssr/config/plugin.js | 15 + site-ssr/jsconfig.json | 24 + site-ssr/package.json | 53 + tsconfig.json | 38 + typings.d.ts | 2 + 826 files changed, 123028 insertions(+) create mode 100644 .circleci/config.yml create mode 100644 .dumi/theme/components/LocaleSelect.less create mode 100644 .dumi/theme/components/LocaleSelect.tsx create mode 100644 .dumi/theme/components/NavRight.less create mode 100644 .dumi/theme/components/NavRight.tsx create mode 100644 .dumi/theme/components/Navbar.less create mode 100644 .dumi/theme/components/Navbar.tsx create mode 100644 .dumi/theme/components/SearchBar.less create mode 100644 .dumi/theme/components/SearchBar.tsx create mode 100644 .dumi/theme/components/SideMenu.less create mode 100644 .dumi/theme/components/SideMenu.tsx create mode 100644 .dumi/theme/components/SlugList.less create mode 100644 .dumi/theme/components/SlugList.tsx create mode 100644 .dumi/theme/layouts/index.tsx create mode 100644 .dumi/theme/layouts/layout.less create mode 100644 .dumi/theme/style/markdown.less create mode 100644 .dumi/theme/style/variables.less create mode 100755 .editorconfig create mode 100644 .fatherrc.ts create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 .umirc.ts create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.zh-CN.md create mode 100644 docs/api/clipboard.md create mode 100644 docs/api/clipboard.zh-CN.md create mode 100644 docs/api/command.md create mode 100644 docs/api/command.zh-CN.md create mode 100644 docs/api/constants.md create mode 100644 docs/api/constants.zh-CN.md create mode 100644 docs/api/editor-block.md create mode 100644 docs/api/editor-block.zh-CN.md create mode 100644 docs/api/editor-card-maximize.md create mode 100644 docs/api/editor-card-maximize.zh-CN.md create mode 100644 docs/api/editor-card-resize.md create mode 100644 docs/api/editor-card-resize.zh-CN.md create mode 100644 docs/api/editor-card-toolbar.md create mode 100644 docs/api/editor-card-toolbar.zh-CN.md create mode 100644 docs/api/editor-card.md create mode 100644 docs/api/editor-card.zh-CN.md create mode 100644 docs/api/editor-change-event.md create mode 100644 docs/api/editor-change-event.zh-CN.md create mode 100644 docs/api/editor-change.md create mode 100644 docs/api/editor-change.zh-CN.md create mode 100644 docs/api/editor-inline.md create mode 100644 docs/api/editor-inline.zh-CN.md create mode 100644 docs/api/editor-list.md create mode 100644 docs/api/editor-list.zh-CN.md create mode 100644 docs/api/editor-mark.md create mode 100644 docs/api/editor-mark.zh-CN.md create mode 100644 docs/api/editor-node.md create mode 100644 docs/api/editor-node.zh-CN.md create mode 100644 docs/api/editor.md create mode 100644 docs/api/editor.zh-CN.md create mode 100644 docs/api/engine.md create mode 100644 docs/api/engine.zh-CN.md create mode 100644 docs/api/history.md create mode 100644 docs/api/history.zh-CN.md create mode 100644 docs/api/hotkey.md create mode 100644 docs/api/hotkey.zh-CN.md create mode 100644 docs/api/language.md create mode 100644 docs/api/language.zh-CN.md create mode 100644 docs/api/node.md create mode 100644 docs/api/node.zh-CN.md create mode 100644 docs/api/parser.md create mode 100644 docs/api/parser.zh-CN.md create mode 100644 docs/api/range.md create mode 100644 docs/api/range.zh-CN.md create mode 100644 docs/api/schema.md create mode 100644 docs/api/schema.zh-CN.md create mode 100644 docs/api/selection.md create mode 100644 docs/api/selection.zh-CN.md create mode 100644 docs/api/utils.md create mode 100644 docs/api/utils.zh-CN.md create mode 100644 docs/api/view.md create mode 100644 docs/api/view.zh-CN.md create mode 100644 docs/config/index.md create mode 100644 docs/config/index.zh-CN.md create mode 100644 docs/config/ot.md create mode 100644 docs/config/ot.zh-CN.md create mode 100644 docs/config/toolbar.md create mode 100644 docs/config/toolbar.zh-CN.md create mode 100644 docs/config/upload.md create mode 100644 docs/config/upload.zh-CN.md create mode 100644 docs/config/view.md create mode 100644 docs/config/view.zh-CN.md create mode 100644 docs/docs/README.md create mode 100644 docs/docs/README.zh-CN.md create mode 100644 docs/docs/concepts-editor.md create mode 100644 docs/docs/concepts-editor.zh-CN.md create mode 100644 docs/docs/concepts-event.md create mode 100644 docs/docs/concepts-event.zh-CN.md create mode 100644 docs/docs/concepts-history.md create mode 100644 docs/docs/concepts-history.zh-CN.md create mode 100644 docs/docs/concepts-node.md create mode 100644 docs/docs/concepts-node.zh-CN.md create mode 100644 docs/docs/concepts-plugin.md create mode 100644 docs/docs/concepts-plugin.zh-CN.md create mode 100644 docs/docs/concepts-range.md create mode 100644 docs/docs/concepts-range.zh-CN.md create mode 100644 docs/docs/concepts-schema.md create mode 100644 docs/docs/concepts-schema.zh-CN.md create mode 100644 docs/docs/contributing.md create mode 100644 docs/docs/contributing.zh-CN.md create mode 100644 docs/docs/faq.md create mode 100644 docs/docs/faq.zh-CN.md create mode 100644 docs/docs/getting-started.md create mode 100644 docs/docs/getting-started.zh-CN.md create mode 100644 docs/docs/resources-icon.md create mode 100644 docs/docs/resources-icon.zh-CN.md create mode 100644 docs/index.md create mode 100644 docs/index.zh-CN.md create mode 100644 docs/plugin/plugin-alignment.md create mode 100644 docs/plugin/plugin-alignment.zh-CN.md create mode 100644 docs/plugin/plugin-backcolor.md create mode 100644 docs/plugin/plugin-backcolor.zh-CN.md create mode 100644 docs/plugin/plugin-bold.md create mode 100644 docs/plugin/plugin-bold.zh-CN.md create mode 100644 docs/plugin/plugin-code.md create mode 100644 docs/plugin/plugin-code.zh-CN.md create mode 100644 docs/plugin/plugin-codelock.md create mode 100644 docs/plugin/plugin-codelock.zh-CN.md create mode 100644 docs/plugin/plugin-file.md create mode 100644 docs/plugin/plugin-file.zh-CN.md create mode 100644 docs/plugin/plugin-fontcolor.md create mode 100644 docs/plugin/plugin-fontcolor.zh-CN.md create mode 100644 docs/plugin/plugin-fontfamily.md create mode 100644 docs/plugin/plugin-fontfamily.zh-CN.md create mode 100644 docs/plugin/plugin-fontsize.md create mode 100644 docs/plugin/plugin-fontsize.zh-CN.md create mode 100644 docs/plugin/plugin-heading.md create mode 100644 docs/plugin/plugin-heading.zh-CN.md create mode 100644 docs/plugin/plugin-hr.md create mode 100644 docs/plugin/plugin-hr.zh-CN.md create mode 100644 docs/plugin/plugin-image.md create mode 100644 docs/plugin/plugin-image.zh-CN.md create mode 100644 docs/plugin/plugin-indent.md create mode 100644 docs/plugin/plugin-indent.zh-CN.md create mode 100644 docs/plugin/plugin-italic.md create mode 100644 docs/plugin/plugin-italic.zh-CN.md create mode 100644 docs/plugin/plugin-line-height.md create mode 100644 docs/plugin/plugin-line-height.zh-CN.md create mode 100644 docs/plugin/plugin-link.md create mode 100644 docs/plugin/plugin-link.zh-CN.md create mode 100644 docs/plugin/plugin-mark-range.md create mode 100644 docs/plugin/plugin-mark-range.zh-CN.md create mode 100644 docs/plugin/plugin-mark.md create mode 100644 docs/plugin/plugin-mark.zh-CN.md create mode 100644 docs/plugin/plugin-math.md create mode 100644 docs/plugin/plugin-math.zh-CN.md create mode 100644 docs/plugin/plugin-mention.md create mode 100644 docs/plugin/plugin-mention.zh-CN.md create mode 100644 docs/plugin/plugin-orderedlist.md create mode 100644 docs/plugin/plugin-orderedlist.zh-CN.md create mode 100644 docs/plugin/plugin-paintformat.md create mode 100644 docs/plugin/plugin-paintformat.zh-CN.md create mode 100644 docs/plugin/plugin-quote.md create mode 100644 docs/plugin/plugin-quote.zh-CN.md create mode 100644 docs/plugin/plugin-redo.md create mode 100644 docs/plugin/plugin-redo.zh-CN.md create mode 100644 docs/plugin/plugin-removeformat.md create mode 100644 docs/plugin/plugin-removeformat.zh-CN.md create mode 100644 docs/plugin/plugin-selectall.md create mode 100644 docs/plugin/plugin-selectall.zh-CN.md create mode 100644 docs/plugin/plugin-status.md create mode 100644 docs/plugin/plugin-status.zh-CN.md create mode 100644 docs/plugin/plugin-strikethrough.md create mode 100644 docs/plugin/plugin-strikethrough.zh-CN.md create mode 100644 docs/plugin/plugin-sub.md create mode 100644 docs/plugin/plugin-sub.zh-CN.md create mode 100644 docs/plugin/plugin-sup.md create mode 100644 docs/plugin/plugin-sup.zh-CN.md create mode 100644 docs/plugin/plugin-table.md create mode 100644 docs/plugin/plugin-table.zh-CN.md create mode 100644 docs/plugin/plugin-tasklist.md create mode 100644 docs/plugin/plugin-tasklist.zh-CN.md create mode 100644 docs/plugin/plugin-underline.md create mode 100644 docs/plugin/plugin-underline.zh-CN.md create mode 100644 docs/plugin/plugin-undo.md create mode 100644 docs/plugin/plugin-undo.zh-CN.md create mode 100644 docs/plugin/plugin-unorderedlist.md create mode 100644 docs/plugin/plugin-unorderedlist.zh-CN.md create mode 100644 docs/plugin/plugin-video.md create mode 100644 docs/plugin/plugin-video.zh-CN.md create mode 100644 docs/plugin/tutorials-block.md create mode 100644 docs/plugin/tutorials-block.zh-CN.md create mode 100644 docs/plugin/tutorials-card.md create mode 100644 docs/plugin/tutorials-card.zh-CN.md create mode 100644 docs/plugin/tutorials-element.md create mode 100644 docs/plugin/tutorials-element.zh-CN.md create mode 100644 docs/plugin/tutorials-inline.md create mode 100644 docs/plugin/tutorials-inline.zh-CN.md create mode 100644 docs/plugin/tutorials-list.md create mode 100644 docs/plugin/tutorials-list.zh-CN.md create mode 100644 docs/plugin/tutorials-mark.md create mode 100644 docs/plugin/tutorials-mark.zh-CN.md create mode 100644 docs/plugin/tutorials.md create mode 100644 docs/plugin/tutorials.zh-CN.md create mode 100644 docs/view/index.md create mode 100644 docs/view/index.zh-CN.md create mode 100644 examples/react/components/comment/button.ts create mode 100644 examples/react/components/comment/edit.tsx create mode 100644 examples/react/components/comment/index.css create mode 100644 examples/react/components/comment/index.tsx create mode 100644 examples/react/components/comment/item.tsx create mode 100644 examples/react/components/comment/types.ts create mode 100644 examples/react/components/editor/config.tsx create mode 100644 examples/react/components/editor/index.less create mode 100644 examples/react/components/editor/index.tsx create mode 100644 examples/react/components/editor/ot/client.ts create mode 100644 examples/react/components/editor/ot/index.tsx create mode 100644 examples/react/components/editor/plugins/test/component/index.tsx create mode 100644 examples/react/components/editor/plugins/test/component/test.tsx create mode 100644 examples/react/components/editor/plugins/test/index.ts create mode 100644 examples/react/components/editor/toolbar.tsx create mode 100644 examples/react/components/engine/index.tsx create mode 100644 examples/react/components/loading/index.less create mode 100644 examples/react/components/loading/index.tsx create mode 100644 examples/react/components/toc/index.css create mode 100644 examples/react/components/toc/index.tsx create mode 100644 examples/react/components/toc/utils.ts create mode 100644 examples/react/components/view/index.less create mode 100644 examples/react/components/view/index.tsx create mode 100644 examples/react/config.ts create mode 100644 examples/react/context.ts create mode 100644 examples/react/editor.css create mode 100644 examples/react/editor.tsx create mode 100644 examples/react/hooks/index.ts create mode 100644 examples/react/hooks/use-dispatch.ts create mode 100644 examples/react/hooks/use-selector.ts create mode 100644 examples/react/models/comment.ts create mode 100644 examples/react/models/doc.ts create mode 100644 examples/react/models/index.ts create mode 100644 examples/react/services/comment.ts create mode 100644 examples/react/services/doc.ts create mode 100644 examples/react/store.ts create mode 100644 examples/react/styles/variables.less create mode 100644 examples/react/types.ts create mode 100644 examples/react/view.tsx create mode 100644 examples/vue/.browserslistrc create mode 100644 examples/vue/.gitignore create mode 100644 examples/vue/babel.config.js create mode 100644 examples/vue/package.json create mode 100644 examples/vue/public/favicon.ico create mode 100644 examples/vue/public/index.html create mode 100644 examples/vue/src/App.vue create mode 100644 examples/vue/src/assets/logo.png create mode 100644 examples/vue/src/components/config.ts create mode 100644 examples/vue/src/components/demo.vue create mode 100644 examples/vue/src/components/loading.vue create mode 100644 examples/vue/src/components/mention.vue create mode 100644 examples/vue/src/components/ot-client.ts create mode 100644 examples/vue/src/main.ts create mode 100644 examples/vue/src/router/index.ts create mode 100644 examples/vue/src/shims-vue.d.ts create mode 100644 examples/vue/src/views/About.vue create mode 100644 examples/vue/src/views/Home.vue create mode 100644 examples/vue/tsconfig.json create mode 100644 examples/vue/tslint.json create mode 100644 examples/vue/vue.config.js create mode 100644 examples/vue/yarn.lock create mode 100644 lerna.json create mode 100644 locale/en-HK.json create mode 100644 locale/en-US.json create mode 100644 locale/ja-JP.json create mode 100644 locale/zh-HK.json create mode 100644 locale/zh-TW.json create mode 100644 locale/zh-cn.json create mode 100644 ot-server/.gitignore create mode 100644 ot-server/README.md create mode 100644 ot-server/config/dev.json create mode 100644 ot-server/nodemon.json create mode 100755 ot-server/package.json create mode 100644 ot-server/src/client.js create mode 100644 ot-server/src/doc.js create mode 100644 ot-server/src/index.js create mode 100644 package.json create mode 100644 packages/engine/README.md create mode 100644 packages/engine/package.json create mode 100644 packages/engine/src/@types/ot-json0.d.ts create mode 100644 packages/engine/src/block/index.ts create mode 100644 packages/engine/src/block/typing/backspace.ts create mode 100644 packages/engine/src/block/typing/enter.ts create mode 100644 packages/engine/src/block/typing/index.ts create mode 100644 packages/engine/src/card/entry.ts create mode 100644 packages/engine/src/card/enum.ts create mode 100644 packages/engine/src/card/index.css create mode 100644 packages/engine/src/card/index.ts create mode 100644 packages/engine/src/card/maximize/index.css create mode 100644 packages/engine/src/card/maximize/index.ts create mode 100644 packages/engine/src/card/resize/index.css create mode 100644 packages/engine/src/card/resize/index.ts create mode 100644 packages/engine/src/card/toolbar/index.css create mode 100644 packages/engine/src/card/toolbar/index.ts create mode 100644 packages/engine/src/card/typing/backspace.ts create mode 100644 packages/engine/src/card/typing/default.ts create mode 100644 packages/engine/src/card/typing/down.ts create mode 100644 packages/engine/src/card/typing/enter.ts create mode 100644 packages/engine/src/card/typing/index.ts create mode 100644 packages/engine/src/card/typing/left.ts create mode 100644 packages/engine/src/card/typing/right.ts create mode 100644 packages/engine/src/card/typing/up.ts create mode 100644 packages/engine/src/change/dragover/index.css create mode 100644 packages/engine/src/change/dragover/index.ts create mode 100644 packages/engine/src/change/event.ts create mode 100644 packages/engine/src/change/index.ts create mode 100644 packages/engine/src/change/native-event.ts create mode 100644 packages/engine/src/change/paste.ts create mode 100644 packages/engine/src/change/range.ts create mode 100644 packages/engine/src/clipboard.ts create mode 100644 packages/engine/src/command.ts create mode 100644 packages/engine/src/constants/card.ts create mode 100644 packages/engine/src/constants/conversion.ts create mode 100644 packages/engine/src/constants/index.ts create mode 100644 packages/engine/src/constants/ot.ts create mode 100644 packages/engine/src/constants/root.ts create mode 100644 packages/engine/src/constants/schema.ts create mode 100644 packages/engine/src/constants/selection.ts create mode 100644 packages/engine/src/engine/container.ts create mode 100644 packages/engine/src/engine/index.css create mode 100644 packages/engine/src/engine/index.ts create mode 100644 packages/engine/src/history.ts create mode 100644 packages/engine/src/hotkey.ts create mode 100644 packages/engine/src/index.ts create mode 100644 packages/engine/src/inline/index.ts create mode 100644 packages/engine/src/inline/typing/backspace.ts create mode 100644 packages/engine/src/inline/typing/index.ts create mode 100644 packages/engine/src/inline/typing/left.ts create mode 100644 packages/engine/src/inline/typing/right.ts create mode 100644 packages/engine/src/language.ts create mode 100644 packages/engine/src/list/index.ts create mode 100644 packages/engine/src/list/typing/backspace.ts create mode 100644 packages/engine/src/list/typing/enter.ts create mode 100644 packages/engine/src/list/typing/index.ts create mode 100644 packages/engine/src/locales/en-US.ts create mode 100644 packages/engine/src/locales/index.ts create mode 100644 packages/engine/src/locales/zh-cn.ts create mode 100644 packages/engine/src/mark/index.ts create mode 100644 packages/engine/src/mark/typing/backspace.ts create mode 100644 packages/engine/src/mark/typing/index.ts create mode 100644 packages/engine/src/node/entry.ts create mode 100644 packages/engine/src/node/event.ts create mode 100644 packages/engine/src/node/hash.ts create mode 100644 packages/engine/src/node/id.ts create mode 100644 packages/engine/src/node/index.ts create mode 100644 packages/engine/src/node/parse.ts create mode 100644 packages/engine/src/node/query.ts create mode 100644 packages/engine/src/node/utils.ts create mode 100644 packages/engine/src/ot/consumer.ts create mode 100644 packages/engine/src/ot/doc.ts create mode 100644 packages/engine/src/ot/index.css create mode 100644 packages/engine/src/ot/index.ts create mode 100644 packages/engine/src/ot/mutation.ts create mode 100644 packages/engine/src/ot/producer.ts create mode 100644 packages/engine/src/ot/range-coloring.ts create mode 100644 packages/engine/src/ot/selection.ts create mode 100644 packages/engine/src/ot/utils.ts create mode 100644 packages/engine/src/parser/conversion.ts create mode 100644 packages/engine/src/parser/index.ts create mode 100644 packages/engine/src/parser/text.ts create mode 100644 packages/engine/src/plugin/base.ts create mode 100644 packages/engine/src/plugin/block.ts create mode 100644 packages/engine/src/plugin/element.ts create mode 100644 packages/engine/src/plugin/index.ts create mode 100644 packages/engine/src/plugin/inline.ts create mode 100644 packages/engine/src/plugin/list/index.css create mode 100644 packages/engine/src/plugin/list/index.ts create mode 100644 packages/engine/src/plugin/mark.ts create mode 100644 packages/engine/src/position/index.ts create mode 100644 packages/engine/src/position/placements.ts create mode 100644 packages/engine/src/range.ts create mode 100644 packages/engine/src/request/ajax/constants.ts create mode 100644 packages/engine/src/request/ajax/index.ts create mode 100644 packages/engine/src/request/ajax/setup.ts create mode 100644 packages/engine/src/request/ajax/utils.ts create mode 100644 packages/engine/src/request/index.ts create mode 100644 packages/engine/src/request/uploader/index.ts create mode 100644 packages/engine/src/request/uploader/mime.ts create mode 100644 packages/engine/src/request/uploader/utils.ts create mode 100644 packages/engine/src/schema.ts create mode 100644 packages/engine/src/scrollbar/index.css create mode 100644 packages/engine/src/scrollbar/index.ts create mode 100644 packages/engine/src/selection.ts create mode 100644 packages/engine/src/toolbar/button.ts create mode 100644 packages/engine/src/toolbar/dropdown/button.ts create mode 100644 packages/engine/src/toolbar/dropdown/index.ts create mode 100644 packages/engine/src/toolbar/dropdown/switch.ts create mode 100644 packages/engine/src/toolbar/index.css create mode 100644 packages/engine/src/toolbar/index.ts create mode 100644 packages/engine/src/toolbar/input.ts create mode 100644 packages/engine/src/toolbar/tooltip/index.css create mode 100644 packages/engine/src/toolbar/tooltip/index.ts create mode 100644 packages/engine/src/types/block.ts create mode 100644 packages/engine/src/types/card.ts create mode 100644 packages/engine/src/types/change.ts create mode 100644 packages/engine/src/types/clipboard.ts create mode 100644 packages/engine/src/types/command.ts create mode 100644 packages/engine/src/types/conversion.ts create mode 100644 packages/engine/src/types/engine.ts create mode 100644 packages/engine/src/types/history.ts create mode 100644 packages/engine/src/types/hotkey.ts create mode 100644 packages/engine/src/types/index.ts create mode 100644 packages/engine/src/types/inline.ts create mode 100644 packages/engine/src/types/language.ts create mode 100644 packages/engine/src/types/list.ts create mode 100644 packages/engine/src/types/mark.ts create mode 100644 packages/engine/src/types/node.ts create mode 100644 packages/engine/src/types/ot.ts create mode 100644 packages/engine/src/types/parser.ts create mode 100644 packages/engine/src/types/plugin.ts create mode 100644 packages/engine/src/types/range.ts create mode 100644 packages/engine/src/types/request.ts create mode 100644 packages/engine/src/types/schema.ts create mode 100644 packages/engine/src/types/selection.ts create mode 100644 packages/engine/src/types/tiny-canvas.ts create mode 100644 packages/engine/src/types/toolbar.ts create mode 100644 packages/engine/src/types/typing.ts create mode 100644 packages/engine/src/types/view.ts create mode 100644 packages/engine/src/typing/index.ts create mode 100644 packages/engine/src/typing/keydown/all.ts create mode 100644 packages/engine/src/typing/keydown/at.ts create mode 100644 packages/engine/src/typing/keydown/backspace.ts create mode 100644 packages/engine/src/typing/keydown/default.ts create mode 100644 packages/engine/src/typing/keydown/delete.ts create mode 100644 packages/engine/src/typing/keydown/down.ts create mode 100644 packages/engine/src/typing/keydown/enter.ts create mode 100644 packages/engine/src/typing/keydown/index.ts create mode 100644 packages/engine/src/typing/keydown/left.ts create mode 100644 packages/engine/src/typing/keydown/right.ts create mode 100644 packages/engine/src/typing/keydown/shift-enter.ts create mode 100644 packages/engine/src/typing/keydown/shift-tab.ts create mode 100644 packages/engine/src/typing/keydown/slash.ts create mode 100644 packages/engine/src/typing/keydown/space.ts create mode 100644 packages/engine/src/typing/keydown/tab.ts create mode 100644 packages/engine/src/typing/keydown/up.ts create mode 100644 packages/engine/src/typing/keyup/backspace.ts create mode 100644 packages/engine/src/typing/keyup/default.ts create mode 100644 packages/engine/src/typing/keyup/enter.ts create mode 100644 packages/engine/src/typing/keyup/index.ts create mode 100644 packages/engine/src/typing/keyup/space.ts create mode 100644 packages/engine/src/typing/keyup/tab.ts create mode 100644 packages/engine/src/utils/index.ts create mode 100644 packages/engine/src/utils/list.ts create mode 100644 packages/engine/src/utils/node.ts create mode 100644 packages/engine/src/utils/string.ts create mode 100644 packages/engine/src/utils/tiny-canvas.ts create mode 100644 packages/engine/src/utils/user-agent.ts create mode 100644 packages/engine/src/view.ts create mode 100644 packages/engine/tsconfig.json create mode 100644 packages/toolbar-vue/.browserslistrc create mode 100644 packages/toolbar-vue/.fatherrc.ts create mode 100644 packages/toolbar-vue/.gitignore create mode 100644 packages/toolbar-vue/package.json create mode 100644 packages/toolbar-vue/src/components/button.vue create mode 100644 packages/toolbar-vue/src/components/collapse/collapse.vue create mode 100644 packages/toolbar-vue/src/components/collapse/group.vue create mode 100644 packages/toolbar-vue/src/components/collapse/item.vue create mode 100644 packages/toolbar-vue/src/components/color/color.vue create mode 100644 packages/toolbar-vue/src/components/color/picker/group.vue create mode 100644 packages/toolbar-vue/src/components/color/picker/item.vue create mode 100644 packages/toolbar-vue/src/components/color/picker/palette.ts create mode 100644 packages/toolbar-vue/src/components/color/picker/picker.vue create mode 100644 packages/toolbar-vue/src/components/dropdown-list.vue create mode 100644 packages/toolbar-vue/src/components/dropdown.vue create mode 100644 packages/toolbar-vue/src/components/group.vue create mode 100644 packages/toolbar-vue/src/components/table.vue create mode 100644 packages/toolbar-vue/src/components/toolbar.vue create mode 100644 packages/toolbar-vue/src/config/fontfamily.ts create mode 100644 packages/toolbar-vue/src/config/index.css create mode 100644 packages/toolbar-vue/src/config/index.ts create mode 100644 packages/toolbar-vue/src/hooks/index.ts create mode 100644 packages/toolbar-vue/src/hooks/useRight.ts create mode 100644 packages/toolbar-vue/src/index.ts create mode 100644 packages/toolbar-vue/src/locales/en-US.ts create mode 100644 packages/toolbar-vue/src/locales/index.ts create mode 100644 packages/toolbar-vue/src/locales/zh-cn.ts create mode 100644 packages/toolbar-vue/src/plugin/component/collapse.ts create mode 100644 packages/toolbar-vue/src/plugin/component/index.css create mode 100644 packages/toolbar-vue/src/plugin/component/index.ts create mode 100644 packages/toolbar-vue/src/plugin/index.ts create mode 100644 packages/toolbar-vue/src/shims-vue.d.ts create mode 100644 packages/toolbar-vue/src/types.ts create mode 100644 packages/toolbar-vue/src/utils.ts create mode 100644 packages/toolbar-vue/tsconfig.json create mode 100644 packages/toolbar-vue/tslint.json create mode 100644 packages/toolbar/package.json create mode 100644 packages/toolbar/src/button/index.css create mode 100644 packages/toolbar/src/button/index.tsx create mode 100644 packages/toolbar/src/collapse/group.tsx create mode 100644 packages/toolbar/src/collapse/index.css create mode 100644 packages/toolbar/src/collapse/index.tsx create mode 100644 packages/toolbar/src/collapse/item.tsx create mode 100644 packages/toolbar/src/color/index.css create mode 100644 packages/toolbar/src/color/index.tsx create mode 100644 packages/toolbar/src/color/picker/group.tsx create mode 100644 packages/toolbar/src/color/picker/index.css create mode 100644 packages/toolbar/src/color/picker/index.tsx create mode 100644 packages/toolbar/src/color/picker/item.tsx create mode 100644 packages/toolbar/src/color/picker/palette.ts create mode 100644 packages/toolbar/src/config/toolbar/fontfamily.tsx create mode 100644 packages/toolbar/src/config/toolbar/index.css create mode 100644 packages/toolbar/src/config/toolbar/index.tsx create mode 100644 packages/toolbar/src/dropdown/index.css create mode 100644 packages/toolbar/src/dropdown/index.tsx create mode 100644 packages/toolbar/src/dropdown/list.tsx create mode 100644 packages/toolbar/src/group/index.css create mode 100644 packages/toolbar/src/group/index.tsx create mode 100644 packages/toolbar/src/hooks/index.ts create mode 100644 packages/toolbar/src/hooks/useRight.ts create mode 100644 packages/toolbar/src/index.css create mode 100644 packages/toolbar/src/index.tsx create mode 100644 packages/toolbar/src/locales/en-US.ts create mode 100644 packages/toolbar/src/locales/index.ts create mode 100644 packages/toolbar/src/locales/zh-cn.ts create mode 100644 packages/toolbar/src/plugin/component/collapse.tsx create mode 100644 packages/toolbar/src/plugin/component/index.css create mode 100644 packages/toolbar/src/plugin/component/index.ts create mode 100644 packages/toolbar/src/plugin/index.ts create mode 100644 packages/toolbar/src/table/index.css create mode 100644 packages/toolbar/src/table/index.tsx create mode 100644 packages/toolbar/src/types.ts create mode 100644 packages/toolbar/src/utils.ts create mode 100644 packages/toolbar/tsconfig.json create mode 100644 plugins/alignment/README.md create mode 100644 plugins/alignment/package.json create mode 100644 plugins/alignment/src/index.ts create mode 100644 plugins/alignment/tsconfig.json create mode 100644 plugins/backcolor/README.md create mode 100644 plugins/backcolor/package.json create mode 100644 plugins/backcolor/src/index.ts create mode 100644 plugins/backcolor/tsconfig.json create mode 100644 plugins/bold/README.md create mode 100644 plugins/bold/package.json create mode 100644 plugins/bold/src/index.ts create mode 100644 plugins/bold/tsconfig.json create mode 100644 plugins/code/README.md create mode 100644 plugins/code/package.json create mode 100644 plugins/code/src/index.css create mode 100644 plugins/code/src/index.ts create mode 100644 plugins/code/tsconfig.json create mode 100644 plugins/codeblock-vue/.browserslistrc create mode 100644 plugins/codeblock-vue/.fatherrc.ts create mode 100644 plugins/codeblock-vue/.gitignore create mode 100644 plugins/codeblock-vue/README.md create mode 100644 plugins/codeblock-vue/package.json create mode 100644 plugins/codeblock-vue/src/component/editor.ts create mode 100644 plugins/codeblock-vue/src/component/index.css create mode 100644 plugins/codeblock-vue/src/component/index.ts create mode 100644 plugins/codeblock-vue/src/component/lang.ts create mode 100644 plugins/codeblock-vue/src/component/mode.ts create mode 100644 plugins/codeblock-vue/src/component/select/component.vue create mode 100644 plugins/codeblock-vue/src/component/select/index.ts create mode 100644 plugins/codeblock-vue/src/component/types.ts create mode 100644 plugins/codeblock-vue/src/index.ts create mode 100644 plugins/codeblock-vue/src/shims-vue.d.ts create mode 100644 plugins/codeblock-vue/tsconfig.json create mode 100644 plugins/codeblock-vue/tslint.json create mode 100644 plugins/codeblock/README.md create mode 100644 plugins/codeblock/package.json create mode 100644 plugins/codeblock/src/component/editor.ts create mode 100644 plugins/codeblock/src/component/index.css create mode 100644 plugins/codeblock/src/component/index.ts create mode 100644 plugins/codeblock/src/component/lang.ts create mode 100644 plugins/codeblock/src/component/mode.ts create mode 100644 plugins/codeblock/src/component/select.tsx create mode 100644 plugins/codeblock/src/component/types.ts create mode 100644 plugins/codeblock/src/index.ts create mode 100644 plugins/codeblock/tsconfig.json create mode 100644 plugins/file/README.md create mode 100644 plugins/file/package.json create mode 100644 plugins/file/src/component/index.css create mode 100644 plugins/file/src/component/index.ts create mode 100644 plugins/file/src/index.ts create mode 100644 plugins/file/src/locales/en-US.ts create mode 100644 plugins/file/src/locales/index.ts create mode 100644 plugins/file/src/locales/zh-cn.ts create mode 100644 plugins/file/src/uploader.ts create mode 100644 plugins/file/tsconfig.json create mode 100644 plugins/fontcolor/README.md create mode 100644 plugins/fontcolor/package.json create mode 100644 plugins/fontcolor/src/index.ts create mode 100644 plugins/fontcolor/tsconfig.json create mode 100644 plugins/fontfamily/README.md create mode 100644 plugins/fontfamily/package.json create mode 100644 plugins/fontfamily/src/index.ts create mode 100644 plugins/fontfamily/tsconfig.json create mode 100644 plugins/fontsize/README.md create mode 100644 plugins/fontsize/package.json create mode 100644 plugins/fontsize/src/index.ts create mode 100644 plugins/fontsize/tsconfig.json create mode 100644 plugins/heading/README.md create mode 100644 plugins/heading/package.json create mode 100644 plugins/heading/src/index.css create mode 100644 plugins/heading/src/index.ts create mode 100644 plugins/heading/src/outline.ts create mode 100644 plugins/heading/tsconfig.json create mode 100644 plugins/hr/README.md create mode 100644 plugins/hr/package.json create mode 100644 plugins/hr/src/component.ts create mode 100644 plugins/hr/src/index.css create mode 100644 plugins/hr/src/index.ts create mode 100644 plugins/hr/tsconfig.json create mode 100644 plugins/image/README.md create mode 100644 plugins/image/package.json create mode 100644 plugins/image/src/component/image/index.css create mode 100644 plugins/image/src/component/image/index.ts create mode 100644 plugins/image/src/component/index.ts create mode 100644 plugins/image/src/component/pswp/index.css create mode 100644 plugins/image/src/component/pswp/index.ts create mode 100644 plugins/image/src/component/pswp/zoom.ts create mode 100644 plugins/image/src/component/resizer/index.css create mode 100644 plugins/image/src/component/resizer/index.ts create mode 100644 plugins/image/src/index.ts create mode 100644 plugins/image/src/locales/en-US.ts create mode 100644 plugins/image/src/locales/index.ts create mode 100644 plugins/image/src/locales/zh-cn.ts create mode 100644 plugins/image/src/types.ts create mode 100644 plugins/image/src/uploader.ts create mode 100644 plugins/image/tsconfig.json create mode 100644 plugins/indent/README.md create mode 100644 plugins/indent/package.json create mode 100644 plugins/indent/src/index.ts create mode 100644 plugins/indent/tsconfig.json create mode 100644 plugins/italic/README.md create mode 100644 plugins/italic/package.json create mode 100644 plugins/italic/src/index.ts create mode 100644 plugins/italic/tsconfig.json create mode 100644 plugins/line-height/README.md create mode 100644 plugins/line-height/package.json create mode 100644 plugins/line-height/src/index.ts create mode 100644 plugins/line-height/tsconfig.json create mode 100644 plugins/link-vue/.browserslistrc create mode 100644 plugins/link-vue/.fatherrc.ts create mode 100644 plugins/link-vue/README.md create mode 100644 plugins/link-vue/package.json create mode 100644 plugins/link-vue/src/index.css create mode 100644 plugins/link-vue/src/index.ts create mode 100644 plugins/link-vue/src/locales/en-US.ts create mode 100644 plugins/link-vue/src/locales/index.ts create mode 100644 plugins/link-vue/src/locales/zh-cn.ts create mode 100644 plugins/link-vue/src/shims-vue.d.ts create mode 100644 plugins/link-vue/src/toolbar/editor.vue create mode 100644 plugins/link-vue/src/toolbar/index.ts create mode 100644 plugins/link-vue/src/toolbar/preview.vue create mode 100644 plugins/link-vue/tsconfig.json create mode 100644 plugins/link-vue/tslint.json create mode 100644 plugins/link/README.md create mode 100644 plugins/link/package.json create mode 100644 plugins/link/src/index.css create mode 100644 plugins/link/src/index.ts create mode 100644 plugins/link/src/locales/en-US.ts create mode 100644 plugins/link/src/locales/index.ts create mode 100644 plugins/link/src/locales/zh-cn.ts create mode 100644 plugins/link/src/toolbar/editor.tsx create mode 100644 plugins/link/src/toolbar/index.tsx create mode 100644 plugins/link/src/toolbar/preview.tsx create mode 100644 plugins/link/tsconfig.json create mode 100644 plugins/mark-range/README.md create mode 100644 plugins/mark-range/package.json create mode 100644 plugins/mark-range/src/index.ts create mode 100644 plugins/mark-range/tsconfig.json create mode 100644 plugins/mark/README.md create mode 100644 plugins/mark/package.json create mode 100644 plugins/mark/src/index.css create mode 100644 plugins/mark/src/index.ts create mode 100644 plugins/mark/tsconfig.json create mode 100644 plugins/math/README.md create mode 100644 plugins/math/package.json create mode 100644 plugins/math/src/component/editor.ts create mode 100644 plugins/math/src/component/index.css create mode 100644 plugins/math/src/component/index.ts create mode 100644 plugins/math/src/index.ts create mode 100644 plugins/math/src/locales/en-US.ts create mode 100644 plugins/math/src/locales/index.ts create mode 100644 plugins/math/src/locales/zh-cn.ts create mode 100644 plugins/math/src/utils.ts create mode 100644 plugins/math/tsconfig.json create mode 100644 plugins/mention/README.md create mode 100644 plugins/mention/package.json create mode 100644 plugins/mention/src/component/collapse.ts create mode 100644 plugins/mention/src/component/index.css create mode 100644 plugins/mention/src/component/index.ts create mode 100644 plugins/mention/src/index.ts create mode 100644 plugins/mention/src/locales/en-US.ts create mode 100644 plugins/mention/src/locales/index.ts create mode 100644 plugins/mention/src/locales/zh-cn.ts create mode 100644 plugins/mention/src/types.ts create mode 100644 plugins/mention/tsconfig.json create mode 100644 plugins/mind/README.md create mode 100644 plugins/mind/package.json create mode 100644 plugins/mind/src/@types/hierarchy.d.ts create mode 100644 plugins/mind/src/component/editor/hot-areas.ts create mode 100644 plugins/mind/src/component/editor/html-node.ts create mode 100644 plugins/mind/src/component/editor/index.css create mode 100644 plugins/mind/src/component/editor/index.ts create mode 100644 plugins/mind/src/component/editor/shape/edges/base.ts create mode 100644 plugins/mind/src/component/editor/utils.ts create mode 100644 plugins/mind/src/component/index.ts create mode 100644 plugins/mind/src/index.ts create mode 100644 plugins/mind/src/types.ts create mode 100644 plugins/mind/tsconfig.json create mode 100644 plugins/orderedlist/README.md create mode 100644 plugins/orderedlist/package.json create mode 100644 plugins/orderedlist/src/index.ts create mode 100644 plugins/orderedlist/tsconfig.json create mode 100644 plugins/paintformat/README.md create mode 100644 plugins/paintformat/package.json create mode 100644 plugins/paintformat/src/index.css create mode 100644 plugins/paintformat/src/index.ts create mode 100644 plugins/paintformat/tsconfig.json create mode 100644 plugins/quote/README.md create mode 100644 plugins/quote/package.json create mode 100644 plugins/quote/src/index.css create mode 100644 plugins/quote/src/index.ts create mode 100644 plugins/quote/tsconfig.json create mode 100644 plugins/redo/README.md create mode 100644 plugins/redo/package.json create mode 100644 plugins/redo/src/index.ts create mode 100644 plugins/redo/tsconfig.json create mode 100644 plugins/removeformat/README.md create mode 100644 plugins/removeformat/package.json create mode 100644 plugins/removeformat/src/index.ts create mode 100644 plugins/removeformat/tsconfig.json create mode 100644 plugins/selectall/README.md create mode 100644 plugins/selectall/package.json create mode 100644 plugins/selectall/src/index.ts create mode 100644 plugins/selectall/tsconfig.json create mode 100644 plugins/status/README.md create mode 100644 plugins/status/package.json create mode 100644 plugins/status/src/components/editor.ts create mode 100644 plugins/status/src/components/index.css create mode 100644 plugins/status/src/components/index.ts create mode 100644 plugins/status/src/index.ts create mode 100644 plugins/status/src/locales/en-US.ts create mode 100644 plugins/status/src/locales/index.ts create mode 100644 plugins/status/src/locales/zh-cn.ts create mode 100644 plugins/status/tsconfig.json create mode 100644 plugins/strikethrough/README.md create mode 100644 plugins/strikethrough/package.json create mode 100644 plugins/strikethrough/src/index.ts create mode 100644 plugins/strikethrough/tsconfig.json create mode 100644 plugins/sub/README.md create mode 100644 plugins/sub/package.json create mode 100644 plugins/sub/src/index.ts create mode 100644 plugins/sub/tsconfig.json create mode 100644 plugins/sup/README.md create mode 100644 plugins/sup/package.json create mode 100644 plugins/sup/src/index.ts create mode 100644 plugins/sup/tsconfig.json create mode 100644 plugins/table/README.md create mode 100644 plugins/table/package.json create mode 100644 plugins/table/src/component/command.ts create mode 100644 plugins/table/src/component/controllbar.ts create mode 100644 plugins/table/src/component/helper.ts create mode 100644 plugins/table/src/component/index.ts create mode 100644 plugins/table/src/component/menu.ts create mode 100644 plugins/table/src/component/selection.ts create mode 100644 plugins/table/src/component/template.ts create mode 100644 plugins/table/src/component/toolbar/color.ts create mode 100644 plugins/table/src/component/toolbar/index.css create mode 100644 plugins/table/src/component/toolbar/index.ts create mode 100644 plugins/table/src/component/toolbar/palette.ts create mode 100644 plugins/table/src/index.css create mode 100644 plugins/table/src/index.ts create mode 100644 plugins/table/src/locale/en-US.ts create mode 100644 plugins/table/src/locale/index.ts create mode 100644 plugins/table/src/locale/zh-cn.ts create mode 100644 plugins/table/src/types.ts create mode 100644 plugins/table/tsconfig.json create mode 100644 plugins/tasklist/README.md create mode 100644 plugins/tasklist/package.json create mode 100644 plugins/tasklist/src/checkbox/index.css create mode 100644 plugins/tasklist/src/checkbox/index.ts create mode 100644 plugins/tasklist/src/index.css create mode 100644 plugins/tasklist/src/index.ts create mode 100644 plugins/tasklist/tsconfig.json create mode 100644 plugins/underline/README.md create mode 100644 plugins/underline/package.json create mode 100644 plugins/underline/src/index.ts create mode 100644 plugins/underline/tsconfig.json create mode 100644 plugins/undo/README.md create mode 100644 plugins/undo/package.json create mode 100644 plugins/undo/src/index.ts create mode 100644 plugins/undo/tsconfig.json create mode 100644 plugins/unorderedlist/README.md create mode 100644 plugins/unorderedlist/package.json create mode 100644 plugins/unorderedlist/src/index.ts create mode 100644 plugins/unorderedlist/tsconfig.json create mode 100644 plugins/video/README.md create mode 100644 plugins/video/package.json create mode 100644 plugins/video/src/component/index.css create mode 100644 plugins/video/src/component/index.ts create mode 100644 plugins/video/src/index.ts create mode 100644 plugins/video/src/locales/en-US.ts create mode 100644 plugins/video/src/locales/index.ts create mode 100644 plugins/video/src/locales/zh-cn.ts create mode 100644 plugins/video/src/uploader.ts create mode 100644 plugins/video/tsconfig.json create mode 100644 scripts/build.js create mode 100644 scripts/rollup-build.js create mode 100644 scripts/rollup.js create mode 100644 site-ssr/.env create mode 100644 site-ssr/.gitignore create mode 100644 site-ssr/README.md create mode 100644 site-ssr/app/controller/comment.js create mode 100644 site-ssr/app/controller/doc.js create mode 100755 site-ssr/app/controller/home.js create mode 100644 site-ssr/app/controller/upload.js create mode 100644 site-ssr/app/controller/user.js create mode 100644 site-ssr/app/data/comment.json create mode 100644 site-ssr/app/data/doc.json create mode 100644 site-ssr/app/data/user.json create mode 100644 site-ssr/app/extend/helper.js create mode 100755 site-ssr/app/router.js create mode 100644 site-ssr/app/view/dev.html create mode 100755 site-ssr/config/config.default.js create mode 100644 site-ssr/config/config.local.js create mode 100755 site-ssr/config/plugin.js create mode 100644 site-ssr/jsconfig.json create mode 100755 site-ssr/package.json create mode 100644 tsconfig.json create mode 100644 typings.d.ts diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..ec359ff7 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,14 @@ +# This config is equivalent to both the '.circleci/extended/orb-free.yml' and the base '.circleci/config.yml' +version: 2.1 + +orbs: + node: circleci/node@4.1 + +workflows: + sample: + jobs: + - node/test: + version: '15.1' + # This is the node version to use for the `cimg/node` tag + # Relevant tags can be found on the CircleCI Developer Hub + # https://circleci.com/developer/images/image/cimg/node diff --git a/.dumi/theme/components/LocaleSelect.less b/.dumi/theme/components/LocaleSelect.less new file mode 100644 index 00000000..ed37ae15 --- /dev/null +++ b/.dumi/theme/components/LocaleSelect.less @@ -0,0 +1,48 @@ +@import (reference) '../style/variables.less'; + +.@{prefix}-locale-select { + position: relative; + display: inline-block; + border: 1px solid #dadadf; + border-radius: 14px; + transition: background 0.2s; + + &:hover { + background-color: #fafafa; + } + + &:not([data-locale-count='1']):not([data-locale-count='2'])::after { + content: ''; + position: absolute; + top: 50%; + right: 10px; + margin-top: -3px; + width: 0; + height: 0; + border: 4px solid transparent; + border-top: 6px solid #7b7f8d; + pointer-events: none; + } + + a, + span, + select { + padding: 0 24px 0 16px; + height: 28px; + text-align: center; + text-decoration: none; + line-height: 28px; + appearance: none; + border: 0; + font-size: 16px; + color: #7b7f8d; + background: transparent; + outline: none; + cursor: pointer; + } + + a, + span { + padding-right: 16px; + } +} diff --git a/.dumi/theme/components/LocaleSelect.tsx b/.dumi/theme/components/LocaleSelect.tsx new file mode 100644 index 00000000..f66d6a13 --- /dev/null +++ b/.dumi/theme/components/LocaleSelect.tsx @@ -0,0 +1,67 @@ +import { FC } from 'react'; +import React, { useContext } from 'react'; +// @ts-ignore +import { history } from 'dumi'; +import { context, Link } from 'dumi/theme'; +import './LocaleSelect.less'; + +const LocaleSelect: FC<{ location: any }> = ({ location }) => { + const { + base, + locale, + config: { locales }, + } = useContext(context); + const firstDiffLocale = locales.find(({ name }) => name !== locale); + + function getLocaleTogglePath(target: string) { + const baseWithoutLocale = base.replace(`/${locale}`, ''); + const pathnameWithoutLocale = + location.pathname.replace(base, baseWithoutLocale) || '/'; + + // append locale prefix to path if it is not the default locale + if (target !== locales[0].name) { + // compatiable with integrate route prefix /~docs + const routePrefix = `${baseWithoutLocale}/${target}`.replace( + /\/\//, + '/', + ); + const pathnameWithoutBase = location.pathname.replace( + // to avoid stripped the first / + base.replace(/^\/$/, '//'), + '', + ); + + return `${routePrefix}${pathnameWithoutBase}`.replace(/\/$/, ''); + } + + return pathnameWithoutLocale; + } + + return firstDiffLocale ? ( +
+ {locales.length > 2 ? ( + + ) : ( + + {firstDiffLocale.label} + + )} +
+ ) : null; +}; + +export default LocaleSelect; diff --git a/.dumi/theme/components/NavRight.less b/.dumi/theme/components/NavRight.less new file mode 100644 index 00000000..372f0f5f --- /dev/null +++ b/.dumi/theme/components/NavRight.less @@ -0,0 +1,17 @@ +@import (reference) '../style/variables.less'; + +.@{prefix}-nav-right { + position: relative; + flex: 1 1; + justify-content: flex-end; + display: flex; + align-items: center; + + @media @mobile { + position: absolute; + display: block; + align-items: center; + top: 0; + right: 0; + } +} diff --git a/.dumi/theme/components/NavRight.tsx b/.dumi/theme/components/NavRight.tsx new file mode 100644 index 00000000..e9d12a7c --- /dev/null +++ b/.dumi/theme/components/NavRight.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import SearchBar from './SearchBar'; +import './NavRight.less'; + +export default () => { + return ( +
+ +
+ ); +}; diff --git a/.dumi/theme/components/Navbar.less b/.dumi/theme/components/Navbar.less new file mode 100644 index 00000000..6a32ee32 --- /dev/null +++ b/.dumi/theme/components/Navbar.less @@ -0,0 +1,173 @@ +@import (reference) '../style/variables.less'; + +.@{prefix}-navbar { + position: fixed; + z-index: 101; + top: 0; + left: 0; + right: 0; + display: none; + align-items: center; + padding: 0 14px; + height: @s-nav-height; + white-space: nowrap; + background: #fff; + box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.08); + z-index: 9999; + + @media @mobile { + display: flex; + justify-content: center; + height: @s-mobile-nav-height; + } + + &-toggle { + position: absolute; + top: 14px; + left: 16px; + display: none; + width: 22px; + height: 22px; + border: 0; + outline: none; + background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFgAAAA4CAYAAAB5YT9uAAAAAXNSR0IArs4c6QAAASVJREFUeAHt3DuOwjAUBVAHscWZjaSkAIqU2QjskV80QwpsF3ArdGiw3hW3OGnQM2Iof6/dfr7+n71/LjAdx+HRsvm8SkNPAHBPJ5ABDiD2KgD3dAIZ4ABirwJwTyeQAQ4g9ioA93QC2fbZsSm/z7MDAQIECBAgQIAAAQIECBAgQIAAAQKvAsvV8mO8O8yn19jkXYHpMC7byXVdeS0/75b5XFvAwr1tE0kARxjbJYDbNpEEcISxXQK4bRNJAEcY2yWA2zaRZP0ePJRzpFEJAQIECBAgQIAAAQIECBAgQIAAgW8VWK/tj7Nb5eBTnvbjsp1c15WX4ncRQeB7lb8zyHrW29xo1F1iU8AxynoR4LpLbAo4RlkvAlx3iU0BxyjrRYDrLrHpDVSAEEPXScHTAAAAAElFTkSuQmCC') + no-repeat center / contain; + + @media @mobile { + display: block; + } + } + + &-logo { + display: inline-block; + height: 40px; + color: #080e29; + font-weight: 500; + text-decoration: none; + font-size: 18px; + line-height: 40px; + + &:not([data-plaintext]) { + padding-left: 56px; + background: url(@img-logo) no-repeat 0 / contain; + } + + &:active, + &:hover { + color: #080e29; + } + + @media @mobile { + height: 28px; + line-height: 28px; + + &:not([data-plaintext]) { + padding-left: 36px; + } + } + } + + nav { + margin-left: 20px; + display: flex; + > span { + position: relative; + margin-left: 40px; + display: inline-block; + color: @c-text; + height: @s-nav-height; + cursor: pointer; + font-size: 14px; + line-height: @s-nav-height; + text-decoration: none; + letter-spacing: 0; + + > a { + color: #4d5164; + text-decoration: none; + + &:hover, + &.active { + color: @c-primary; + } + + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + right: -18px; + left: -18px; + } + + &.active::after { + content: ''; + position: absolute; + bottom: 0; + left: -2px; + right: -2px; + height: 2px; + background-color: @c-primary; + border-radius: 1px; + } + } + + + *:not(a) { + margin-left: 40px; + } + + // second nav + > ul { + list-style: none; + position: absolute; + top: 100%; + left: 50%; + margin: 0; + min-width: 100px; + padding: 8px 18px; + line-height: 2; + background-color: #fff; + box-shadow: 0 8px 24px -2px rgba(0, 0, 0, 0.08); + transform: translate(-50%); + transform-origin: top; + border-radius: 1px; + transition: all 0.2s; + + a { + position: relative; + display: block; + color: @c-text; + text-decoration: none; + + &:hover, + &.active { + color: @c-primary; + } + } + } + + &:not(:hover) > ul { + visibility: hidden; + pointer-events: none; + transform: translate(-50%) scaleY(0.9); + opacity: 0; + } + } + + .@{prefix}-search + .@{prefix}-locale-select { + margin-left: 40px; + } + + @media @mobile { + > a, + > span, + > div { + display: none; + } + } + } + + &[data-mode='site'] { + display: flex; + } +} diff --git a/.dumi/theme/components/Navbar.tsx b/.dumi/theme/components/Navbar.tsx new file mode 100644 index 00000000..63352d13 --- /dev/null +++ b/.dumi/theme/components/Navbar.tsx @@ -0,0 +1,104 @@ +import { FC, MouseEvent } from 'react'; +import React, { useContext } from 'react'; +import { context, Link, NavLink } from 'dumi/theme'; +import LocaleSelect from './LocaleSelect'; +import './Navbar.less'; + +interface INavbarProps { + location: any; + navPrefix?: React.ReactNode; + navSuffix?: React.ReactNode; + navLast?: React.ReactNode; + onMobileMenuClick: (ev: MouseEvent) => void; +} + +const Navbar: FC = ({ + onMobileMenuClick, + navPrefix, + navSuffix, + navLast, + location, +}) => { + const { + base, + config: { mode, title, logo }, + nav: navItems, + } = useContext(context); + + return ( +
+ {/* menu toogle button (only for mobile) */} +
+ ); +}; + +export default Navbar; diff --git a/.dumi/theme/components/SearchBar.less b/.dumi/theme/components/SearchBar.less new file mode 100644 index 00000000..c5f0ed03 --- /dev/null +++ b/.dumi/theme/components/SearchBar.less @@ -0,0 +1,107 @@ +@import (reference) '../style/variables.less'; + +.@{prefix}-search { + margin-left: 20px; + position: relative; + display: inline-block; + + &-input { + width: 200px; + height: 32px; + padding: 0 38px 0 14px; + color: @c-heading; + font-size: 14px; + border: 0; + outline: none; + transition: all 0.2s; + border-radius: 16px; + background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAABpRJREFUaAXNWWts01UUv+f+264DVEABqY8IjhhCCCGKRoMaE0HACKJxsI3WDwqd67qHEoiPT2qCEpJ1bB1vElpJZYnyMMEgfCCQEUFIDOGDihLiY7yUGAHb9XGP59+t3b3/de2/XYvtl/855557zvndc+6zwAr4tW0KPQQithQRZjEUVcCgChkbw4CFGcMwIPxJZk8zzk5qFkuP1137QwFuTHUBU1qk1N3dXdl7LVKPyGoZ4mNm+w3oneKMb7bbxu5xu1/6N8++WdVzAkBE7usKukCwj5Dh/Vmt5WgEgL8oS2ubG5w7iaakjfyXFUD7lsCDGMMvadQfHbmrQQsA7BjjfFVLg/PHQWlh1LAA2v2BuQLZF1QuE42mB0bvNGOwj3NxinGtN1Exulez3hJwizuYiDsSgs1myF6mDD5F/bnRBmXiBkO+uNXrPDqkLQ9BRgDtnYHlVC4BGnmrbItGLoYMtmoV/JOmlSt+l9uGozdtCkyMxNk7VDFNZM8u65G9CAeobvK4vpLl+dBDAGzsCj6dSOARWk1ssiEa9UNWgEaPx/mzLDdL+/2hB2IYa6OMvKr2gbjG+YtNnhXfqHJznAKgq2v31D6ROEllc4/aHda3NDrfJRBClefP+ToDH1CvDwnIoG+Aqzawz/J4qi/nazFdm7rBvkQ8ZAweOK9v9brWFiN4PbiWRtfHCFA3MI/646V5FsPwZxRDOh6zQLSU4riJVS4KvjHFJ7+cbWj1uNYpsiIwhw7uPbdg0VJ9GX1OMjf129NnL1Lb95IsJ5lMo9/fPSYqIj9R3U9O9aAROtzscS4o1sin7Ka+esZ9/uBeGrQlKRkFc94xqXJ6dXV1IiXL9U2mLMbCLjl4Wh7jjIO3VMHrQSVLSLO2UNB9qSApJdP+uNZXneLNfPtrDmGFrExL3o5ibDKyzUx061s1FwlKl9wGQjTJfC6a+/3BKkrnk7KijVvWy3wpaY1ZNySzMeCEsvD49u3d48365HEhFsrKtLmcbWiouyDLSkl7vTW9NIDfST74zb7wPInPSnLaWWeqGrBP5UvPAfD9shfasZ+X+Ww0zQFUASCcydahJG2Iik+a2FPM+tEn8cOyssawV+ZvB60xbvQ5waxfKiGW3sz6O1mNxszaKljPZuOXDJ2HnIAN7Wm2fxlNs4xZLBpl8H/+GU6t2aLhFK2y6/WJqCNbh1K0RaMifQLotw+mq0DPwC9KUGLwOKHIS8jQEU4BQHcRU3cNPSROJ8OzSmyAcxT+NjAJFMqVlV45fjPrllMKFAA0qdOHK7NGRqoHiItlGwh4Quaz0dxCNy1FAXGmfrFRZCVk6CR8L53snki5oGNFYpTVeiDF5/rygStij6wYFfE1Ml9KOoaR1frRWvJx3O2u1R/GTP2Syyhw2CVrk7036C3oEVlWClq/JxsvUfRatCcfX0kAVmYP0bFW2kzQggmxkUYm2Z6PwXx0YyLmozlXke4DcGHyJPvONG+CSAZIl+mbRKw26M/3dQbXGWRFY8n2e7RcviIb1ICvpdtYVJblouXaY20dgaN0uHtW7kTltarF49omy0ZK+/y7ljGEkKH2e1q9r8/N17ZSIjYOb5KB67IRFLjV1xFYV6xyokF6P0PwSZeBQGC07NsMrWRA76A/bImEOKzUJslJ8Wtuq/A2uZerO7cZL6STfGeNsjZj2cjd6TJ1bPydsMjlct2S5dnoIQB05TZ/sIY2lyCNunJSJQcxeu/cwu3ap2afFjs6Qo4ExN7WVxvjoGQKLF8QGQHohqlsXqD58Dk5HWt0pN9hSX6GAx5AAae4FS5bES9xbhHhBHMgE/eBwNmIYgnpzjHUutHcED4fEMMC0K22b949TcTj9HbDZgzxMjJBDzn+lQahZjgzZkEok9horLm+7vwo64zZ9ILcSNv9VWN73jyt8xrXXtNXm+ZGVx0d2oZd3SjFz1z/Bw/mmthZMyAHuGPH/jtuhP+uJ9kyMq6cHmW9DLSgMjrBAEOOCZXb5XVeL632zuAWmtgrM/RLinJlwjQA2UHHtsAUEYHFCGI6gZlC2ZlKZfYgGYvQvLlCpXGVRreXAj+CFdr+lpW1V+T+Mm0GBK2Bx+++iy3MtDoVBEAOoBj0SECUBQB9EMyCsHH7Iv3okxq4sgFQKIiyAlAIiLIDkC+IsgRgFgQtsQfKFkAuEBT8ZQ21+WUNYFgQtKNXcG2e/jdA2QMwgqAN8lylDea73U7pCqxrlfkvuU/4A2voyWecHOp/C76d7/ws9hcAAAAASUVORK5CYII=') + #f5f6f7 no-repeat right 14px center / 16px; + appearance: none; + } + + > ul { + list-style: none; + position: absolute; + top: 100%; + right: 0; + z-index: 10; + margin: 8px 0 0; + min-width: 280px; + max-width: 400px; + padding: 6px 0; + background-color: #fff; + border: 1px solid @c-border; + border-radius: 1px; + box-shadow: 0 2px 20px 0 rgba(0, 0, 0, 0.05); + box-sizing: border-box; + + &:empty { + display: none; + } + + li { + font-size: 15px; + + a { + display: block; + padding: 6px 20px; + color: @c-secondary; + text-decoration: none; + transition: background-color 0.3s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + color: @c-primary; + background-color: @c-light-bg; + } + } + + span:first-child { + position: relative; + display: inline-block; + max-width: 50%; + padding-right: 26px; + vertical-align: -0.37em; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.8; + + &::after { + content: '>'; + position: absolute; + top: 50%; + right: 6px; + opacity: 0.6; + transform: translateY(-54%); + } + } + } + } + + @media only screen and (max-width: 1024px) { + margin-right: -14px; + + > input:not(:focus) { + width: 32px; + padding-right: 0; + box-shadow: none; + cursor: pointer; + background-position: right 8px center; + + + ul { + transition: 0.1s visibility; + visibility: hidden; + } + } + } + + @media @mobile { + position: absolute; + top: 9px; + right: 24px; + display: block !important; + } +} diff --git a/.dumi/theme/components/SearchBar.tsx b/.dumi/theme/components/SearchBar.tsx new file mode 100644 index 00000000..028a4946 --- /dev/null +++ b/.dumi/theme/components/SearchBar.tsx @@ -0,0 +1,46 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useSearch, AnchorLink } from 'dumi/theme'; +import './SearchBar.less'; + +export default () => { + const [keywords, setKeywords] = useState(''); + const [items, setItems] = useState([]); + const input = useRef(); + const result = useSearch(keywords); + + useEffect(() => { + if (Array.isArray(result)) { + setItems(result); + } else if (typeof result === 'function') { + result(`.${input.current.className}`); + } + }, [result]); + + return ( +
+ setKeywords(ev.target.value), + } + : {})} + /> +
    + {items.map((meta) => ( +
  • setKeywords('')}> + + {meta.parent?.title && ( + {meta.parent.title} + )} + {meta.title} + +
  • + ))} +
+
+ ); +}; diff --git a/.dumi/theme/components/SideMenu.less b/.dumi/theme/components/SideMenu.less new file mode 100644 index 00000000..8444b56f --- /dev/null +++ b/.dumi/theme/components/SideMenu.less @@ -0,0 +1,334 @@ +@import (reference) '../style/variables.less'; + +.@{prefix}-menu { + position: fixed; + z-index: 100; + top: 0; + left: 0; + bottom: 0; + width: @s-menu-width; + background-color: #f2f5fa; + box-sizing: border-box; + transition: left 0.3s; + + &[data-hidden] { + display: none; + } + + @media @mobile { + left: -@s-menu-mobile-width; + top: @s-mobile-nav-height; + display: block !important; + width: @s-menu-mobile-width; + background-color: #fff; + + &[data-mobile-show] { + left: 0; + } + } + + // shadow + &::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + display: block; + width: 20px; + background: linear-gradient( + to right, + rgba(0, 0, 0, 0), + rgba(0, 0, 0, 0.03) + ); + pointer-events: none; + + // use border on mobile devices + @media @mobile { + width: 1px; + background: @c-border; + } + } + + &-header { + position: relative; + padding-top: 40px; + text-align: center; + border-bottom: 1px solid @c-border; + + @media @mobile { + display: none; + } + + .@{prefix}-menu-logo { + display: inline-block; + width: 66px; + height: 65px; + background: url(@img-logo) no-repeat 0 / contain; + } + + h1 { + margin: 10px 0 0; + color: @c-heading; + font-weight: 500; + line-height: 1.40625; + } + + p { + margin: 0 0 5px; + color: lighten(@c-secondary, 10%); + + // badges + > object[data^='https://img.shields.io'] + { + max-height: 20px; + } + + + p { + margin-bottom: 10px; + } + } + } + + &-doc-locale { + padding: 16px 0; + text-align: center; + border-bottom: 1px solid @c-border; + + &:empty { + display: none; + } + } + + &-inner { + width: 100%; + height: 100%; + overflow: auto; + overscroll-behavior: contain; + + // common list styles + ul { + list-style: none; + margin: 0; + padding: 0; + font-size: 16px; + + li { + color: @c-text; + a, + > span { + position: relative; + display: block; + padding-right: 24px; + color: @c-heading; + line-height: 2.4; + text-decoration: none; + outline: none; + transition: color 0.3s, background 0.3s; + + span { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + &:hover, + &.active { + color: @c-primary; + } + + &::before { + content: ''; + position: absolute; + top: 50%; + left: -10px; + margin-top: -2.5px; + display: inline-block; + width: 5px; + height: 5px; + background-color: @c-primary; + border-radius: 50%; + opacity: 0; + transition: transform 0.2s, opacity 0.2s; + transform: scale(0) translateX(-10px); + } + } + + &.active a, + a.active { + &::before { + opacity: 1; + transform: scale(1) translateX(0); + } + } + + // level larger, offset larger, font size smaller + ul { + font-size: 0.9em; + padding-left: 1em; + } + } + } + + // 1-level list styles + > ul { + > li > a { + line-height: 2.875; + + &:not([href]) { + padding-top: 24px; + line-height: 1; + font-weight: 500; + color: @c-heading !important; + background: transparent !important; + cursor: default; + } + } + + > li:first-child > a:not([href]) { + padding-top: 0; + } + } + + // n-level list styles + > ul ul { + a { + color: @c-secondary; + + &.active { + color: @c-primary; + } + } + } + + .@{prefix}-menu-mobile-area { + display: none; + padding-bottom: 16px; + margin-bottom: 16px; + text-align: center; + border-bottom: 1px solid @c-border; + + @media @mobile { + display: block; + } + } + + // mobile nav list + .@{prefix}-menu-nav-list { + padding: 16px 0; + + > li, + > li > a { + padding-right: 0; + line-height: 2.4; + + ul { + padding-left: 0; + + a { + padding-right: 0; + font-size: 90%; + } + } + } + } + + // menu list + .@{prefix}-menu-list { + padding: 8px 0; + margin-bottom: 40px; + + > li > a { + @c-active-bg: #e8ecf4; + + padding-left: 28px; + + &.active { + background: linear-gradient( + to left, + #e8ecf4, + rgba(232, 236, 244, 0) + ); + } + + ~ ul { + margin-top: 8px; + margin-left: 28px; + } + + @media @mobile { + padding-left: 16px; + + ~ ul { + margin-left: 16px; + } + } + } + } + } + + &[data-mode='site'] { + &::after { + width: 1px; + background: @c-border; + } + + .@{prefix}-menu-list { + padding: 0; + + > li > a { + position: relative; + + &::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + right: 0; + display: block; + width: 3px; + background-color: @c-primary; + visibility: hidden; + opacity: 0; + transition: all 0.3s; + border-radius: 1px; + } + + &.active { + z-index: 1; + background: linear-gradient( + to left, + #f8faff, + rgba(248, 250, 255, 0) + ); + + &::after { + opacity: 1; + visibility: visible; + } + } + } + } + + @media @desktop { + top: @s-nav-height; + width: @s-site-menu-width; + padding-top: 50px; + background: transparent; + + .@{prefix}-menu-nav, + .@{prefix}-menu-header { + display: none; + } + + .@{prefix}-menu-list > li > a { + padding-left: 58px; + + ~ ul { + margin-left: 58px; + } + } + } + } +} diff --git a/.dumi/theme/components/SideMenu.tsx b/.dumi/theme/components/SideMenu.tsx new file mode 100644 index 00000000..73c183d4 --- /dev/null +++ b/.dumi/theme/components/SideMenu.tsx @@ -0,0 +1,180 @@ +import { FC } from 'react'; +import React, { useContext } from 'react'; +import { context, Link, NavLink } from 'dumi/theme'; +import LocaleSelect from './LocaleSelect'; +import SlugList from './SlugList'; +import './SideMenu.less'; + +interface INavbarProps { + mobileMenuCollapsed: boolean; + location: any; +} + +const SideMenu: FC = ({ mobileMenuCollapsed, location }) => { + const { + config: { + logo, + title, + description, + mode, + repository: { url: repoUrl }, + }, + menu, + nav: navItems, + base, + meta, + } = useContext(context); + const isHiddenMenus = + Boolean( + (meta.hero || meta.features || meta.gapless) && mode === 'site', + ) || + meta.sidemenu === false || + undefined; + + return ( +
+
+
+ +

{title}

+

{description}

+ {/* github star badge */} + {/github\.com/.test(repoUrl) && mode === 'doc' && ( +

+ +

+ )} + + {/* mobile nav list */} + {navItems.length ? ( +
+
    + {navItems.map((nav) => { + const child = Boolean(nav.children?.length) && ( +
      + {nav.children.map((item) => ( +
    • + + {item.title} + +
    • + ))} +
    + ); + + return ( +
  • + {nav.path ? ( + + {nav.title} + + ) : ( + nav.title + )} + {child} +
  • + ); + })} +
+ {/* site mode locale select */} + +
+ ) : ( +
+ {/* doc mode locale select */} + +
+ )} + {/* menu list */} +
    + {!isHiddenMenus && + menu.map((item) => { + // always use meta from routes to reduce menu data size + const hasSlugs = Boolean(meta.slugs?.length); + const hasChildren = + item.children && Boolean(item.children.length); + const show1LevelSlugs = + meta.toc === 'menu' && + !hasChildren && + hasSlugs && + item.path === + location.pathname.replace( + /([^^])\/$/, + '$1', + ); + + return ( +
  • + + {item.title} + + {/* group children */} + {Boolean( + item.children && item.children.length, + ) && ( +
      + {item.children.map((child) => ( +
    • + + + {child.title} + + + {/* group children slugs */} + {Boolean( + meta.toc === 'menu' && + typeof window !== + 'undefined' && + child.path === + location.pathname && + hasSlugs, + ) && ( + + )} +
    • + ))} +
    + )} + {/* group slugs */} + {show1LevelSlugs && ( + + )} +
  • + ); + })} +
+ + + ); +}; + +export default SideMenu; diff --git a/.dumi/theme/components/SlugList.less b/.dumi/theme/components/SlugList.less new file mode 100644 index 00000000..d78ae0d0 --- /dev/null +++ b/.dumi/theme/components/SlugList.less @@ -0,0 +1,18 @@ +@import (reference) '../style/variables.less'; + +ul[role='slug-list'] { + &:empty { + margin: 0 !important; + padding: 0 !important; + } + + li { + > a.active { + color: darken(@c-primary, 2%); + } + + &[data-depth='3'] { + padding-left: 12px; + } + } +} diff --git a/.dumi/theme/components/SlugList.tsx b/.dumi/theme/components/SlugList.tsx new file mode 100644 index 00000000..10592156 --- /dev/null +++ b/.dumi/theme/components/SlugList.tsx @@ -0,0 +1,27 @@ +import { FC } from 'react'; +import React from 'react'; +import { AnchorLink } from 'dumi/theme'; +import './SlugList.less'; + +const SlugsList: FC<{ slugs: any; className?: string }> = ({ + slugs, + ...props +}) => ( +
    + {slugs + .filter(({ depth }) => depth > 1 && depth < 4) + .map((slug) => ( +
  • + + {slug.value} + +
  • + ))} +
+); + +export default SlugsList; diff --git a/.dumi/theme/layouts/index.tsx b/.dumi/theme/layouts/index.tsx new file mode 100644 index 00000000..47c87aec --- /dev/null +++ b/.dumi/theme/layouts/index.tsx @@ -0,0 +1,145 @@ +import React, { useContext, useState } from 'react'; +import { IRouteComponentProps } from '@umijs/types'; +import { context, Link } from 'dumi/theme'; +import Navbar from '../components/Navbar'; +import SideMenu from '../components/SideMenu'; +import SlugList from '../components/SlugList'; +import NavRight from '../components/NavRight'; +import './layout.less'; + +const Hero = (hero) => ( + <> +
+ {hero.image && } +

{hero.title}

+
+ {hero.actions && + hero.actions.map((action) => ( + + + + ))} +
+ +); + +const Features = (features) => ( +
+ {features.map((feat) => ( +
+ {feat.link ? ( + +
{feat.title}
+ + ) : ( +
{feat.title}
+ )} +
+
+ ))} +
+); + +const Layout: React.FC = ({ children, location }) => { + const { + config: { mode, repository }, + meta, + locale, + } = useContext(context); + const { url: repoUrl, branch, platform } = repository; + const [menuCollapsed, setMenuCollapsed] = useState(true); + const isSiteMode = mode === 'site'; + const showHero = isSiteMode && meta.hero; + const showFeatures = isSiteMode && meta.features; + const showSideMenu = + meta.sidemenu !== false && !showHero && !showFeatures && !meta.gapless; + const showSlugs = + !showHero && + !showFeatures && + Boolean(meta.slugs?.length) && + (meta.toc === 'content' || meta.toc === undefined) && + !meta.gapless; + const isCN = /^zh|cn$/i.test(locale); + const updatedTimeIns = new Date(meta.updatedTime); + const updatedTime: any = `${updatedTimeIns.toLocaleDateString([], { + hour12: false, + })} ${updatedTimeIns.toLocaleTimeString([], { hour12: false })}`; + const repoPlatform = + { github: 'GitHub', gitlab: 'GitLab' }[ + (repoUrl || '').match(/(github|gitlab)/)?.[1] || 'nothing' + ] || platform; + return ( +
{ + if (menuCollapsed) return; + setMenuCollapsed(true); + }} + > + } + onMobileMenuClick={(ev) => { + setMenuCollapsed((val) => !val); + ev.stopPropagation(); + }} + /> + + {showSlugs && ( + + )} + {showHero && Hero(meta.hero)} + {showFeatures && Features(meta.features)} +
+ {children} + {!showHero && + !showFeatures && + meta.filePath && + meta.showFooter !== false && + !meta.gapless && ( +
+ {repoPlatform && ( + + {isCN + ? `在 ${repoPlatform} 上编辑此页` + : `Edit this doc on ${repoPlatform}`} + + )} + + {updatedTime} + +
+ )} + {(showHero || showFeatures) && meta.footer && ( +
+ )} +
+
+ ); +}; + +export default Layout; diff --git a/.dumi/theme/layouts/layout.less b/.dumi/theme/layouts/layout.less new file mode 100644 index 00000000..70ee86de --- /dev/null +++ b/.dumi/theme/layouts/layout.less @@ -0,0 +1,327 @@ +@import '../style/markdown.less'; +@import '../style/variables.less'; + +@s-toc-width: 136px; + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, + Hiragino Sans GB, Microsoft YaHei, Helvetica Neue, Helvetica, Arial, + sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol; + font-variant: tabular-nums; + font-feature-settings: 'tnum'; +} + +.@{prefix}-layout { + box-sizing: border-box; + min-height: 100vh; + //padding: 16px (@s-content-margin + @s-toc-width) 50px @s-menu-width + + //@s-content-margin; + + @media @mobile { + padding-top: 66px !important; + padding-left: 16px !important; + padding-right: 16px !important; + } + + &[data-gapless='true'] { + padding-top: @s-nav-height !important; + padding-right: 0 !important; + padding-left: 0 !important; + padding-bottom: 0; + + @media @mobile { + padding-top: @s-mobile-nav-height !important; + } + } + + &[data-show-sidemenu='false'] { + padding-left: 0; + } + + &[data-show-slugs='false'] { + padding-right: 0; + } + + &[data-site-mode='true'] { + padding-top: 64px; + + &[data-show-sidemenu='true'] { + padding-top: @s-nav-height + 50px; + padding-left: @s-site-menu-width + 50px; + padding-right: 50px; + padding-bottom: 50px; + } + + &[data-show-slugs='true'] { + padding-right: @s-content-margin + @s-toc-width + 14; + } + + .@{prefix}-layout-content > .markdown:first-child > *:first-child { + margin-top: 0; + } + + .@{prefix}-layout-toc { + top: 114px; + max-height: calc(90vh - 144px); + } + } + + &-hero { + margin: -50px -58px 0; + padding: 100px 0; + text-align: center; + background-color: #f5f6f8; + + @media @mobile { + margin: -16px -16px 0; + padding: 48px 0; + } + + img { + max-width: 100%; + max-height: 200px; + margin-bottom: 1rem; + } + + h1 { + margin: 0 0 16px; + font-size: 48px; + font-weight: 600; + line-height: 56px; + color: #080e29; + + + div { + margin: 16px 0 32px; + opacity: 0.78; + + .markdown { + font-size: 16px; + } + } + } + + button { + margin-right: 16px; + padding: 0 32px; + height: 44px; + color: @c-primary; + font-size: 16px; + background: transparent; + border: 1px solid @c-primary; + border-radius: 22px; + box-sizing: border-box; + cursor: pointer; + outline: none; + transition: all 0.3s; + + &:hover { + opacity: 0.8; + } + + &:active { + opacity: 0.9; + } + } + + a:last-child button { + margin-right: 0; + color: #fff; + background: @c-primary; + } + } + + &-features { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-column-gap: 96px; + grid-row-gap: 56px; + padding: 72px 0; + + > dl { + flex: 1; + margin: 0; + text-align: center; + background: no-repeat center top / auto 48px; + + &[style*='background-image'] { + padding-top: 64px; + } + + dt { + margin-bottom: 12px; + font-size: 20px; + line-height: 1; + color: @c-heading; + } + + a { + transition-duration: none; + } + + a dt { + color: @c-link; + transition: opacity 0.2s; + &:hover { + opacity: 0.7; + text-decoration: underline; + } + + &:active { + opacity: 0.9; + } + } + + dd { + margin: 0; + + .markdown { + color: @c-secondary; + font-size: 14px; + line-height: 22px; + + > p:first-child { + margin-top: 0; + } + + > p:last-child { + margin-bottom: 0; + } + } + } + } + + @media @mobile { + display: block; + padding: 40px 0; + + > dl { + text-align: left; + background-position: left top; + + &[style*='background-image'] { + padding: 0 0 0 60px; + } + + + dl { + margin-top: 32px; + } + } + } + } + + &-features, + &-features + &-content, + &-hero + &-content { + margin-left: auto; + margin-right: auto; + max-width: 960px; + } + + &-hero + &-content { + margin-top: 60px; + } + + &-toc { + list-style: none; + position: fixed; + z-index: 10; + top: 50px; + right: 0; + width: @s-toc-width; + max-height: calc(90vh - 80px); + margin: 0; + padding: 0 24px 0 0; + background-color: #fff; + box-shadow: 0 0 16px 16px #fff; + box-sizing: content-box; + overflow: auto; + + @media @mobile { + display: none; + } + + li { + position: relative; + margin: 0; + padding: 4px 0 4px 6px; + text-indent: 12px; + font-size: 13px; + line-height: 1.40625; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + + a { + color: @c-text; + text-decoration: none; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + bottom: 0; + display: inline-block; + width: 2px; + background: @c-border; + } + + &:hover { + color: lighten(@c-primary, 5%); + } + + &:active { + color: lighten(@c-primary, 3%); + } + + &.active { + color: @c-primary; + + &::before { + background: @c-primary; + } + } + } + } + } + + &-footer-meta { + margin-top: 40px; + padding-top: 24px; + display: flex; + color: @c-secondary; + font-size: 14px; + justify-content: space-between; + border-top: 1px solid @c-border; + + @media only screen and (max-width: 960px) { + display: block; + } + + > a { + margin-bottom: 4px; + display: block; + color: @c-primary; + text-decoration: none; + } + + > span:last-child { + &::before { + content: attr(data-updated-text); + color: @c-primary; + } + } + } +} + +.__dumi-default-layout-footer { + margin: 72px 0 -32px; + padding-top: 24px; + border-top: 1px solid @c-border; + text-align: center; + + .markdown { + color: #b0b1ba; + } +} diff --git a/.dumi/theme/style/markdown.less b/.dumi/theme/style/markdown.less new file mode 100644 index 00000000..1250f1e5 --- /dev/null +++ b/.dumi/theme/style/markdown.less @@ -0,0 +1,196 @@ +@import (reference) './variables.less'; + +.markdown { + color: @c-text; + font-size: 15px; + line-height: 1.60625; + + &:not(:first-child):empty { + min-height: 32px; + } + + // titles + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 42px 0 18px; + color: @c-heading; + font-weight: 500; + line-height: 1.40625; + + // anchor link + &:hover > a[aria-hidden] { + float: left; + margin-top: 0.06em; + margin-left: -20px; + width: 20px; + padding-right: 4px; + line-height: 1; + box-sizing: border-box; + + @media @mobile { + width: 14px; + margin-left: -14px; + } + + &::after { + content: '#'; + display: inline-block; + vertical-align: middle; + font-size: 20px; + } + + span { + display: none; + } + } + + + h1, + + h2, + + h3, + + h4, + + h5, + + h6 { + margin-top: 16px; + } + } + + h1 { + margin-top: 48px; + margin-bottom: 32px; + font-size: 32px; + } + + h2 { + font-size: 24px; + } + + h3 { + font-size: 20px; + } + + h4 { + font-size: 18px; + } + + h5 { + font-size: 16px; + } + + h6 { + font-size: 14px; + } + + // paragraph + p { + margin: 16px 0; + } + + // inline code + *:not(pre) code { + padding: 2px 5px; + color: #d56161; + background: darken(@c-light-bg, 1%); + } + + code { + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + } + + // code block + pre { + font-size: 14px; + background: darken(@c-light-bg, 1%); + + &:not([class^='language-']) { + padding: 1em; + } + } + + // horizontal line + hr { + margin: 16px 0; + border: 0; + border-top: 1px solid @c-border; + } + + // blockquote + blockquote { + margin: 16px 0; + padding: 0 24px; + color: fadeout(@c-text, 30%); + border-left: 4px solid @c-border; + overflow: hidden; + } + + // list + ul, + ol { + margin: 8px 0 8px 32px; + padding: 0; + + li { + margin-bottom: 4px; + } + } + + // table + table { + width: 100%; + border-collapse: collapse; + border: 1px solid @c-border; + + th, + td { + padding: 10px 24px; + border: 1px solid @c-border; + } + + th { + font-weight: 600; + background: @c-light-bg; + } + + td:first-child { + font-weight: 500; + } + + a { + svg { + display: none; + } + } + } + + // links + a { + color: @c-link; + text-decoration: none; + transition: opacity 0.2s; + outline: none; + + &:hover { + opacity: 0.7; + text-decoration: underline; + } + + &:active { + opacity: 0.9; + } + } + + // images + img { + max-width: 100%; + } +} + +.@{prefix} { + &-external-link-icon { + vertical-align: -0.155em; + margin-left: 2px; + } +} diff --git a/.dumi/theme/style/variables.less b/.dumi/theme/style/variables.less new file mode 100644 index 00000000..cf814d79 --- /dev/null +++ b/.dumi/theme/style/variables.less @@ -0,0 +1,26 @@ +/* 颜色表 */ +@c-primary: #2f54eb; +@c-heading: #454d64; +@c-text: #454d64; +@c-secondary: #717484; +@c-link: @c-primary; +@c-border: #ebedf1; +@c-light-bg: #f9fafb; + +/* 尺寸表 */ +@s-nav-height: 64px; +@s-mobile-nav-height: 50px; +@s-menu-width: 260px; +@s-site-menu-width: 300px; +@s-menu-mobile-width: 240px; +@s-content-margin: 58px; + +@img-logo: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIQAAACCCAMAAACww5CIAAACf1BMVEUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8YkP8AAAACCxMamv/6+voaGhoXi/YYjv8aoP8cq/8dr/8bo/8cqP8bpv8Ykv8drv8BAwUcrP8Zlf8Xjf/s7OzLy8scp/8anP8ZmP/d3d0BBArg4ODT09O7u7sEGCsKCgoanf8YlP/8/Pz09PTIyMgMTIV1dXUGKEVEREQ0NDQODg4GBgYdsv8dsf8Zl//m5uYVgOXj4+MWgtfW1tYTc87BwcERbLWzs7Ovr6+np6cQX6OgoKCTk5MMSXlwcHBra2tiYmIVFRUetf/39/fp6ekWhOkXi+QVfNvY2NjPz88TdcUSb7u6urq3t7cPYK0NUJGQkJCLi4ttbW0JO2cINFtVVVVRUVEHMFEHLEs6OjoEHDEiIiIcHBwXj/vx8fEWh+4Sb8gRbL+rq6upqakOVZiWlpaJiYmGhoYMSIF9fX15eXkKPnQLRHJMTExHR0c9PT0FHzkqKiomJiYEFyUBBw8bovfu7u4Wht4UedsUeMrFxcW9vb0RZrOkpKSampoPXZqAgIALQmtlZWUJOGJZWVkIMFcFIUExMTEwMDAtLS0DEh8Zl/v4+PgXj/QWhvEWhvAYku8YjuwUfNcUfNAVfc0RaLkSaKsRZ6kPWqENUYlbW1sCEBhkSPCkAAAAOHRSTlMA87y4BeKrltbFnUDo0MCup6D67t7ayZKGemtmWS8rEwLNso1wVEpFGaR+UDUlHwmBYls5i1oN/DMym4YAAAfTSURBVHjaxNndS1NxHMfxX5s6t1Kz1KzsuazMnqjgyxv03ovtQrYxUBEfLkREVBQf0AsFBRUUQvEiSVFQ0YsuiiIiqKC/oH+o31lzjtPZg55zttfVNnbx5ffw+X53pmx5UFl2+XLZ4zpVOPWlJFTntYyiBwF/VbX39Sv9upYU9/QHjbXe6qqayrrnylXXi0kov3GVuFiMuNqbHhIu3FcuuohZZ+jDh7mdXkwqlGtKMGmOSFzrGiYe5ZL4+vdsd/SHFyYxtIQlIdiD4ftCa39osTlxRtzwHO1tUOLm0XYk6T3asMRtdKHdUs6qv+L1l/vKgak2SYjqN+1yYg2G5NgR4Pd5/F7fk9sO3YhSkoYkaW40KCk2Rj9KUoikqmtOn8YpydE6J7xFyq5yUhxIjvZJcUfZ5EOb6oxGQmPdtEQlR4Mxupc6IoOdzWiVypabaF1BiesIS876OiSufRXtvO0DcSi2dAN+ZcclYFZsCaOps3nYUOKprDTiSWzqAioCnpIX9ep03pxkw7jYtMWx0pdn7Jb2i1jixN3cM6OGFCti0zgpyopOsw6xiZHoyHIPLIhNHdD7bWR+c7znFD3+PNp+vxhmRkNi28BoWAzBPbQHKhdlQLe4ogsoVTl4ijYjrmiKATdUdvfjh9Ely8DVHFvWe3HJMBBQ2QWAd+KSeeBxjtuxKC7ZzG07Ht0DusQlfwDfs2wZ4b2EYVBcESHO81BlcIWESXHFV7Qss5aXY1FxRSj7L7QAhv3tsaVBMVn8Ou1MFUtjW3sYKjL0jO6QWJiA7iZxysBbtDplpRT4KZbQWkUbHRMnGFUUKwuNaH1iaRJ+Tf8bDbqcWJH2HuCV+l9DpkuxtdsuGlpYHNAJ1FqNMjnE9QocOXJCPwJ309zPT9la8e5yUJwwC/jTBNWQ5EkIqEyzHROSJzvWSeFDW5M8OUArsdgMq2EmanOyGB4WSyMYAhZp2TwkJouw2mZvmusUSwtraA//m7DXZ8SsBxiQM5tGSxNuv3+ZU/NmIpfN9qDXxp1sO4LDNrE202J6cHE1TVq2f1uNiA39K9/7JJ0JwGe6nvOSZ4OA1/R0bFbyrBWoMUX2nOTZAOA3pcSXjFW7UOJnU17VAYeZv98pTvsB1KsTRVXAtqQVA/rFWSNo11SKiuRYZeknEBRn7WJ4rZKuX8pcROvBj6g4rLUZQ8NJYBo2Jb/ax2KkhKYf6I1I3oWngKqUhfgkBTCL1pics1elICaS/5Y9jk+XBdEBeJKhHZGCCLZAWTIkBqQgNlr+NbGi2wHgS1tTAbQNAxW3i1R58WWgd725ANZ7gXPFNaqagrvwt1t7aW0qiOIAPlErPqJCq6JWrW8r1ar1xf0n4NxnnpCELEKyCNmkJZSQRSCbQltooS4sVApiC10U2kWhFRUEEdGF4vuNH8g7c9NQ2pjepPcB/r5ADjlnzp2ZM+QMXHeYb+1WfO5hi5QfveYe33XJ4+d8a3MNQHbI75KhMt9z9wF4FRNcIi3wO94bAHJiQHCHNgmgh3QD8D1MCK6I+KeNCUgbgFFRcEX8Qwhov014o/juUlEoxeqrgpsA7oWp4AZprnpv1ANgShFcoU4a+36jMgOuVGYmnuJ1Wb0hKWqCC8QCgI4dqyfRbNCFoqDBX7Xz6C0AS660K3UKQCdhuqAbdqFT+B8mAXQTbhtbpM7ng4Yn1oytOwFMu5AP9QGAa4Qz8lFwvFWIH6G7Qjijc8/LDueDyvd4z151EYBvwOF+lRFTAK6TGi+ACWdLk0ozANqvkpojAFJKRnCSlFt3m8pLc9bJTylVn64ty9rJfEl1cpVKbH3uJ2v1QleUqOCI2h9xeeP0aVqLCA4JSLk6s7hu6CbkqOAIGpyB7iRZ5xLvFWlHEkITyjK/41/v9h0AC3lngpCz0PXWf0yDUcmBhFDt0T/flx8CkNL8VLAZjUhvAHSQek5AtyALdqP5e9BdbPCkZsbuFRKVvlRHs/W1AfC902yNgoriWwCeqw1fSL+J2VkWNBF8vckr6mPQ3ZcjtkVBA/3z4Ju6Bs5ANzck2BQFpUMTxlVZQ4ege95vUxRUHoPOe5s01OWBbryf2hEFDX4Fc4Vs4gaYZ3ZEQeXBJPgMcFPnwYzJVmeE6jGsGCNAE/rAlPIBamkMQv9YCLpzxJRjYMr5BLXyg5EvgTlKTOoEkw2LUct6dTz4ojqCNO04mMm4ZE150mhMuQ+jHppwAUxqUM5QK9qkPLIE5jhpygkvmHJYiW45FaL8IwmdZy9pUtc2MK9HtvgloZngJyMVp3tJ846ASb7Q1NYrg1JN+ukDs4e05LwHTO5bUKG0tRBEeXAKzJ3rpEXdB8C9fBIWKW0hhOBIBdy2K6R11zvALY6EFYE21yHF4OdKEkz7ObIlXXvAhV4OquoApaYbpCo9qayA29lLturibhimSgOSFjG1ILRwYnwShn09xArnT8PwdnHML6n+hl+2gD8Wjj+rLMOwq49Y5dZpVKUWS++VcCwdCdT5/Uhck5SH45VpVO3qJFbq2Y5Vvly2VBgQY5KqKWI6HY+n06KiqVJMSQyP/37wB6v29xGrnThyEDWh5dyr+fJscbQw/OjRcGG0OFvO3n+QSqKm7exlYgsvNgolkyFs1HGV2OQgTGsjNjnVBtO8Owj3nwbhgWnttgWxy2PaoWaC+AuAXqWYKHupMgAAAABJRU5ErkJggg=='; +@prefix: __dumi-default; +@mobile: ~'only screen and (max-width: 767px)'; +@desktop: ~'only screen and (min-width: 768px)'; +@icons: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAcwAAAA8CAMAAADc4HoZAAABFFBMVEUAAABGT2VGTmZaYXpCvGtIUGg3tGBGTmU3s2A/tmFKUGxFTmRFTWVQWmxFTWRJUGZFTWRGTmRLWWpFTWRGTmVGTmVLVG1FTmRGTWVHTmVFTWRHUGdFTWRGT2ZFTWVGTmU6t2I7xHA3tF9GTWRIT2dFTmRGTmVFTWQ3s2BFTWRGTmVGTmZKUmVFTWRFTWRGTWRGTmVcXHxFTmVFTmVGTmVFTWRIUGdGTWVNU3FGT2ZGTmVHTmVFTWRFTWVFTmVITWRHUGZFTWVFTmRGTmZGTmVFTWVLU2g4tF83tGBFTWQ3s1/LzdT09faoq7Zwdoieoq58gpLj5OeCh5fq6+2/wsmTmKWQlKJfZnpIUGfU1tu0t8BOVWynlyFSAAAASXRSTlMAkoAHEkDQ/dgYFuf0C8gj+KQQmm1oEuyNWNg53kSXfCYI5tEtzq7ivbOgTBy924R1BfHUibcpYw1JcU7v+7E3Nav6XVPDGraPqQuKawAACh1JREFUeNrsm2lT6kgUhg9QFFUkgWDYZfnAVsi+KQJeQdGqt1xm7jZ3lv//PyYdhe7QWQw1zP0w83xQsY9Um4fTp7vToeBczmaX5MN5rXZO/+NGJzGuLejnkw3dADehLHkQyceAWD5C/0my9XqWPLlCK9WHQScirUMk18g7J9ZosYLFajFyT8siLIpuyQkHKBDw4NgYsnDr0Xybaii60rjYzsmdbrqnw0TvpbvkhjYuzinygDXJXLewR2/O/f73w1cWCUj0LkmiU8SeYsc9LXMZIJNjyXkqmbWQCzV8ICawzLO8jh3q4IyciYfugMnMMGYT4C4UJ2fOEbbSc0EyrVp4T/7u4kiZs6jANjwBxkupWMLG7NIlLZvxM+As3nRLTsD/N5xtekmHIEQuhBAoBuREtmaXWVgB41Smc97JbMZA7pqcKKgopbu7FC1BLUgD22MyeVnPWD0bonLLeCQRhIkzQNnz6gHiK0HmxeF4qkKPSsVygh2x2q50SmlZIGIyiQo8OY+XGVExOLVM2WVRbAkDSma0609aQaxKMgOo6YjQ77Tc8d3laxPRxS7R564yI8WSFkymgUNuJqlbomQLisblpnNAf0nrB1j06rTsA7n0SE5L2skkh+Qcm2CP3vGV2QHWp5Ypu4wDosumRpyzNrBwcFmqk4166dBmrFgJ5aeDKhvSklWLBLokgBhcaF3bFL59lV45EQsR3QLVfV0uAuNFhEy2JaC/fcveMVC8ltKSy3RITtjRl34yDSj0r8rMNkyXQksByJOdCmIdslNAKS7V0BIKdpmGQ1+S9slA2IVa60My89HoRKyZ5XTD8rhBX1DwEN85Gw53drIsT6W0FGTKyYmYtgcI427rI1NB5bQyZZeTuNCSXaEpBX2Cotm9qWqdJOqqajN85y8zTC6E8SGZGalmjja4uaQC0OUy0UzSAckNTKS0FGTKyYmYbfQP42brcFGr/X5+N/XDNVG+36+eXCZ3Kbbkbd644cHBW6bpnTlx0vZO6PL0NI/LE8uksxtUqQ7sUgpoAfp0TgLzqQ4oAFkkeFqadCwFxJMz4SKTwogVpIsaBtrv+qdQzZ8ibSB8cpncJW+Z68iQTBq5EXG6N6UIvTHVr2hPpHTX9ZY5Rf0ImfIEyEMmFWHQmk89gHKhBShCP68UoHVfFtZnqV0yahWYVLTdJyMFwE0ms8l+cnFJfWyIuM2TyuQuecsW4xFJMMcd0S1PzBRQGdkaOKosc4DKYn1amSM2rb4H5lwmaVUVqEXJItoA1LBGokwoHWKUS0AqBZTKxOgocJXJl74uLi+Be+I2TyuTu+SkkCInmrZS3kNXkMnnF9RFT5Qpv1cVJkYwmRzxlavMIRClmTgBYmIeU1bpfC+WqS6RKPOKOTxjaWlZXSpWcp4xq1dBZIaBTxH+v95kySLyCQifSCZ3WYuTnYbDKNvpnVMVPUpulvSGPiFRJlq39M5E95bZNYZXD1icTOaoHophQ1EgLcpkrBOsdLJimbglsstMzpnGxZtSE0vjwlKalGVyuEzZJSXQIxJs2kVVDJOLC6NKVK/0jLWrzEzPYB/G6SxV9pJZq2XlyXSHDqlAjW5XjaSCzfsfom2XiX3hbEN4y3G/r64agy7ZifRrXOa6wmMkmT7YZfbwTuPsUoGi2WUyWOlkxZJIkskGWD7YkpWcb4NtAJlVm8tHYEF2m6KofW/pXLe2INxkTs0QeszB5N5rmJVckg55RzI+gTpEToFySRZ1GAcy94lg8AmOtmtSh2QnNebrTCnmWJlzHRatYeRegbomWSZpU2Cq0UdkdgLKlBMzA2EZNpJkmnmZQ9EwqtSDMijqGU+ZeeSqD/pCkikhZ6ZsU8cNc+kuc3EoU0tgT4hE5q3ELgZCTIBh1nECVAWm0fMs3daA8bV4wUN7f0nhAkdCgkztnx9mZ5iQ+zDLSLxdx5bZFK+Tp8wZDNLqFEAmr5myzRh36TfM8obXX01eAeyaqT4LhYvouMccLzNSRIlZmwGzLnGskVWWWWhBmgBZlXPpOwHieEyA5joGsktZJvumXBN5yzSQW/puGhy2XGBDTjZbWDGXLhMgRZ4ArQF8f375+vnP5y/gFawKYHzlEuOzNPGRSVFgSkT37LcCYDSidpnnCUCQaTmUlyaW1QAyxaVJAVjLLmWZViQSUW+Z9RsWE1DmFuMIOZAddIMtTSrA69PTy/dfXr798QMo7GVmzjXyijleJqVwV7d6t4rL2+NlUeY5GE6bBnNp0yCQTG4zBYVIWGa6y6SMCmDoKZOuFQDVYDI1FWlyJtimQR8/vv76/O319enrl89/wdjLZEnsFeO/nee6NImv8MAW6zpSssylKLMMxrHbebJM2eZohYrkUpL5HhKfqohdesokbZED1oFk0gC5M/Kje+e7nafi9fnl8y8mn1+ef6AtyXSNOV4mZd4q7wAo+8s8fqOdA7httJd3Hwlpo12WeUZUv0PaVWaCuTSVqxgGkznPYTYiP/w32lfAr0+/fAF+++2PV6ApyvSK8ZcpL034LbAWclm2kEU/4i8z8C0wf5mcENQIcTxkJnuTOMV1ZBxkniceqYkmnRmtR4ooQWVSJwbD16b/LbAGTEffnvD705NpC3lZphxzrEwbYVZg2Dd+c9pZZpCb08FltrChj8nsAGpiDD0py9RWUIvAkFWOuwcFuA0ok4bALCuKswQFvTk9gMnL85fvz99h0ttsmp8+tdt9LlOKuXC5OS1fOa42c3jUUrW6sIGetB8bwVCUuUCgYyPBZa6B+w/KpHsVgOq4adBhTQ8RonIOwE3ACRBjGMNquJ/ODcc9YgQ8NtJVYfLn568vMImtVrmcoiitVmLuFON6bMRfpiOPY/QOD3T16juZ9V6AA10+MhkkE0Ys6yuzXFgTY35fzTw6L03iV8MOMbTt8CpJwWVa02C9PSyUt8NPKtBK0hEHuoYAzAH0G0z0c+IEjIGALDMfdeYCuD88ahmrxJnMuBE77qilLHPkKnOZlhLz9CcNnFu06hg7lLBGRx21DMHkr9+ZJ6HFKya4TC9atIOf6woBIX6SK8AhaM8D0D//ELR3ryLXlV4xV0qElhEiz0PQbcNoOx+CvlJgIT6H4xUTHCMGd1LE4aVTKpa+jyf4y/z5jycE7lXwxxO0gtFu5svECRrz/4NDf7dvxjYQwzAMdGEE8RaWq2ySh/cf6OGoyQCRANLkBHenWqnzxyGU6aVP0zRN0zTtmzUru64ZWZ923kC0n6tT9WnnnL+y5R51pj6L9ahlx7k6UR8kVt2Sh1W35GHVLXlYdUseVt2Sh1W3fK8aDmuSOmyfelyGwpqkjtvnnvMyENYcdeA+fSxaDNYUdeg+TovBmqAO3sdpMVjD1eH7OC0Ga7A6QR+nxWANVafo47QYrIHqJH0eWhDWMHWaPosWhTVInahPHzisIepUffrAYQ1QJ+vTgVgD1IP6/AHM0QJdY511NAAAAABJRU5ErkJggg=='; + +.@{prefix}-icon { + background: url(@icons) no-repeat ~'0 0/230px auto'; +} diff --git a/.editorconfig b/.editorconfig new file mode 100755 index 00000000..90a17ecd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = tab +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.fatherrc.ts b/.fatherrc.ts new file mode 100644 index 00000000..8763f25d --- /dev/null +++ b/.fatherrc.ts @@ -0,0 +1,5 @@ +export default { + esm: 'rollup', + cjs: 'rollup', + runtimeHelpers: true, +}; diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..f44de2f5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,40 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] + +**Smartphone (please complete the following information):** + +- Device: [e.g. iPhone6] +- OS: [e.g. iOS8.1] +- Browser [e.g. stock browser, safari] +- Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..2f28cead --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..c32275d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +**/node_modules +/npm-debug.log* +/yarn-error.log +/yarn.lock +/package-lock.json + +# production +**/dist +/docs-dist + +# misc +.DS_Store + +# umi +.umi +.umi-production +.umi-test +.env.local + +# log +*.log +.vscode \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..ecb24d33 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +**/*.svg +**/*.ejs +**/*.html +package.json +.umi +.umi-production +.umi-test diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..94beb148 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 80, + "overrides": [ + { + "files": ".prettierrc", + "options": { "parser": "json" } + } + ] +} diff --git a/.umirc.ts b/.umirc.ts new file mode 100644 index 00000000..14adfb59 --- /dev/null +++ b/.umirc.ts @@ -0,0 +1,360 @@ +import { defineConfig } from 'dumi'; + +function getMenus(opts: { lang?: string; base: '/docs' | '/plugin' | '/api' }) { + const menus = { + '/docs': [ + { + title: 'Introduction', + 'title_zh-CN': '介绍', + children: ['/docs/README', '/docs/getting-started'], + }, + { + title: 'Basis', + 'title_zh-CN': '基础', + children: [ + '/docs/concepts-node', + '/docs/concepts-schema', + '/docs/concepts-range', + '/docs/concepts-editor', + '/docs/concepts-event', + '/docs/concepts-plugin', + '/docs/concepts-history', + ], + }, + { + title: 'Resource', + 'title_zh-CN': '资源文件', + children: ['/docs/resources-icon'], + }, + { + title: 'Contribution', + 'title_zh-CN': '贡献', + path: '/docs/contributing', + }, + { + title: 'FAQ', + path: '/docs/faq', + }, + ], + '/plugin': [ + { + title: 'Plug-in development', + 'title_zh-CN': '插件开发', + children: [ + { + title: opts.lang === 'zh-CN' ? '基础' : 'Basis', + path: `${ + opts.lang === 'zh-CN' ? '/zh-CN' : '' + }/plugin/tutorials`, + exact: true, + }, + 'plugin/tutorials-element', + 'plugin/tutorials-mark', + 'plugin/tutorials-inline', + 'plugin/tutorials-block', + 'plugin/tutorials-list', + 'plugin/tutorials-card', + ], + }, + { + title: 'List of plugins', + 'title_zh-CN': '插件列表', + children: [ + '/plugin/plugin-alignment', + '/plugin/plugin-backcolor', + '/plugin/plugin-bold', + '/plugin/plugin-code', + '/plugin/plugin-codelock', + '/plugin/plugin-file', + '/plugin/plugin-fontcolor', + '/plugin/plugin-fontsize', + '/plugin/plugin-fontfamily', + '/plugin/plugin-heading', + '/plugin/plugin-hr', + '/plugin/plugin-indent', + '/plugin/plugin-italic', + '/plugin/plugin-image', + '/plugin/plugin-link', + '/plugin/plugin-line-height', + { + title: '@aomao/plugin-mark', + path: '/plugin/plugin-mark', + exact: true, + }, + '/plugin/plugin-mark-range', + '/plugin/plugin-math', + '/plugin/plugin-mention', + '/plugin/plugin-orderedlist', + '/plugin/plugin-paintformat', + '/plugin/plugin-quote', + '/plugin/plugin-redo', + '/plugin/plugin-removeformat', + '/plugin/plugin-selectall', + '/plugin/plugin-strikethrough', + '/plugin/plugin-status', + '/plugin/plugin-sub', + '/plugin/plugin-sup', + '/plugin/plugin-table', + '/plugin/plugin-tasklist', + '/plugin/plugin-underline', + '/plugin/plugin-undo', + '/plugin/plugin-unorderedlist', + '/plugin/plugin-video', + ], + }, + ], + '/api': [ + { + title: 'Node', + 'title_zh-CN': 'DOM节点', + children: [ + '/api/node', + '/api/editor-node', + '/api/editor-mark', + '/api/editor-inline', + '/api/editor-block', + '/api/editor-list', + ], + }, + { + title: 'Card', + 'title_zh-CN': '卡片', + children: [ + { + title: 'Card', + path: `${ + opts.lang === 'zh-CN' ? '/zh-CN' : '' + }/api/editor-card`, + exact: true, + }, + '/api/editor-card-toolbar', + '/api/editor-card-resize', + '/api/editor-card-maximize', + ], + }, + { + title: 'Schema', + 'title_zh-CN': '架构', + path: '/api/schema', + }, + { + title: 'Range', + 'title_zh-CN': '光标范围', + children: ['/api/range', '/api/selection'], + }, + { + title: 'History', + 'title_zh-CN': '历史记录', + path: '/api/history', + }, + { + title: 'Editor', + 'title_zh-CN': '编辑器', + children: [ + { + title: 'Change', + path: `${ + opts.lang === 'zh-CN' ? '/zh-CN' : '' + }/api/editor-change`, + children: [ + `${ + opts.lang === 'zh-CN' ? '/zh-CN' : '' + }/api/editor-change-event`, + ], + }, + { + title: + opts.lang === 'zh-CN' + ? '共有属性和方法' + : 'Common attributes and methods', + path: `${ + opts.lang === 'zh-CN' ? '/zh-CN' : '' + }/api/editor`, + exact: true, + }, + { + title: opts.lang === 'zh-CN' ? '引擎' : 'Engine', + path: `${ + opts.lang === 'zh-CN' ? '/zh-CN' : '' + }/api/engine`, + }, + { + title: opts.lang === 'zh-CN' ? '阅读器' : 'View', + path: `${ + opts.lang === 'zh-CN' ? '/zh-CN' : '' + }/api/view`, + }, + ], + }, + { + title: 'Language', + 'title_zh-CN': '语言', + path: '/api/language', + }, + { + title: 'Command', + 'title_zh-CN': '命令', + path: '/api/command', + }, + { + title: 'Constants', + 'title_zh-CN': '常量', + path: '/api/constants', + }, + { + title: 'Hotkey', + 'title_zh-CN': '热键', + path: '/api/hotkey', + }, + { + title: 'Clipboard', + 'title_zh-CN': '剪贴板', + path: '/api/clipboard', + }, + { + title: 'Parser', + 'title_zh-CN': '解析器', + path: '/api/parser', + }, + { + title: 'Utility method/constant', + 'title_zh-CN': '实用方法/常量', + path: '/api/utils', + }, + ], + }; + return (menus[opts.base] as []).map((menu: any) => { + if (!opts.lang) return menu; + return { + ...menu, + title: menu[`title_${opts.lang}`] || menu.title, + }; + }); +} + +export default defineConfig({ + title: 'AoMao Editor', + favicon: 'https://cdn-object.yanmao.cc/icon/shortcut.png', + logo: 'https://cdn-object.yanmao.cc/icon/icon.svg', + outputPath: 'docs-dist', + hash: true, + mode: 'site', + locales: [ + ['en-US', 'English'], + ['zh-CN', '中文'], + ], + ssr: { + devServerRender: false, + removeWindowInitialProps: true, + }, + navs: { + 'en-US': [ + { + title: 'Edit', + path: '/', + }, + { + title: 'View', + path: '/view', + }, + { + title: 'Docs', + path: '/docs', + }, + { + title: 'Config', + path: '/config', + }, + { + title: 'Plug-in', + path: '/plugin', + }, + { + title: 'API', + path: '/api', + }, + { + title: 'AoMao', + path: 'https://www.yanmao.cc', + }, + { + title: 'Github', + path: 'https://github.com/yanmao-cc/am-editor', + }, + ], + 'zh-CN': [ + { + title: '编辑', + path: '/zh-CN', + }, + { + title: '阅读', + path: '/zh-CN/view', + }, + { + title: '文档', + path: '/zh-CN/docs', + }, + { + title: '配置', + path: '/zh-CN/config', + }, + { + title: '插件', + path: '/zh-CN/plugin', + }, + { + title: 'API', + path: '/zh-CN/api', + }, + { + title: 'AoMao', + path: 'https://www.yanmao.cc', + }, + { + title: 'Github', + path: 'https://github.com/yanmao-cc/am-editor', + }, + ], + }, + menus: { + '/zh-CN/docs': getMenus({ lang: 'zh-CN', base: '/docs' }), + '/docs': getMenus({ base: '/docs' }), + '/zh-CN/plugin': getMenus({ lang: 'zh-CN', base: '/plugin' }), + '/plugin': getMenus({ base: '/plugin' }), + '/zh-CN/api': getMenus({ lang: 'zh-CN', base: '/api' }), + '/api': getMenus({ base: '/api' }), + }, + analytics: { + baidu: 'c2e2e4254b6e4388806848d06be68a69', + }, + manifest: { + fileName: 'manifest.json', + }, + metas: [ + { + name: 'viewport', + content: + 'viewport-fit=cover,width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no', + }, + { + name: 'apple-mobile-web-app-capable', + content: 'yes', + }, + { + name: 'apple-mobile-web-app-status-bar-style', + content: 'black', + }, + { + name: 'renderer', + content: 'webkit', + }, + ], + headScripts: [ + { + src: 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js', + 'data-ad-client': 'ca-pub-3706417744839656', + }, + ], + // more config: https://d.umijs.org/config +}); diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..fab7efe4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2021-present AoMao (me@yanmao.cc) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..06efef75 --- /dev/null +++ b/README.md @@ -0,0 +1,331 @@ +# am-editor + +

+ A rich text collaborative editor framework that can use React and Vue custom plug-ins +

+ +

+ 中文 · + Demo · + Documentation · + Plugins · + QQ-Group 907664876 · +

+ +![aomao-preview](https://user-images.githubusercontent.com/55792257/125074830-62d79300-e0f0-11eb-8d0f-bb96a7775568.png) + +

+ + + + + + + + + + + + + + + +

+ +> Thanks to Google Translate + +Use the `contenteditable` attribute provided by the browser to make a DOM node editable. + +The engine takes over most of the browser's default behaviors such as cursors and events. + +Monitor the changes of the `DOM` tree in the editing area through `MutationObserver`, and generate a data format of `json0` type to interact with the [ShareDB](https://github.com/share/sharedb) library to achieve collaborative editing Needs. + +**`Vue2`** example [https://github.com/zb201307/am-editor-vue2](https://github.com/zb201307/am-editor-vue2) + +**`Vue3`** example [https://github.com/yanmao-cc/am-editor/tree/master/examples/vue](https://github.com/yanmao-cc/am-editor/tree/master/examples/vue) + +**`React`** example [https://github.com/yanmao-cc/am-editor/tree/master/examples/react](https://github.com/yanmao-cc/am-editor/tree/master/examples/react) + +## Features + +- Out of the box, it provides dozens of rich plug-ins to meet most needs +- High extensibility, in addition to the basic plug-in of `mark`, inline`and`block`type`, we also provide`card`component combined with`React`, `Vue` and other front-end libraries to render the plug-in UI +- Rich multimedia support, not only supports pictures, audio and video, but also supports insertion of embedded multimedia content +- Support Markdown syntax +- Support internationalization +- The engine is written in pure JavaScript and does not rely on any front-end libraries. Plug-ins can be rendered using front-end libraries such as `React` and `Vue`. Easily cope with complex architecture +- Built-in collaborative editing program, ready to use with lightweight configuration +- Compatible with most of the latest mobile browsers + +## Plugins + +| **Package** | **Version** | **Size** | **Description** | +| :---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------- | +| [`@aomao/toolbar`](./packages/toolbar) | [![](https://img.shields.io/npm/v/@aomao/toolbar.svg?maxAge=3600&label=&colorB=007ec6)](./packages/toolbar/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/toolbar/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/toolbar/dist/index.js) | Toolbar, for React. | +| [`@aomao/toolbar-vue`](./packages/toolbar-vue) | [![](https://img.shields.io/npm/v/@aomao/toolbar-vue.svg?maxAge=3600&label=&colorB=007ec6)](./packages/toolbar-vue/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/toolbar-vue/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/toolbar-vue/dist/index.js) | Toolbar, for `Vue3`. | +| [`am-editor-toolbar-vue2`](https://github.com/zb201307/am-editor-vue2/tree/main/packages/toolbar) | [![](https://img.shields.io/npm/v/am-editor-toolbar-vue2.svg?maxAge=3600&label=&colorB=007ec6)](https://github.com/zb201307/am-editor-vue2/blob/main/packages/toolbar/package.json) | [![](http://img.badgesize.io/https://unpkg.com/am-editor-toolbar-vue2/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/am-editor-toolbar-vue2/dist/index.js) | Toolbar, for `Vue2` | +| [`@aomao/plugin-alignment`](./plugins/alignment) | [![](https://img.shields.io/npm/v/@aomao/plugin-alignment.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/alignment/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-alignment/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-alignment/dist/index.js) | Alignment. | +| [`@aomao/plugin-backcolor`](./plugins/backcolor) | [![](https://img.shields.io/npm/v/@aomao/plugin-backcolor.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/backcolor/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-backcolor/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-backcolor/dist/index.js) | Background color. | +| [`@aomao/plugin-bold`](./plugins/bold) | [![](https://img.shields.io/npm/v/@aomao/plugin-bold.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/bold/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-bold/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-bold/dist/index.js) | Bold. | +| [`@aomao/plugin-code`](./plugins/code) | [![](https://img.shields.io/npm/v/@aomao/plugin-code.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/code/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-code/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-code/dist/index.js) | Inline code. | +| [`@aomao/plugin-codeblock`](./plugins/codeblock) | [![](https://img.shields.io/npm/v/@aomao/plugin-codeblock.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/codeblock/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-codeblock/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-codeblock/dist/index.js) | Code block, for React. | +| [`@aomao/plugin-codeblock-vue`](./plugins/codeblock-vue) | [![](https://img.shields.io/npm/v/@aomao/plugin-codeblock-vue.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/codeblock-vue/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-codeblock-vue/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-codeblock-vue/dist/index.js) | Code block, for `Vue3`. | +| [`am-editor-codeblock-vue2`](https://github.com/zb201307/am-editor-vue2/tree/main/packages/codeblock) | [![](https://img.shields.io/npm/v/am-editor-codeblock-vue2.svg?maxAge=3600&label=&colorB=007ec6)](https://github.com/zb201307/am-editor-vue2/tree/main/packages/codeblock/package.json) | [![](http://img.badgesize.io/https://unpkg.com/am-editor-codeblock-vue2/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/am-editor-codeblock-vue2/dist/index.js) | Code Block, for `Vue2` | +| [`@aomao/plugin-fontcolor`](./plugins/fontcolor) | [![](https://img.shields.io/npm/v/@aomao/plugin-fontcolor.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/fontcolor/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-fontcolor/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-fontcolor/dist/index.js) | Font color. | +| [`@aomao/plugin-fontfamily`](./plugins/fontfamily) | [![](https://img.shields.io/npm/v/@aomao/plugin-fontfamily.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/fontfamily/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-fontfamily/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-fontfamily/dist/index.js) | Font. | +| [`@aomao/plugin-fontsize`](./plugins/fontsize) | [![](https://img.shields.io/npm/v/@aomao/plugin-fontsize.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/fontsize/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-fontsize/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-fontsize/dist/index.js) | Font size. | +| [`@aomao/plugin-heading`](./plugins/heading) | [![](https://img.shields.io/npm/v/@aomao/plugin-heading.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/heading/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-heading/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-heading/dist/index.js) | Heading. | +| [`@aomao/plugin-hr`](./plugins/hr) | [![](https://img.shields.io/npm/v/@aomao/plugin-hr.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/hr/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-hr/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-hr/dist/index.js) | Dividing line. | +| [`@aomao/plugin-indent`](./plugins/indent) | [![](https://img.shields.io/npm/v/@aomao/plugin-indent.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/indent/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-indent/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-indent/dist/index.js) | Indent. | +| [`@aomao/plugin-italic`](./plugins/italic) | [![](https://img.shields.io/npm/v/@aomao/plugin-italic.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/italic/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-italic/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-italic/dist/index.js) | Italic. | +| [`@aomao/plugin-link`](./plugins/link) | [![](https://img.shields.io/npm/v/@aomao/plugin-link.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/link/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-link/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-link/dist/index.js) | Link, for React. | +| [`@aomao/plugin-link-vue`](./plugins/link-vue) | [![](https://img.shields.io/npm/v/@aomao/plugin-link-vue.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/link-vue/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-link-vue/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-link-vue/dist/index.js) | Link, for `Vue3`. | +| [`am-editor-link-vue2`](https://github.com/zb201307/am-editor-vue2/tree/main/packages/link) | [![](https://img.shields.io/npm/v/am-editor-link-vue2.svg?maxAge=3600&label=&colorB=007ec6)](https://github.com/zb201307/am-editor-vue2/tree/main/packages/link/package.json) | [![](http://img.badgesize.io/https://unpkg.com/am-editor-link-vue2/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/am-editor-link-vue2/dist/index.js) | Link, for `Vue2` | +| [`@aomao/plugin-line-height`](./plugins/line-height) | [![](https://img.shields.io/npm/v/@aomao/plugin-line-height.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/line-height/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-line-height/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-line-height/dist/index.js) | Line height. | +| [`@aomao/plugin-mark`](./plugins/mark) | [![](https://img.shields.io/npm/v/@aomao/plugin-mark.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/mark/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-mark/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-mark/dist/index.js) | Mark. | +| [`@aomao/plugin-mention`](./plugins/mention) | [![](https://img.shields.io/npm/v/@aomao/plugin-mention.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/mention/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-mention/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-mention/dist/index.js) | Mention | +| [`@aomao/plugin-orderedlist`](./plugins/orderedlist) | [![](https://img.shields.io/npm/v/@aomao/plugin-orderedlist.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/orderedlist/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-orderedlist/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-orderedlist/dist/index.js) | Ordered list. | +| [`@aomao/plugin-paintformat`](./plugins/paintformat) | [![](https://img.shields.io/npm/v/@aomao/plugin-paintformat.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/paintformat/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-paintformat/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-paintformat/dist/index.js) | Format Painter. | +| [`@aomao/plugin-quote`](./plugins/quote) | [![](https://img.shields.io/npm/v/@aomao/plugin-quote.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/quote/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-quote/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-quote/dist/index.js) | Quote block. | +| [`@aomao/plugin-redo`](./plugins/redo) | [![](https://img.shields.io/npm/v/@aomao/plugin-redo.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/redo/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-redo/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-redo/dist/index.js) | Redo history. | +| [`@aomao/plugin-removeformat`](./plugins/removeformat) | [![](https://img.shields.io/npm/v/@aomao/plugin-removeformat.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/removeformat/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-removeformat/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-removeformat/dist/index.js) | Remove style. | +| [`@aomao/plugin-selectall`](./plugins/selectall) | [![](https://img.shields.io/npm/v/@aomao/plugin-selectall.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/selectall/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-selectall/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-selectall/dist/index.js) | Select all. | +| [`@aomao/plugin-status`](./plugins/status) | [![](https://img.shields.io/npm/v/@aomao/plugin-status.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/status/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-status/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-status/dist/index.js) | Status. | +| [`@aomao/plugin-strikethrough`](./plugins/strikethrough) | [![](https://img.shields.io/npm/v/@aomao/plugin-strikethrough.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/strikethrough/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-strikethrough/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-strikethrough/dist/index.js) | Strikethrough. | +| [`@aomao/plugin-sub`](./plugins/sub) | [![](https://img.shields.io/npm/v/@aomao/plugin-sub.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/sub/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-sub/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-sub/dist/index.js) | Sub. | +| [`@aomao/plugin-sup`](./plugins/sup) | [![](https://img.shields.io/npm/v/@aomao/plugin-sup.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/sup/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-sup/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-sup/dist/index.js) | Sup. | +| [`@aomao/plugin-tasklist`](./plugins/tasklist) | [![](https://img.shields.io/npm/v/@aomao/plugin-tasklist.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/tasklist/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-tasklist/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-tasklist/dist/index.js) | task list. | +| [`@aomao/plugin-underline`](./plugins/underline) | [![](https://img.shields.io/npm/v/@aomao/plugin-underline.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/underline/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-underline/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-underline/dist/index.js) | Underline. | +| [`@aomao/plugin-undo`](./plugins/undo) | [![](https://img.shields.io/npm/v/@aomao/plugin-undo.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/undo/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-undo/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-undo/dist/index.js) | Undo history. | +| [`@aomao/plugin-unorderedlist`](./plugins/unorderedlist) | [![](https://img.shields.io/npm/v/@aomao/plugin-unorderedlist.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/unorderedlist/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-unorderedlist/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-unorderedlist/dist/index.js) | Unordered list. | +| [`@aomao/plugin-image`](./plugins/image) | [![](https://img.shields.io/npm/v/@aomao/plugin-image.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/image/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-image/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-image/dist/index.js) | Image. | +| [`@aomao/plugin-table`](./plugins/table) | [![](https://img.shields.io/npm/v/@aomao/plugin-table.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/table/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-table/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-table/dist/index.js) | Table. | +| [`@aomao/plugin-file`](./plugins/file) | [![](https://img.shields.io/npm/v/@aomao/plugin-file.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/file/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-file/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-file/dist/index.js) | File. | +| [`@aomao/plugin-mark-range`](./plugins/mark-range) | [![](https://img.shields.io/npm/v/@aomao/plugin-mark-range.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/mark-range/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-mark-range/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-mark-range/dist/index.js) | Mark the cursor, for example: comment. | +| [`@aomao/plugin-math`](./plugins/math) | [![](https://img.shields.io/npm/v/@aomao/plugin-math.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/math/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-math/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-math/dist/index.js) | Mathematical formula. | +| [`@aomao/plugin-video`](./plugins/video) | [![](https://img.shields.io/npm/v/@aomao/plugin-video.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/video/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-video/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-video/dist/index.js) | Video. | + +## Getting Started + +### Installation + +The editor consists of `engine`, `toolbar`, and `plugin`. `Engine` provides us with core editing capabilities. + +Install engine package using npm or yarn + +```bash +$ npm install @aomao/engine +# or +$ yarn add @aomao/engine +``` + +### Usage + +We follow the convention to output a `Hello word!` + +```tsx +import React, { useEffect, useRef, useState } from 'react'; +import Engine, { EngineInterface } from '@aomao/engine'; + +const EngineDemo = () => { + //Editor container + const ref = useRef(null); + //Engine instance + const [engine, setEngine] = useState(); + //Editor content + const [content, setContent] = useState('

Hello word!

'); + + useEffect(() => { + if (!ref.current) return; + //Instantiate the engine + const engine = new Engine(ref.current); + //Set the editor value + engine.setValue(content); + //Listen to the editor value change event + engine.on('change', (value) => { + setContent(value); + console.log(`value:${value}`); + }); + //Set the engine instance + setEngine(engine); + }, []); + + return
; +}; +export default EngineDemo; +``` + +### Plugins + +Import `@aomao/plugin-bold` bold plug-in + +```tsx +import Bold from '@aomao/plugin-bold'; +``` + +Add the `Bold` plugin to the engine + +```tsx +//Instantiate the engine +const engine = new Engine(ref.current, { + plugins: [Bold], +}); +``` + +### Card + +A card is a separate area in the editor. The UI and logic inside the card can be customized using React, Vue or other front-end libraries to customize the rendering content, and finally mount it to the editor. + +Import the `@aomao/plugin-codeblock` code block plugin. The `Language drop-down box` of this plugin is rendered using `React`, so there is a distinction. `Vue3` uses `@aomao/plugin-codeblock-vue` + +```tsx +import CodeBlock, { CodeBlockComponent } from '@aomao/plugin-codeblock'; +``` + +Add the `CodeBlock` plugin and `CodeBlockComponent` card component to the engine + +```tsx +//Instantiate the engine +const engine = new Engine(ref.current, { + plugins: [CodeBlock], + cards: [CodeBlockComponent], +}); +``` + +The `CodeBlock` plugin supports `markdown` by default. Enter the code block syntax ````javascript` at the beginning of a line in the editor to trigger it after pressing Enter. + +### Toolbar + +Import the `@aomao/toolbar` toolbar. Due to the complex interaction, the toolbar is basically rendered using `React` + `Antd` UI components, while `Vue3` uses `@aomao/toolbar-vue` + +Except for UI interaction, most of the work of the toolbar is just to call the engine to execute the corresponding plug-in commands after different button events are triggered. In the case of complicated requirements or the need to re-customize the UI, it is easier to modify after the fork. + +```tsx +import Toolbar, { ToolbarPlugin, ToolbarComponent } from '@aomao/toolbar'; +``` + +Add the `ToolbarPlugin` plugin and `ToolbarComponent` card component to the engine, which allows us to use the shortcut key `/` in the editor to wake up the card toolbar + +```tsx +//Instantiate the engine +const engine = new Engine(ref.current, { + plugins: [ToolbarPlugin], + cards: [ToolbarComponent], +}); +``` + +Rendering toolbar, the toolbar has been configured with all plug-ins, here we only need to pass in the plug-in name + +```tsx +return ( + ... + { + engine && ( + + ) + } + ... +) +``` + +For more complex toolbar configuration, please check the document [https://editor.yanmao.cc/config/toolbar](https://editor.yanmao.cc/config/toolbar) + +### Collaborative editing + +Collaborative editing is implemented based on the [ShareDB](https://github.com/share/sharedb) open source library. Those who are unfamiliar can learn about it first. + +#### Interactive mode + +Each editor acts as a [Client](https://github.com/yanmao-cc/am-editor/tree/master/examples/react/components/editor/ot/client.ts) through `WebSocket` and [ Server](https://github.com/yanmao-cc/am-editor/tree/master/ot-server) Communication and exchange of data in `json0` format generated by the editor. + +The server will keep a copy of the `html` structure data in the `json` format. After receiving the instructions from the client, it will modify the data, and finally forward it to each client. + +Before enabling collaborative editing, we need to configure [Client](https://github.com/yanmao-cc/am-editor/tree/master/examples/react/components/editor/ot/client.ts) and [Server](https://github.com/yanmao-cc/am-editor/tree/master/ot-server) + +The server is a `NodeJs` environment, and a network service built using `express` + `WebSocket`. + +#### Example + +In the example, we have a relatively basic client code + +[View the complete React example](https://github.com/yanmao-cc/am-editor/tree/master/examples/react) + +[View the complete example of Vue3](https://github.com/yanmao-cc/am-editor/tree/master/examples/vue) + +[View the complete example of Vue2](https://github.com/zb201307/am-editor-vue2) + +```tsx +//Instantiate the collaborative editing client and pass in the current editor engine instance +const otClient = new OTClient(engine); +//Connect to the collaboration server, `demo` is the same as the server document ID +otClient.connect( + `ws://127.0.0.1:8080${currentMember ? '?uid=' + currentMember.id : ''}`, + 'demo', +); +``` + +### Project icon + +[Iconfont](https://at.alicdn.com/t/project/1456030/0cbd04d3-3ca1-4898-b345-e0a9150fcc80.html?spm=a313x.7781069.1998910419.35) + +## Development + +### React + +Need to install dependencies separately in `am-editor root directory` `site-ssr` `ot-server` + +```base +//After the dependencies are installed, you only need to execute the following commands in the root directory + +yarn ssr +``` + +- `packages` engine and toolbar +- `plugins` all plugins +- `site-ssr` All backend API and SSR configuration. The egg used. Use yarn ssr in the am-editor root directory to automatically start `site-ssr` +- `ot-server` collaborative server. Start: yarn start + +Visit localhost:7001 after startup + +### Vue + +Just enter the examples/vue directory to install the dependencies + +```base +//After the dependencies are installed, execute the following commands in the examples/vue directory + +yarn serve +``` + +In the Vue runtime environment, the default is the installed code that has been published to npm. If you need to modify the code of the engine or plug-in and see the effect immediately, we need to do the following steps: + +- Delete the examples/vue/node_modules/@aomao folder +- Delete the examples/vue/node_modules/vue folder. Because there are plugins that depend on Vue, the Vue package will be installed in the project root directory. If you do not delete the Vue package in examples/vue, and the Vue package of the plugin is not in the same environment, the plugin cannot be loaded +- Execute and install all dependent commands in the root directory of am-editor, for example: `yarn` +- Finally restart in examples/vue + +There is no backend API configured in the `Vue` case. For details, please refer to `React` and `site-ssr` + +## Contribution + +Thanks [pleasedmi](https://github.com/pleasedmi)、[Elena211314](https://github.com/Elena211314)、[zb201307](https://github.com/zb201307) for donation + +### Alipay + +![alipay](https://cdn-object.yanmao.cc/contribution/alipay.png?x-oss-process=image/resize,w_200) + +### WeChat Pay + +![wechat](https://cdn-object.yanmao.cc/contribution/weichat.png?x-oss-process=image/resize,w_200) + +### PayPal + +[https://paypal.me/aomaocom](https://paypal.me/aomaocom) diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000..2c3251e5 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,335 @@ +# am-editor + +

+ 一个富文本协同编辑器框架,可以使用ReactVue自定义插件 +

+ +

+ English · + Demo · + 文档 · + 插件 · + QQ群 907664876 · +

+ +![aomao-preview](https://user-images.githubusercontent.com/55792257/125074830-62d79300-e0f0-11eb-8d0f-bb96a7775568.png) + +

+ + + + + + + + + + + + + + + +

+ +`广告`:[科学上网,方便、快捷的上网冲浪](https://xiyou4you.us/r/?s=18517120) 稳定、可靠,访问 Github 或者其它外网资源很方便。 + +使用浏览器提供的 `contenteditable` 属性让一个 DOM 节点具有可编辑能力。 + +引擎接管了浏览器大部分光标、事件等默认行为。 + +可编辑器区域内的节点通过 `schema` 规则,制定了 `mark` `inline` `block` `card` 4 种组合节点,他们由不同的属性、样式或 `html` 结构组成,并对它们的嵌套进行了一定的约束。 + +通过 `MutationObserver` 监听编辑区域内的 `DOM` 树的改变,并生成 `json0` 类型的数据格式与 [ShareDB](https://github.com/share/sharedb) 库进行交互,从而达到协同编辑的需要。 + +**`Vue2`** 案例 [https://github.com/zb201307/am-editor-vue2](https://github.com/zb201307/am-editor-vue2) + +**`Vue3`** 案例 [https://github.com/yanmao-cc/am-editor/tree/master/examples/vue](https://github.com/yanmao-cc/am-editor/tree/master/examples/vue) + +**`React`** 案例 [https://github.com/yanmao-cc/am-editor/tree/master/examples/react](https://github.com/yanmao-cc/am-editor/tree/master/examples/react) + +## 特性 + +- 开箱即用,提供几十种丰富的插件来满足大部分需求 +- 高扩展性,除了 `mark` `inline` `block` 类型基础插件外,我们还提供 `card` 组件结合`React` `Vue`等前端库渲染插件 UI +- 丰富的多媒体支持,不仅支持图片和音视频,更支持插入嵌入式多媒体内容 +- 支持 Markdown 语法 +- 支持国际化 +- 引擎纯 JavaScript 编写,不依赖任何前端库,插件可以使用 `React` `Vue` 等前端库渲染。复杂架构轻松应对 +- 内置协同编辑方案,轻量配置即可使用 +- 兼容大部分最新移动端浏览器 + +## 插件 + +| **包** | **版本** | **大小** | **描述** | +| :---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------- | +| [`@aomao/toolbar`](./packages/toolbar) | [![](https://img.shields.io/npm/v/@aomao/toolbar.svg?maxAge=3600&label=&colorB=007ec6)](./packages/toolbar/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/toolbar/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/toolbar/dist/index.js) | 工具栏, 适用于 `React` | +| [`@aomao/toolbar-vue`](./packages/toolbar-vue) | [![](https://img.shields.io/npm/v/@aomao/toolbar-vue.svg?maxAge=3600&label=&colorB=007ec6)](./packages/toolbar-vue/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/toolbar-vue/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/toolbar-vue/dist/index.js) | 工具栏, 适用于 `Vue3` | +| [`am-editor-toolbar-vue2`](https://github.com/zb201307/am-editor-vue2/tree/main/packages/toolbar) | [![](https://img.shields.io/npm/v/am-editor-toolbar-vue2.svg?maxAge=3600&label=&colorB=007ec6)](https://github.com/zb201307/am-editor-vue2/blob/main/packages/toolbar/package.json) | [![](http://img.badgesize.io/https://unpkg.com/am-editor-toolbar-vue2/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/am-editor-toolbar-vue2/dist/index.js) | 工具栏, 适用于 `Vue2` | +| [`@aomao/plugin-alignment`](./plugins/alignment) | [![](https://img.shields.io/npm/v/@aomao/plugin-alignment.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/alignment/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-alignment/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-alignment/dist/index.js) | 对齐方式 | +| [`@aomao/plugin-backcolor`](./plugins/backcolor) | [![](https://img.shields.io/npm/v/@aomao/plugin-backcolor.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/backcolor/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-backcolor/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-backcolor/dist/index.js) | 背景色 | +| [`@aomao/plugin-bold`](./plugins/bold) | [![](https://img.shields.io/npm/v/@aomao/plugin-bold.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/bold/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-bold/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-bold/dist/index.js) | 加粗 | +| [`@aomao/plugin-code`](./plugins/code) | [![](https://img.shields.io/npm/v/@aomao/plugin-code.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/code/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-code/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-code/dist/index.js) | 行内代码 | +| [`@aomao/plugin-codeblock`](./plugins/codeblock) | [![](https://img.shields.io/npm/v/@aomao/plugin-codeblock.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/codeblock/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-codeblock/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-codeblock/dist/index.js) | 代码块, 适用于 `React` | +| [`@aomao/plugin-codeblock-vue`](./plugins/codeblock-vue) | [![](https://img.shields.io/npm/v/@aomao/plugin-codeblock-vue.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/codeblock-vue/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-codeblock-vue/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-codeblock-vue/dist/index.js) | 代码块, 适用于 `Vue3` | +| [`am-editor-codeblock-vue2`](https://github.com/zb201307/am-editor-vue2/tree/main/packages/codeblock) | [![](https://img.shields.io/npm/v/am-editor-codeblock-vue2.svg?maxAge=3600&label=&colorB=007ec6)](https://github.com/zb201307/am-editor-vue2/tree/main/packages/codeblock/package.json) | [![](http://img.badgesize.io/https://unpkg.com/am-editor-codeblock-vue2/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/am-editor-codeblock-vue2/dist/index.js) | 代码块, 适用于 `Vue2` | +| [`@aomao/plugin-fontcolor`](./plugins/fontcolor) | [![](https://img.shields.io/npm/v/@aomao/plugin-fontcolor.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/fontcolor/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-fontcolor/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-fontcolor/dist/index.js) | 前景色 | +| [`@aomao/plugin-fontfamily`](./plugins/fontfamily) | [![](https://img.shields.io/npm/v/@aomao/plugin-fontfamily.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/fontfamily/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-fontfamily/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-fontfamily/dist/index.js) | 字体 | +| [`@aomao/plugin-fontsize`](./plugins/fontsize) | [![](https://img.shields.io/npm/v/@aomao/plugin-fontsize.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/fontsize/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-fontsize/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-fontsize/dist/index.js) | 字体大小 | +| [`@aomao/plugin-heading`](./plugins/heading) | [![](https://img.shields.io/npm/v/@aomao/plugin-heading.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/heading/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-heading/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-heading/dist/index.js) | 标题 | +| [`@aomao/plugin-hr`](./plugins/hr) | [![](https://img.shields.io/npm/v/@aomao/plugin-hr.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/hr/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-hr/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-hr/dist/index.js) | 分割线 | +| [`@aomao/plugin-indent`](./plugins/indent) | [![](https://img.shields.io/npm/v/@aomao/plugin-indent.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/indent/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-indent/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-indent/dist/index.js) | 缩进 | +| [`@aomao/plugin-italic`](./plugins/italic) | [![](https://img.shields.io/npm/v/@aomao/plugin-italic.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/italic/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-italic/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-italic/dist/index.js) | 斜体 | +| [`@aomao/plugin-link`](./plugins/link) | [![](https://img.shields.io/npm/v/@aomao/plugin-link.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/link/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-link/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-link/dist/index.js) | 链接, 适用于 `React` | +| [`@aomao/plugin-link-vue`](./plugins/link-vue) | [![](https://img.shields.io/npm/v/@aomao/plugin-link-vue.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/link-vue/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-link-vue/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-link-vue/dist/index.js) | 链接, 适用于 `Vue3` | +| [`am-editor-link-vue2`](https://github.com/zb201307/am-editor-vue2/tree/main/packages/link) | [![](https://img.shields.io/npm/v/am-editor-link-vue2.svg?maxAge=3600&label=&colorB=007ec6)](https://github.com/zb201307/am-editor-vue2/tree/main/packages/link/package.json) | [![](http://img.badgesize.io/https://unpkg.com/am-editor-link-vue2/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/am-editor-link-vue2/dist/index.js) | 链接, 适用于 `Vue2` | +| [`@aomao/plugin-line-height`](./plugins/line-height) | [![](https://img.shields.io/npm/v/@aomao/plugin-line-height.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/line-height/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-line-height/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-line-height/dist/index.js) | 行高 | +| [`@aomao/plugin-mark`](./plugins/mark) | [![](https://img.shields.io/npm/v/@aomao/plugin-mark.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/mark/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-mark/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-mark/dist/index.js) | 标记 | +| [`@aomao/plugin-mention`](./plugins/mention) | [![](https://img.shields.io/npm/v/@aomao/plugin-mention.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/mention/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-mention/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-mention/dist/index.js) | 提及 | +| [`@aomao/plugin-orderedlist`](./plugins/orderedlist) | [![](https://img.shields.io/npm/v/@aomao/plugin-orderedlist.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/orderedlist/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-orderedlist/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-orderedlist/dist/index.js) | 有序列表 | +| [`@aomao/plugin-paintformat`](./plugins/paintformat) | [![](https://img.shields.io/npm/v/@aomao/plugin-paintformat.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/paintformat/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-paintformat/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-paintformat/dist/index.js) | 格式刷 | +| [`@aomao/plugin-quote`](./plugins/quote) | [![](https://img.shields.io/npm/v/@aomao/plugin-quote.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/quote/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-quote/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-quote/dist/index.js) | 引用块 | +| [`@aomao/plugin-redo`](./plugins/redo) | [![](https://img.shields.io/npm/v/@aomao/plugin-redo.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/redo/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-redo/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-redo/dist/index.js) | 重做 | +| [`@aomao/plugin-removeformat`](./plugins/removeformat) | [![](https://img.shields.io/npm/v/@aomao/plugin-removeformat.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/removeformat/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-removeformat/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-removeformat/dist/index.js) | 移除样式 | +| [`@aomao/plugin-selectall`](./plugins/selectall) | [![](https://img.shields.io/npm/v/@aomao/plugin-selectall.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/selectall/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-selectall/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-selectall/dist/index.js) | 全选 | +| [`@aomao/plugin-status`](./plugins/status) | [![](https://img.shields.io/npm/v/@aomao/plugin-status.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/status/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-status/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-status/dist/index.js) | 状态 | +| [`@aomao/plugin-strikethrough`](./plugins/strikethrough) | [![](https://img.shields.io/npm/v/@aomao/plugin-strikethrough.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/strikethrough/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-strikethrough/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-strikethrough/dist/index.js) | 删除线 | +| [`@aomao/plugin-sub`](./plugins/sub) | [![](https://img.shields.io/npm/v/@aomao/plugin-sub.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/sub/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-sub/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-sub/dist/index.js) | 下标 | +| [`@aomao/plugin-sup`](./plugins/sup) | [![](https://img.shields.io/npm/v/@aomao/plugin-sup.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/sup/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-sup/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-sup/dist/index.js) | 上标 | +| [`@aomao/plugin-tasklist`](./plugins/tasklist) | [![](https://img.shields.io/npm/v/@aomao/plugin-tasklist.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/tasklist/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-tasklist/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-tasklist/dist/index.js) | 任务列表 | +| [`@aomao/plugin-underline`](./plugins/underline) | [![](https://img.shields.io/npm/v/@aomao/plugin-underline.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/underline/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-underline/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-underline/dist/index.js) | 下划线 | +| [`@aomao/plugin-undo`](./plugins/undo) | [![](https://img.shields.io/npm/v/@aomao/plugin-undo.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/undo/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-undo/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-undo/dist/index.js) | 撤销 | +| [`@aomao/plugin-unorderedlist`](./plugins/unorderedlist) | [![](https://img.shields.io/npm/v/@aomao/plugin-unorderedlist.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/unorderedlist/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-unorderedlist/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-unorderedlist/dist/index.js) | 无序列表 | +| [`@aomao/plugin-image`](./plugins/image) | [![](https://img.shields.io/npm/v/@aomao/plugin-image.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/image/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-image/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-image/dist/index.js) | 图片 | +| [`@aomao/plugin-table`](./plugins/table) | [![](https://img.shields.io/npm/v/@aomao/plugin-table.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/table/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-table/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-table/dist/index.js) | 表格 | +| [`@aomao/plugin-file`](./plugins/file) | [![](https://img.shields.io/npm/v/@aomao/plugin-file.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/file/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-file/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-file/dist/index.js) | 文件 | +| [`@aomao/plugin-mark-range`](./plugins/mark-range) | [![](https://img.shields.io/npm/v/@aomao/plugin-mark-range.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/mark-range/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-mark-range/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-mark-range/dist/index.js) | 标记光标, 例如: 批注. | +| [`@aomao/plugin-math`](./plugins/math) | [![](https://img.shields.io/npm/v/@aomao/plugin-math.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/math/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-math/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-math/dist/index.js) | 数学公式 | +| [`@aomao/plugin-video`](./plugins/video) | [![](https://img.shields.io/npm/v/@aomao/plugin-video.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/video/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-video/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-video/dist/index.js) | 视频 | + +## 快速上手 + +### 安装 + +编辑器由 `引擎`、`工具栏`、`插件` 组成。`引擎` 为我们提供了核心的编辑能力。 + +使用 npm 或者 yarn 安装引擎包 + +```bash +$ npm install @aomao/engine +# or +$ yarn add @aomao/engine +``` + +### 使用 + +我们按照惯例先输出一个`Hello word!` + +```tsx +import React, { useEffect, useRef, useState } from 'react'; +import Engine, { EngineInterface } from '@aomao/engine'; + +const EngineDemo = () => { + //编辑器容器 + const ref = useRef(null); + //引擎实例 + const [engine, setEngine] = useState(); + //编辑器内容 + const [content, setContent] = useState('

Hello word!

'); + + useEffect(() => { + if (!ref.current) return; + //实例化引擎 + const engine = new Engine(ref.current); + //设置编辑器值 + engine.setValue(content); + //监听编辑器值改变事件 + engine.on('change', (value) => { + setContent(value); + console.log(`value:${value}`); + }); + //设置引擎实例 + setEngine(engine); + }, []); + + return
; +}; +export default EngineDemo; +``` + +### 插件 + +引入 `@aomao/plugin-bold` 加粗插件 + +```tsx +import Bold from '@aomao/plugin-bold'; +``` + +把 `Bold` 插件加入引擎 + +```tsx +//实例化引擎 +const engine = new Engine(ref.current, { + plugins: [Bold], +}); +``` + +### 卡片 + +卡片是编辑器中单独划分的一个区域,其 UI 以及逻辑在卡片内部可以使用 React、Vue 或其它前端库自定义渲染内容,最后再挂载到编辑器上。 + +引入 `@aomao/plugin-codeblock` 代码块插件,这个插件的 `语言下拉框` 使用 `React` 渲染,所以有区分。 `Vue3` 使用 `@aomao/plugin-codeblock-vue` + +```tsx +import CodeBlock, { CodeBlockComponent } from '@aomao/plugin-codeblock'; +``` + +把 `CodeBlock` 插件和 `CodeBlockComponent` 卡片组件加入引擎 + +```tsx +//实例化引擎 +const engine = new Engine(ref.current, { + plugins: [CodeBlock], + cards: [CodeBlockComponent], +}); +``` + +`CodeBlock` 插件默认支持 `markdown`,在编辑器一行开头位置输入代码块语法` ```javascript ` 回车后即可触发。 + +### 工具栏 + +引入 `@aomao/toolbar` 工具栏,工具栏由于交互复杂,基本上都是使用 `React` + `Antd` UI 组件渲染,`Vue3` 使用 `@aomao/toolbar-vue` + +工具栏除了 UI 交互外,大部分工作只是对不同的按钮事件触发后调用了引擎执行对应的插件命令,在需求比较复杂或需要重新定制 UI 的情况下,Fork 后修改起来也比较容易。 + +```tsx +import Toolbar, { ToolbarPlugin, ToolbarComponent } from '@aomao/toolbar'; +``` + +把 `ToolbarPlugin` 插件和 `ToolbarComponent` 卡片组件加入引擎,它可以让我们在编辑器中可以使用快捷键 `/` 唤醒出卡片工具栏 + +```tsx +//实例化引擎 +const engine = new Engine(ref.current, { + plugins: [ToolbarPlugin], + cards: [ToolbarComponent], +}); +``` + +渲染工具栏,工具栏已配置好所有插件,这里我们只需要传入插件名称即可 + +```tsx +return ( + ... + { + engine && ( + + ) + } + ... +) +``` + +更复杂的工具栏配置请查看文档 [https://editor.yanmao.cc/zh-CN/config/toolbar](https://editor.yanmao.cc/zh-CN/config/toolbar) + +### 协同编辑 + +协同编辑基于 [ShareDB](https://github.com/share/sharedb) 开源库实现,比较陌生的朋友可以先了解它。 + +#### 交互模式 + +每位编辑者作为 [客户端](https://github.com/yanmao-cc/am-editor/tree/master/examples/react/components/editor/ot/client.ts) 通过 `WebSocket` 与 [服务端](https://github.com/yanmao-cc/am-editor/tree/master/ot-server) 通信交换由编辑器生成的 `json0` 格式的数据。 + +服务端会保留一份 `json` 格式的 `html` 结构数据,接收到来自客户端的指令后,再去修改这份数据,最后再转发到每个客户端。 + +在启用协同编辑前,我们需要配置好 [客户端](https://github.com/yanmao-cc/am-editor/tree/master/examples/react/components/editor/ot/client.ts) 和 [服务端](https://github.com/yanmao-cc/am-editor/tree/master/ot-server) + +服务端是 `NodeJs` 环境,使用 `express` + `WebSocket` 搭建的网络服务。 + +#### 案例 + +案例中我们已经一份比较基础的客户端代码 + +[查看 React 完整案例](https://github.com/yanmao-cc/am-editor/tree/master/examples/react) + +[查看 Vue3 完整案例](https://github.com/yanmao-cc/am-editor/tree/master/examples/vue) + +[查看 Vue2 完整案例](https://github.com/zb201307/am-editor-vue2) + +```tsx +//实例化协作编辑客户端,传入当前编辑器引擎实例 +const otClient = new OTClient(engine); +//连接到协作服务端,`demo` 与服务端文档ID相同 +otClient.connect( + `ws://127.0.0.1:8080${currentMember ? '?uid=' + currentMember.id : ''}`, + 'demo', +); +``` + +### 项目图标 + +[Iconfont](https://at.alicdn.com/t/project/1456030/0cbd04d3-3ca1-4898-b345-e0a9150fcc80.html?spm=a313x.7781069.1998910419.35) + +## 开发 + +### React + +需要在 `am-editor 根目录` `site-ssr` `ot-server` 中分别安装依赖 + +```base +//依赖安装好后,只需要在根目录执行以下命令 + +yarn ssr +``` + +- `packages` 引擎和工具栏 +- `plugins` 所有的插件 +- `site-ssr` 所有的后端 API 和 SSR 配置。使用的 egg 。在 am-editor 根目录下使用 yarn ssr 自动启动 `site-ssr` +- `ot-server` 协同服务端。启动:yarn start + +启动后访问 localhost:7001 + +### Vue + +只需要进入 examples/vue 目录安装依赖 + +```base +//依赖安装好后,在 examples/vue 目录执行以下命令 + +yarn serve +``` + +在 Vue 运行环境中,默认是安装的已发布到 npm 上的代码。如果需要修改引擎或者插件的代码后立即看到效果,我们需要做以下步骤: + +- 删除 examples/vue/node_modules/@aomao 文件夹 +- 删除 examples/vue/node_modules/vue 文件夹。因为有插件依赖了 Vue,所以 Vue 的包会在项目根目录中安装。如果不删除 examples/vue 中的 Vue 包,和插件的 Vue 包不在一个环境中,就无法加载插件 +- 在 am-editor 根目录下执行安装所有依赖命令,例如:`yarn` +- 最后在 examples/vue 中重新启动 + +`Vue` 案例中没有配置任何后端 API,具体可以参考 `React` 和 `site-ssr` + +## 贡献 + +感谢 [pleasedmi](https://github.com/pleasedmi)、[Elena211314](https://github.com/Elena211314)、[zb201307](https://github.com/zb201307) 的捐赠 + +如果您愿意,可以在这里留下你的名字。 + +### 支付宝 + +![alipay](https://cdn-object.yanmao.cc/contribution/alipay.png?x-oss-process=image/resize,w_200) + +### 微信支付 + +![wechat](https://cdn-object.yanmao.cc/contribution/weichat.png?x-oss-process=image/resize,w_200) + +### PayPal + +[https://paypal.me/aomaocom](https://paypal.me/aomaocom) diff --git a/docs/api/clipboard.md b/docs/api/clipboard.md new file mode 100644 index 00000000..a5901042 --- /dev/null +++ b/docs/api/clipboard.md @@ -0,0 +1,68 @@ +# Clipboard + +Clipboard related operations + +Type: `ClipboardInterface` + +## Constructor + +```ts +new (editor: EditorInterface): CommandInterface +``` + +## Method + +### `getData` + +Get clipboard data + +```ts +/** + * Get clipboard data + * @param event event + */ +getData(event: DragEvent | ClipboardEvent): ClipboardData; +``` + +### `write` + +Write to clipboard + +```ts +/** + * Write to clipboard + * @param event event + * @param range cursor, get the current cursor position by default + * @param callback callback + */ +write( + event: ClipboardEvent, + range?: RangeInterface | null, + callback?: (data: {html: string; text: string }) => void, +): void; +``` + +### `cut` + +Perform cut and paste operations at the current cursor position + +```ts +/** + * Perform cut and paste operations at the current cursor position + */ +cut(): void; +``` + +### `copy` + +copy + +```ts +/** + * Copy + * @param data The data to be copied, which can be a node or a string + * @param trigger Whether to trigger the clipping event and notify the plug-in to process the conversion + * @returns returns whether the copy is successful + */ +copy(data: Node | string, trigger?: boolean): boolean; +``` diff --git a/docs/api/clipboard.zh-CN.md b/docs/api/clipboard.zh-CN.md new file mode 100644 index 00000000..4223d947 --- /dev/null +++ b/docs/api/clipboard.zh-CN.md @@ -0,0 +1,68 @@ +# 剪贴板 + +剪贴板相关操作 + +类型:`ClipboardInterface` + +## 构造函数 + +```ts +new (editor: EditorInterface): CommandInterface +``` + +## 方法 + +### `getData` + +获取剪贴板数据 + +```ts +/** + * 获取剪贴板数据 + * @param event 事件 + */ +getData(event: DragEvent | ClipboardEvent): ClipboardData; +``` + +### `write` + +写入剪贴板 + +```ts +/** + * 写入剪贴板 + * @param event 事件 + * @param range 光标,默认获取当前光标位置 + * @param callback 回调 + */ +write( + event: ClipboardEvent, + range?: RangeInterface | null, + callback?: (data: { html: string; text: string }) => void, +): void; +``` + +### `cut` + +在当前光标位置执行剪贴操作 + +```ts +/** + * 在当前光标位置执行剪贴操作 + */ +cut(): void; +``` + +### `copy` + +复制 + +```ts +/** + * 复制 + * @param data 要复制的数据,可以是节点或者字符串 + * @param trigger 是否触发剪贴事件,通知插件处理转换 + * @returns 返回是否复制成功 + */ +copy(data: Node | string, trigger?: boolean): boolean; +``` diff --git a/docs/api/command.md b/docs/api/command.md new file mode 100644 index 00000000..9fb4b562 --- /dev/null +++ b/docs/api/command.md @@ -0,0 +1,45 @@ +# Command + +Execute plugin commands + +Type: `CommandInterface` + +## Constructor + +```ts +new (editor: EditorInterface): CommandInterface +``` + +## Method + +### `queryEnabled` + +Query whether there is a command to enable the specified plug-in + +```ts +queryEnabled(name: string): boolean; +``` + +### `queryState` + +Check plug-in status + +```ts +queryState(name: string, ...args: any): any; +``` + +### `execute` + +Execute plugin commands + +```ts +execute(name: string, ...args: any): any; +``` + +### `executeMethod` + +To simply execute the plug-in method, you need to ensure that there are methods defined in the plug-in that need to be called. The difference with `execute`: the `execute` method mainly changes the editor + +```ts +executeMethod(name: string, method: string, ...args: any): any; +``` diff --git a/docs/api/command.zh-CN.md b/docs/api/command.zh-CN.md new file mode 100644 index 00000000..2825e777 --- /dev/null +++ b/docs/api/command.zh-CN.md @@ -0,0 +1,45 @@ +# 命令 + +执行插件命令 + +类型:`CommandInterface` + +## 构造函数 + +```ts +new (editor: EditorInterface): CommandInterface +``` + +## 方法 + +### `queryEnabled` + +查询是否有启用指定插件命令 + +```ts +queryEnabled(name: string): boolean; +``` + +### `queryState` + +查询插件状态 + +```ts +queryState(name: string, ...args: any): any; +``` + +### `execute` + +执行插件命令 + +```ts +execute(name: string, ...args: any): any; +``` + +### `executeMethod` + +单纯的执行插件方法,需要保证插件中有定义需要调用的方法。与 `execute` 的区别:`execute` 方法主要对编辑器有所更改 + +```ts +executeMethod(name: string, method: string, ...args: any): any; +``` diff --git a/docs/api/constants.md b/docs/api/constants.md new file mode 100644 index 00000000..96f44a76 --- /dev/null +++ b/docs/api/constants.md @@ -0,0 +1,111 @@ +# Constant + +## Node + +### `DATA_ELEMENT` + +Mark node type + +### `ROOT` + +Mark as root node + +### `ROOT_SELECTOR` + +Root node selector + +### `UI` + +Mark as UI node + +### `UI_SELECTOR` + +UI node CSS selector + +### `EDITABLE` + +Mark as editable node + +### `EDITABLE_SELECTOR` + +Editable node CSS selector + +### `DATA_TRANSIENT_ATTRIBUTES` + +Mark node attributes that do not participate in collaboration + +### `DATA_TRANSIENT_ELEMENT` + +Mark nodes that do not participate in collaboration + +## Selection area + +### `ANCHOR` + +Start node marker + +### `FOCUS` + +End node marker + +### `CURSOR` + +Mark where the start position and end position coincide + +### `ANCHOR_SELECTOR` + +Start Node Marker CSS Queryer + +### `FOCUS_SELECTOR` + +End node marker CSS finder + +### `CURSOR_SELECTOR` + +Mark the CSS finder where the start position and end position coincide + +## Card + +### `CARD_TAG` + +Card node label name + +### `CARD_KEY` + +Card name + +### `READY_CARD_KEY` + +Name of the card to be rendered + +### `CARD_TYPE_KEY` + +Card type + +### `CARD_VALUE_KEY` + +Card value + +### `CARD_ELEMENT_KEY` + +Card node + +### `CARD_SELECTOR` + +Card CSS selector + +### `READY_CARD_SELECTOR` + +CSS selector for the card to be rendered + +### `CARD_LEFT_SELECTOR` + +CSS selector on the left side of the card + +### `CARD_CENTER_SELECTOR` + +CSS selector for card center node + +### `CARD_RIGHT_SELECTOR` + +CSS selector on the right side of the card diff --git a/docs/api/constants.zh-CN.md b/docs/api/constants.zh-CN.md new file mode 100644 index 00000000..b896b7be --- /dev/null +++ b/docs/api/constants.zh-CN.md @@ -0,0 +1,111 @@ +# 常量 + +## 节点 + +### `DATA_ELEMENT` + +标记节点类型 + +### `ROOT` + +标记为根节点 + +### `ROOT_SELECTOR` + +根节点选择器 + +### `UI` + +标记为 UI 节点 + +### `UI_SELECTOR` + +UI 节点 CSS 选择器 + +### `EDITABLE` + +标记为可编辑器节点 + +### `EDITABLE_SELECTOR` + +可编辑节点 CSS 选择器 + +### `DATA_TRANSIENT_ATTRIBUTES` + +标记不参与协同的节点属性 + +### `DATA_TRANSIENT_ELEMENT` + +标记不参与协同的节点 + +## 选区范围 + +### `ANCHOR` + +开始节点标记 + +### `FOCUS` + +结束节点标记 + +### `CURSOR` + +开始位置和结束位置重合处标记 + +### `ANCHOR_SELECTOR` + +开始节点标记 CSS 查询器 + +### `FOCUS_SELECTOR` + +结束节点标记 CSS 查询器 + +### `CURSOR_SELECTOR` + +开始位置和结束位置重合处标记 CSS 查询器 + +## 卡片 + +### `CARD_TAG` + +卡片节点标签名称 + +### `CARD_KEY` + +卡片名称 + +### `READY_CARD_KEY` + +待渲染卡片名称 + +### `CARD_TYPE_KEY` + +卡片类型 + +### `CARD_VALUE_KEY` + +卡片值 + +### `CARD_ELEMENT_KEY` + +卡片节点 + +### `CARD_SELECTOR` + +卡片 CSS 选择器 + +### `READY_CARD_SELECTOR` + +待渲染卡片 CSS 选择器 + +### `CARD_LEFT_SELECTOR` + +卡片左侧 CSS 选择器 + +### `CARD_CENTER_SELECTOR` + +卡片中心节点 CSS 选择器 + +### `CARD_RIGHT_SELECTOR` + +卡片右侧 CSS 选择器 diff --git a/docs/api/editor-block.md b/docs/api/editor-block.md new file mode 100644 index 00000000..cc4ffda2 --- /dev/null +++ b/docs/api/editor-block.md @@ -0,0 +1,320 @@ +# BlockModel + +Edit related operations of block-level nodes + +Type: `BlockModelInterface` + +## Use + +```ts +new Engine(...).block +``` + +## Constructor + +```ts +new (editor: EditorInterface): BlockModelInterface +``` + +## Method + +### `init` + +initialization + +```ts +/** + * Initialization + */ +init(): void; +``` + +### `findPlugin` + +Find the block plugin instance according to the node + +```ts +/** + * Find the block plugin instance according to the node + * @param node node + */ +findPlugin(node: NodeInterface): BlockInterface | undefined; +``` + +### `findTop` + +Find the first-level node of the Block node. For example, div -> H2 returns H2 node + +```ts +/** + * Find the first level node of the Block node. For example, div -> H2 returns H2 node + * @param parentNode parent node + * @param childNode child node + */ +findTop(parentNode: NodeInterface, childNode: NodeInterface): NodeInterface; +``` + +### `closest` + +Get the nearest block node, can not find the return node + +```ts +/** + * Get the nearest block node, the return node cannot be found + * @param node node + */ +closest(node: NodeInterface): NodeInterface; +``` + +### `wrap` + +Wrap a block node at the cursor position + +```ts +/** + * Wrap a block node at the cursor position + * @param block node + * @param range cursor + */ +wrap(block: NodeInterface | Node | string, range?: RangeInterface): void; +``` + +### `unwrap` + +Remove the package of the block node where the cursor is located + +```ts +/** + * Remove the package of the block node where the cursor is located + * @param block node + * @param range cursor + */ +unwrap(block: NodeInterface | Node | string, range?: RangeInterface): void; +``` + +### `getSiblings` + +Get the node's sibling node set relative to the cursor start position and end position + +```ts +/** + * Get the node's sibling node set relative to the cursor start position and end position + * @param range cursor + * @param block node + */ +getSiblings( + range: RangeInterface, + block: NodeInterface, +): Array<{ node: NodeInterface; position:'left' |'center' |'right' }>; +``` + +### `split` + +Split the block node selected by the current cursor + +```ts +/** + * Split the block node selected by the current cursor + * @param range cursor + */ +split(range?: RangeInterface): void; +``` + +### `insert` + +Insert a block node at the current cursor position + +```ts +/** + * Insert a block node at the current cursor position + * @param block node + * @param range cursor + * @param splitNode split node, the default is the block node at the beginning of the cursor + */ +insert( + block: NodeInterface | Node | string, + range?: RangeInterface, + splitNode?: (node: NodeInterface) => NodeInterface, +): void; +``` + +### `setBlocks` + +Set all block nodes where the current cursor is located as new nodes or set new attributes + +```ts +/** + * Set all block nodes where the current cursor is located as new nodes or set new attributes + * @param block The node or node attribute that needs to be set + * @param range cursor + */ +setBlocks( + block: string | {[k: string]: any }, + range?: RangeInterface, +): void; +``` + +### `merge` + +Merge blocks adjacent to the current cursor position + +```ts +/** + * Combine blocks adjacent to the current cursor position + * @param range cursor + */ +merge(range?: RangeInterface): void; +``` + +### `findBlocks` + +Find all blocks that have an effect on the range + +```ts +/** + * Find all blocks that have an effect on the range + * @param range + */ +findBlocks(range: RangeInterface): Array; +``` + +### `isFirstOffset` + +Determine whether the {Edge}Offset of the range is at the beginning of the Block + +```ts +/** + * Determine whether the {Edge}Offset of the range is at the beginning of the Block + * @param range cursor + * @param edge start | end + */ +isFirstOffset(range: RangeInterface, edge:'start' |'end'): boolean; +``` + +### `isLastOffset` + +Determine whether the {Edge}Offset of the range is at the last position of the Block + +```ts +/** + * Determine whether the {Edge}Offset of the range is at the last position of the Block + * @param range cursor + * @param edge start | end + */ +isLastOffset(range: RangeInterface, edge:'start' |'end'): boolean; +``` + +### `getBlocks` + +Get all blocks in the range + +```ts +/** + * Get all blocks in the range + * @param range cursors + */ +getBlocks(range: RangeInterface): Array; +``` + +### `getLeftText` + +Get the left text of Block + +```ts +/** + * Get the left text of Block + * @param block node + */ +getLeftText(block: NodeInterface | Node): string; +``` + +### `removeLeftText` + +Delete the left text of Block + +```ts +/** + * Delete the text on the left side of Block + * @param block node + */ +removeLeftText(block: NodeInterface | Node): void; +``` + +### `getBlockByRange` + +Generate the node on the left or right side of the cursor and place it in the same container as the parent node + +```ts +/** + * Generate the node on the left or right side of the cursor and place it in the same container as the parent node + * isLeft = true: left + * isLeft = false: the right side + * @param {block,range,isLeft,clone,keepID} node, cursor, left or right, whether to copy, whether to keep id + * + */ +getBlockByRange({ + block, + range, + isLeft, + clone, + keepID, +}: { + block: NodeInterface | Node; + range: RangeInterface; + isLeft: boolean; + clone?: boolean; + keepID?: boolean; +}): NodeInterface; +``` + +### `normal` + +Sort block-level nodes into standard editor values + +```ts +/** + * Sorting block-level nodes + * @param node node + * @param root root node + */ +normal(node: NodeInterface, root: NodeInterface): void; +``` + +### `brToBlock` + +br change line to paragraph + +```ts +/** + * br change lines to paragraphs + * @param block node + */ +brToBlock(block: NodeInterface): void; +``` + +### `insertEmptyBlock` + +Insert an empty block node + +```ts +/** + * Insert an empty block node + * @param range cursor position + * @param block node + * @returns + */ +insertEmptyBlock(range: RangeInterface, block: NodeInterface): void; +``` + +### `insertOrSplit` + +Insert or split node at cursor position + +```ts +/** + * Insert or split a node at the cursor position + * @param range cursor position + * @param block node + */ +insertOrSplit(range: RangeInterface, block: NodeInterface): void; +``` diff --git a/docs/api/editor-block.zh-CN.md b/docs/api/editor-block.zh-CN.md new file mode 100644 index 00000000..3932752b --- /dev/null +++ b/docs/api/editor-block.zh-CN.md @@ -0,0 +1,320 @@ +# BlockModel + +编辑块级节点的相关操作 + +类型:`BlockModelInterface` + +## 使用 + +```ts +new Engine(...).block +``` + +## 构造函数 + +```ts +new (editor: EditorInterface): BlockModelInterface +``` + +## 方法 + +### `init` + +初始化 + +```ts +/** + * 初始化 + */ +init(): void; +``` + +### `findPlugin` + +根据节点查找 block 插件实例 + +```ts +/** + * 根据节点查找block插件实例 + * @param node 节点 + */ +findPlugin(node: NodeInterface): BlockInterface | undefined; +``` + +### `findTop` + +查找 Block 节点的一级节点。如 div -> H2 返回 H2 节点 + +```ts +/** + * 查找Block节点的一级节点。如 div -> H2 返回 H2节点 + * @param parentNode 父节点 + * @param childNode 子节点 + */ +findTop(parentNode: NodeInterface, childNode: NodeInterface): NodeInterface; +``` + +### `closest` + +获取最近的 block 节点,找不到返回 node + +```ts +/** + * 获取最近的block节点,找不到返回 node + * @param node 节点 + */ +closest(node: NodeInterface): NodeInterface; +``` + +### `wrap` + +在光标位置包裹一个 block 节点 + +```ts +/** + * 在光标位置包裹一个block节点 + * @param block 节点 + * @param range 光标 + */ +wrap(block: NodeInterface | Node | string, range?: RangeInterface): void; +``` + +### `unwrap` + +移除光标所在 block 节点包裹 + +```ts +/** + * 移除光标所在block节点包裹 + * @param block 节点 + * @param range 光标 + */ +unwrap(block: NodeInterface | Node | string, range?: RangeInterface): void; +``` + +### `getSiblings` + +获取节点相对于光标开始位置、结束位置下的兄弟节点集合 + +```ts +/** + * 获取节点相对于光标开始位置、结束位置下的兄弟节点集合 + * @param range 光标 + * @param block 节点 + */ +getSiblings( + range: RangeInterface, + block: NodeInterface, +): Array<{ node: NodeInterface; position: 'left' | 'center' | 'right' }>; +``` + +### `split` + +分割当前光标选中的 block 节点 + +```ts +/** + * 分割当前光标选中的block节点 + * @param range 光标 + */ +split(range?: RangeInterface): void; +``` + +### `insert` + +在当前光标位置插入 block 节点 + +```ts +/** + * 在当前光标位置插入block节点 + * @param block 节点 + * @param range 光标 + * @param splitNode 分割节点,默认为光标开始位置的block节点 + */ +insert( + block: NodeInterface | Node | string, + range?: RangeInterface, + splitNode?: (node: NodeInterface) => NodeInterface, +): void; +``` + +### `setBlocks` + +设置当前光标所在的所有 block 节点为新的节点或设置新属性 + +```ts +/** + * 设置当前光标所在的所有block节点为新的节点或设置新属性 + * @param block 需要设置的节点或者节点属性 + * @param range 光标 + */ +setBlocks( + block: string | { [k: string]: any }, + range?: RangeInterface, +): void; +``` + +### `merge` + +合并当前光标位置相邻的 block + +```ts +/** + * 合并当前光标位置相邻的block + * @param range 光标 + */ +merge(range?: RangeInterface): void; +``` + +### `findBlocks` + +查找对范围有效果的所有 Block + +```ts +/** + * 查找对范围有效果的所有 Block + * @param range 范围 + */ +findBlocks(range: RangeInterface): Array; +``` + +### `isFirstOffset` + +判断范围的 {Edge}Offset 是否在 Block 的开始位置 + +```ts +/** + * 判断范围的 {Edge}Offset 是否在 Block 的开始位置 + * @param range 光标 + * @param edge start | end + */ +isFirstOffset(range: RangeInterface, edge: 'start' | 'end'): boolean; +``` + +### `isLastOffset` + +判断范围的 {Edge}Offset 是否在 Block 的最后位置 + +```ts +/** + * 判断范围的 {Edge}Offset 是否在 Block 的最后位置 + * @param range 光标 + * @param edge start | end + */ +isLastOffset(range: RangeInterface, edge: 'start' | 'end'): boolean; +``` + +### `getBlocks` + +获取范围内的所有 Block + +```ts +/** + * 获取范围内的所有 Block + * @param range 光标s + */ +getBlocks(range: RangeInterface): Array; +``` + +### `getLeftText` + +获取 Block 左侧文本 + +```ts +/** + * 获取 Block 左侧文本 + * @param block 节点 + */ +getLeftText(block: NodeInterface | Node): string; +``` + +### `removeLeftText` + +删除 Block 左侧文本 + +```ts +/** + * 删除 Block 左侧文本 + * @param block 节点 + */ +removeLeftText(block: NodeInterface | Node): void; +``` + +### `getBlockByRange` + +生成 cursor 左侧或右侧的节点,放在一个和父节点一样的容器里 + +```ts +/** + * 生成 cursor 左侧或右侧的节点,放在一个和父节点一样的容器里 + * isLeft = true:左侧 + * isLeft = false:右侧 + * @param {block,range,isLeft,clone,keepID} 节点,光标,左侧或右侧, 是否复制,是否保持id + * + */ +getBlockByRange({ + block, + range, + isLeft, + clone, + keepID, +}: { + block: NodeInterface | Node; + range: RangeInterface; + isLeft: boolean; + clone?: boolean; + keepID?: boolean; +}): NodeInterface; +``` + +### `normal` + +整理块级节点为符合标准的编辑器值 + +```ts +/** + * 整理块级节点 + * @param node 节点 + * @param root 根节点 + */ +normal(node: NodeInterface, root: NodeInterface): void; +``` + +### `brToBlock` + +br 换行改成段落 + +```ts +/** + * br 换行改成段落 + * @param block 节点 + */ +brToBlock(block: NodeInterface): void; +``` + +### `insertEmptyBlock` + +插入一个空的 block 节点 + +```ts +/** + * 插入一个空的block节点 + * @param range 光标所在位置 + * @param block 节点 + * @returns + */ +insertEmptyBlock(range: RangeInterface, block: NodeInterface): void; +``` + +### `insertOrSplit` + +在光标位置插入或分割节点 + +```ts +/** + * 在光标位置插入或分割节点 + * @param range 光标所在位置 + * @param block 节点 + */ +insertOrSplit(range: RangeInterface, block: NodeInterface): void; +``` diff --git a/docs/api/editor-card-maximize.md b/docs/api/editor-card-maximize.md new file mode 100644 index 00000000..5e16afa1 --- /dev/null +++ b/docs/api/editor-card-maximize.md @@ -0,0 +1,35 @@ +# Maximize + +Adjust the card to maximize/minimize + +Type: `MaximizeInterface` + +## Constructor + +```ts +new (editor: EditorInterface, card: CardInterface): MaximizeInterface +``` + +## Method + +### `restore` + +restore + +```ts +/** + * Restore + */ +restore(): void; +``` + +### `maximize` + +maximize + +```ts +/** + * Maximize + */ +maximize(): void; +``` diff --git a/docs/api/editor-card-maximize.zh-CN.md b/docs/api/editor-card-maximize.zh-CN.md new file mode 100644 index 00000000..08113529 --- /dev/null +++ b/docs/api/editor-card-maximize.zh-CN.md @@ -0,0 +1,35 @@ +# 最大化 + +调整卡片最大化/最小化 + +类型:`MaximizeInterface` + +## 构造函数 + +```ts +new (editor: EditorInterface, card: CardInterface): MaximizeInterface +``` + +## 方法 + +### `restore` + +恢复 + +```ts +/** + * 恢复 + */ +restore(): void; +``` + +### `maximize` + +最大化 + +```ts +/** + * 最大化 + */ +maximize(): void; +``` diff --git a/docs/api/editor-card-resize.md b/docs/api/editor-card-resize.md new file mode 100644 index 00000000..f3b7f6e2 --- /dev/null +++ b/docs/api/editor-card-resize.md @@ -0,0 +1,106 @@ +# Resize + +A tool that can adjust the size of the card content area + +Type: `ResizeInterface` + +## Constructor + +```ts +new (editor: EditorInterface, card: CardInterface): ResizeInterface +``` + +## Method + +### `create` + +Create and bind events + +```ts +/** + * Create and bind events + * @param options optional + */ +create(options: ResizeCreateOptions): void; +``` + +### `render` + +Rendering tools + +```ts +/** + * Render + * The target node rendered by @param container, the default is the root node of the current card + * @param minHeight minimum height, default 80px + */ +render(container?: NodeInterface, minHeight?: number): void; +``` + +### `dragStart` + +Pull start + +```ts +/** + * Pull to start + * @param event event + */ +dragStart(event: MouseEvent): void; +``` + +### `dragMove` + +Pulling moving + +```ts +/** + * Pulling and moving + * @param event event + */ +dragMove(event: MouseEvent): void; +``` + +### `dragEnd` + +Pull over + +```ts +/** + * Pull end + */ +dragEnd(event: MouseEvent): void; +``` + +### `show` + +Show off + +```ts +/** + * Show + */ +show(): void; +``` + +### `hide` + +hide + +```ts +/** + * Hide + */ +hide(): void; +``` + +### `destroy` + +Logout + +```ts +/** + * Logout + */ +destroy(): void; +``` diff --git a/docs/api/editor-card-resize.zh-CN.md b/docs/api/editor-card-resize.zh-CN.md new file mode 100644 index 00000000..0e081176 --- /dev/null +++ b/docs/api/editor-card-resize.zh-CN.md @@ -0,0 +1,106 @@ +# 调整大小 + +可以调整卡片内容区域大小的工具 + +类型:`ResizeInterface` + +## 构造函数 + +```ts +new (editor: EditorInterface, card: CardInterface): ResizeInterface +``` + +## 方法 + +### `create` + +创建并绑定事件 + +```ts +/** + * 创建并绑定事件 + * @param options 可选项 + */ +create(options: ResizeCreateOptions): void; +``` + +### `render` + +渲染工具 + +```ts +/** + * 渲染 + * @param container 渲染到的目标节点,默认为当前卡片根节点 + * @param minHeight 最小高度,默认80px + */ +render(container?: NodeInterface, minHeight?: number): void; +``` + +### `dragStart` + +拉动开始 + +```ts +/** + * 拉动开始 + * @param event 事件 + */ +dragStart(event: MouseEvent): void; +``` + +### `dragMove` + +拉动移动中 + +```ts +/** + * 拉动移动中 + * @param event 事件 + */ +dragMove(event: MouseEvent): void; +``` + +### `dragEnd` + +拉动结束 + +```ts +/** + * 拉动结束 + */ +dragEnd(event: MouseEvent): void; +``` + +### `show` + +展示 + +```ts +/** + * 展示 + */ +show(): void; +``` + +### `hide` + +隐藏 + +```ts +/** + * 隐藏 + */ +hide(): void; +``` + +### `destroy` + +注销 + +```ts +/** + * 注销 + */ +destroy(): void; +``` diff --git a/docs/api/editor-card-toolbar.md b/docs/api/editor-card-toolbar.md new file mode 100644 index 00000000..73784da1 --- /dev/null +++ b/docs/api/editor-card-toolbar.md @@ -0,0 +1,81 @@ +# Toolbar + +Card toolbar + +Type: `CardToolbarInterface` + +## Constructor + +```ts +new (editor: EditorInterface, card: CardInterface): CardToolbarInterface +``` + +## Method + +### `create` + +Create card toolbar + +```ts +/** + * Toolbar for creating cards + */ +create(): void; +``` + +### `hide` + +Hide toolbar, including dnd + +```ts +/** +* Hide toolbar, including dnd +*/ +hide(): void; +``` + +### `show` + +Show toolbar, including dnd + +```ts +/** + * Display toolbar, including dnd + * @param event mouse event, used for positioning + */ +show(event?: MouseEvent): void; +``` + +### `hideCardToolbar` + +Only hide the toolbar of the card, not including dnd + +```ts +/** + * Only hide the toolbar of the card, not including dnd + */ +hideCardToolbar(): void; +``` + +### `showCardToolbar` + +Only show the toolbar of the card, not including dnd + +```ts +/** + * Only display the toolbar of the card, not including dnd + * @param event mouse event, used for positioning + */ +showCardToolbar(event?: MouseEvent): void; +``` + +### `getContainer` + +Get toolbar container + +```ts +/** + * Get the toolbar container + */ +getContainer(): NodeInterface | undefined; +``` diff --git a/docs/api/editor-card-toolbar.zh-CN.md b/docs/api/editor-card-toolbar.zh-CN.md new file mode 100644 index 00000000..6d23206b --- /dev/null +++ b/docs/api/editor-card-toolbar.zh-CN.md @@ -0,0 +1,81 @@ +# 工具栏 + +卡片工具栏 + +类型:`CardToolbarInterface` + +## 构造函数 + +```ts +new (editor: EditorInterface, card: CardInterface): CardToolbarInterface +``` + +## 方法 + +### `create` + +创建卡片的 toolbar + +```ts +/** + * 创建卡片的toolbar + */ +create(): void; +``` + +### `hide` + +隐藏 toolbar,包含 dnd + +```ts +/** +* 隐藏toolbar,包含dnd +*/ +hide(): void; +``` + +### `show` + +展示 toolbar,包含 dnd + +```ts +/** + * 展示toolbar,包含dnd + * @param event 鼠标事件,用于定位 + */ +show(event?: MouseEvent): void; +``` + +### `hideCardToolbar` + +只隐藏卡片的 toolbar,不包含 dnd + +```ts +/** + * 只隐藏卡片的toolbar,不包含dnd + */ +hideCardToolbar(): void; +``` + +### `showCardToolbar` + +只显示卡片的 toolbar,不包含 dnd + +```ts +/** + * 只显示卡片的toolbar,不包含dnd + * @param event 鼠标事件,用于定位 + */ +showCardToolbar(event?: MouseEvent): void; +``` + +### `getContainer` + +获取工具栏容器 + +```ts +/** + * 获取工具栏容器 + */ +getContainer(): NodeInterface | undefined; +``` diff --git a/docs/api/editor-card.md b/docs/api/editor-card.md new file mode 100644 index 00000000..1995df5a --- /dev/null +++ b/docs/api/editor-card.md @@ -0,0 +1,335 @@ +# Card + +Edit card related operations + +Type: `CardModelInterface` + +## Use + +```ts +new Engine(...).card +``` + +## Constructor + +```ts +new (editor: EditorInterface): CardModelInterface +``` + +## Attributes + +### `classes` + +Instantiated card collection object + +### `active` + +Currently activated card + +### `length` + +The length of the instantiated card collection object + +## Method + +### `init` + +Instantiate + +```ts +/** + * Instantiate cards + * @param cards card collection + */ +init(cards: Array): void; +``` + +### `add` + +Add card + +```ts +/** + * Add cards + * @param name name + * @param clazz class + */ +add(clazz: CardEntry): void; +``` + +### `each` + +Traverse all created cards + +```ts +/** + * Traverse all created cards + * @param callback callback function + */ +each(callback: (card: CardInterface) => boolean | void): void; +``` + +### `closest` + +Query the card node closest to the parent node + +```ts +/** + * Query the card node closest to the parent node + * @param selector querier + * @param ignoreEditable Whether to ignore editable nodes + */ +closest( + selector: Node | NodeInterface, + ignoreEditable?: boolean, +): NodeInterface | undefined; +``` + +### `find` + +Find Card according to the selector + +```ts +/** + * Find Card according to the selector + * @param selector card ID, or child node + * @param ignoreEditable Whether to ignore editable nodes + */ +find( + selector: NodeInterface | Node | string, + ignoreEditable?: boolean, +): CardInterface | undefined; +``` + +### `findBlock` + +Find Block Type Card according to the selector + +```ts +/** + * Find the Block type Card according to the selector + * @param selector card ID, or child node + */ +findBlock(selector: Node | NodeInterface): CardInterface | undefined; +``` + +### `getSingleCard` + +Get a single card in the cursor selection + +```ts +/** + * Get a single card + * @param range cursor range + */ +getSingleCard(range: RangeInterface): CardInterface | undefined; +``` + +### `getSingleSelectedCard` + +Get the card when a node is selected in the selection + +```ts +/** + * Get the card when a node is selected in the selection + * @param rang selection + */ +getSingleSelectedCard(rang: RangeInterface): CardInterface | undefined; +``` + +### `insertNode` + +Insert card + +```ts +/** + * Insert card + * @param range selection + * @param card card + */ +insertNode(range: RangeInterface, card: CardInterface): CardInterface; +``` + +### `removeNode` + +Remove card node + +```ts +/** + * Remove card node + * @param card card + */ +removeNode(card: CardInterface): void; +``` + +### `replaceNode` + +Replace the specified node with the Card DOM node waiting to be created + +```ts +/** + * Replace the specified node with the Card DOM node waiting to be created + * @param node node + * @param name card name + * @param value card value + */ +replaceNode( + node: NodeInterface, + name: string, + value?: CardValue, +): NodeInterface; +``` + +### `updateNode` + +Update the card to re-render + +```ts +/** + * Update the card to re-render + * @param card card + * @param value + */ +updateNode(card: CardInterface, value: CardValue): void; +``` + +### `activate` + +Activate the card where the card node is located + +```ts +/** + * Activate the card where the card node is located + * @param node node + * @param trigger activation method + * @param event event + */ +activate( + node: NodeInterface, + trigger?: CardActiveTrigger, + event?: MouseEvent, +): void; +``` + +### `select` + +Selected card + +```ts +/** + * Select the card + * @param card card + */ +select(card: CardInterface): void; +``` + +### `focus` + +Focus card + +```ts +/** + * Focus card + * @param card card + * @param toStart Whether to focus to the start position + */ +focus(card: CardInterface, toStart?: boolean): void; +``` + +### `insert` + +Insert card + +```ts +/** + * Insert card + * @param name card name + * @param value card value + */ +insert(name: string, value?: CardValue): CardInterface; +``` + +### `update` + +Update card + +```ts +/** + * Update card + * @param selector card selector + * @param value The card value to be updated + */ +update(selector: NodeInterface | Node | string, value: CardValue): void; +``` + +### `replace` + +Replace the location of a card with another specified card to be rendered + +### `replace` + +Replace the location of a card with another specified card to be rendered + +```ts +/** + * Replace card + * @param source The card to be replaced + * @param name new card name + * @param value New card value + */ +replace(source: CardInterface, name: string, value?: CardValue) +``` + +### `remove` + +Remove card + +```ts +/** + * Remove card + * @param selector card selector + */ +remove(selector: NodeInterface | Node | string): void; +``` + +### `create` + +Create a card + +```ts +/** + * Create a card + * @param name plugin name + * @param options option + */ +create( + name: string, + options?: { + value?: CardValue; + root?: NodeInterface; + }, +): CardInterface; +``` + +### `render` + +Render the card + +```ts +/** + * Render the card + * @param container needs to re-render the node containing the card, if not passed, then render all the card nodes to be created + */ +render(container?: NodeInterface): void; +``` + +### `gc` + +Release card + +```ts +/** + * Release card + */ +gc(): void; +``` diff --git a/docs/api/editor-card.zh-CN.md b/docs/api/editor-card.zh-CN.md new file mode 100644 index 00000000..207945e7 --- /dev/null +++ b/docs/api/editor-card.zh-CN.md @@ -0,0 +1,331 @@ +# Card + +编辑卡片的相关操作 + +类型:`CardModelInterface` + +## 使用 + +```ts +new Engine(...).card +``` + +## 构造函数 + +```ts +new (editor: EditorInterface): CardModelInterface +``` + +## 属性 + +### `classes` + +已实例化的卡片集合对象 + +### `active` + +当前已激活的卡片 + +### `length` + +已实例化的卡片集合对象长度 + +## 方法 + +### `init` + +实例化 + +```ts +/** + * 实例化卡片 + * @param cards 卡片集合 + */ +init(cards: Array): void; +``` + +### `add` + +增加卡片 + +```ts +/** + * 增加卡片 + * @param name 名称 + * @param clazz 类 + */ +add(clazz: CardEntry): void; +``` + +### `each` + +遍历所有已创建的卡片 + +```ts +/** + * 遍历所有已创建的卡片 + * @param callback 回调函数 + */ +each(callback: (card: CardInterface) => boolean | void): void; +``` + +### `closest` + +查询父节点距离最近的卡片节点 + +```ts +/** + * 查询父节点距离最近的卡片节点 + * @param selector 查询器 + * @param ignoreEditable 是否忽略可编辑节点 + */ +closest( + selector: Node | NodeInterface, + ignoreEditable?: boolean, +): NodeInterface | undefined; +``` + +### `find` + +根据选择器查找 Card + +```ts +/** + * 根据选择器查找Card + * @param selector 卡片ID,或者子节点 + * @param ignoreEditable 是否忽略可编辑节点 + */ +find( + selector: NodeInterface | Node | string, + ignoreEditable?: boolean, +): CardInterface | undefined; +``` + +### `findBlock` + +根据选择器查找 Block 类型 Card + +```ts +/** + * 根据选择器查找Block 类型 Card + * @param selector 卡片ID,或者子节点 + */ +findBlock(selector: Node | NodeInterface): CardInterface | undefined; +``` + +### `getSingleCard` + +获取光标选区中的单个卡片 + +```ts +/** + * 获取单个卡片 + * @param range 光标范围 + */ +getSingleCard(range: RangeInterface): CardInterface | undefined; +``` + +### `getSingleSelectedCard` + +获取选区选中一个节点时候的卡片 + +```ts +/** + * 获取选区选中一个节点时候的卡片 + * @param rang 选区 + */ +getSingleSelectedCard(rang: RangeInterface): CardInterface | undefined; +``` + +### `insertNode` + +插入卡片 + +```ts +/** + * 插入卡片 + * @param range 选区 + * @param card 卡片 + */ +insertNode(range: RangeInterface, card: CardInterface): CardInterface; +``` + +### `removeNode` + +移除卡片节点 + +```ts +/** + * 移除卡片节点 + * @param card 卡片 + */ +removeNode(card: CardInterface): void; +``` + +### `replaceNode` + +将指定节点替换成等待创建的 Card DOM 节点 + +```ts +/** + * 将指定节点替换成等待创建的Card DOM 节点 + * @param node 节点 + * @param name 卡片名称 + * @param value 卡片值 + */ +replaceNode( + node: NodeInterface, + name: string, + value?: CardValue, +): NodeInterface; +``` + +### `updateNode` + +更新卡片重新渲染 + +```ts +/** + * 更新卡片重新渲染 + * @param card 卡片 + * @param value 值 + */ +updateNode(card: CardInterface, value: CardValue): void; +``` + +### `activate` + +激活卡片节点所在的卡片 + +```ts +/** + * 激活卡片节点所在的卡片 + * @param node 节点 + * @param trigger 激活方式 + * @param event 事件 + */ +activate( + node: NodeInterface, + trigger?: CardActiveTrigger, + event?: MouseEvent, +): void; +``` + +### `select` + +选中卡片 + +```ts +/** + * 选中卡片 + * @param card 卡片 + */ +select(card: CardInterface): void; +``` + +### `focus` + +聚焦卡片 + +```ts +/** + * 聚焦卡片 + * @param card 卡片 + * @param toStart 是否聚焦到开始位置 + */ +focus(card: CardInterface, toStart?: boolean): void; +``` + +### `insert` + +插入卡片 + +```ts +/** + * 插入卡片 + * @param name 卡片名称 + * @param value 卡片值 + */ +insert(name: string, value?: CardValue): CardInterface; +``` + +### `update` + +更新卡片 + +```ts +/** + * 更新卡片 + * @param selector 卡片选择器 + * @param value 要更新的卡片值 + */ +update(selector: NodeInterface | Node | string, value: CardValue): void; +``` + +### `replace` + +把一个卡片所在位置替换成另一个指定的待渲染卡片 + +```ts +/** + * 替换卡片 + * @param source 需要替换的卡片 + * @param name 新的卡片名称 + * @param value 新的卡片值 + */ +replace(source: CardInterface, name: string, value?: CardValue) +``` + +### `remove` + +移除卡片 + +```ts +/** + * 移除卡片 + * @param selector 卡片选择器 + */ +remove(selector: NodeInterface | Node | string): void; +``` + +### `create` + +创建卡片 + +```ts +/** + * 创建卡片 + * @param name 插件名称 + * @param options 选项 + */ +create( + name: string, + options?: { + value?: CardValue; + root?: NodeInterface; + }, +): CardInterface; +``` + +### `render` + +渲染卡片 + +```ts +/** + * 渲染卡片 + * @param container 需要重新渲染包含卡片的节点,如果不传,则渲染全部待创建的卡片节点 + */ +render(container?: NodeInterface): void; +``` + +### `gc` + +释放卡片 + +```ts +/** + * 释放卡片 + */ +gc(): void; +``` diff --git a/docs/api/editor-change-event.md b/docs/api/editor-change-event.md new file mode 100644 index 00000000..5bb91c5f --- /dev/null +++ b/docs/api/editor-change-event.md @@ -0,0 +1,112 @@ +# Change events + +Related events in editor changes + +Type: `ChangeEventInterface` + +## Constructor + +```ts +new (engine: EngineInterface, options: ChangeEventOptions = {}): ChangeEventInterface; +``` + +## Attributes + +### `isComposing` + +Whether to combine input + +### `isSelecting` + +Is it being selected + +## Method + +### `isCardInput` + +Is it entered in the card + +```ts +isCardInput(e: Event): boolean; +``` + +### `onInput` + +Input event + +```ts +onInput(callback: (event?: Event) => void): void; +``` + +### `onSelect` + +Cursor selection event + +```ts +onSelect(callback: (event?: Event) => void): void; +``` + +### `onPaste` + +Paste event + +```ts +onPaste( + callback: (data: ClipboardData & {isPasteText: boolean }) => void, +): void; +``` + +### `onDrop` + +Drag event + +```ts +onDrop( + callback: (params: { + event: DragEvent; + range?: RangeInterface; + card?: CardInterface; + files: Array; + }) => void, +): void; +``` + +### `onDocument` + +Bind the document event + +```ts +onDocument( + eventType: string, + listener: EventListener, + rewrite?: boolean, +): void; +``` + +### `onWindow` + +Bind window events + +```ts +onWindow( + eventType: string, + listener: EventListener, + rewrite?: boolean, +): void; +``` + +### `onContainer` + +Binding editor root node event + +```ts +onContainer(eventType: string, listener: EventListener): void; +``` + +### `destroy` + +destroy + +```ts +destroy(): void; +``` diff --git a/docs/api/editor-change-event.zh-CN.md b/docs/api/editor-change-event.zh-CN.md new file mode 100644 index 00000000..dd91f824 --- /dev/null +++ b/docs/api/editor-change-event.zh-CN.md @@ -0,0 +1,112 @@ +# 变更中的事件 + +编辑器变更中的相关事件 + +类型:`ChangeEventInterface` + +## 构造函数 + +```ts +new (engine: EngineInterface, options: ChangeEventOptions = {}): ChangeEventInterface; +``` + +## 属性 + +### `isComposing` + +是否组合输入中 + +### `isSelecting` + +是否正在选择中 + +## 方法 + +### `isCardInput` + +是否是在卡片输入 + +```ts +isCardInput(e: Event): boolean; +``` + +### `onInput` + +输入事件 + +```ts +onInput(callback: (event?: Event) => void): void; +``` + +### `onSelect` + +光标选择事件 + +```ts +onSelect(callback: (event?: Event) => void): void; +``` + +### `onPaste` + +粘贴事件 + +```ts +onPaste( + callback: (data: ClipboardData & { isPasteText: boolean }) => void, +): void; +``` + +### `onDrop` + +拖动事件 + +```ts +onDrop( + callback: (params: { + event: DragEvent; + range?: RangeInterface; + card?: CardInterface; + files: Array; + }) => void, +): void; +``` + +### `onDocument` + +绑定 document 事件 + +```ts +onDocument( + eventType: string, + listener: EventListener, + rewrite?: boolean, +): void; +``` + +### `onWindow` + +绑定 window 事件 + +```ts +onWindow( + eventType: string, + listener: EventListener, + rewrite?: boolean, +): void; +``` + +### `onContainer` + +绑定编辑器根节点事件 + +```ts +onContainer(eventType: string, listener: EventListener): void; +``` + +### `destroy` + +销毁 + +```ts +destroy(): void; +``` diff --git a/docs/api/editor-change.md b/docs/api/editor-change.md new file mode 100644 index 00000000..a6dea85b --- /dev/null +++ b/docs/api/editor-change.md @@ -0,0 +1,295 @@ +# Change + +Operations related to editor changes + +Type: `ChangeInterface` + +## Use + +```ts +new Engine(...).change +``` + +## Constructor + +```ts +new (container: NodeInterface, options: ChangeOptions): ChangeInterface; +``` + +## Attributes + +### `rangePathBeforeCommand` + +Path after cursor conversion before command execution + +```ts +rangePathBeforeCommand: Path[] | null; +``` + +### `event` + +event + +```ts +event: ChangeEventInterface; +``` + +### `marks` + +All style nodes in the current cursor selection + +```ts +marks: Array; +``` + +### `blocks` + +All block-level nodes in the current cursor selection + +```ts +blocks: Array; +``` + +### `inlines` + +All inline nodes in the current cursor selection + +```ts +inlines: Array; +``` + +## Method + +### `getRange` + +Get the range of the current selection + +```ts +/** + * Get the range of the current selection + */ +getRange(): RangeInterface; +``` + +### `getSafeRange` + +Obtain a safe and controllable cursor object + +```ts +/** + * Obtain a safe and controllable cursor object + * @param range default current cursor + */ +getSafeRange(range?: RangeInterface): RangeInterface; +``` + +### `select` + +Select the specified range + +```ts +/** + * Select the specified range + * @param range cursor + */ +select(range: RangeInterface): ChangeInterface; +``` + +### `focus` + +Focus editor + +```ts +/** + * Focus editor + * @param toStart true: start position, false: end position, the default is the previous operation position + */ +focus(toStart?: boolean): ChangeInterface; +``` + +### `blur` + +Cancel focus + +```ts +/** + * Cancel focus + */ +blur(): ChangeInterface; +``` + +### `apply` + +Apply an operation that changes the dom structure + +```ts +/** + * Apply an operation that changes the dom structure + * @param range cursor + */ +apply(range?: RangeInterface): void; +``` + +### `combinText` + +Combine the interrupted characters in the current editing into an uninterrupted character + +```ts +combinText(): void; +``` + +### `isComposing` + +Is it in the combined input + +```ts +isComposing(): boolean; +``` + +### `isSelecting` + +Is it being selected + +```ts +isSelecting(): boolean; +``` + +### `setValue` + +Set editor value + +```ts +/** + * @param value + * @param onParse uses root node parsing and filtering before converting to standard editor values + * @param options Card asynchronous rendering callback + * */ +setValue(value: string, onParse?: (node: Node) => void, callback?: (count: number) => void): void; +``` + +### `setHtml` + +Set html as editor value + +```ts +/** + * Set html, it will be formatted as a legal editor value + * @param html html + * @param options Card asynchronous rendering callback + */ +setHtml(html: string, , callback?: (count: number) => void): void +``` + +### `getOriginValue` + +Get the original value of the editor + +```ts +getOriginValue(): string; +``` + +### `getValue` + +Get editor value + +```ts +/** + * @param ignoreCursor Whether to fool the record node where the cursor is located + * */ +getValue(options: {ignoreCursor?: boolean }): string; +``` + +### `cacheRangeBeforeCommand` + +Cache the cursor object before executing the command + +```ts +cacheRangeBeforeCommand(): void; +``` + +### `getRangePathBeforeCommand` + +Get the path after the cursor conversion before the command is executed + +```ts +getRangePathBeforeCommand(): Path[] | null; +``` + +### `isEmpty` + +Whether the current editor is empty + +```ts +isEmpty(): boolean; +``` + +### `destroy` + +destroy + +```ts +destroy(): void; +``` + +### `insert` + +Insert + +```ts +/** + * Insert fragment + * @param fragment fragment + * @param callback callback function after insertion + */ +insert(fragment: DocumentFragment, callback?: () => void): void; +``` + +### `delete` + +Delete content + +```ts +/** + * Delete content + * @param range cursor, get the current cursor by default + * @param isDeepMerge Perform merge operation after deletion + */ +delete(range?: RangeInterface, isDeepMerge?: boolean): void; +``` + +### `unwrap` + +Remove the block node closest to the current cursor or the outer package of the incoming node + +```ts +/** + * Remove the block node closest to the current cursor or the outer package of the incoming node + * @param node node + */ +unwrap(node?: NodeInterface): void; +``` + +### `mergeAfterDelete` + +Delete the block node closest to the current cursor or the previous node of the incoming node and merge it + +```ts +/** + * Delete the block node closest to the current cursor or the previous node of the incoming node and merge it + * @param node node + */ +mergeAfterDelete(node?: NodeInterface): void; +``` + +### `focusPrevBlock` + +The focus moves to the block node closest to the current cursor or the block before the incoming node + +```ts +/** + * The focus moves to the block node closest to the current cursor or the block before the incoming node + * @param block node + * @param isRemoveEmptyBlock If the previous block is empty, whether to delete, the default is no + */ +focusPrevBlock(block?: NodeInterface, isRemoveEmptyBlock?: boolean): void; +``` diff --git a/docs/api/editor-change.zh-CN.md b/docs/api/editor-change.zh-CN.md new file mode 100644 index 00000000..a4cd2f0c --- /dev/null +++ b/docs/api/editor-change.zh-CN.md @@ -0,0 +1,295 @@ +# Change + +编辑器变更的相关操作 + +类型:`ChangeInterface` + +## 使用 + +```ts +new Engine(...).change +``` + +## 构造函数 + +```ts +new (container: NodeInterface, options: ChangeOptions): ChangeInterface; +``` + +## 属性 + +### `rangePathBeforeCommand` + +命令执行前的光标转换后的路径 + +```ts +rangePathBeforeCommand: Path[] | null; +``` + +### `event` + +事件 + +```ts +event: ChangeEventInterface; +``` + +### `marks` + +当前光标选区中的所有样式节点 + +```ts +marks: Array; +``` + +### `blocks` + +当前光标选区中的所有块级节点 + +```ts +blocks: Array; +``` + +### `inlines` + +当前光标选区中的所有行内节点 + +```ts +inlines: Array; +``` + +## 方法 + +### `getRange` + +获取当前选区的范围 + +```ts +/** + * 获取当前选区的范围 + */ +getRange(): RangeInterface; +``` + +### `getSafeRange` + +获取安全可控的光标对象 + +```ts +/** + * 获取安全可控的光标对象 + * @param range 默认当前光标 + */ +getSafeRange(range?: RangeInterface): RangeInterface; +``` + +### `select` + +选中指定的范围 + +```ts +/** + * 选中指定的范围 + * @param range 光标 + */ +select(range: RangeInterface): ChangeInterface; +``` + +### `focus` + +聚焦编辑器 + +```ts +/** + * 聚焦编辑器 + * @param toStart true:开始位置,false:结束位置,默认为之前操作位置 + */ +focus(toStart?: boolean): ChangeInterface; +``` + +### `blur` + +取消焦点 + +```ts + /** + * 取消焦点 + */ +blur(): ChangeInterface; +``` + +### `apply` + +应用一个具有改变 dom 结构的操作 + +```ts +/** + * 应用一个具有改变dom结构的操作 + * @param range 光标 + */ +apply(range?: RangeInterface): void; +``` + +### `combinText` + +把当前编辑中间断的字符组合成一段不间断的字符 + +```ts +combinText(): void; +``` + +### `isComposing` + +是否在组合输入中 + +```ts +isComposing(): boolean; +``` + +### `isSelecting` + +是否正在选择中 + +```ts +isSelecting(): boolean; +``` + +### `setValue` + +设置编辑器值 + +```ts +/** + * @param value 值 + * @param onParse 在转换为符合标准的编辑器值前使用根节点解析过滤 + * @param options 异步渲染卡片回调 + * */ +setValue(value: string, onParse?: (node: Node) => void, callback?: (count: number) => void): void; +``` + +### `setHtml` + +设置 html 作为编辑器值 + +```ts +/** + * 设置html,会格式化为合法的编辑器值 + * @param html html + * @param options 异步渲染卡片回调 + */ +setHtml(html: string, callback?: (count: number) => void): void +``` + +### `getOriginValue` + +获取编辑器原始值 + +```ts +getOriginValue(): string; +``` + +### `getValue` + +获取编辑器值 + +```ts +/** + * @param ignoreCursor 是否忽悠光标所在的记录节点 + * */ +getValue(options: { ignoreCursor?: boolean }): string; +``` + +### `cacheRangeBeforeCommand` + +在执行命令前缓存光标对象 + +```ts +cacheRangeBeforeCommand(): void; +``` + +### `getRangePathBeforeCommand` + +获取命令执行前的光标转换后的路径 + +```ts +getRangePathBeforeCommand(): Path[] | null; +``` + +### `isEmpty` + +当前编辑器是否是空值 + +```ts +isEmpty(): boolean; +``` + +### `destroy` + +销毁 + +```ts +destroy(): void; +``` + +### `insert` + +插入片段 + +```ts +/** + * 插入片段 + * @param fragment 片段 + * @param callback 插入后的回调函数 + */ +insert(fragment: DocumentFragment, callback?: () => void): void; +``` + +### `delete` + +删除内容 + +```ts +/** + * 删除内容 + * @param range 光标,默认获取当前光标 + * @param isDeepMerge 删除后执行合并操作 + */ +delete(range?: RangeInterface, isDeepMerge?: boolean): void; +``` + +### `unwrap` + +去除当前光标最接近的 block 节点或传入的节点外层包裹 + +```ts +/** + * 去除当前光标最接近的block节点或传入的节点外层包裹 + * @param node 节点 + */ +unwrap(node?: NodeInterface): void; +``` + +### `mergeAfterDelete` + +删除当前光标最接近的 block 节点或传入的节点的前面一个节点后合并 + +```ts +/** + * 删除当前光标最接近的block节点或传入的节点的前面一个节点后合并 + * @param node 节点 + */ +mergeAfterDelete(node?: NodeInterface): void; +``` + +### `focusPrevBlock` + +焦点移动到当前光标最接近的 block 节点或传入的节点前一个 Block + +```ts +/** + * 焦点移动到当前光标最接近的block节点或传入的节点前一个 Block + * @param block 节点 + * @param isRemoveEmptyBlock 如果前一个block为空是否删除,默认为否 + */ +focusPrevBlock(block?: NodeInterface, isRemoveEmptyBlock?: boolean): void; +``` diff --git a/docs/api/editor-inline.md b/docs/api/editor-inline.md new file mode 100644 index 00000000..6d914b55 --- /dev/null +++ b/docs/api/editor-inline.md @@ -0,0 +1,138 @@ +# InlineModel + +Edit related operations of in-line nodes + +Type: `InlineModelInterface` + +## Use + +```ts +new Engine(...).inline +``` + +## Constructor + +```ts +new (editor: EditorInterface): InlineModelInterface +``` + +## Method + +### `init` + +initialization + +```ts +/** + * Initialization + */ +init(): void; +``` + +### `closest` + +Get the nearest Inline node, the return node cannot be found + +```ts +/** + * Get the nearest Inline node, the return node cannot be found + */ +closest(node: NodeInterface): NodeInterface; +``` + +### `closestNotInline` + +Get the first non-inline node up + +```ts +/** + * Get the first non-inline node up + */ +closestNotInline(node: NodeInterface): NodeInterface; +``` + +### `wrap` + +Add an inline package to the current cursor node + +```ts +/** + * Add an inline package to the current cursor node + * @param inline inline tag + * @param range cursor, get the current cursor by default + */ +wrap(inline: NodeInterface | Node | string, range?: RangeInterface): void; +``` + +### `unwrap` + +Remove inline package + +```ts +/** + * Remove inline package + * @param range cursor, the current editor cursor is the default, or the inline node that needs to be removed + */ +unwrap(range?: RangeInterface | NodeInterface): void; +``` + +### `insert` + +Insert inline tag + +```ts +/** + * Insert inline tag + * @param inline inline tag + * @param range cursor + */ +insert(inline: NodeInterface | Node | string, range?: RangeInterface): void; +``` + +### `split` + +Split inline tags + +```ts +/** + * Split inline tags + * @param range cursor, get the current cursor by default + */ +split(range?: RangeInterface): void; +``` + +### `findInlines` + +Get all inline tags within the cursor + +```ts +/** + * Get all inline tags within the cursor + * @param range cursor + */ +findInlines(range: RangeInterface): Array; +``` + +### `repairCursor` + +Fix inline node cursor placeholder + +```ts +/** + * Fix cursor placeholder for inline node + * @param node inlne node + */ +repairCursor(node: NodeInterface | Node): void; +``` + +### `repairRange` + +Fix the cursor selection position, ​acde​ -> ​acde​ + +```ts +/** + * Fix the cursor selection position, ​acde​ ->​acde​ + * Otherwise, in ot, the ​ changes on both sides of the inline node may not be applied correctly + */ +repairRange(range?: RangeInterface): RangeInterface; +``` diff --git a/docs/api/editor-inline.zh-CN.md b/docs/api/editor-inline.zh-CN.md new file mode 100644 index 00000000..e636a4ea --- /dev/null +++ b/docs/api/editor-inline.zh-CN.md @@ -0,0 +1,138 @@ +# InlineModel + +编辑行内节点的相关操作 + +类型:`InlineModelInterface` + +## 使用 + +```ts +new Engine(...).inline +``` + +## 构造函数 + +```ts +new (editor: EditorInterface): InlineModelInterface +``` + +## 方法 + +### `init` + +初始化 + +```ts +/** + * 初始化 + */ +init(): void; +``` + +### `closest` + +获取最近的 Inline 节点,找不到返回 node + +```ts +/** + * 获取最近的 Inline 节点,找不到返回 node + */ +closest(node: NodeInterface): NodeInterface; +``` + +### `closestNotInline` + +获取向上第一个非 Inline 节点 + +```ts +/** + * 获取向上第一个非 Inline 节点 + */ +closestNotInline(node: NodeInterface): NodeInterface; +``` + +### `wrap` + +给当前光标节点添加 inline 包裹 + +```ts +/** + * 给当前光标节点添加inline包裹 + * @param inline inline标签 + * @param range 光标,默认获取当前光标 + */ +wrap(inline: NodeInterface | Node | string, range?: RangeInterface): void; +``` + +### `unwrap` + +移除 inline 包裹 + +```ts +/** + * 移除inline包裹 + * @param range 光标,默认当前编辑器光标,或者需要移除的inline节点 + */ +unwrap(range?: RangeInterface | NodeInterface): void; +``` + +### `insert` + +插入 inline 标签 + +```ts +/** + * 插入inline标签 + * @param inline inline标签 + * @param range 光标 + */ +insert(inline: NodeInterface | Node | string, range?: RangeInterface): void; +``` + +### `split` + +分割 inline 标签 + +```ts +/** + * 分割inline标签 + * @param range 光标,默认获取当前光标 + */ +split(range?: RangeInterface): void; +``` + +### `findInlines` + +获取光标范围内的所有 inline 标签 + +```ts +/** + * 获取光标范围内的所有 inline 标签 + * @param range 光标 + */ +findInlines(range: RangeInterface): Array; +``` + +### `repairCursor` + +修复 inline 节点光标占位符 + +```ts +/** + * 修复inline节点光标占位符 + * @param node inlne 节点 + */ +repairCursor(node: NodeInterface | Node): void; +``` + +### `repairRange` + +修复光标选区位置,​acde​ ->​acde​ + +```ts +/** + * 修复光标选区位置,​acde​ ->​acde​ + * 否则在ot中,可能无法正确的应用inline节点两边​的更改 + */ +repairRange(range?: RangeInterface): RangeInterface; +``` diff --git a/docs/api/editor-list.md b/docs/api/editor-list.md new file mode 100644 index 00000000..a720235e --- /dev/null +++ b/docs/api/editor-list.md @@ -0,0 +1,331 @@ +# ListModel + +Related operations for editing list nodes + +Type: `ListModelInterface` + +## Use + +```ts +new Engine(...).list +``` + +## Constructor + +```ts +new (editor: EditorInterface): ListModelInterface +``` + +## Attributes + +### `CUSTOMZIE_UL_CLASS` + +Read only + +Custom list style markup + +### `CUSTOMZIE_LI_CLASS` + +Read only + +Custom list item style mark + +### `INDENT_KEY` + +Read only + +List indentation key tag, used to get the list indentation value + +## Method + +### `init` + +initialization + +```ts +/** + * Initialization + */ +init(): void; +``` + +### `isEmptyItem` + +Determine whether the list item node is empty + +```ts +/** + * Determine whether the list item node is empty + * @param node node + */ +isEmptyItem(node: NodeInterface): boolean; +``` + +### `isSame` + +Determine whether two nodes are the same List node + +```ts +/** + * Determine whether two nodes are the same List node + * @param sourceNode source node + * @param targetNode target node + */ +isSame(sourceNode: NodeInterface, targetNode: NodeInterface): boolean; +``` + +### `isSpecifiedType` + +Determine whether the node set is a List list of the specified type + +```ts +/** + * Determine whether the node collection is a List list of the specified type + * @param blocks node collection + * @param name node label type + * @param card is the card name of the specified custom list item + */ +isSpecifiedType( + blocks: Array, + name?:'ul' |'ol', + card?: string, +): boolean; +``` + +### `getPlugins` + +Get all List plugins + +```ts +/** + * Get all List plugins + */ +getPlugins(): Array; +``` + +### `getPluginNameByNode` + +Get the name of the list plugin according to the list node + +```ts +/** + * Get the name of the list plug-in according to the list node + * @param block node + */ +getPluginNameByNode(block: NodeInterface): string; +``` + +### `getPluginNameByNodes` + +Get the name of the list plugin that a list node collection belongs + +```ts +/** + * Get the name of the list plugin to which a list node collection belongs + * @param blocks node collection + */ +getPluginNameByNodes(blocks: Array): string; +``` + +### `unwrapCustomize` + +Clear the related attributes of the custom list node + +```ts +/** + * Clear the related attributes of the custom list node + * @param node node + */ +unwrapCustomize(node: NodeInterface): NodeInterface; +``` + +### `unwrap` + +Cancel the list of nodes + +```ts +/** + * Cancel the list of nodes + * @param blocks node collection + */ +unwrap(blocks: Array): void; +``` + +### `normalize` + +Get the node collection after the repair list of the current selection + +```ts +/** + * Get the node collection after the repair list of the current selection + */ +normalize(): Array; +``` + +### `split` + +Split the list of selected items into a single list + +```ts +/** + * Split the list of selected items into a single list + */ +split(): void; +``` + +### `merge` + +Merge list + +```ts +/** + * Consolidated list + * @param blocks node collection, the default is the blocks of the current selection + */ +merge(blocks?: Array, range?: RangeInterface): void; +``` + +### `addStart` + +Add the start number to the list + +```ts +/** + * Add the start number to the list + * @param block list node + */ +addStart(block?: NodeInterface): void; +``` + +### `addIndent` + +Add indentation to list nodes + +```ts +/** + * Add indentation to list nodes + * @param block list node + * @param value indentation value + */ +addIndent(block: NodeInterface, value: number, maxValue?: number): void; +``` + +### `getIndent` + +Get the indent value of the list node + +```ts +/** + * Get the indent value of the list node + * @param block list node + * @returns + */ +getIndent(block: NodeInterface): number; +``` + +### `addCardToCustomize` + +Add card nodes to custom list items + +```ts +/** + * Add card nodes for custom list items + * @param node list node item + * @param cardName card name, must support inline card type + * @param value card value + */ +addCardToCustomize( + node: NodeInterface | Node, + cardName: string, + value?: any, +): CardInterface | undefined; +``` + +### `addReadyCardToCustomize` + +Add a card node to be rendered for the custom list item + +```ts +/** + * Add a card node to be rendered for the custom list item + * @param node list node item + * @param cardName card name, must support inline card type + * @param value card value + */ +addReadyCardToCustomize( + node: NodeInterface | Node, + cardName: string, + value?: any, +): NodeInterface | undefined; +``` + +### `addBr` + +Add the BR tag to the list + +```ts +/** + * Add the BR tag to the list + * @param node list node item + */ +addBr(node: NodeInterface): void; +``` + +### `toCustomize` + +Convert node to custom node + +```ts +/** + * Convert nodes to custom nodes + * @param blocks node + * @param cardName card name + * @param value card value + */ +toCustomize( + blocks: Array | NodeInterface, + cardName: string, + value?: any, +): Array | NodeInterface; +``` + +### `toNormal` + +Convert node to list node + +```ts +/** + * Convert a node to a list node + * @param blocks node + * @param tagName list node name, ul or ol, the default is ul + * @param start the start number of the ordered list + */ +toNormal( + blocks: Array | NodeInterface, + tagName?:'ul' |'ol', + start?: number, +): Array | NodeInterface; +``` + +### `isFirst` + +Determine whether the selected area is at the beginning of the list + +```ts +/** + * Determine whether the selected area is at the beginning of the list + * Selected area + */ +isFirst(range: RangeInterface): boolean; +``` + +### `isLast` + +Determine whether the selected area is at the end of the list + +```ts +/** + * Determine whether the selected area is at the end of the list + */ +isLast(range: RangeInterface): boolean; +``` diff --git a/docs/api/editor-list.zh-CN.md b/docs/api/editor-list.zh-CN.md new file mode 100644 index 00000000..db5ccfd3 --- /dev/null +++ b/docs/api/editor-list.zh-CN.md @@ -0,0 +1,331 @@ +# ListModel + +编辑列表节点的相关操作 + +类型:`ListModelInterface` + +## 使用 + +```ts +new Engine(...).list +``` + +## 构造函数 + +```ts +new (editor: EditorInterface): ListModelInterface +``` + +## 属性 + +### `CUSTOMZIE_UL_CLASS` + +只读 + +自定义列表样式标记 + +### `CUSTOMZIE_LI_CLASS` + +只读 + +自定义列表项样式标记 + +### `INDENT_KEY` + +只读 + +列表缩进 key 标记,用于获取列表缩进值 + +## 方法 + +### `init` + +初始化 + +```ts +/** + * 初始化 + */ +init(): void; +``` + +### `isEmptyItem` + +判断列表项节点是否为空 + +```ts +/** + * 判断列表项节点是否为空 + * @param node 节点 + */ +isEmptyItem(node: NodeInterface): boolean; +``` + +### `isSame` + +判断两个节点是否是一样的 List 节点 + +```ts +/** + * 判断两个节点是否是一样的List节点 + * @param sourceNode 源节点 + * @param targetNode 目标节点 + */ +isSame(sourceNode: NodeInterface, targetNode: NodeInterface): boolean; +``` + +### `isSpecifiedType` + +判断节点集合是否是指定类型的 List 列表 + +```ts +/** + * 判断节点集合是否是指定类型的List列表 + * @param blocks 节点集合 + * @param name 节点标签类型 + * @param card 是否是指定的自定义列表项的卡片名称 + */ +isSpecifiedType( + blocks: Array, + name?: 'ul' | 'ol', + card?: string, +): boolean; +``` + +### `getPlugins` + +获取所有 List 插件 + +```ts +/** + * 获取所有List插件 + */ +getPlugins(): Array; +``` + +### `getPluginNameByNode` + +根据列表节点获取列表插件名称 + +```ts +/** + * 根据列表节点获取列表插件名称 + * @param block 节点 + */ +getPluginNameByNode(block: NodeInterface): string; +``` + +### `getPluginNameByNodes` + +获取一个列表节点集合所属列表插件名称 + +```ts +/** + * 获取一个列表节点集合所属列表插件名称 + * @param blocks 节点集合 + */ +getPluginNameByNodes(blocks: Array): string; +``` + +### `unwrapCustomize` + +清除自定义列表节点相关属性 + +```ts +/** + * 清除自定义列表节点相关属性 + * @param node 节点 + */ +unwrapCustomize(node: NodeInterface): NodeInterface; +``` + +### `unwrap` + +取消节点的列表 + +```ts +/** + * 取消节点的列表 + * @param blocks 节点集合 + */ +unwrap(blocks: Array): void; +``` + +### `normalize` + +获取当前选区的修复列表后的节点集合 + +```ts +/** + * 获取当前选区的修复列表后的节点集合 + */ +normalize(): Array; +``` + +### `split` + +将选中列表项列表分割出来单独作为一个列表 + +```ts +/** + * 将选中列表项列表分割出来单独作为一个列表 + */ +split(): void; +``` + +### `merge` + +合并列表 + +```ts +/** + * 合并列表 + * @param blocks 节点集合,默认为当前选区的blocks + */ +merge(blocks?: Array, range?: RangeInterface): void; +``` + +### `addStart` + +给列表添加 start 序号 + +```ts +/** + * 给列表添加start序号 + * @param block 列表节点 + */ +addStart(block?: NodeInterface): void; +``` + +### `addIndent` + +给列表节点增加缩进 + +```ts +/** + * 给列表节点增加缩进 + * @param block 列表节点 + * @param value 缩进值 + */ +addIndent(block: NodeInterface, value: number, maxValue?: number): void; +``` + +### `getIndent` + +获取列表节点 indent 值 + +```ts +/** + * 获取列表节点 indent 值 + * @param block 列表节点 + * @returns + */ +getIndent(block: NodeInterface): number; +``` + +### `addCardToCustomize` + +为自定义列表项添加卡片节点 + +```ts +/** + * 为自定义列表项添加卡片节点 + * @param node 列表节点项 + * @param cardName 卡片名称,必须是支持inline卡片类型 + * @param value 卡片值 + */ +addCardToCustomize( + node: NodeInterface | Node, + cardName: string, + value?: any, +): CardInterface | undefined; +``` + +### `addReadyCardToCustomize` + +为自定义列表项添加待渲染卡片节点 + +```ts +/** + * 为自定义列表项添加待渲染卡片节点 + * @param node 列表节点项 + * @param cardName 卡片名称,必须是支持inline卡片类型 + * @param value 卡片值 + */ +addReadyCardToCustomize( + node: NodeInterface | Node, + cardName: string, + value?: any, +): NodeInterface | undefined; +``` + +### `addBr` + +给列表添加 BR 标签 + +```ts +/** + * 给列表添加BR标签 + * @param node 列表节点项 + */ +addBr(node: NodeInterface): void; +``` + +### `toCustomize` + +将节点转换为自定义节点 + +```ts +/** + * 将节点转换为自定义节点 + * @param blocks 节点 + * @param cardName 卡片名称 + * @param value 卡片值 + */ +toCustomize( + blocks: Array | NodeInterface, + cardName: string, + value?: any, +): Array | NodeInterface; +``` + +### `toNormal` + +将节点转换为列表节点 + +```ts +/** + * 将节点转换为列表节点 + * @param blocks 节点 + * @param tagName 列表节点名称,ul 或者 ol,默认为ul + * @param start 有序列表开始序号 + */ +toNormal( + blocks: Array | NodeInterface, + tagName?: 'ul' | 'ol', + start?: number, +): Array | NodeInterface; +``` + +### `isFirst` + +判断选中的区域是否在列表的开始 + +```ts +/** + * 判断选中的区域是否在列表的开始 + * 选中的区域 + */ +isFirst(range: RangeInterface): boolean; +``` + +### `isLast` + +判断选中的区域是否在列表的末尾 + +```ts +/** + * 判断选中的区域是否在列表的末尾 + */ +isLast(range: RangeInterface): boolean; +``` diff --git a/docs/api/editor-mark.md b/docs/api/editor-mark.md new file mode 100644 index 00000000..6469a81b --- /dev/null +++ b/docs/api/editor-mark.md @@ -0,0 +1,179 @@ +# MarkModel + +Related operations for editing style nodes + +Type: `MarkModelInterface` + +## Use + +```ts +new Engine(...).mark +``` + +## Constructor + +```ts +new (editor: EditorInterface): MarkModelInterface +``` + +## Method + +### `init` + +initialization + +```ts +/** + * Initialization + */ +init(): void; +``` + +### `findPlugin` + +Find the mark plugin instance according to the node + +```ts +/** + * Find the mark plug-in instance according to the node + * @param node node + */ +findPlugin(node: NodeInterface): MarkInterface | undefined; +``` + +### `closestNotMark` + +Get the first non-mark node up + +```ts +/** + * Get the first non-Mark node up + */ +closestNotMark(node: NodeInterface): NodeInterface; +``` + +### `compare` + +Compare whether two nodes are the same, including attributes, style, class + +```ts +/** + * Compare whether two nodes are the same, including attributes, style, and class + * @param source source node + * @param target target node + * @param isCompareValue Whether to compare the value of each attribute + */ +compare( + source: NodeInterface, + target: NodeInterface, + isCompareValue?: boolean, +): boolean; +``` + +### `contain` + +Determine whether the source node contains all the attributes and styles of the target node + +```ts +/** + * Determine whether the source node contains all the attributes and styles of the target node + * @param source source node + * @param target target node + */ +contain(source: NodeInterface, target: NodeInterface): boolean; +``` + +### `split` + +Split mark tags + +```ts +/** + * Split mark tags + * @param range cursor, get the current cursor by default + * @param removeMark The empty mark tag that needs to be removed + */ +split( + range?: RangeInterface, + removeMark?: NodeInterface | Node | string | Array, +): void; +``` + +### `wrap` + +Wrap the mark label in the current cursor selection + +```ts +/** + * Wrap the mark label in the current cursor selection area + * @param mark mark tag + * @param both mark nodes on both sides of the label + */ +wrap(mark: NodeInterface | Node | string, range?: RangeInterface): void; +``` + +### `unwrap` + +Remove the mark package + +```ts +/** + * Remove the mark package + * @param range cursor + * @param removeMark the mark tag to be removed + */ +unwrap( + removeMark?: NodeInterface | Node | string | Array, + range?: RangeInterface, +): void; +``` + +### `merge` + +Merge the mark node of the selection + +```ts +/** + * Merge the mark node of the selection + * @param range cursor, the current selection cursor by default + */ +merge(range?: RangeInterface): void; +``` + +### `insert` + +Insert the mark tag at the cursor + +```ts +/** + * Insert a mark tag at the cursor + * @param mark mark tag + * @param range specifies the cursor, the default is the cursor selected by the editor + */ +insert(mark: NodeInterface | Node | string, range?: RangeInterface): void; +``` + +### `findMarks` + +Find all Marks that have an effect on the range + +```ts +/** + * Find all Marks that have an effect on the range + * @param range + */ +findMarks(range: RangeInterface): Array; +``` + +### `removeEmptyMarks` + +Traverse from the bottom up to delete empty Marks, when encountering empty Blocks, add BR tags + +```ts +/** + * Traverse from bottom to top to delete empty Marks, when encountering empty Blocks, add BR tags + * @param node node + * @param addBr whether to add br + */ +removeEmptyMarks(node: NodeInterface, addBr?: boolean): void; +``` diff --git a/docs/api/editor-mark.zh-CN.md b/docs/api/editor-mark.zh-CN.md new file mode 100644 index 00000000..ecf68098 --- /dev/null +++ b/docs/api/editor-mark.zh-CN.md @@ -0,0 +1,179 @@ +# MarkModel + +编辑样式节点的相关操作 + +类型:`MarkModelInterface` + +## 使用 + +```ts +new Engine(...).mark +``` + +## 构造函数 + +```ts +new (editor: EditorInterface): MarkModelInterface +``` + +## 方法 + +### `init` + +初始化 + +```ts +/** + * 初始化 + */ +init(): void; +``` + +### `findPlugin` + +根据节点查找 mark 插件实例 + +```ts +/** + * 根据节点查找mark插件实例 + * @param node 节点 + */ +findPlugin(node: NodeInterface): MarkInterface | undefined; +``` + +### `closestNotMark` + +获取向上第一个非 Mark 节点 + +```ts +/** + * 获取向上第一个非 Mark 节点 + */ +closestNotMark(node: NodeInterface): NodeInterface; +``` + +### `compare` + +比较两个节点是否相同,包括 attributes、style、class + +```ts +/** + * 比较两个节点是否相同,包括attributes、style、class + * @param source 源节点 + * @param target 目标节点 + * @param isCompareValue 是否比较每项属性的值 + */ +compare( + source: NodeInterface, + target: NodeInterface, + isCompareValue?: boolean, +): boolean; +``` + +### `contain` + +判断源节点是否包含目标节点的所有属性和样式 + +```ts +/** + * 判断源节点是否包含目标节点的所有属性和样式 + * @param source 源节点 + * @param target 目标节点 + */ +contain(source: NodeInterface, target: NodeInterface): boolean; +``` + +### `split` + +分割 mark 标签 + +```ts +/** + * 分割mark标签 + * @param range 光标,默认获取当前光标 + * @param removeMark 需要移除的空mark标签 + */ +split( + range?: RangeInterface, + removeMark?: NodeInterface | Node | string | Array, +): void; +``` + +### `wrap` + +在当前光标选区包裹 mark 标签 + +```ts +/** + * 在当前光标选区包裹mark标签 + * @param mark mark标签 + * @param both mark标签两侧节点 + */ +wrap(mark: NodeInterface | Node | string, range?: RangeInterface): void; +``` + +### `unwrap` + +去掉 mark 包裹 + +```ts +/** + * 去掉mark包裹 + * @param range 光标 + * @param removeMark 要移除的mark标签 + */ +unwrap( + removeMark?: NodeInterface | Node | string | Array, + range?: RangeInterface, +): void; +``` + +### `merge` + +合并选区的 mark 节点 + +```ts +/** + * 合并选区的mark节点 + * @param range 光标,默认当前选区光标 + */ +merge(range?: RangeInterface): void; +``` + +### `insert` + +光标处插入 mark 标签 + +```ts +/** + * 光标处插入mark标签 + * @param mark mark标签 + * @param range 指定光标,默认为编辑器选中的光标 + */ +insert(mark: NodeInterface | Node | string, range?: RangeInterface): void; +``` + +### `findMarks` + +查找对范围有效果的所有 Mark + +```ts +/** + * 查找对范围有效果的所有 Mark + * @param range 范围 + */ +findMarks(range: RangeInterface): Array; +``` + +### `removeEmptyMarks` + +从下开始往上遍历删除空 Mark,当遇到空 Block,添加 BR 标签 + +```ts +/** + * 从下开始往上遍历删除空 Mark,当遇到空 Block,添加 BR 标签 + * @param node 节点 + * @param addBr 是否添加br + */ +removeEmptyMarks(node: NodeInterface, addBr?: boolean): void; +``` diff --git a/docs/api/editor-node.md b/docs/api/editor-node.md new file mode 100644 index 00000000..c564f2f5 --- /dev/null +++ b/docs/api/editor-node.md @@ -0,0 +1,362 @@ +# NodeModel + +Edit node related operations + +Type: `NodeModelInterface` + +## Use + +```ts +new Engine(...).node +``` + +## Constructor + +```ts +new (editor: EditorInterface) +``` + +## Method + +### `isVoid` + +Whether it is an empty node + +```ts +/** + * Is it an empty node + * @param node node or node name + * @param schema takes the schema from this.editor by default + */ +isVoid( + node: NodeInterface | Node | string, + schema?: SchemaInterface, +): boolean; +``` + +### `isMark` + +Whether it is a mark style label + +```ts +/** + * Is it a mark tag + * @param node node + */ +isMark(node: NodeInterface | Node, schema?: SchemaInterface): boolean; +``` + +### `isInline` + +Is it an inline tag + +```ts +/** + * Is it an inline tag + * @param node node + */ +isInline(node: NodeInterface | Node, schema?: SchemaInterface): boolean; +``` + +### `isBlock` + +Is it a block node + +```ts +/** + * Is it a block node + * @param node node + */ +isBlock(node: NodeInterface | Node, schema?: SchemaInterface): boolean; +``` + +### `isSimpleBlock` + +Determine whether the node is a simple node of block type (child nodes do not contain blcok tags) + +```ts +/** + * Determine whether the node is a simple node of block type (child nodes do not contain blcok tags) + */ +isSimpleBlock(node: NodeInterface): boolean; +``` + +### `isRootBlock` + +Determine whether the node is the top-level root node, the parent is the editor root node, and the child node does not have a block node + +```ts +/** + * Determine whether the node is the top-level root node, the parent is the editor root node, and the child node has no block node + * @param node node + * @returns + */ +isRootBlock(node: NodeInterface, schema?: SchemaInterface): boolean; +``` + +### `isEmpty` + +Determine whether the text under the node is empty + +```ts +/** + * Determine whether the text under the node is empty + * @param node node + * @param withTrim is trim + */ +isEmpty(node: NodeInterface, withTrim?: boolean): boolean; +``` + +### `isEmptyWithTrim` + +Determine whether the text under a node is empty or only white space characters + +```ts +/** + * Determine whether the text under a node is empty, or there are only blank characters + * @param node node + */ +isEmptyWithTrim(node: NodeInterface): boolean; +``` + +### `isLikeEmpty` + +Judge whether a node is empty, a card is not counted as an empty node + +```ts +/** + * Determine whether a node is empty + * @param node node + */ +isLikeEmpty(node: NodeInterface): boolean; +``` + +### `isList` + +Determine whether the node is a list node + +```ts +/** + * Determine whether the node is a list node + * @param node node or node name + */ +isList(node: NodeInterface | string | Node): boolean; +``` + +### `isCustomize` + +Determine whether the node is a custom list + +```ts +/** + * Determine whether the node is a custom list + * @param node node + */ +isCustomize(node: NodeInterface): boolean; +``` + +### `unwrap` + +Remove the outer wrapper of the node + +```ts +/** + * Remove package + * @param node The node that needs to remove the package + */ +unwrap(node: NodeInterface): void; +``` + +### `wrap` + +Wrap a layer of nodes outside the node + +```ts +/** + * Package node + * @param source The node that needs to be wrapped + * @param outer packaged external node + * @param mergeSame merges the node styles and attributes of the same name on the same node + */ +wrap( + source: NodeInterface | Node, + outer: NodeInterface, + mergeSame?: boolean, +): NodeInterface; +``` + +### `merge` + +Merge node + +```ts +/** + * Merge nodes + * @param source merged node + * @param target The node that needs to be merged + * @param remove Whether to remove after merging + */ +merge(source: NodeInterface, target: NodeInterface, remove?: boolean): void; +``` + +### `replace` + +Append the child nodes of the source node to the target node and replace the source node + +```ts +/** + * Append the child nodes of the source node to the target node and replace the source node + * @param source old node + * @param target new node + */ +replace(source: NodeInterface, target: NodeInterface): NodeInterface; +``` + +### `insert` + +Insert a node at the cursor position + +```ts +/** + * Insert a node at the cursor position + * @param node node + * @param range cursor + */ +insert( + node: Node | NodeInterface, + range?: RangeInterface, +): RangeInterface | undefined; +``` + +### `insertText` + +Insert text at cursor position + +```ts +/** + * Insert text at the cursor position + * @param text text + * @param range cursor + */ +insertText( + text: string, + range?: RangeInterface, +): RangeInterface | undefined; +``` + +### `setAttributes` + +Set node properties + +```ts +/** + * Set node attributes + * @param node node + * @param props property + */ +setAttributes(node: NodeInterface, attributes: any): NodeInterface; +``` + +### `removeMinusStyle` + +Remove styles with negative values + +```ts +/** + * Remove styles with negative values + * @param node node + * @param style style name + */ +removeMinusStyle(node: NodeInterface, style: string): void; +``` + +### `mergeAdjacent` + +The child nodes under the merged node, the child nodes of two identical adjacent nodes, usually blockquote, ul, ol tags + +```ts +/** + * The child nodes under the merged node, the child nodes of two identical adjacent nodes, usually blockquote, ul, ol tags + * @param node current node + */ +mergeAdjacent(node: NodeInterface): void; +``` + +### `removeSide` + +Remove the labels on both sides of the node + +```ts +/** + * Delete the labels on both sides of the node + * @param node node + * @param tagName tag name, the default is br tag + */ +removeSide(node: NodeInterface, tagName?: string): void; +``` + +### `flatten` + +Organize the nodes and restore the nodes to the state that meets the editor value + +```ts +/** + * Organize nodes + * @param node node + * @param root root node, the default is node node + */ +flatten(node: NodeInterface, root?: NodeInterface): void; +``` + +### `normalize` + +Standardized node + +```ts +/** + * Standardized node + * @param node node + */ +normalize(node: NodeInterface): void; +``` + +### `html` + +Get or set the html text of the element node + +```ts +/** + * Get or set the html text of the element node + * @param {string|undefined} val html text + * @return {NodeEntry|string} current instance or html text + */ +html(node: NodeInterface): string; +html(node: NodeInterface, val: string): NodeInterface; +html(node: NodeInterface, val?: string): NodeInterface | string; +``` + +### `clone` + +Copy element node + +```ts +/** + * Copy element node + * @param {boolean} deep whether deep copy + * @return copied element node + */ +clone(node: NodeInterface, deep?: boolean): NodeInterface; +``` + +### `getBatchAppendHTML` + +Get outerHTML after batch appending child nodes + +```ts +/** + * Get outerHTML after batch appending child nodes + * @param nodes node collection + * @param appendExp appended node + */ +getBatchAppendHTML(nodes: Array, appendExp: string): string; +``` diff --git a/docs/api/editor-node.zh-CN.md b/docs/api/editor-node.zh-CN.md new file mode 100644 index 00000000..5e39f7ae --- /dev/null +++ b/docs/api/editor-node.zh-CN.md @@ -0,0 +1,362 @@ +# NodeModel + +编辑节点的相关操作 + +类型:`NodeModelInterface` + +## 使用 + +```ts +new Engine(...).node +``` + +## 构造函数 + +```ts +new (editor: EditorInterface) +``` + +## 方法 + +### `isVoid` + +是否是空节点 + +```ts +/** + * 是否是空节点 + * @param node 节点或节点名称 + * @param schema 默认从 this.editor 中取 schema + */ +isVoid( + node: NodeInterface | Node | string, + schema?: SchemaInterface, +): boolean; +``` + +### `isMark` + +是否是 mark 样式标签 + +```ts +/** + * 是否是mark标签 + * @param node 节点 + */ +isMark(node: NodeInterface | Node, schema?: SchemaInterface): boolean; +``` + +### `isInline` + +是否是 inline 标签 + +```ts +/** + * 是否是inline标签 + * @param node 节点 + */ +isInline(node: NodeInterface | Node, schema?: SchemaInterface): boolean; +``` + +### `isBlock` + +是否是 block 节点 + +```ts +/** + * 是否是block节点 + * @param node 节点 + */ +isBlock(node: NodeInterface | Node, schema?: SchemaInterface): boolean; +``` + +### `isSimpleBlock` + +判断节点是否为 block 类型的简单节点(子节点不包含 blcok 标签) + +```ts +/** + * 判断节点是否为block类型的简单节点(子节点不包含blcok标签) + */ +isSimpleBlock(node: NodeInterface): boolean; +``` + +### `isRootBlock` + +判断节点是否是顶级根节点,父级为编辑器根节点,且,子级节点没有 block 节点 + +```ts +/** + * 判断节点是否是顶级根节点,父级为编辑器根节点,且,子级节点没有block节点 + * @param node 节点 + * @returns + */ +isRootBlock(node: NodeInterface, schema?: SchemaInterface): boolean; +``` + +### `isEmpty` + +判断节点下的文本是否为空 + +```ts +/** + * 判断节点下的文本是否为空 + * @param node 节点 + * @param withTrim 是否 trim + */ +isEmpty(node: NodeInterface, withTrim?: boolean): boolean; +``` + +### `isEmptyWithTrim` + +判断一个节点下的文本是否为空,或者只有空白字符 + +```ts +/** + * 判断一个节点下的文本是否为空,或者只有空白字符 + * @param node 节点 + */ +isEmptyWithTrim(node: NodeInterface): boolean; +``` + +### `isLikeEmpty` + +判断一个节点是否为空,有卡片不算作空节点 + +```ts +/** + * 判断一个节点是否为空 + * @param node 节点 + */ +isLikeEmpty(node: NodeInterface): boolean; +``` + +### `isList` + +判断节点是否为列表节点 + +```ts +/** + * 判断节点是否为列表节点 + * @param node 节点或者节点名称 + */ +isList(node: NodeInterface | string | Node): boolean; +``` + +### `isCustomize` + +判断节点是否是自定义列表 + +```ts +/** + * 判断节点是否是自定义列表 + * @param node 节点 + */ +isCustomize(node: NodeInterface): boolean; +``` + +### `unwrap` + +去除节点的外层包裹 + +```ts +/** + * 去除包裹 + * @param node 需要去除包裹的节点 + */ +unwrap(node: NodeInterface): void; +``` + +### `wrap` + +给节点外面包裹一层节点 + +```ts +/** + * 包裹节点 + * @param source 需要包裹的节点 + * @param outer 包裹的外部节点 + * @param mergeSame 合并相同名称的节点样式和属性在同一个节点上 + */ +wrap( + source: NodeInterface | Node, + outer: NodeInterface, + mergeSame?: boolean, +): NodeInterface; +``` + +### `merge` + +合并节点 + +```ts +/** + * 合并节点 + * @param source 合并的节点 + * @param target 需要合并的节点 + * @param remove 合并后是否移除 + */ +merge(source: NodeInterface, target: NodeInterface, remove?: boolean): void; +``` + +### `replace` + +将源节点的子节点追加到目标节点,并替换源节点 + +```ts +/** + * 将源节点的子节点追加到目标节点,并替换源节点 + * @param source 旧节点 + * @param target 新节点 + */ +replace(source: NodeInterface, target: NodeInterface): NodeInterface; +``` + +### `insert` + +在光标位置插入一个节点 + +```ts +/** + * 在光标位置插入一个节点 + * @param node 节点 + * @param range 光标 + */ +insert( + node: Node | NodeInterface, + range?: RangeInterface, +): RangeInterface | undefined; +``` + +### `insertText` + +光标位置插入文本 + +```ts +/** + * 光标位置插入文本 + * @param text 文本 + * @param range 光标 + */ +insertText( + text: string, + range?: RangeInterface, +): RangeInterface | undefined; +``` + +### `setAttributes` + +设置节点属性 + +```ts +/** + * 设置节点属性 + * @param node 节点 + * @param props 属性 + */ +setAttributes(node: NodeInterface, attributes: any): NodeInterface; +``` + +### `removeMinusStyle` + +移除值为负的样式 + +```ts +/** + * 移除值为负的样式 + * @param node 节点 + * @param style 样式名称 + */ +removeMinusStyle(node: NodeInterface, style: string): void; +``` + +### `mergeAdjacent` + +合并节点下的子节点,两个相同的相邻节点的子节点,通常是 blockquote、ul、ol 标签 + +```ts +/** + * 合并节点下的子节点,两个相同的相邻节点的子节点,通常是 blockquote、ul、ol 标签 + * @param node 当前节点 + */ +mergeAdjacent(node: NodeInterface): void; +``` + +### `removeSide` + +删除节点两边标签 + +```ts +/** + * 删除节点两边标签 + * @param node 节点 + * @param tagName 标签名称,默认为br标签 + */ +removeSide(node: NodeInterface, tagName?: string): void; +``` + +### `flatten` + +整理节点,把节点修复到符合编辑器值的状态 + +```ts +/** + * 整理节点 + * @param node 节点 + * @param root 根节点,默认为node节点 + */ +flatten(node: NodeInterface, root?: NodeInterface): void; +``` + +### `normalize` + +标准化节点 + +```ts +/** + * 标准化节点 + * @param node 节点 + */ +normalize(node: NodeInterface): void; +``` + +### `html` + +获取或设置元素节点 html 文本 + +```ts +/** + * 获取或设置元素节点html文本 + * @param {string|undefined} val html文本 + * @return {NodeEntry|string} 当前实例或html文本 + */ +html(node: NodeInterface): string; +html(node: NodeInterface, val: string): NodeInterface; +html(node: NodeInterface, val?: string): NodeInterface | string; +``` + +### `clone` + +复制元素节点 + +```ts +/** + * 复制元素节点 + * @param {boolean} deep 是否深度复制 + * @return 复制后的元素节点 + */ +clone(node: NodeInterface, deep?: boolean): NodeInterface; +``` + +### `getBatchAppendHTML` + +获取批量追加子节点后的 outerHTML + +```ts +/** + * 获取批量追加子节点后的outerHTML + * @param nodes 节点集合 + * @param appendExp 追加的节点 + */ +getBatchAppendHTML(nodes: Array, appendExp: string): string; +``` diff --git a/docs/api/editor.md b/docs/api/editor.md new file mode 100644 index 00000000..d4bb59a2 --- /dev/null +++ b/docs/api/editor.md @@ -0,0 +1,201 @@ +# Engine and reader share attributes and methods + +Type: `EditorInterface` + +Editing engine and reader share attributes and methods + +## Attributes + +### `kind` + +Editor type, editing engine or reader + +```ts +readonly kind:'engine' |'view'; +``` + +### `language` + +Language + +Type: `LanguageInterface` + +### `container` + +Editor node + +Type: `NodeInterface` + +### `root` + +Editor root node, the default is the parent node of the editor node + +Type: `NodeInterface` + +### `command` + +Editor commands + +Type: `CommandInterface` + +### `card` + +Card management, you can create cards, delete, modify, update and other related operations + +Type: `CardModelInterface` + +### `plugin` + +Can manage all instantiated plugin instances + +Type: `PluginModelInterface` + +### `node` + +Node management, including node type judgment, inserting nodes in the DOM tree + +Type: `NodeModelInterface` + +### `list` + +List node management + +Type: `ListModelInterface` + +### `mark` + +Style node management + +Type: `MarkModelInterface` + +### `inline` + +In-line node management + +Type: `InlineModelInterface` + +### `block` + +Block-level node management + +Type: `BlockModelInterface` + +### `event` + +Incident management + +Type: `EventInterface` + +### `schema` + +Element structure management + +Type: `SchemaInterface` + +### `conversion` + +Element name conversion rules + +Type: `ConversionInterface` + +### `clipboard` + +Clipboard management + +Type: `ClipboardInterface` + +## Method + +### `on` + +Event binding + +```ts +/** + * Bind event + * @param eventType event type + * @param listener event callback + * @param rewrite whether to rewrite + */ +on(eventType: string, listener: EventListener, rewrite?: boolean): void; +``` + +### `off` + +Remove event binding + +```ts +/** + * Remove bound event + * @param eventType event type + * @param listener event callback + */ +off(eventType: string, listener: EventListener): void; +``` + +### `trigger` + +trigger event + +```ts +/** +* trigger event +* @param eventType event name +* @param args trigger parameters +*/ +trigger(eventType: string, ...args: any): any; +``` + +### `messageSuccess` + +Show success messages, and print messages on the console by default. You can modify the `messageSuccess` method and use the UI to display `engine.messageSuccess = text => Message.show(text)` + +This method may be called in the plug-in or the engine to pop up a message + +```ts +/** +* Show success information +* @param message +*/ +messageSuccess(message: string): void; +``` + +### `messageError` + +Show error message + +```ts +/** + * Display error message + * @param error error message + */ +messageError(error: string): void; +``` + +### `messageConfirm` + +A confirmation prompt box pops up, no UI is displayed in the engine by default, and false is always returned. So you need to re-assign a meaningful confirmation prompt box function + +For example, using the Modal.confirm component of antd + +```ts +engine.messageConfirm = (msg: string) => { + return new Promise((resolve, reject) => { + Modal.confirm({ + content: msg, + onOk: () => resolve(true), + onCancel: () => reject(), + }); + }); +}; +``` + +Method signature + +```ts +/** +* Message confirmation +* @param message +*/ +messageConfirm(message: string): Promise; +``` diff --git a/docs/api/editor.zh-CN.md b/docs/api/editor.zh-CN.md new file mode 100644 index 00000000..9ec2f949 --- /dev/null +++ b/docs/api/editor.zh-CN.md @@ -0,0 +1,201 @@ +# 引擎和阅读器共有属性和方法 + +类型:`EditorInterface` + +编辑引擎和阅读器共有属性和方法 + +## 属性 + +### `kind` + +编辑器类型,编辑引擎或者阅读器 + +```ts +readonly kind: 'engine' | 'view'; +``` + +### `language` + +语言 + +类型:`LanguageInterface` + +### `container` + +编辑器节点 + +类型:`NodeInterface` + +### `root` + +编辑器根节点,默认为编辑器节点的父节点 + +类型:`NodeInterface` + +### `command` + +编辑器命令 + +类型:`CommandInterface` + +### `card` + +卡片管理,可以创建卡片、删除、修改、更新等相关操作 + +类型:`CardModelInterface` + +### `plugin` + +可以管理所有已实例化的插件实例 + +类型:`PluginModelInterface` + +### `node` + +节点管理,包括节点类型判断,在 DOM 树中插入节点 + +类型:`NodeModelInterface` + +### `list` + +列表节点管理 + +类型:`ListModelInterface` + +### `mark` + +样式节点管理 + +类型:`MarkModelInterface` + +### `inline` + +行内节点管理 + +类型:`InlineModelInterface` + +### `block` + +块级节点管理 + +类型:`BlockModelInterface` + +### `event` + +事件管理 + +类型:`EventInterface` + +### `schema` + +元素结构管理 + +类型:`SchemaInterface` + +### `conversion` + +元素名称转换规则 + +类型:`ConversionInterface` + +### `clipboard` + +剪贴板管理 + +类型:`ClipboardInterface` + +## 方法 + +### `on` + +事件绑定 + +```ts +/** + * 绑定事件 + * @param eventType 事件类型 + * @param listener 事件回调 + * @param rewrite 是否重写 + */ +on(eventType: string, listener: EventListener, rewrite?: boolean): void; +``` + +### `off` + +移除事件绑定 + +```ts +/** + * 移除绑定事件 + * @param eventType 事件类型 + * @param listener 事件回调 + */ +off(eventType: string, listener: EventListener): void; +``` + +### `trigger` + +触发事件 + +```ts +/** +* 触发事件 +* @param eventType 事件名称 +* @param args 触发参数 +*/ +trigger(eventType: string, ...args: any): any; +``` + +### `messageSuccess` + +显示成功类的消息,默认在控制台打印消息。可以修改`messageSuccess`方法,使用 UI 显示 `engine.messageSuccess = text => Message.show(text)` + +在插件内部或引擎内部都可能会调用此方法弹出讯息 + +```ts +/** +* 显示成功的信息 +* @param message 信息 +*/ +messageSuccess(message: string): void; +``` + +### `messageError` + +显示错误消息 + +```ts +/** + * 显示错误信息 + * @param error 错误信息 + */ +messageError(error: string): void; +``` + +### `messageConfirm` + +弹出一个确认提示框,引擎内默认没有 UI 显示,并且始终返回 false。所以需要重新赋值一个有意义的确认提示框功能 + +例如,使用 antd 的 Modal.confirm 组件 + +```ts +engine.messageConfirm = (msg: string) => { + return new Promise((resolve, reject) => { + Modal.confirm({ + content: msg, + onOk: () => resolve(true), + onCancel: () => reject(), + }); + }); +}; +``` + +方法签名 + +```ts +/** +* 消息确认 +* @param message 消息 +*/ +messageConfirm(message: string): Promise; +``` diff --git a/docs/api/engine.md b/docs/api/engine.md new file mode 100644 index 00000000..7cf1a48e --- /dev/null +++ b/docs/api/engine.md @@ -0,0 +1,184 @@ +# Engine + +Type: `EngineInterface` + +## Attributes + +### `options` + +Options + +Type: `EngineOptions` + +### `readonly` + +Read-only + +Type: `boolean` + +### `change` + +Edit state + +Type: `ChangeInterface` + +### `typing` + +Key processing + +Type: `TypingInterface` + +### `ot` + +Co-editing related + +Type: `OTInterface` + +### `history` + +history record + +Type: `HistoryInterface` + +### `request` + +Network request + +Type: `RequestInterface` + +## Method + +### `focus` + +Focus on the editor + +```ts +/** + * Focus on the editor + * @param start is the start position of the focus, the default is true, false is the focus to the end position + */ +focus(start?: boolean): void; +``` + +### `isFocus` + +Whether the current cursor is focused on the editor + +```ts +/** + * Whether the current cursor has been focused on the editor + */ +isFocus(): boolean; +``` + +### `isEmpty` + +Whether the current editor is empty + +```ts +/** + * Whether the current editor is empty + */ +isEmpty(): boolean; +``` + +### `getValue` + +Get editor value + +```ts +/** + * Get editor value + * @param ignoreCursor whether to include cursor position information + */ +getValue(ignoreCursor?: boolean): string; +``` + +### `getValueAsync` + +Get the editor value asynchronously, and will wait for the plug-in processing to complete before getting the value + +```ts +/** + * Obtain the editor value asynchronously, and wait for the plug-in processing to complete before obtaining the value + * For example, plug-in upload is waiting, and the value will be obtained after the upload is completed. + * @param ignoreCursor whether to include cursor position information + */ +getValueAsync(ignoreCursor?: boolean): Promise; +``` + +### `getHtml` + +Get the html of the editor + +```ts +/** + * Get the html of the editor + */ +getHtml(): string; +``` + +### `getJsonValue` + +Get the value in JSON format + +```ts +/** + * Get the value in JSON format + */ +getJsonValue(): string | undefined | (string | {})[]; +``` + +### `setValue` + +Set editor value + +```ts +/** + * Set editor value + * @param value + * @param options Card asynchronous rendering callback + */ +setValue(value: string, callback?: (count: number) => void): EngineInterface; +``` + +### `setHtml` + +Set html as editor value + +```ts +/** +* Set html, it will be formatted as a legal editor value +* @param html html +* @param options Card asynchronous rendering callback +*/ +setHtml(html: string, callback?: (count: number) => void): EngineInterface +``` + +### `setJsonValue` + +Set the json format value, which is mainly used to synchronize with the value of the collaborative server + +```ts +/** + * Set the json format value, mainly used for collaboration + * @param value + */ +setJsonValue(value: Array): EngineInterface; +``` + +### `setScrollNode` + +Set editor scroll bar node + +```ts +setScrollNode(node?: HTMLElement) +``` + +### `destroy` + +Destroy the editor + +```ts +destroy():void +``` diff --git a/docs/api/engine.zh-CN.md b/docs/api/engine.zh-CN.md new file mode 100644 index 00000000..de90b085 --- /dev/null +++ b/docs/api/engine.zh-CN.md @@ -0,0 +1,184 @@ +# 引擎 + +类型:`EngineInterface` + +## 属性 + +### `options` + +选项 + +类型:`EngineOptions` + +### `readonly` + +是否只读 + +类型:`boolean` + +### `change` + +编辑时状态 + +类型:`ChangeInterface` + +### `typing` + +按键处理 + +类型:`TypingInterface` + +### `ot` + +协同编辑相关 + +类型:`OTInterface` + +### `history` + +历史记录 + +类型:`HistoryInterface` + +### `request` + +网络请求 + +类型:`RequestInterface` + +## 方法 + +### `focus` + +聚焦到编辑器 + +```ts +/** + * 聚焦到编辑器 + * @param start 是否聚焦的开始位置,默认为 true,false 为聚焦到结束位置 + */ +focus(start?: boolean): void; +``` + +### `isFocus` + +当前光标是否已聚焦到编辑器 + +```ts +/** + * 当前光标是否已聚焦到编辑器 + */ +isFocus(): boolean; +``` + +### `isEmpty` + +当前编辑器是否为空值 + +```ts +/** + * 当前编辑器是否为空值 + */ +isEmpty(): boolean; +``` + +### `getValue` + +获取编辑器值 + +```ts +/** + * 获取编辑器值 + * @param ignoreCursor 是否包含光标位置信息 + */ +getValue(ignoreCursor?: boolean): string; +``` + +### `getValueAsync` + +异步获取编辑器值,将等候插件处理完成后再获取值 + +```ts +/** + * 异步获取编辑器值,将等候插件处理完成后再获取值 + * 比如插件上传等待中,将等待上传完成后再获取值 + * @param ignoreCursor 是否包含光标位置信息 + */ +getValueAsync(ignoreCursor?: boolean): Promise; +``` + +### `getHtml` + +获取编辑器的 html + +```ts +/** + * 获取编辑器的html + */ +getHtml(): string; +``` + +### `getJsonValue` + +获取 JSON 格式的值 + +```ts +/** + * 获取JSON格式的值 + */ +getJsonValue(): string | undefined | (string | {})[]; +``` + +### `setValue` + +设置编辑器值 + +```ts +/** + * 设置编辑器值 + * @param value 值 + * @param options 异步渲染卡片回调 + */ +setValue(value: string, callback?: (count: number) => void): EngineInterface; +``` + +### `setHtml` + +设置 html 作为编辑器值 + +```ts +/** +* 设置html,会格式化为合法的编辑器值 +* @param html html +* @param options 异步渲染卡片回调 +*/ +setHtml(html: string, callback?: (count: number) => void): EngineInterface +``` + +### `setJsonValue` + +设置 json 格式值,主要用于与协同服务端的值同步 + +```ts +/** + * 设置json格式值,主要用于协同 + * @param value 值 + */ +setJsonValue(value: Array): EngineInterface; +``` + +### `setScrollNode` + +设置编辑器滚动条节点 + +```ts +setScrollNode(node?: HTMLElement) +``` + +### `destroy` + +销毁编辑器 + +```ts +destroy():void +``` diff --git a/docs/api/history.md b/docs/api/history.md new file mode 100644 index 00000000..0115d18a --- /dev/null +++ b/docs/api/history.md @@ -0,0 +1,170 @@ +# History + +Editor's edit history + +Type: `HistoryInterface` + +## Constructor + +```ts +new (engine: EngineInterface): HistoryInterface +``` + +## Method + +### `reset` + +Reset history, it will clear all history + +```ts +reset(): void; +``` + +### `hasUndo` + +Is there an undo operation + +```ts +hasUndo(): boolean; +``` + +### `hasRedo` + +Is there a redo operation + +```ts +hasRedo(): boolean; +``` + +### `undo` + +Perform undo operation + +```ts +undo(): void; +``` + +### `redo` + +Perform redo operation + +```ts +redo(): void; +``` + +### `hold` + +The action in the next milliseconds remains as a historical segment + +```ts +/** + * How many milliseconds the action remains as a historical segment + * @param time milliseconds + */ +hold(time?: number): void; +``` + +### `releaseHold` + +Reset hold + +```ts +/** + * Reset hold + */ +releaseHold(): void; +``` + +### `onFilter` + +Monitor and filter ops stored in history + +```ts +/** +* Monitoring and filtering are stored in the history stack +* @param filter true to filter and exclude, false to record in the history stack +*/ +onFilter(filter: (op: Op) => boolean): void +``` + +### `onSelf` + +Monitor the current change ops and decide whether to write to the history record + +```ts +/** +* +* @param collect method undefined The default delay save, true save immediately, false immediately discard. Promise blocks all subsequent ops until it returns false or true +*/ +onSelf(collect: (ops: Op[]) => Promise | boolean | undefined): void +``` + +### `clear` + +Delay to clear all history records + +```ts +clear(): void; +``` + +### `saveOp` + +Save the currently unmaintained operations to the stack + +```ts +saveOp(): void; +``` + +### `handleSelfOps` + +Collect local editing operations + +```ts +/** + * @param ops operation set + * */ +handleSelfOps(ops: Op[]): void; +``` + +### `collectRemoteOps` + +Collect remote operations (operations from other coordinators) + +```ts +/** + * @param ops operation set + * */ +collectRemoteOps(ops: Op[]): void; +``` + +### `getUndoOp` + +Get the undo operation of the current top position + +```ts +getUndoOp(): Operation | undefined; +``` + +### `getRedoOp` + +Get the redo operation of the current top position + +```ts +getRedoOp(): Operation | undefined; +``` + +### `getCurrentRangePath` + +Get the converted path of the current cursor + +```ts +getCurrentRangePath(): Path[]; +``` + +### `getRangePathBeforeCommand` + +Get the converted path of the cursor recorded before executing the command + +```ts +getRangePathBeforeCommand(): Path[]; +``` diff --git a/docs/api/history.zh-CN.md b/docs/api/history.zh-CN.md new file mode 100644 index 00000000..32e3df42 --- /dev/null +++ b/docs/api/history.zh-CN.md @@ -0,0 +1,174 @@ +# 历史 + +编辑器的编辑历史记录 + +类型:`HistoryInterface` + +## 构造函数 + +```ts +new (engine: EngineInterface): HistoryInterface +``` + +## 方法 + +### `reset` + +重置历史记录,会清空所有的历史记录 + +```ts +reset(): void; +``` + +### `hasUndo` + +是否有撤销操作 + +```ts +hasUndo(): boolean; +``` + +### `hasRedo` + +是否有重做操作 + +```ts +hasRedo(): boolean; +``` + +### `undo` + +执行撤销操作 + +```ts +undo(): void; +``` + +### `redo` + +执行重做操作 + +```ts +redo(): void; +``` + +### `hold` + +在接下来的多少毫秒内的动作保持为一个历史片段 + +```ts +/** + * 多少毫秒内的动作保持为一个历史片段 + * @param time 毫秒 + */ +hold(time?: number): void; +``` + +### `releaseHold` + +重置 hold + +```ts +/** + * 重置 hold + */ +releaseHold(): void; +``` + +### `lock` + +在接下来的多少毫秒内的动作将不作为历史记录 + +### `onFilter` + +监听过滤存入历史记录的 ops + +```ts +/** +* 监听过滤存入历史记录堆栈中 +* @param filter true 过滤排除,false 记录到历史堆栈中 +*/ +onFilter(filter: (op: Op) => boolean): void +``` + +### `onSelf` + +监听当前变更 ops,并决定是否写入到历史记录 + +```ts +/** +* +* @param collect 方法 undefined 默认延时保存,true 立即保存,false 立即丢弃。Promise 阻拦接下来的所有ops直到返回false或者true +*/ +onSelf(collect: (ops: Op[]) => Promise | boolean | undefined): void +``` + +### `clear` + +延时清除全部的历史记录 + +```ts +clear(): void; +``` + +### `saveOp` + +把当前还未保持的操作保存到堆栈里 + +```ts +saveOp(): void; +``` + +### `handleSelfOps` + +收集本地编辑的操作 + +```ts +/** + * @param ops 操作集合 + * */ +handleSelfOps(ops: Op[]): void; +``` + +### `collectRemoteOps` + +收集远程的操作(来自其它协同者的操作) + +```ts +/** + * @param ops 操作集合 + * */ +collectRemoteOps(ops: Op[]): void; +``` + +### `getUndoOp` + +获取当前最前位置的撤销操作 + +```ts +getUndoOp(): Operation | undefined; +``` + +### `getRedoOp` + +获取当前最前位置的重做操作 + +```ts +getRedoOp(): Operation | undefined; +``` + +### `getCurrentRangePath` + +获取当前光标转换后的路径 + +```ts +getCurrentRangePath(): Path[]; +``` + +### `getRangePathBeforeCommand` + +获取执行命令前记录的光标转换后的路径 + +```ts +getRangePathBeforeCommand(): Path[]; +``` diff --git a/docs/api/hotkey.md b/docs/api/hotkey.md new file mode 100644 index 00000000..e389daf7 --- /dev/null +++ b/docs/api/hotkey.md @@ -0,0 +1,45 @@ +# Hotkey + +Editor hotkeys/shortcut keys + +Type: `HotkeyInterface` + +## Constructor + +```ts +new (engine: EngineInterface): HotkeyInterface +``` + +## Method + +### `trigger` + +Trigger a keyboard event + +```ts +trigger(e: KeyboardEvent): void; +``` + +### `enable` + +Enable shortcut keys + +```ts +enable(): void; +``` + +### `disable` + +Disable shortcut keys + +```ts +disable(): void; +``` + +### `destroy` + +destroy + +```ts +destroy(): void; +``` diff --git a/docs/api/hotkey.zh-CN.md b/docs/api/hotkey.zh-CN.md new file mode 100644 index 00000000..875a441c --- /dev/null +++ b/docs/api/hotkey.zh-CN.md @@ -0,0 +1,45 @@ +# 热键 + +编辑器热键/快捷键 + +类型:`HotkeyInterface` + +## 构造函数 + +```ts +new (engine: EngineInterface): HotkeyInterface +``` + +## 方法 + +### `trigger` + +触发一个键盘事件 + +```ts +trigger(e: KeyboardEvent): void; +``` + +### `enable` + +启用快捷键 + +```ts +enable(): void; +``` + +### `disable` + +禁用快捷键 + +```ts +disable(): void; +``` + +### `destroy` + +销毁 + +```ts +destroy(): void; +``` diff --git a/docs/api/language.md b/docs/api/language.md new file mode 100644 index 00000000..7586f507 --- /dev/null +++ b/docs/api/language.md @@ -0,0 +1,34 @@ +# Language + +Add multi-language configuration to the editor + +Type: `LanguageInterface` + +## Constructor + +```ts +new (lange: string, data: {} = {}): LanguageInterface +``` + +## Method + +### `add` + +Increase language configuration + +Method signature + +```ts +add(data: {}): void; +``` + +### `get` + +Get the value of the language configuration item + +```ts +/** + * @param keys The keys of multiple configuration items + * */ +get(...keys: Array): T; +``` diff --git a/docs/api/language.zh-CN.md b/docs/api/language.zh-CN.md new file mode 100644 index 00000000..3f117c21 --- /dev/null +++ b/docs/api/language.zh-CN.md @@ -0,0 +1,34 @@ +# 语言 + +给编辑器增加多语言配置 + +类型:`LanguageInterface` + +## 构造函数 + +```ts +new (lange: string, data: {} = {}): LanguageInterface +``` + +## 方法 + +### `add` + +增加语言配置 + +方法签名 + +```ts +add(data: {}): void; +``` + +### `get` + +获取语言配置项的值 + +```ts +/** + * @param keys 多个配置项的key + * */ +get(...keys: Array): T; +``` diff --git a/docs/api/node.md b/docs/api/node.md new file mode 100644 index 00000000..390f688c --- /dev/null +++ b/docs/api/node.md @@ -0,0 +1,767 @@ +# NodeInterface + +Expand on the `Node` node of the DOM + +Type: `NodeInterface` + +## Create `NodeInterface` object + +Use the `$` node selector provided in the engine to instantiate the `NodeInterface` object + +```ts +import { $ } from '@aomao/engine'; +//Use CSS selector to find nodes +const content = $('.content'); +//Create node +const div = $('
'); +document.body.append(div[0]); +//Conversion +const p = $(document.querySelector('p')); +const target = $(event.target); +``` + +## Attributes + +### `length` + +Node node collection length + +Type: `number` + +### `events` + +The collection of event objects of all Node nodes in the current object + +Type: `EventInterface[]` + +### `document` + +The Document object where the current Node node is located. In the use of iframe, the document in different frames is not consistent, and there are other environments as well, so we need to follow this object. + +Type: `Document | null` + +### `window` + +The Window object where the current Node node is located. In the use of iframes, the windows in different frames are not consistent, and the same is true in some other environments, so we need to follow this object. + +Type: `Window | null` + +### `context` + +Context node + +Type: `Context | undefined` + +### `name` + +Node name + +Type: `string` + +### `type` + +Node type, consistent with `Node.nodeType` [API](https://developer.mozilla.org/zh-CN/docs/Web/API/Node/nodeType) + +Type: `number | undefined` + +### `display` + +Node display status + +Type: `string | undefined` + +### `isFragment` + +Whether the Node node collection in the current object is a frame fragment + +Type: `boolean` + +### `[n: number]` + +Node node collection, which can be accessed by subscript index + +Return type: Node + +## Method + +### `each` + +Traverse all Node nodes in the current object + +```ts +/** +* Traverse +* @param {Function} callback callback function +* @return {NodeInterface} returns the current instance +*/ +each( + callback: (node: Node, index: number) => boolean | void, +): NodeInterface; +``` + +### `toArray` + +Convert all Node nodes in the current object to an array + +```ts +toArray(): Array; +``` + +### `isElement` + +Whether the current node is Node.ELEMENT_NODE node type + +```ts +isElement(): boolean; +``` + +### `isText` + +Whether the current node is Node.TEXT_NODE node type + +```ts +isText(): boolean; +``` + +### `isCard` + +Whether the current node is a Card component + +```ts +isCard(): boolean; +``` + +### `isBlockCard` + +Whether the current node is a Card component of block type + +```ts +isBlockCard(): boolean; +``` + +### `isInlineCard` + +Whether the current node is a Card component of inline type + +```ts +isInlineCard(): boolean; +``` + +### `isEditableCard` + +Is it an editable card + +```ts +isEditableCard(): boolean; +``` + +### `isRoot` + +Whether it is the root node + +```ts +isRoot(): boolean; +``` + +### `isEditable` + +Whether it is an editable node + +```ts +isEditable(): boolean; +``` + +### `inEditor` + +Is it in the root node + +```ts +inEditor(): boolean; +``` + +### `isCursor` + +Whether it is a cursor marked node + +```ts +isCursor(): boolean +``` + +### `get` + +Get the current Node node + +```ts +get(): E | null; +``` + +### `eq` + +Get the current index node + +```ts +/** + * Get the current index node + * @param {number} index + * @return {NodeInterface|undefined} NodeInterface class, or undefined + */ +eq(index: number): NodeInterface | undefined; +``` + +### `index` + +Get the index of the parent node where the current node is located, and only count the nodes whose node type is ELEMENT_NODE + +```ts +/** + * Get the index of the parent node where the current node is located, and only calculate the node whose node type is ELEMENT_NODE + * @return {number} return index + */ +index(): number; +``` + +### `parent` + +Get the parent node of the current node + +```ts +/** + * Get the parent node of the current node + * @return {NodeInterface} parent node + */ +parent(): NodeInterface | undefined; +``` + +### `children` + +Query all child nodes of the current node + +```ts +/** + * + * @param {Node | string} selector finder + * @return {NodeInterface} Eligible child nodes + */ +children(selector?: string): NodeInterface; +``` + +### `first` + +Get the first child node of the current node + +```ts +/** + * Get the first child node of the current node + * @return {NodeInterface} NodeInterface child node + */ +first(): NodeInterface | null; +``` + +### `last` + +Get the last child node of the current node + +```ts +/** + * Get the last child node of the current node + * @return {NodeInterface} NodeInterface child node + */ +last(): NodeInterface | null; +``` + +### `prev` + +Return the sibling nodes before the node (including text nodes and comment nodes) + +```ts +/** + * Return the sibling nodes before the node (including text nodes and comment nodes) + * @return {NodeInterface} NodeInterface node + */ +prev(): NodeInterface | null; +``` + +### `next` + +Return the sibling nodes after the node (including text nodes and comment nodes) + +```ts +/** + * Return the sibling nodes after the node (including text nodes and comment nodes) + * @return {NodeInterface} NodeInterface node + */ +next(): NodeInterface | null; +``` + +### `prevElement` + +Return the sibling nodes before the node (not including text nodes and comment nodes) + +```ts +/** + * Return the sibling nodes before the node (not including text nodes and comment nodes) + * @return {NodeInterface} NodeInterface node + */ +prevElement(): NodeInterface | null; +``` + +### `nextElement` + +Return the sibling nodes after the node (not including text nodes and comment nodes) + +```ts +/** + * Return the sibling nodes after the node (not including text nodes and comment nodes) + * @return {NodeInterface} NodeInterface node + */ +nextElement(): NodeInterface | null; +``` + +### `getPath` + +Returns the path of the root node where the node is located, the default root node is document.body + +```ts +/** + * Return the path of the root node where the node is located, the default root node is document.body + * @param {Node} context root node, the default is document.body + * @return {number} path + */ +getPath(context?: Node | NodeInterface): Array; +``` + +### `contains` + +Determine whether the node contains the node to be queried + +```ts +/** + * Determine whether the node contains the node to be queried + * @param {NodeInterface | Node} node The node to be queried + * @return {Boolean} Does it contain + */ +contains(node: NodeInterface | Node): boolean; +``` + +### `find` + +Query the current node according to the querier + +```ts +/** + * Query the current node according to the querier + * @param {String} selector finder + * @return {NodeInterface} returns a NodeInterface instance + */ +find(selector: string): NodeInterface; +``` + +### closest + +Query the parent node closest to the current node that meets the criteria according to the querier + +```ts +/** + * Query the parent node closest to the current node that meets the criteria according to the querier + * @param {string} selector querier + * @return {NodeInterface} returns a NodeInterface instance + */ +closest( + selector: string, + callback?: (node: Node) => Node | undefined, +): NodeInterface; +``` + +### `on` + +Bind events to the current node + +```ts +/** + * Bind events to the current node + * @param {String} eventType event type + * @param {Function} listener event function + * @return {NodeInterface} returns the current instance + */ +on(eventType: string, listener: EventListener): NodeInterface; +``` + +### `off` + +Remove current node event + +```ts +/** + * Remove the current node event + * @param {String} eventType event type + * @param {Function} listener event function + * @return {NodeInterface} returns the current instance + */ +off(eventType: string, listener: EventListener): NodeInterface; +``` + +### `getBoundingClientRect` + +Get the position of the current node relative to the viewport + +```ts +/** + * Get the position of the current node relative to the viewport + * @param {Object} defaultValue default value + * @return {Object} + * { + * top, + * bottom, + * left, + * right + *} + */ +getBoundingClientRect(defaultValue?: { + top: number; + bottom: number; + left: number; + right: number; +}): + | {top: number; bottom: number; left: number; right: number} + | undefined; +``` + +### `removeAllEvents` + +Remove all bound events of the current node + +```ts +/** + * Remove all bound events of the current node + * @return {NodeInterface} current NodeInterface instance + */ +removeAllEvents(): NodeInterface; +``` + +### `offset` + +Get the offset of the current node relative to the parent node + +```ts +/** + * Get the offset of the current node relative to the parent node + */ +offset(): number; +``` + +### `attributes` + +Get or set node attributes + +```ts +/** + * Get or set node attributes + * @param {string|undefined} key attribute name, key is empty to get all attributes, return Map + * @param {string|undefined} val attribute value, val is empty to get the attribute of the current key, return string|null + * @return {NodeInterface|{[k:string]:string}} return value or current instance + */ +attributes(): {[k: string]: string }; +attributes(key: {[k: string]: string }): string; +attributes(key: string, val: string | number): NodeInterface; +attributes(key: string): string; +attributes( + key?: string | {[k: string]: string }, + val?: string | number, +): NodeInterface | {[k: string]: string} | string; +``` + +### `removeAttributes` + +Remove node attributes + +```ts +/** + * Remove node attributes + * @param {String} key attribute name + * @return {NodeInterface} returns the current instance + */ +removeAttributes(key: string): NodeInterface; +``` + +### `hasClass` + +Determine whether the node contains a certain class + +```ts +/** + * Determine whether the node contains a certain class + * @param {String} className style name + * @return {Boolean} Does it contain + */ +hasClass(className: string): boolean; +``` + +### `addClass` + +Add a class to the node + +```ts +/** + * + * @param {string} className + * @return {NodeInterface} returns the current instance + */ +addClass(className: string): NodeInterface; +``` + +### `removeClass` + +Remove node class + +```ts +/** + * Remove node class + * @param {String} className + * @return {NodeInterface} returns the current instance + */ +removeClass(className: string): NodeInterface; +``` + +### `css` + +Get or set the node style + +```ts +/** + * Get or set the node style + * @param {String|undefined} key style name + * @param {String|undefined} val style value + * @return {NodeInterface|{[k:string]:string}} return value or current instance + */ +css(): {[k: string]: string }; +css(key: {[k: string]: string | number }): NodeInterface; +css(key: string): string; +css(key: string, val: string | number): NodeInterface; +css( + key?: string | {[k: string]: string | number }, + val?: string | number, +): NodeInterface | {[k: string]: string} | string; +``` + +### `width` + +Get node width + +```ts +/** + * Get node width + * @return {number} width + */ +width(): number; +``` + +### `height` + +Get node height + +```ts +/** + * Get node height + * @return {Number} height + */ +height(): number; +``` + +### `html` + +Get or set node html text + +```ts +/** + * Get or set node html text + */ +html(): string; +html(html: string): NodeInterface; +html(html?: string): NodeInterface | string; +``` + +### `text` + +```ts +/** + * Get or set the node text + */ +text(): string; +text(text: string): NodeInterface; +text(text?: string): string | NodeInterface; +``` + +### `show` + +Set the node to display state + +```ts +/** + * Set the node to display state + * @param {String} display display value + * @return {NodeInterface} current instance + */ +show(display?: string): NodeInterface; +``` + +### `hide` + +Set node to hidden state + +```ts +/** + * Set the node to hidden + * @return {NodeInterface} current instance + */ +hide(): NodeInterface; +``` + +### `remove` + +Remove all nodes of the current instance + +```ts +/** + * Remove all nodes of the current instance + * @return {NodeInterface} current instance + */ +remove(): NodeInterface; +``` + +### `empty` + +Clear all child nodes under the node, including text + +```ts +/** + * Clear all child nodes under the node + * @return {NodeInterface} current instance + */ +empty(): NodeInterface; +``` + +### `equal` + +Compare whether two nodes are the same, including the reference address + +```ts +/** +* Compare whether two nodes are the same +* @param {NodeInterface|Node} node The node to compare +* @return {Boolean} are they the same +*/ +equal(node: NodeInterface | Node): boolean; +``` + +### `clone` + +Copy node + +```ts +/** + * Copy node + * @param deep Whether to deep copy + */ +clone(deep?: boolean): NodeInterface; +``` + +### `prepend` + +Insert the specified content at the beginning of the node + +```ts +/** + * Insert the specified content at the beginning of the node + * @param {Selector} selector selector or node + * @return {NodeInterface} current instance + */ +prepend(selector: Selector): NodeInterface; +``` + +### `append` + +Insert the specified content at the end of the node + +```ts +/** + * Insert the specified content at the end of the node + * @param {Selector} selector selector or node + * @return {NodeInterface} current instance + */ +append(selector: Selector): NodeInterface; +``` + +### `before` + +Insert a new node before the node + +```ts +/** + * Insert a new node before the node + * @param {Selector} selector selector or node + * @return {NodeInterface} current instance + */ +before(selector: Selector): NodeInterface; +``` + +### `after` + +Insert content after the node + +```ts +/** + * Insert content after the node + * @param {Selector} selector selector or node + * @return {NodeInterface} current instance + */ +after(selector: Selector): NodeInterface; +``` + +### `replaceWith` + +Replace node with new content + +```ts +/** + * Replace the node with new content + * @param {Selector} selector selector or node + * @return {NodeInterface} current instance + */ +replaceWith(selector: Selector): NodeInterface; +``` + +### `getRoot` + +Get the root node of the editing area where the node is located + +```ts +/** + * Get the root node of the editing area where the node is located + */ +getRoot(): NodeInterface; +``` + +### `traverse` + +Traverse all child nodes + +```ts +/** + * Traverse all child nodes + * @param callback callback function, false: stop traversal, true: stop traversing the current node and child nodes, and continue to traverse the next sibling node + * @param order true: order, false: reverse order, default true + */ +traverse( + callback: (node: NodeInterface) => boolean | void, + order?: boolean, +): void; +``` + +### `getChildByPath` + +Get child nodes according to path + +```ts +/** + * According to the path to obtain +``` diff --git a/docs/api/node.zh-CN.md b/docs/api/node.zh-CN.md new file mode 100644 index 00000000..387398c1 --- /dev/null +++ b/docs/api/node.zh-CN.md @@ -0,0 +1,861 @@ +# NodeInterface + +在 DOM 的 `Node` 节点上进行扩展 + +类型:`NodeInterface` + +## 创建 `NodeInterface` 对象 + +使用引擎内提供的 `$` 节点选择器来实例化 `NodeInterface` 对象 + +```ts +import { $ } from '@aomao/engine'; +//使用CSS选择器查找节点 +const content = $('.content'); +//创建节点 +const div = $('
'); +document.body.append(div[0]); +//转换 +const p = $(document.querySelector('p')); +const target = $(event.target); +``` + +## 属性 + +### `length` + +Node 节点集合长度 + +类型:`number` + +### `events` + +当前对象中所有 Node 节点的事件对象集合 + +类型:`EventInterface[]` + +### `document` + +当前 Node 节点所在的 Document 对象。在使用 iframe 中,不同框架中的 document 并是不一致的,还有一些其它环境中也是如此,所以我们需要跟随这个对象。 + +类型:`Document | null` + +### `window` + +当前 Node 节点所在的 Window 对象。在使用 iframe 中,不同框架中的 window 并是不一致的,还有一些其它环境中也是如此,所以我们需要跟随这个对象。 + +类型:`Window | null` + +### `context` + +上下文节点 + +类型:`Context | undefined` + +### `name` + +节点名称 + +类型:`string` + +### `type` + +节点类型,与 `Node.nodeType` 一致 [API](https://developer.mozilla.org/zh-CN/docs/Web/API/Node/nodeType) + +类型:`number | undefined` + +### `display` + +节点显示状态 + +类型:`string | undefined` + +### `isFragment` + +当前对象中的 Node 节点集合是否是框架片段 + +类型:`boolean` + +### `[n: number]` + +Node 节点集合,可以通过下标索引访问 + +返回类型:Node + +## 方法 + +### `each` + +遍历当前对象内的所有 Node 节点 + +```ts +/** +* 遍历 +* @param {Function} callback 回调函数 +* @return {NodeInterface} 返回当前实例 +*/ +each( + callback: (node: Node, index: number) => boolean | void, +): NodeInterface; +``` + +### `toArray` + +把当前对象内的所有 Node 节点转换为数组 + +```ts +toArray(): Array; +``` + +### `isElement` + +当前节点是否为 Node.ELEMENT_NODE 节点类型 + +```ts +isElement(): boolean; +``` + +### `isText` + +当前节点是否为 Node.TEXT_NODE 节点类型 + +```ts +isText(): boolean; +``` + +### `isCard` + +当前节点是否为 Card 组件 + +```ts +isCard(): boolean; +``` + +### `isBlockCard` + +当前节点是否为 block 类型的 Card 组件 + +```ts +isBlockCard(): boolean; +``` + +### `isInlineCard` + +当前节点是否为 inline 类型的 Card 组件 + +```ts +isInlineCard(): boolean; +``` + +### `isEditableCard` + +是否是可编辑的卡片 + +```ts +isEditableCard(): boolean; +``` + +### `isRoot` + +是否为根节点 + +```ts +isRoot(): boolean; +``` + +### `isEditable` + +是否为可编辑节点 + +```ts +isEditable(): boolean; +``` + +### `inEditor` + +是否在根节点内 + +```ts +inEditor(): boolean; +``` + +### `isCursor` + +是否是光标标记节点 + +```ts +isCursor(): boolean +``` + +### `get` + +获取当前 Node 节点 + +```ts +get(): E | null; +``` + +### `eq` + +获取当前第 index 个节点 + +```ts +/** + * 获取当前第 index 节点 + * @param {number} index + * @return {NodeInterface|undefined} NodeInterface 类,或 undefined + */ +eq(index: number): NodeInterface | undefined; +``` + +### `index` + +获取当前节点所在父节点中的索引,仅计算节点类型为 ELEMENT_NODE 的节点 + +```ts +/** + * 获取当前节点所在父节点中的索引,仅计算节点类型为ELEMENT_NODE的节点 + * @return {number} 返回索引 + */ +index(): number; +``` + +### `parent` + +获取当前节点父节点 + +```ts +/** + * 获取当前节点父节点 + * @return {NodeInterface} 父节点 + */ +parent(): NodeInterface | undefined; +``` + +### `children` + +查询当前节点的所有子节点 + +```ts +/** + * + * @param {Node | string} selector 查询器 + * @return {NodeInterface} 符合条件的子节点 + */ +children(selector?: string): NodeInterface; +``` + +### `first` + +获取当前节点第一个子节点 + +```ts +/** + * 获取当前节点第一个子节点 + * @return {NodeInterface} NodeInterface 子节点 + */ +first(): NodeInterface | null; +``` + +### `last` + +获取当前节点最后一个子节点 + +```ts +/** + * 获取当前节点最后一个子节点 + * @return {NodeInterface} NodeInterface 子节点 + */ +last(): NodeInterface | null; +``` + +### `prev` + +返回节点之前的兄弟节点(包括文本节点、注释节点) + +```ts +/** + * 返回节点之前的兄弟节点(包括文本节点、注释节点) + * @return {NodeInterface} NodeInterface 节点 + */ +prev(): NodeInterface | null; +``` + +### `next` + +返回节点之后的兄弟节点(包括文本节点、注释节点) + +```ts +/** + * 返回节点之后的兄弟节点(包括文本节点、注释节点) + * @return {NodeInterface} NodeInterface 节点 + */ +next(): NodeInterface | null; +``` + +### `prevElement` + +返回节点之前的兄弟节点(不包括文本节点、注释节点) + +```ts +/** + * 返回节点之前的兄弟节点(不包括文本节点、注释节点) + * @return {NodeInterface} NodeInterface 节点 + */ +prevElement(): NodeInterface | null; +``` + +### `nextElement` + +返回节点之后的兄弟节点(不包括文本节点、注释节点) + +```ts +/** + * 返回节点之后的兄弟节点(不包括文本节点、注释节点) + * @return {NodeInterface} NodeInterface 节点 + */ +nextElement(): NodeInterface | null; +``` + +### `getPath` + +返回节点所在根节点路径,默认根节点为 document.body + +```ts +/** + * 返回节点所在根节点路径,默认根节点为 document.body + * @param {Node} context 根节点,默认为 document.body + * @return {number} 路径 + */ +getPath(context?: Node | NodeInterface): Array; +``` + +### `contains` + +判断节点是否包含要查询的节点 + +```ts +/** + * 判断节点是否包含要查询的节点 + * @param {NodeInterface | Node} node 要查询的节点 + * @return {Boolean} 是否包含 + */ +contains(node: NodeInterface | Node): boolean; +``` + +### `find` + +根据查询器查询当前节点 + +```ts +/** + * 根据查询器查询当前节点 + * @param {String} selector 查询器 + * @return {NodeInterface} 返回一个 NodeInterface 实例 + */ +find(selector: string): NodeInterface; +``` + +### closest + +根据查询器查询符合条件的离当前节点最近的父节点 + +```ts +/** + * 根据查询器查询符合条件的离当前节点最近的父节点 + * @param {string} selector 查询器 + * @return {NodeInterface} 返回一个 NodeInterface 实例 + */ +closest( + selector: string, + callback?: (node: Node) => Node | undefined, +): NodeInterface; +``` + +### `on` + +为当前节点绑定事件 + +```ts +/** + * 为当前节点绑定事件 + * @param {String} eventType 事件类型 + * @param {Function} listener 事件函数 + * @return {NodeInterface} 返回当前实例 + */ +on(eventType: string, listener: EventListener): NodeInterface; +``` + +### `off` + +移除当前节点事件 + +```ts +/** + * 移除当前节点事件 + * @param {String} eventType 事件类型 + * @param {Function} listener 事件函数 + * @return {NodeInterface} 返回当前实例 + */ +off(eventType: string, listener: EventListener): NodeInterface; +``` + +### `getBoundingClientRect` + +获取当前节点相对于视口的位置 + +```ts +/** + * 获取当前节点相对于视口的位置 + * @param {Object} defaultValue 默认值 + * @return {Object} + * { + * top, + * bottom, + * left, + * right + * } + */ +getBoundingClientRect(defaultValue?: { + top: number; + bottom: number; + left: number; + right: number; +}): + | { top: number; bottom: number; left: number; right: number } + | undefined; +``` + +### `removeAllEvents` + +移除当前节点所有已绑定的事件 + +```ts +/** + * 移除当前节点所有已绑定的事件 + * @return {NodeInterface} 当前 NodeInterface 实例 + */ +removeAllEvents(): NodeInterface; +``` + +### `offset` + +获取当前节点相对父节点的偏移量 + +```ts +/** + * 获取当前节点相对父节点的偏移量 + */ +offset(): number; +``` + +### `attributes` + +获取或设置节点属性 + +```ts +/** + * 获取或设置节点属性 + * @param {string|undefined} key 属性名称,key为空获取所有属性,返回Map + * @param {string|undefined} val 属性值,val为空获取当前key的属性,返回string|null + * @return {NodeInterface|{[k:string]:string}} 返回值或当前实例 + */ +attributes(): { [k: string]: string }; +attributes(key: { [k: string]: string }): string; +attributes(key: string, val: string | number): NodeInterface; +attributes(key: string): string; +attributes( + key?: string | { [k: string]: string }, + val?: string | number, +): NodeInterface | { [k: string]: string } | string; +``` + +### `removeAttributes` + +移除节点属性 + +```ts +/** + * 移除节点属性 + * @param {String} key 属性名称 + * @return {NodeInterface} 返当前实例 + */ +removeAttributes(key: string): NodeInterface; +``` + +### `hasClass` + +判断节点是否包含某个 class + +```ts +/** + * 判断节点是否包含某个 class + * @param {String} className 样式名称 + * @return {Boolean} 是否包含 + */ +hasClass(className: string): boolean; +``` + +### `addClass` + +为节点增加一个 class + +```ts +/** + * + * @param {string} className + * @return {NodeInterface} 返当前实例 + */ +addClass(className: string): NodeInterface; +``` + +### `removeClass` + +移除节点 class + +```ts +/** + * 移除节点 class + * @param {String} className + * @return {NodeInterface} 返当前实例 + */ +removeClass(className: string): NodeInterface; +``` + +### `css` + +获取或设置节点样式 + +```ts +/** + * 获取或设置节点样式 + * @param {String|undefined} key 样式名称 + * @param {String|undefined} val 样式值 + * @return {NodeInterface|{[k:string]:string}} 返回值或当前实例 + */ +css(): { [k: string]: string }; +css(key: { [k: string]: string | number }): NodeInterface; +css(key: string): string; +css(key: string, val: string | number): NodeInterface; +css( + key?: string | { [k: string]: string | number }, + val?: string | number, +): NodeInterface | { [k: string]: string } | string; +``` + +### `width` + +获取节点宽度 + +```ts +/** + * 获取节点宽度 + * @return {number} 宽度 + */ +width(): number; +``` + +### `height` + +获取节点高度 + +```ts +/** + * 获取节点高度 + * @return {Number} 高度 + */ +height(): number; +``` + +### `html` + +获取或设置节点 html 文本 + +```ts +/** + * 获取或设置节点html文本 + */ +html(): string; +html(html: string): NodeInterface; +html(html?: string): NodeInterface | string; +``` + +### `text` + +```ts +/** + * 获取或设置节点文本 + */ +text(): string; +text(text: string): NodeInterface; +text(text?: string): string | NodeInterface; +``` + +### `show` + +设置节点为显示状态 + +```ts +/** + * 设置节点为显示状态 + * @param {String} display display值 + * @return {NodeInterface} 当前实例 + */ +show(display?: string): NodeInterface; +``` + +### `hide` + +设置节点为隐藏状态 + +```ts +/** + * 设置节点为隐藏状态 + * @return {NodeInterface} 当前实例 + */ +hide(): NodeInterface; +``` + +### `remove` + +移除当前实例所有节点 + +```ts +/** + * 移除当前实例所有节点 + * @return {NodeInterface} 当前实例 + */ +remove(): NodeInterface; +``` + +### `empty` + +清空节点下的所有子节点,包括文本 + +```ts +/** + * 清空节点下的所有子节点 + * @return {NodeInterface} 当前实例 + */ +empty(): NodeInterface; +``` + +### `equal` + +比较两个节点是否相同,包括引用地址 + +```ts +/** +* 比较两个节点是否相同 +* @param {NodeInterface|Node} node 比较的节点 +* @return {Boolean} 是否相同 +*/ +equal(node: NodeInterface | Node): boolean; +``` + +### `clone` + +复制节点 + +```ts +/** + * 复制节点 + * @param deep 是否深度复制 + */ +clone(deep?: boolean): NodeInterface; +``` + +### `prepend` + +在节点的开头插入指定内容 + +```ts +/** + * 在节点的开头插入指定内容 + * @param {Selector} selector 选择器或节点 + * @return {NodeInterface} 当前实例 + */ +prepend(selector: Selector): NodeInterface; +``` + +### `append` + +在节点的结尾插入指定内容 + +```ts +/** + * 在节点的结尾插入指定内容 + * @param {Selector} selector 选择器或节点 + * @return {NodeInterface} 当前实例 + */ +append(selector: Selector): NodeInterface; +``` + +### `before` + +在节点前插入新的节点 + +```ts +/** + * 在节点前插入新的节点 + * @param {Selector} selector 选择器或节点 + * @return {NodeInterface} 当前实例 + */ +before(selector: Selector): NodeInterface; +``` + +### `after` + +在节点后插入内容 + +```ts +/** + * 在节点后插入内容 + * @param {Selector} selector 选择器或节点 + * @return {NodeInterface} 当前实例 + */ +after(selector: Selector): NodeInterface; +``` + +### `replaceWith` + +将节点替换为新的内容 + +```ts +/** + * 将节点替换为新的内容 + * @param {Selector} selector 选择器或节点 + * @return {NodeInterface} 当前实例 + */ +replaceWith(selector: Selector): NodeInterface; +``` + +### `getRoot` + +获取节点所在编辑区域的根节点 + +```ts +/** + * 获取节点所在编辑区域的根节点 + */ +getRoot(): NodeInterface; +``` + +### `traverse` + +遍历所有子节点 + +```ts +/** + * 遍历所有子节点 + * @param callback 回调函数,false:停止遍历 ,true:停止遍历当前节点及子节点,继续遍历下一个兄弟节点 + * @param order true:顺序 ,false:倒序,默认 true + */ +traverse( + callback: (node: NodeInterface) => boolean | void, + order?: boolean, +): void; +``` + +### `getChildByPath` + +根据路径获取子节点 + +```ts +/** + * 根据路径获取子节点 + * @param path 路径 + */ +getChildByPath(path: Path, filter?: (node: Node) => boolean): Node; +``` + +### `getIndex` + +获取当前节点所在父节点中的索引 + +```ts +/** + * 获取当前节点所在父节点中的索引 + */ +getIndex(filter?: (node: Node) => boolean): number; +``` + +### `findParent` + +在指定容器里获取父节点 + +```ts +/** + * 在指定容器里获取父节点 + * @param container 容器节点,默认为编辑器根节点 + */ +findParent(container?: Node | NodeInterface): NodeInterface | null; +``` + +### `allChildren` + +获取节点下的所有子节点 + +```ts +/** + * 获取节点下的所有子节点 + */ +allChildren(): Array; +``` + +### `getViewport` + +返回当前节点或者传入的节点所在当前节点的顶级 window 对象的视图边界 + +```ts +/** + * 返回当前节点或者传入的节点所在当前节点的顶级window对象的视图边界 + * @param node 节点 + */ +getViewport( + node?: NodeInterface, +): { top: number; left: number; bottom: number; right: number }; +``` + +### `inViewport` + +判断 view 是否在 node 节点根据当前节点的顶级 window 对象计算的视图边界内 + +```ts +/** + * 判断view是否在node节点根据当前节点的顶级window对象计算的视图边界内 + * @param node 节点 + * @param view 是否在视图的节点 + */ +inViewport(node: NodeInterface, view: NodeInterface): boolean; +``` + +### `scrollIntoView` + +如果 view 节点不可见,将滚动到 align 位置,默认为 nearest + +```ts +/** + * 如果view节点不可见,将滚动到align位置,默认为nearest + * @param node 节点 + * @param view 视图节点 + * @param align 位置 + */ +scrollIntoView( + node: NodeInterface, + view: NodeInterface, + align?: 'start' | 'center' | 'end' | 'nearest', +): void; +``` + +### `removeZeroWidthSpace` + +移除节点内的所有零宽字符占位符 \u200B + +```ts +/** +* 移除占位符 \u200B +* @param root 节点 +*/ +removeZeroWidthSpace(): void; +``` diff --git a/docs/api/parser.md b/docs/api/parser.md new file mode 100644 index 00000000..f36b2948 --- /dev/null +++ b/docs/api/parser.md @@ -0,0 +1,99 @@ +# Parser + +Type: `ParserInterface` + +## Constructor + +```ts +/** + * @param source value or node is finally parsed as a DOM tree + * @param editor editor example + * @param paserBefore callback before parsing + * */ +new (source: string | Node | NodeInterface, editor: EditorInterface, paserBefore?: (node: NodeInterface) => void): ParserInterface +``` + +## Method + +### `traverse` + +Traverse nodes + +```ts +/** + * Traverse nodes + * @param node root node + * @param conversionRules tag name converter + * @param callbacks callbacks + * @param isCardNode is it a card + * @param includeCard whether to include the card + */ +traverse( + node: NodeInterface, + conversionRules: any, + callbacks: Callbacks, + isCardNode?: boolean, + includeCard?: boolean, +): void +``` + +### `toValue` + +Traverse the DOM tree to generate standard editor values + +```ts +/** + * Traverse the DOM tree to generate standard editor values + * @param schemaRules tag retention rules + * @param conversionRules tag conversion rules + * @param replaceSpaces whether to replace spaces + * @param customTags Whether to convert the cursor and card nodes into standard codes + */ +toValue( + schema?: SchemaInterface | null, + conversionRules?: any, + replaceSpaces?: boolean, + customTags?: boolean, +): string +``` + +### `toHTML` + +Convert to HTML code + +```ts +/** + * Convert to HTML code + * @param inner inner package node + * @param outter outer package node + */ +toHTML(inner?: Node, outter?: Node): {html: string, text: string} +``` + +### `toDOM` + +Return to the DOM tree + +```ts +/** + * Return to the DOM tree + */ +toDOM(schema?: SchemaInterface | null, conversionRules?: any): DocumentFragment +``` + +### `toText` + +Convert to text + +```ts +/** + * Convert to text + * @param conversionRules tag conversion rules + * @param includeCard whether to include the card + */ +toText( + schema?: SchemaInterface | null, + conversionRules?: any, + includeCard?: boolean, +): string +``` diff --git a/docs/api/parser.zh-CN.md b/docs/api/parser.zh-CN.md new file mode 100644 index 00000000..6ba373af --- /dev/null +++ b/docs/api/parser.zh-CN.md @@ -0,0 +1,99 @@ +# 解析器 + +类型:`ParserInterface` + +## 构造函数 + +```ts +/** + * @param source 值或者节点最终为解析为DOM树 + * @param editor 编辑器实例 + * @param paserBefore 解析前回调 + * */ +new (source: string | Node | NodeInterface, editor: EditorInterface, paserBefore?: (node: NodeInterface) => void): ParserInterface +``` + +## 方法 + +### `traverse` + +遍历节点 + +```ts +/** + * 遍历节点 + * @param node 根节点 + * @param conversionRules 标签名称转换器 + * @param callbacks 回调 + * @param isCardNode 是否是卡片 + * @param includeCard 是否包含卡片 + */ +traverse( + node: NodeInterface, + conversionRules: any, + callbacks: Callbacks, + isCardNode?: boolean, + includeCard?: boolean, +): void +``` + +### `toValue` + +遍历 DOM 树,生成符合标准的编辑器值 + +```ts +/** + * 遍历 DOM 树,生成符合标准的编辑器值 + * @param schemaRules 标签保留规则 + * @param conversionRules 标签转换规则 + * @param replaceSpaces 是否替换空格 + * @param customTags 是否将光标、卡片节点转换为标准代码 + */ +toValue( + schema?: SchemaInterface | null, + conversionRules?: any, + replaceSpaces?: boolean, + customTags?: boolean, +): string +``` + +### `toHTML` + +转换为 HTML 代码 + +```ts +/** + * 转换为HTML代码 + * @param inner 内包裹节点 + * @param outter 外包裹节点 + */ +toHTML(inner?: Node, outter?: Node): { html: string, text: string} +``` + +### `toDOM` + +返回 DOM 树 + +```ts +/** + * 返回DOM树 + */ +toDOM(schema?: SchemaInterface | null, conversionRules?: any): DocumentFragment +``` + +### `toText` + +转换为文本 + +```ts +/** + * 转换为文本 + * @param conversionRules 标签转换规则 + * @param includeCard 是否包含卡片 + */ +toText( + schema?: SchemaInterface | null, + conversionRules?: any, + includeCard?: boolean, +): string +``` diff --git a/docs/api/range.md b/docs/api/range.md new file mode 100644 index 00000000..ab372364 --- /dev/null +++ b/docs/api/range.md @@ -0,0 +1,345 @@ +# Range + +Inherited from `Range`, has all the methods and attributes of `Range`, if you need to know the detailed attributes and methods, please visit the browser API [Range](https://developer.mozilla.org/zh-CN/docs/Web/ API/Range/Range) + +Type: `RangeInterface` + +## Attributes + +The following only lists the properties and methods extended from the `Range` object + +### `base` + +`Range` object + +Read only + +### `startNode` + +The node where the range starts, read-only + +Type: `NodeInterface` + +### `endNode` + +Node at the end of the range, read-only + +Type: `NodeInterface` + +### `commonAncestorNode` + +The nearest parent node shared by the start node and the end node + +Type: `NodeInterface` + +## Static method + +### `create` + +Create a RangeInterface object from a Point position + +Point can be understood as the x,y coordinate point of the mouse pointer position + +```ts +/** + * Create a RangeInterface object from a Point position + */ +create: ( + editor: EditorInterface, + doc?: Document, + point?: { x: number; y: number }, +) => RangeInterface; +``` + +### `from` + +Create RangeInterface objects from Window, Selection, Range + +```ts +/** + * Create RangeInterface objects from Window, Selection, Range + */ +from: ( + editor: EditorInterface, + win?: Window | globalThis.Selection | globalThis.Range, +) => RangeInterface | null; +``` + +### `fromPath` + +Restore the path to a RangeInterface object + +```ts +/** + * Convert from path to range + * @param path + * @param context, the default editor node + */ +fromPath(path: Path[], context?: NodeInterface): RangeInterface; +``` + +## Method + +### `select` + +Let the range select a node + +```ts +/** + * Select a node + * @param node node + * @param contents whether only selected contents + */ +select(node: NodeInterface | Node, contents?: boolean): RangeInterface; +``` + +### `getText` + +Get the text of all nodes selected by the range + +```ts +/** + * Get the text selected by the range + */ +getText(): string | null; +``` + +### `getClientRect` + +Get the area occupied by the range + +```ts +/** + * Get the area occupied by the range + */ +getClientRect(): DOMRect; +``` + +### `enlargeFromTextNode` + +Extend the selection marker from the TextNode to the nearest non-TextNode node + +```ts +/** + * Expand the selection mark from TextNode to the nearest non-TextNode node + * The selected content of the range remains unchanged + */ +enlargeFromTextNode(): RangeInterface; +``` + +### `shrinkToTextNode` + +Reduce the selection marker from a non-TextNode to a TextNode node, as opposed to enlargeFromTextNode + +```ts +/** + * Reduce the selection marker from a non-TextNode to a TextNode node, as opposed to enlargeFromTextNode + * The selected content of the range remains unchanged + */ +shrinkToTextNode(): RangeInterface; +``` + +### `enlargeToElementNode` + +Extend the range selection boundary + +```ts +/** + * Expand the border + *

[123abc]def

+ * to + *

[123abc]def

+ * @param range selection + * @param toBlock whether to expand to block-level nodes + */ +enlargeToElementNode(toBlock?: boolean): RangeInterface; +``` + +### `shrinkToElementNode` + +Shrink the range selection boundary + +```ts +/** + * Reduce the border + * [

123

] + * to + *

[123]

+ */ +shrinkToElementNode(): RangeInterface; +``` + +### `createSelection` + +Create selectionElement and mark the position of the range, focus or range by inserting a custom span node. Through these marks, we can easily get the nodes in the selection area + +For more properties and methods, please see the `SelectionInterface` API + +```ts +/** + * Create selectionElement, mark the position by inserting a span node + */ +createSelection(): SelectionInterface; +``` + +### `getSubRanges` + +Split the range selection into multiple sub-selections according to text nodes and card nodes + +```ts +/** + * Get a collection of sub-selections + * @param includeCard whether to include the card + */ +getSubRanges(includeCard?: boolean): Array; +``` + +### `setOffset` + +Let the range select a node and set its start position offset and end position offset + +```ts +/** + * @param node The node to be set + * @param start the offset of the starting position + * @param end The offset of the end position + * */ +setOffset( + node: Node | NodeInterface, + start: number, + end: number, +): RangeInterface; +``` + +### `findElements` + +Find a collection of element nodes in the range area, excluding Text text nodes + +```ts +findElements(): Array; +``` + +### `inCard` + +Query whether the range is in the card + +```ts +inCard(): boolean; +``` + +### `getStartOffsetNode` + +Get the node at the offset relative to the node at the beginning of the range + +```ts +getStartOffsetNode(): Node; +``` + +### `getEndOffsetNode` + +Get the node at the offset relative to the node at the end of the range + +```ts +getEndOffsetNode(): Node; +``` + +### `containsCard` + +Whether the range area contains a card + +```ts +/** + * Whether to include a card + */ +containsCard(): boolean; +``` + +### `handleBr` + +Repair the Br node at the range position + +```ts +/** + * When entering content, delete the BR tag generated by the browser, and add BR to the empty block + * Delete scene + *


foo

+ *

foo

+ * Keep the scene + *



foo

+ *

foo

+ *

foo
bar

+ * Add scene + *

+ * @param isLeft + */ +handleBr(isLeft?: boolean): RangeInterface; +``` + +### `getPrevNode` + +Get the node before the range start position + +```ts +/** + * Get the node before the start position + * foo|bar + */ +getPrevNode(): NodeInterface | undefined; +``` + +### `getNextNode` + +Get the node after the end position + +```ts +/** + * Get the node after the end position + * foo|bar + */ +getNextNode(): NodeInterface | undefined; +``` + +### `deepCut` + +Cut the contents of the area selected by the range. Data will be on the clipboard + +```ts +/** + * Deep cut + */ +deepCut(): void; +``` + +### `equal` + +Compare whether the range of two range objects are equal + +```ts +/** + * Compare whether the two ranges are equal + *range + */ +equal(range: RangeInterface | globalThis.Range): boolean; +``` + +### `getRootBlock` + +Get the nearest root node of the current selection + +```ts +/** + * Get the nearest root node of the current selection + */ +getRootBlock(): NodeInterface | undefined; +``` + +### `toPath` + +Convert range selection to path + +```ts +/** + * Get the range path + */ +toPath(): Path[]; +``` diff --git a/docs/api/range.zh-CN.md b/docs/api/range.zh-CN.md new file mode 100644 index 00000000..8fc4a5b8 --- /dev/null +++ b/docs/api/range.zh-CN.md @@ -0,0 +1,345 @@ +# 光标 + +继承自 `Range`,拥有`Range`所有的方法和属性,需要了解详细属性和方法,请访问浏览器 API[Range](https://developer.mozilla.org/zh-CN/docs/Web/API/Range/Range) + +类型:`RangeInterface` + +## 属性 + +以下只列出从`Range`对象扩展出来的属性和方法 + +### `base` + +`Range` 对象 + +只读 + +### `startNode` + +光标开始位置节点,只读 + +类型:`NodeInterface` + +### `endNode` + +光标结束位置节点,只读 + +类型:`NodeInterface` + +### `commonAncestorNode` + +开始节点和结束节点所共有最近的父节点 + +类型:`NodeInterface` + +## 静态方法 + +### `create` + +从一个 Point 位置创建 RangeInterface 对象 + +Point 可以理解为鼠标指针位置的 x,y 坐标点 + +```ts +/** + * 从一个 Point 位置创建 RangeInterface 对象 + */ +create: ( + editor: EditorInterface, + doc?: Document, + point?: { x: number; y: number }, +) => RangeInterface; +``` + +### `from` + +从 Window 、Selection、Range 中创建 RangeInterface 对象 + +```ts +/** + * 从 Window 、Selection、Range 中创建 RangeInterface 对象 + */ +from: ( + editor: EditorInterface, + win?: Window | globalThis.Selection | globalThis.Range, +) => RangeInterface | null; +``` + +### `fromPath` + +把路径还原为 RangeInterface 对象 + +```ts +/** + * 从路径转换为光标 + * @param path + * @param 上下文,默认编辑器节点 + */ +fromPath(path: Path[], context?: NodeInterface): RangeInterface; +``` + +## 方法 + +### `select` + +让光标选中一个节点 + +```ts +/** + * 选中一个节点 + * @param node 节点 + * @param contents 是否只选中内容 + */ +select(node: NodeInterface | Node, contents?: boolean): RangeInterface; +``` + +### `getText` + +获取光标选中的所有节点的文本 + +```ts +/** + * 获取光标选中的文本 + */ +getText(): string | null; +``` + +### `getClientRect` + +获取光标所占的区域 + +```ts +/** + * 获取光标所占的区域 + */ +getClientRect(): DOMRect; +``` + +### `enlargeFromTextNode` + +将选择标记从 TextNode 扩大到最近非 TextNode 节点 + +```ts +/** + * 将选择标记从 TextNode 扩大到最近非TextNode节点 + * range 实质所选择的内容不变 + */ +enlargeFromTextNode(): RangeInterface; +``` + +### `shrinkToTextNode` + +将选择标记从非 TextNode 缩小到 TextNode 节点上,与 enlargeFromTextNode 相反 + +```ts +/** + * 将选择标记从非 TextNode 缩小到TextNode节点上,与 enlargeFromTextNode 相反 + * range 实质所选择的内容不变 + */ +shrinkToTextNode(): RangeInterface; +``` + +### `enlargeToElementNode` + +扩大光标选区边界 + +```ts +/** + * 扩大边界 + *

[123abc]def

+ * to + *

[123abc]def

+ * @param range 选区 + * @param toBlock 是否扩大到块级节点 + */ +enlargeToElementNode(toBlock?: boolean): RangeInterface; +``` + +### `shrinkToElementNode` + +缩小光标选区边界 + +```ts +/** + * 缩小边界 + * [

123

] + * to + *

[123]

+ */ +shrinkToElementNode(): RangeInterface; +``` + +### `createSelection` + +创建 selectionElement,通过插入自定义 span 节点标记光标 anchor、focus 或 cursor 的位置。通过这些标记我们可以很轻松的获取到选区内的节点 + +更多属性和方法请查看 `SelectionInterface` API + +```ts +/** + * 创建 selectionElement,通过插入 span 节点标记位置 + */ +createSelection(): SelectionInterface; +``` + +### `getSubRanges` + +将光标选区按照文本节点和卡片节点分割为多个子选区 + +```ts +/** + * 获取子选区集合 + * @param includeCard 是否包含卡片 + */ +getSubRanges(includeCard?: boolean): Array; +``` + +### `setOffset` + +让光标选择一个节点,并设置它的开始位置偏移量和结束位置偏移量 + +```ts +/** + * @param node 要设置的节点 + * @param start 开始位置的偏移量 + * @param end 结束位置的偏移量 + * */ +setOffset( + node: Node | NodeInterface, + start: number, + end: number, +): RangeInterface; +``` + +### `findElements` + +在光标区域中查找元素节点集合,不包括 Text 文本节点 + +```ts +findElements(): Array; +``` + +### `inCard` + +查询光标是否在卡片内 + +```ts +inCard(): boolean; +``` + +### `getStartOffsetNode` + +获取相对于光标开始位置节点的偏移量处的节点 + +```ts +getStartOffsetNode(): Node; +``` + +### `getEndOffsetNode` + +获取相对于光标结束位置节点的偏移量处的节点 + +```ts +getEndOffsetNode(): Node; +``` + +### `containsCard` + +光标区域是否包含卡片 + +```ts +/** + * 是否包含卡片 + */ +containsCard(): boolean; +``` + +### `handleBr` + +在光标位置修复 Br 节点 + +```ts +/** + * 输入内容时,删除浏览器生成的 BR 标签,对空 block 添加 BR + * 删除场景 + *


foo

+ *

foo

+ * 保留场景 + *



foo

+ *

foo

+ *

foo
bar

+ * 添加场景 + *

+ * @param isLeft + */ +handleBr(isLeft?: boolean): RangeInterface; +``` + +### `getPrevNode` + +获取光标开始位置前的节点 + +```ts +/** + * 获取开始位置前的节点 + * foo|bar + */ +getPrevNode(): NodeInterface | undefined; +``` + +### `getNextNode` + +获取结束位置后的节点 + +```ts +/** + * 获取结束位置后的节点 + * foo|bar + */ +getNextNode(): NodeInterface | undefined; +``` + +### `deepCut` + +剪切光标选择区域的内容。数据会在剪贴板上 + +```ts +/** + * 深度剪切 + */ +deepCut(): void; +``` + +### `equal` + +对比两个光标对象范围是否相等 + +```ts +/** + * 对比两个范围是否相等 + *范围 + */ +equal(range: RangeInterface | globalThis.Range): boolean; +``` + +### `getRootBlock` + +获取当前选区最近的根节点 + +```ts +/** + * 获取当前选区最近的根节点 + */ +getRootBlock(): NodeInterface | undefined; +``` + +### `toPath` + +将光标选区转换为路径 + +```ts +/** + * 获取光标路径 + */ +toPath(): Path[]; +``` diff --git a/docs/api/schema.md b/docs/api/schema.md new file mode 100644 index 00000000..ac20bdaa --- /dev/null +++ b/docs/api/schema.md @@ -0,0 +1,287 @@ +# Schema + +Type: `SchemaInterface` + +## Attributes + +### `data` + +Set of all rule constraints + +```ts +data: { + blocks: Array;//Block-level nodes + inlines: Array;//Inline node + marks: Array;//Style node + globals: {[key: string]: SchemaAttributes | SchemaStyle };//Global rules +}; +``` + +## Method + +### `add` + +Increase rule constraints + +```ts +/** +* Added rules, div tags are not allowed, div will be used as card +* When only type and attributes are used, they will be regarded as global attributes of this type, and will be merged with all other label attributes of the same type +* @param rules +*/ +add( + rules: SchemaRule | SchemaGlobal | Array, +): void; +``` + +### `find` + +Find rules + +```ts +/** + * Find rules + * @param callback search condition + */ +find(callback: (rule: SchemaRule) => boolean): Array; +``` + +### `getType` + +Get node type + +```ts +/** + * Get the node type + * @param node node + */ +getType(node: NodeInterface):'block' |'mark' |'inline' | undefined; +``` + +### `checkNode` + +Check whether the node conforms to a certain attribute rule + +```ts +/** + * Check whether the node conforms to a certain attribute rule + * @param node node + * @param attributes attribute rules + */ +checkNode( + node: NodeInterface, + attributes?: SchemaAttributes | SchemaStyle, +): boolean; +``` + +### `checkStyle` + +Check whether the style value meets the node style rules + +```ts +/** + * Check whether the style value meets the node style rules + * @param name node name + * @param styleName style name + * @param styleValue style value + */ +checkStyle(name: string, styleName: string, styleValue: string): boolean; +``` + +### `checkAttributes` + +Check whether the value meets the rules of node attributes + +```ts +/** + * Check whether the value meets the rules of node attributes + * @param name node name + * @param attributesName attribute name + * @param attributesValue attribute value + */ +checkAttributes( + name: string, + attributesName: string, + attributesValue: string, +): boolean; +``` + +### `checkValue` + +Check whether the value meets the rules + +```ts +/** + * Whether the detection value meets the rules + * @param rule + * @param attributesName attribute name + * @param attributesValue attribute value + */ +checkValue( + rule: SchemaAttributes | SchemaStyle, + attributesName: string, + attributesValue: string, +): boolean; +``` + +### `checkStyle` + +Check whether the style value meets the node style rules + +```ts +/** + * Check whether the style value meets the node style rules + * @param name node name + * @param styleName style name + * @param styleValue style value + * @param type specifies the type + */ +checkStyle( + name: string, + styleName: string, + styleValue: string, + type?:'block' |'mark' |'inline', +): void; +``` + +### `checkAttributes` + +Check whether the value meets the rules of node attributes + +```ts +/** + * Check whether the value meets the rules of node attributes + * @param name node name + * @param attributesName attribute name + * @param attributesValue attribute value + * @param type specifies the type + */ +checkAttributes( + name: string, + attributesName: string, + attributesValue: string, + type?:'block' |'mark' |'inline', +): void; +``` + +### `filterStyles` + +Filter node style + +```ts +/** + * Filter node style + * @param name node name + * @param styles style + * @param type specifies the type + */ +filterStyles( + name: string, + styles: {[k: string]: string }, + type?:'block' |'mark' |'inline', +): void; +``` + +### `filterAttributes` + +Filter node attributes + +```ts +/** + * Filter node attributes + * @param name node name + * @param attributes + * @param type specifies the type + */ +filterAttributes( + name: string, + attributes: {[k: string]: string }, + type?:'block' |'mark' |'inline', +): void; +``` + +### `clone` + +Clone the current schema object + +```ts +/** + * Clone the current schema object + */ +clone(): SchemaInterface; +``` + +### `toAttributesMap` + +Combine attributes of the same label and gloals attributes into map format + +```ts +/** + * Combine and convert the attributes of the same tag and the attributes of gloals into map format + * @param type specifies the type of conversion "block" | "mark" | "inline" + */ +toAttributesMap(type?:'block' |'mark' |'inline'): SchemaMap; +``` + +### `getMapCache` + +Get the merged Map format + +```ts +/** + * Get the merged Map format + * @param type, default is all + */ +getMapCache(type?:'block' |'mark' |'inline'): SchemaMap; +``` + +### `closest` + +Find the name of the topmost node where the node matches the rule + +```ts +/** + * Find the name of the top-level node where the node meets the rule + * @param name node name + * @returns The name of the top block node + */ +closest(name: string): string; +``` + +### `isAllowIn` + +Determine whether the child node name is allowed to be placed in the specified parent node + +```ts +/** + * Determine whether the child node name is allowed to be placed in the specified parent node + * @param source parent node name + * @param target child node name + * @returns true | false + */ +isAllowIn(source: string, target: string): boolean; +``` + +### `getAllowInTags` + +Get the label collection that allows sub-block nodes + +```ts +/** + * Get the label collection that allows child block nodes + * @returns + */ +getAllowInTags(): Array; +``` + +### `getCanMergeTags` + +Get the label collection of block nodes that can be merged + +```ts +/** + * Get the label collection of block nodes that can be merged + * @returns + */ +getCanMergeTags(): Array; +``` diff --git a/docs/api/schema.zh-CN.md b/docs/api/schema.zh-CN.md new file mode 100644 index 00000000..e0fce321 --- /dev/null +++ b/docs/api/schema.zh-CN.md @@ -0,0 +1,287 @@ +# Schema + +类型:`SchemaInterface` + +## 属性 + +### `data` + +所有规则约束集合 + +```ts +data: { + blocks: Array;//块级节点 + inlines: Array;//行内节点 + marks: Array;//样式节点 + globals: { [key: string]: SchemaAttributes | SchemaStyle };//全局规则 +}; +``` + +## 方法 + +### `add` + +增加规则约束 + +```ts +/** +* 增加规则,不允许设置div标签,div将用作card使用 +* 只有 type 和 attributes 时,将作为此类型全局属性,与其它所有同类型标签属性将合并 +* @param rules 规则 +*/ +add( + rules: SchemaRule | SchemaGlobal | Array, +): void; +``` + +### `find` + +查找规则 + +```ts +/** + * 查找规则 + * @param callback 查找条件 + */ +find(callback: (rule: SchemaRule) => boolean): Array; +``` + +### `getType` + +获取节点类型 + +```ts +/** + * 获取节点类型 + * @param node 节点 + */ +getType(node: NodeInterface): 'block' | 'mark' | 'inline' | undefined; +``` + +### `checkNode` + +检测节点是否符合某一属性规则 + +```ts +/** + * 检测节点是否符合某一属性规则 + * @param node 节点 + * @param attributes 属性规则 + */ +checkNode( + node: NodeInterface, + attributes?: SchemaAttributes | SchemaStyle, +): boolean; +``` + +### `checkStyle` + +检测样式值是否符合节点样式规则 + +```ts +/** + * 检测样式值是否符合节点样式规则 + * @param name 节点名称 + * @param styleName 样式名称 + * @param styleValue 样式值 + */ +checkStyle(name: string, styleName: string, styleValue: string): boolean; +``` + +### `checkAttributes` + +检测值是否符合节点属性的规则 + +```ts +/** + * 检测值是否符合节点属性的规则 + * @param name 节点名称 + * @param attributesName 属性名称 + * @param attributesValue 属性值 + */ +checkAttributes( + name: string, + attributesName: string, + attributesValue: string, +): boolean; +``` + +### `checkValue` + +检测值是否符合规则 + +```ts +/** + * 检测值是否符合规则 + * @param rule 规则 + * @param attributesName 属性名称 + * @param attributesValue 属性值 + */ +checkValue( + rule: SchemaAttributes | SchemaStyle, + attributesName: string, + attributesValue: string, +): boolean; +``` + +### `checkStyle` + +检测样式值是否符合节点样式规则 + +```ts +/** + * 检测样式值是否符合节点样式规则 + * @param name 节点名称 + * @param styleName 样式名称 + * @param styleValue 样式值 + * @param type 指定类型 + */ +checkStyle( + name: string, + styleName: string, + styleValue: string, + type?: 'block' | 'mark' | 'inline', +): void; +``` + +### `checkAttributes` + +检测值是否符合节点属性的规则 + +```ts +/** + * 检测值是否符合节点属性的规则 + * @param name 节点名称 + * @param attributesName 属性名称 + * @param attributesValue 属性值 + * @param type 指定类型 + */ +checkAttributes( + name: string, + attributesName: string, + attributesValue: string, + type?: 'block' | 'mark' | 'inline', +): void; +``` + +### `filterStyles` + +过滤节点样式 + +```ts +/** + * 过滤节点样式 + * @param name 节点名称 + * @param styles 样式 + * @param type 指定类型 + */ +filterStyles( + name: string, + styles: { [k: string]: string }, + type?: 'block' | 'mark' | 'inline', +): void; +``` + +### `filterAttributes` + +过滤节点属性 + +```ts +/** + * 过滤节点属性 + * @param name 节点名称 + * @param attributes 属性 + * @param type 指定类型 + */ +filterAttributes( + name: string, + attributes: { [k: string]: string }, + type?: 'block' | 'mark' | 'inline', +): void; +``` + +### `clone` + +克隆当前 schema 对象 + +```ts +/** + * 克隆当前schema对象 + */ +clone(): SchemaInterface; +``` + +### `toAttributesMap` + +将相同标签的属性和 gloals 属性合并转换为 map 格式 + +```ts +/** + * 将相同标签的属性和gloals属性合并转换为map格式 + * @param type 指定转换的类别 "block" | "mark" | "inline" + */ +toAttributesMap(type?: 'block' | 'mark' | 'inline'): SchemaMap; +``` + +### `getMapCache` + +获取合并后的 Map 格式 + +```ts +/** + * 获取合并后的Map格式 + * @param 类型,默认为所有 + */ +getMapCache(type?: 'block' | 'mark' | 'inline'): SchemaMap; +``` + +### `closest` + +查找节点符合规则的最顶层的节点名称 + +```ts +/** + * 查找节点符合规则的最顶层的节点名称 + * @param name 节点名称 + * @returns 最顶级的block节点名称 + */ +closest(name: string): string; +``` + +### `isAllowIn` + +判断子节点名称是否允许放入指定的父节点中 + +```ts +/** + * 判断子节点名称是否允许放入指定的父节点中 + * @param source 父节点名称 + * @param target 子节点名称 + * @returns true | false + */ +isAllowIn(source: string, target: string): boolean; +``` + +### `getAllowInTags` + +获取允许有子 block 节点的标签集合 + +```ts +/** + * 获取允许有子block节点的标签集合 + * @returns + */ +getAllowInTags(): Array; +``` + +### `getCanMergeTags` + +获取能够合并的 block 节点的标签集合 + +```ts +/** + * 获取能够合并的block节点的标签集合 + * @returns + */ +getCanMergeTags(): Array; +``` diff --git a/docs/api/selection.md b/docs/api/selection.md new file mode 100644 index 00000000..b19daac8 --- /dev/null +++ b/docs/api/selection.md @@ -0,0 +1,89 @@ +# Selection + +With `Selection`, you can easily create a mark in the DOM tree based on the selection of `RangeInterface`, and then get the nodes in the middle or on both sides of the mark + +## Constructor + +```ts +new (editor: EditorInterface, range: RangeInterface): SelectionInterface +``` + +## Attributes + +### `anchor` + +Mark the node at the beginning of the selection + +Type: `NodeInterface | null` + +### `focus` + +Mark the node at the end of the selection. If the collapsed of `Range` is true, then the focus node and the anchor node are consistent + +Type: `NodeInterface | null` + +## Static method + +### `removeTags` + +Remove cursor position placeholder label + +```ts +/** + * Remove the placeholder label at the cursor position + * @param value The string to be removed + */ +static removeTags = (value: string) => void +``` + +## Method + +### `has` + +Is there a created mark? + +```ts +has(): boolean; +``` + +### `create` + +Create a mark + +```ts +/** + * Create mark + */ +create(): void; +``` + +### `move` + +Set Range to return to the marked position and delete the mark + +```ts +/** + * Let Range select the mark position and delete the mark + */ +move(): void; +``` + +### `getNode` + +Get the node relative to the marked position of the node, and the mark will be removed after acquisition + +```ts +/** +* Get the node of the node relative to the marked position, and the mark will be removed after acquisition +* @param node node +* @param position +* @param isClone whether to make a copy +* @param callback Call back when deleting a node, return a boolean to indicate whether the current node is deleted +*/ +getNode( +node: NodeInterface, +position?:'left' |'center' |'right', +isClone?: boolean, + callback?: (node: NodeInterface) => boolean +): NodeInterface; +``` diff --git a/docs/api/selection.zh-CN.md b/docs/api/selection.zh-CN.md new file mode 100644 index 00000000..82b183a8 --- /dev/null +++ b/docs/api/selection.zh-CN.md @@ -0,0 +1,89 @@ +# 范围标记 + +通过 `Selection` 可以很轻松的根据`RangeInterface`的选区在 DOM 树中创建标记,然后获取标记中间或者两侧的节点 + +## 构造函数 + +```ts +new (editor: EditorInterface, range: RangeInterface): SelectionInterface +``` + +## 属性 + +### `anchor` + +选区开始位置标记节点 + +类型:`NodeInterface | null` + +### `focus` + +选区结束位置标记节点。如果 `Range` 的 collapsed 为 true,那么 focus 节点与 anchor 节点是一致的 + +类型:`NodeInterface | null` + +## 静态方法 + +### `removeTags` + +移除光标位置占位标签 + +```ts +/** + * 移除光标位置占位标签 + * @param value 需要移除的字符串 + */ +static removeTags = (value: string) => void +``` + +## 方法 + +### `has` + +是否有创建好的标记 + +```ts +has(): boolean; +``` + +### `create` + +创建标记 + +```ts +/** + * 创建标记 + */ +create(): void; +``` + +### `move` + +设置 Range 恢复到标记位置,并删除标记 + +```ts +/** + * 让Range选择标记位置,并删除标记 + */ +move(): void; +``` + +### `getNode` + +获取节点相对于标记位置的节点,获取后会移除标记 + +```ts +/** + * 获取节点相对于标记位置的节点,获取后会移除标记 + * @param node 节点 + * @param position 位置 + * @param isClone 是否复制一个副本 + * @param callback 删除节点时回调,返回一个 boolean 来表示当前节点是否删除 + */ +getNode( + node: NodeInterface, + position?: 'left' | 'center' | 'right', + isClone?: boolean, + callback?: (node: NodeInterface) => boolean +): NodeInterface; +``` diff --git a/docs/api/utils.md b/docs/api/utils.md new file mode 100644 index 00000000..50f8bd8d --- /dev/null +++ b/docs/api/utils.md @@ -0,0 +1,185 @@ +# Useful methods and constants + +## Constant + +### `isEdge` + +Edge browser + +### `isChrome` + +Is it a Chrome browser + +### `isFirefox` + +Is it a Firefox browser + +### `isSafari` + +Is it a Safari browser + +### `isMobile` + +Is it a mobile browser + +### `isIos` + +Is it an iOS system + +### `isAndroid` + +Whether it is Android + +### `isMacos` + +Is it a Mac OS X system + +### `isWindows` + +Is it a Windows system + +## Method + +### `isNodeEntry` + +Whether it is a NodeInterface object + +Accept the following types of objects + +- `string` +- `HTMLElement` +- `Node` +- `Array` +- `NodeList` +- `NodeInterface` +- `EventTarget` + +### `isNodeList` + +Is it a NodeList object + +Accept the following types of objects + +- `string` +- `HTMLElement` +- `Node` +- `Array` +- `NodeList` +- `NodeInterface` +- `EventTarget` + +### `isNode` + +Is it a Node object + +Accept the following types of objects + +- `string` +- `HTMLElement` +- `Node` +- `Array` +- `NodeList` +- `NodeInterface` +- `EventTarget` + +### `isSelection` + +Is it a window.Selection object + +Accept the following types of objects + +- Window +- Selection +- Range + +### `isRange` + +Is it window.Range + +Accept the following types of objects + +- Window +- Selection +- Range + +### `isRangeInterface` + +Whether it is a RangeInterface object extended from Range + +Accept the following types of objects + +- NodeInterface +- RangeInterface + +### `isSchemaRule` + +Is it an object of type `SchemaRule` + +Accept the following types of objects + +- SchemaRule +- SchemaGlobal + +### `isMarkPlugin` + +Is it a Mark type plug-in + +Accepted object: `PluginInterface` + +### `isInlinePlugin` + +Is it an Inline type plug-in + +Accepted object: `PluginInterface` + +### `isBlockPlugin` + +Is it a Block type plug-in + +Accepted object: `PluginInterface` + +### `isEngine` + +Is it an engine + +Accepted object: `EditorInterface` + +### `getWindow` + +Get the window object from the node + +If window is undefined, it will try to get the window object from global['__amWindow'] + +```ts +(node?: Node): Window & typeof globalThis +``` + +### `getDocument` + +Get the document object from the node + +```ts +getDocument(node?: Node): Document +``` + +### `combinText` + +Remove empty text nodes and connect adjacent text nodes + +```ts +combinText(node: NodeInterface | Node): void +``` + +### `getTextNodes` + +Get all textnode type elements in a dom element + +```ts +/** + * Get all textnode type elements in a dom element + * @param {Node} node-dom node + * @param {Function} filter-filter + * @return {Array} the obtained text node + */ +getTextNodes(node: Node, filter?:(node: Node) => boolean): Array +``` diff --git a/docs/api/utils.zh-CN.md b/docs/api/utils.zh-CN.md new file mode 100644 index 00000000..6e771300 --- /dev/null +++ b/docs/api/utils.zh-CN.md @@ -0,0 +1,185 @@ +# 实用方法和常量 + +## 常量 + +### `isEdge` + +否是 Edge 浏览器 + +### `isChrome` + +是否是 Chrome 浏览器 + +### `isFirefox` + +是否是 Firefox 浏览器 + +### `isSafari` + +是否是 Safari 浏览器 + +### `isMobile` + +是否是 手机浏览器 + +### `isIos` + +是否是 iOS 系统 + +### `isAndroid` + +是否是 安卓系统 + +### `isMacos` + +是否是 Mac OS X 系统 + +### `isWindows` + +是否是 Windows 系统 + +## 方法 + +### `isNodeEntry` + +是否是 NodeInterface 对象 + +接受以下类型对象 + +- `string` +- `HTMLElement` +- `Node` +- `Array` +- `NodeList` +- `NodeInterface` +- `EventTarget` + +### `isNodeList` + +是否是 NodeList 对象 + +接受以下类型对象 + +- `string` +- `HTMLElement` +- `Node` +- `Array` +- `NodeList` +- `NodeInterface` +- `EventTarget` + +### `isNode` + +是否是 Node 对象 + +接受以下类型对象 + +- `string` +- `HTMLElement` +- `Node` +- `Array` +- `NodeList` +- `NodeInterface` +- `EventTarget` + +### `isSelection` + +是否是 window.Selection 对象 + +接受以下类型对象 + +- Window +- Selection +- Range + +### `isRange` + +是否是 window.Range + +接受以下类型对象 + +- Window +- Selection +- Range + +### `isRangeInterface` + +是否是从 Range 扩展的 RangeInterface 对象 + +接受以下类型对象 + +- NodeInterface +- RangeInterface + +### `isSchemaRule` + +是否是 `SchemaRule` 类型对象 + +接受以下类型对象 + +- SchemaRule +- SchemaGlobal + +### `isMarkPlugin` + +是否是 Mark 类型插件 + +接受对象:`PluginInterface` + +### `isInlinePlugin` + +是否是 Inline 类型插件 + +接受对象:`PluginInterface` + +### `isBlockPlugin` + +是否是 Block 类型插件 + +接受对象:`PluginInterface` + +### `isEngine` + +是否是引擎 + +接受对象:`EditorInterface` + +### `getWindow` + +从节点中获取 window 对象 + +如果 window 是 undefined 会尝试从 global['__amWindow'] 中获取 window 对象 + +```ts +(node?: Node): Window & typeof globalThis +``` + +### `getDocument` + +从节点中获取 document 对象 + +```ts +getDocument(node?: Node): Document +``` + +### `combinText` + +移除空的文本节点,并连接相邻的文本节点 + +```ts +combinText(node: NodeInterface | Node): void +``` + +### `getTextNodes` + +获取一个 dom 元素内所有的 textnode 类型的元素 + +```ts +/** + * 获取一个 dom 元素内所有的 textnode 类型的元素 + * @param {Node} node - dom节点 + * @param {Function} filter - 过滤器 + * @return {Array} 获取的文本节点 + */ +getTextNodes(node: Node, filter?:(node: Node) => boolean): Array +``` diff --git a/docs/api/view.md b/docs/api/view.md new file mode 100644 index 00000000..6c4cbd65 --- /dev/null +++ b/docs/api/view.md @@ -0,0 +1,31 @@ +# View + +Type: `ViewInterface` + +## Method + +### `render` + +Render content + +```ts +/** + * Render content + * @param content rendered content + * @param trigger Whether to trigger the rendering completion event, used to show the special effects of the plug-in. For example, in the heading plug-in, the anchor point display function is displayed. The default is true + */ +render(content: string, trigger?: boolean): void; +``` + +### `trigger` + +Trigger events, you can actively trigger `render` events `trigger("render","nodes to be rendered")` + +```ts +/** + * trigger event + * @param eventType event name + * @param args parameters + */ +trigger(eventType: string, ...args: any): any; +``` diff --git a/docs/api/view.zh-CN.md b/docs/api/view.zh-CN.md new file mode 100644 index 00000000..60434826 --- /dev/null +++ b/docs/api/view.zh-CN.md @@ -0,0 +1,31 @@ +# 阅读器 + +类型:`ViewInterface` + +## 方法 + +### `render` + +渲染内容 + +```ts +/** + * 渲染内容 + * @param content 渲染的内容 + * @param trigger 是否触发渲染完成事件,用来展示插件的特俗效果。例如在heading插件中,展示锚点显示功能。默认为 true + */ +render(content: string, trigger?: boolean): void; +``` + +### `trigger` + +触发事件,可以主动触发 `render` 事件 `trigger("render","需要渲染的节点")` + +```ts +/** + * 触发事件 + * @param eventType 事件名称 + * @param args 参数 + */ +trigger(eventType: string, ...args: any): any; +``` diff --git a/docs/config/index.md b/docs/config/index.md new file mode 100644 index 00000000..1499ba81 --- /dev/null +++ b/docs/config/index.md @@ -0,0 +1,234 @@ +--- +toc: menu +--- + +# Engine configuration + +Passed in when instantiating the engine + +```ts +//Instantiate the engine +const engine = new Engine(render node, { +... configuration items, +}); +``` + +### lang + +- Type: `string` +- Default value: `zh-CN` +- Detailed: Language configuration, temporarily supports `zh-CN`, `en-US`. Can use `locale` configuration + +```ts +const view = new View(render node, { + lang:'zh-CN', +}); +``` + +### locale + +- Type: `object` +- Default value: `zh-CN` +- Detailed: Configure additional language packs + +Language pack, default language pack [https://github.com/yanmao-cc/am-editor/blob/master/locale](https://github.com/yanmao-cc/am-editor/blob/master/locale) + +```ts +const view = new View(render node, { + locale: { + 'zh-CN': { + test:'Test', + a: { + b: "B" + } + }, + } +}); +console.log(view.language.get('test')); +``` + +### className + +- Type: `string` +- Default value: `null` +- Detailed: Add additional styles of editor render nodes + +### tabIndex + +- Type: `number` +- Default value: `null` +- Detailed: Which tab item is the current editor located in + +### root + +- Type: `Node` +- Default value: the parent node of the render node of the current editor +- Detailed: Editor root node + +### plugins + +- Type: `Array` +- Default value: `[]` +- Detailed: A collection of plugins that implement the abstract class of `Plugin` + +### cards + +- Type: `Array` +- Default value: `[]` +- Detailed: Implement the card collection of the `Card` abstract class + +### config + +- Type: `{ [key: string]: PluginOptions }` +- Default value: `{}` +- Detailed: the configuration item of each plug-in, the key is the name of the plug-in, please refer to the description of each plug-in for detailed configuration. [Configuration example](https://github.com/yanmao-cc/am-editor/blob/master/examples/react/components/editor/config.tsx) + +Some plugins require the configuration of additional properties: + +```ts +// Configure italic markdown syntax +[Italic.pluginName]: { + // The default is _ underscore, here is modified to a single * sign + markdown:'*', +}, +// upload picture +[ImageUploader.pluginName]: { + file: { + action: `${DOMAIN}/upload/image`, + headers: {Authorization: 213434 }, + }, + remote: { + action: `${DOMAIN}/upload/image`, + }, + isRemote: (src: string) => src.indexOf(DOMAIN) <0, +}, +// File Upload +[FileUploader.pluginName]: { + action: `${DOMAIN}/upload/file`, +}, +// video upload +[VideoUploader.pluginName]: { + action: `${DOMAIN}/upload/video`, +}, +// Mathematical formula generation address, the project is at: https://drawing.yanmao.cc +[Math.pluginName]: { + action: `https://g.yanmao.cc/latex`, + parse: (res: any) => { + if (res.success) return {result: true, data: res.svg }; + return {result: false }; + }, +}, +// Submit plugin configuration +[Mention.pluginName]: { + action: `${DOMAIN}/user/search`, + onLoading: (root: NodeInterface) => { + // Vue can be rendered using createApp + return ReactDOM.render(, root.get()!); + }, + onEmpty: (root: NodeInterface) => { + // Vue can be rendered using createApp + return ReactDOM.render(, 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 }, + ) => { + // Vue can be rendered using createApp + ReactDOM.render( +
+

This is name: {name}

+

Configure the onMouseEnter method of the mention plugin

+

Use ReactDOM.render to customize rendering here

+

Use ReactDOM.render to customize rendering here

+
, + layout.get()!, + ); + }, +}, +// Font size configuration +[Fontsize.pluginName]: { + //Configure the font size to be filtered after pasting + filter: (fontSize: string) => { + return ( + [ + '12px', + '13px', + '14px', + '15px', + '16px', + '19px', + '22px', + '24px', + '29px', + '32px', + '40px', + '48px', + ].indexOf(fontSize)> -1 + ); + }, +}, +// Font configuration +[Fontfamily.pluginName]: { + //Configure the font to be filtered after pasting + 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; + }, +}, +// Row height configuration +[LineHeight.pluginName]: { + //Configure the row height to be filtered after pasting + 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'; + // Remove if the conditions are not met + return ( + ['1', '1.15', '1.5', '2', '2.5', '3'].indexOf(lineHeight)> -1 + ); + }, +}, +``` + +### placeholder + +- Type: `string` +- Default value: `None` +- Detailed: placeholder + +### readonly + +- Type: `boolean` +- Default value: `false` +- Detailed: Whether it is read-only or not, and cannot be edited after setting to read-only + +The difference with `View` rendering is that you can still see the editor's edits after `readonly` is set to read-only status. + +After rendering, `View` loses all editing capabilities and collaboration capabilities, `View` can render a `card` plug-in with interactive effects + +`engine.getHtml()` can only get static `html` and cannot restore the interaction effect of `card` component, but it is very friendly to search engines + +### scrollNode + +- Type: `Node | (() => Node | null)` +- Default value: Find the node whose parent style `overflow` or `overflow-y` is `auto` or `scroll`, if not, take `document.body` +- Detailed: The editor scroll bar node is mainly used to monitor the `scroll` event to set the floating position of the bomb layer and actively set the scroll to the editor target position diff --git a/docs/config/index.zh-CN.md b/docs/config/index.zh-CN.md new file mode 100644 index 00000000..7cc3a52f --- /dev/null +++ b/docs/config/index.zh-CN.md @@ -0,0 +1,234 @@ +--- +toc: menu +--- + +# 引擎配置 + +在实例化引擎时传入 + +```ts +//实例化引擎 +const engine = new Engine(渲染节点, { + ...配置项, +}); +``` + +### lang + +- 类型: `string` +- 默认值:`zh-CN` +- 详细:语言配置,暂时支持 `zh-CN`、`en-US`。可使用 `locale` 配置 + +```ts +const view = new View(渲染节点, { + lang: 'zh-CN', +}); +``` + +### locale + +- 类型: `object` +- 默认值:`zh-CN` +- 详细:配置额外语言包 + +语言包,默认语言包 [https://github.com/yanmao-cc/am-editor/blob/master/locale](https://github.com/yanmao-cc/am-editor/blob/master/locale) + +```ts +const view = new View(渲染节点, { + locale: { + 'zh-CN': { + test: '测试', + a: { + b: 'B', + }, + }, + }, +}); +console.log(view.language.get('test')); +``` + +### className + +- 类型: `string` +- 默认值:`null` +- 详细:添加编辑器渲染节点额外样式 + +### tabIndex + +- 类型: `number` +- 默认值:`null` +- 详细:当前编辑器位于第几个 tab 项 + +### root + +- 类型: `Node` +- 默认值:当前编辑器渲染节点父节点 +- 详细:编辑器根节点 + +### plugins + +- 类型: `Array` +- 默认值:`[]` +- 详细:实现 `Plugin` 抽象类的插件集合 + +### cards + +- 类型: `Array` +- 默认值:`[]` +- 详细:实现 `Card` 抽象类的卡片集合 + +### config + +- 类型: `{ [key: string]: PluginOptions }` +- 默认值:`{}` +- 详细:每个插件的配置项,key 为插件名称,详细配置请参考每个插件的说明。 [配置案例](https://github.com/yanmao-cc/am-editor/blob/master/examples/react/components/editor/config.tsx) + +一些插件需要额外属性的配置: + +```ts +// 配置斜体 markdown 语法 +[Italic.pluginName]: { + // 默认为 _ 下划线,这里修改为单个 * 号 + markdown: '*', +}, +// 图片上传 +[ImageUploader.pluginName]: { + file: { + action: `${DOMAIN}/upload/image`, + headers: { Authorization: 213434 }, + }, + remote: { + action: `${DOMAIN}/upload/image`, + }, + isRemote: (src: string) => src.indexOf(DOMAIN) < 0, +}, +// 文件上传 +[FileUploader.pluginName]: { + action: `${DOMAIN}/upload/file`, +}, +// 视频上传 +[VideoUploader.pluginName]: { + action: `${DOMAIN}/upload/video`, +}, +// 数学公式生成地址,项目在:https://drawing.yanmao.cc +[Math.pluginName]: { + action: `https://g.yanmao.cc/latex`, + parse: (res: any) => { + if (res.success) return { result: true, data: res.svg }; + return { result: false }; + }, +}, +// 提交插件配置 +[Mention.pluginName]: { + action: `${DOMAIN}/user/search`, + onLoading: (root: NodeInterface) => { + // Vue 可以使用 createApp 渲染 + return ReactDOM.render(, root.get()!); + }, + onEmpty: (root: NodeInterface) => { + // Vue 可以使用 createApp 渲染 + return ReactDOM.render(, 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 }, + ) => { + // Vue 可以使用 createApp 渲染 + ReactDOM.render( +
+

This is name: {name}

+

配置 mention 插件的 onMouseEnter 方法

+

此处使用 ReactDOM.render 自定义渲染

+

Use ReactDOM.render to customize rendering here

+
, + 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 + ); + }, +}, +``` + +### placeholder + +- 类型: `string` +- 默认值:`无` +- 详细:占位符 + +### readonly + +- 类型: `boolean` +- 默认值:`false` +- 详细:是否只读,设置为只读后不可编辑 + +与 `View` 渲染不同的是,`readonly` 设置只读状态后依然可以看到协同者的编辑。 + +`View` 渲染后失去一切编辑能力和协同能力,`View` 能够渲染出具有交互效果的 `card` 插件 + +`engine.getHtml()` 只能获取到静态的 `html`,无法还原 `card` 组件的交互效果,但是它对搜索引擎很友好 + +### scrollNode + +- 类型: `Node | (() => Node | null)` +- 默认值:查找父级样式 `overflow` 或者 `overflow-y` 为 `auto` 或者 `scroll` 的节点,如果没有就取 `document.body` +- 详细:编辑器滚动条节点,主要用于监听 `scroll` 事件设置弹层浮动位置和主动设置滚动到编辑器目标位置 diff --git a/docs/config/ot.md b/docs/config/ot.md new file mode 100644 index 00000000..93a486d7 --- /dev/null +++ b/docs/config/ot.md @@ -0,0 +1,74 @@ +--- +toc: menu +--- + +# Collaborative editing configuration + +The editor is based on the [sharedb](https://github.com/share/sharedb) and [json0](https://github.com/ottypes/json0) protocols to interactively manipulate data + +The client (editor) establishes a long connection communication with the server through `WebSocket`, and every time the editor changes the dom structure, it will be converted to `json0` format operation command (ops) and sent to the server and modify the server data. Distribute to each client + +## Client + +In the demo case, an editor has provided a client code that uses the `json0` protocol to interact with `sharedb` through `WebSocket` and `sharedb` according to common needs. + +[React](https://github.com/yanmao-cc/am-editor/blob/master/examples/react/components/editor/ot/client.ts) + +[Vue](https://github.com/yanmao-cc/am-editor/blob/master/examples/vue/src/components/ot-client.ts) + +```ts +//Instantiate the collaborative editing client, you need to pass in the current editor instance +const ot = new OTClient(engine); +// Engine.setValue is no longer needed here. Only need to pass the value to OTClient when connecting. After connecting to the server, if the server does not have the document, it will be created with the default value, otherwise the latest document data of the server will be returned +// Connect to the collaborative server, if the server does not have a document corresponding to the docId, it will be initialized with defaultValue +// url server ws link +// docId The unique identification id of the document +// defaultValue on the server side, if the document corresponding to docId does not exist, a new document will be created with this value +ot.connect(url, docId, defaultValue); +ot.on('ready', (member) => { + console.log('OT Ready'); +}); +``` + +This code has been able to meet the basic editing needs, if you need more functions, you can expand by yourself + +## Server + +`ot-server` is a network service created with `nodejs`, and `WebSocket.Server` is used to handle the client's `WebSocket` connection + +[ot-server](https://github.com/yanmao-cc/am-editor/tree/master/ot-server) + +In the demonstration case, only simulated user data is provided. In the production environment, we need the client to transmit the `token` parameter for identity verification + +Use the command + +```bash +# Development environment +yarn dev +# or +# Formal environment +yarn start +``` + +## Collaborative data + +`sharedb` will save the operation data of each client and server as a log, and will keep the newly generated document data after each operation. These operations are performed in `ot-server` + +`sharedb` provides two ways to save these data + +- RAM +- Database + +In the case that no database is provided by default, `sharedb` saves these data in memory by default, but these data cannot be persisted and will disappear after restart. It is not recommended to use in a production environment. + +If you need to use the memory storage test, [ot-server -> client](https://github.com/yanmao-cc/am-editor/blob/master/ot-server/src/client.js) this in the file Delete the `{db: mongodb}` of the code `constructor(backend = new ShareDB({ db: mongodb }))`, and the corresponding reference and instantiation of `mongodb` need to be deleted + +In the demonstration case, the `mongodb` database is used to save all data persistence, so we need to install the `mongodb` database + +- You can install the database version that suits your environment from [MongoDB official website download](https://www.mongodb.com/try/download/community) +- After the installation is complete, create a database and set the user name, password and other permissions +- Finally, configure the database name, user name, password and other information to [config](https://github.com/yanmao-cc/am-editor/tree/master/ot-server/config) to start normally `ot -server` + +[Linux Installation Tutorial](https://www.jianshu.com/p/62455ccaeefe) + +[Windows installation tutorial](https://segmentfault.com/a/1190000039742854) Windows can download the msi version directly, the installation is relatively easy, the next step is the next step. . . diff --git a/docs/config/ot.zh-CN.md b/docs/config/ot.zh-CN.md new file mode 100644 index 00000000..345ae92d --- /dev/null +++ b/docs/config/ot.zh-CN.md @@ -0,0 +1,74 @@ +--- +toc: menu +--- + +# 协同编辑配置 + +编辑器基于 [sharedb](https://github.com/share/sharedb) 与 [json0](https://github.com/ottypes/json0) 协议交互协作操作数据 + +客户端(编辑器)通过 `WebSocket` 与服务端建立长连接通信,编辑器每次的 dom 结构变更都将转换为`json0`格式操作命令(ops)发送到服务端并修改服务端数据后再分发给各个客户端 + +## 客户端 + +在演示案例中已经有根据常用需求提供了一份编辑器通过 `WebSocket` 与 `sharedb` 使用 `json0` 协议交互的客户端代码 + +[React](https://github.com/yanmao-cc/am-editor/blob/master/examples/react/components/editor/ot/client.ts) + +[Vue](https://github.com/yanmao-cc/am-editor/blob/master/examples/vue/src/components/ot-client.ts) + +```ts +//实例化协作编辑客户端,需要传入当前编辑器实例 +const ot = new OTClient(engine); +// 这里不再需要使用 engine.setValue。只需要在连接的时候把 value 传给 OTClient。在连接到服务端后,如果服务端没有该文档将以默认值创建,否则就返回服务端的最新文档数据 +// 连接协同服务端,如果服务端没有对应docId的文档,将使用 defaultValue 初始化 +// url 服务端ws链接 +// docId 文档的唯一识别id +// defaultValue 在服务端如果docId对应的文档不存在,将会以这个值创建一个新文档 +ot.connect(url, docId, defaultValue); +ot.on('ready', (member) => { + console.log('OT Ready'); +}); +``` + +这份代码已经能满足基本的编辑需求,如果需要更多功能可自行扩展 + +## 服务端 + +`ot-server` 是使用 `nodejs` 创建的一个网络服务,使用 `WebSocket.Server` 处理客户端的 `WebSocket` 连接 + +[ot-server](https://github.com/yanmao-cc/am-editor/tree/master/ot-server) + +演示案例中仅提供了模拟的用户数据,在生产环境中我们需要客户端传输`token`参数进行身份效验 + +使用命令 + +```bash +# 开发环境 +yarn dev +# or +# 正式环境 +yarn start +``` + +## 协同数据 + +`sharedb` 会把每次客户端与服务端的操作数据保存为日志,并且在每次操作后都会把最新生成的文档数据保留下来。这些操作都在 `ot-server` 中进行 + +`sharedb` 提供了两种方式保存这些数据 + +- 内存 +- 数据库 + +在默认不提供数据库的情况下,`sharedb` 默认把这些数据都保存在内存中,但是这些数据并不能持久化,重启后将会消失,在生产环境中并不建议使用。 + +如果需要使用内存存储测试,[ot-server -> client](https://github.com/yanmao-cc/am-editor/blob/master/ot-server/src/client.js) 文件中的这段代码 `constructor(backend = new ShareDB({ db: mongodb }))` 的 `{db: mongodb}` 删除即可,相应的也需要把 `mongodb` 上面的引用及实例化删除 + +演示案例中使用了 `mongodb` 数据库保存了所有的数据持久化,所以我们需要安装 `mongodb` 数据库 + +- 可以在 [MongoDB 官网下载](https://www.mongodb.com/try/download/community) 安装符合自己环境的数据库版本 +- 安装完成后,创建一个数据库,并设置用户名、密码等权限 +- 最后把数据库名称、用户名、密码等信息配置到[config](https://github.com/yanmao-cc/am-editor/tree/master/ot-server/config) 就可以正常启动 `ot-server` 了 + +[Linux 安装教程](https://www.jianshu.com/p/62455ccaeefe) + +[Windows 安装教程](https://segmentfault.com/a/1190000039742854) Windows 可以直接下载 msi 版本的,安装比较容易,下一步下一步。。。 diff --git a/docs/config/toolbar.md b/docs/config/toolbar.md new file mode 100644 index 00000000..c7803edd --- /dev/null +++ b/docs/config/toolbar.md @@ -0,0 +1,509 @@ +# Toolbar configuration + +Introduce the toolbar + +```ts +//vue3 please use @aomao/toolbar-vue +//vue2 please use am-editor-toolbar-vue2 +import Toolbar, { ToolbarPlugin, ToolbarComponent } from '@aomao/toolbar'; +``` + +-Toolbar Toolbar component +-ToolbarPlugin provides plugins to the engine +-ToolbarComponent provides the card component to the engine + +Except for the `Toolbar` component, the latter two are shortcuts to realize the toolbar card plug-in option when you press `/` in the editor + +## Types of + +There are now four ways to display the toolbar + +-`button` button -`downdrop` drop-down box -`color` color palette -`collapse` drop-down panel, the drop-down box that appears on the first button of the toolbar, and card-form components are basically placed here + +## Attributes + +The attributes that the Toolbar component needs to pass in: + +-An instance of the `editor` editor, which can be used to automatically invoke the plug-in execution -`items` plugin display configuration list + +## Configuration item + +items is a two-dimensional array. We can put plugins of the same concept in a group for easy searching. After rendering, each group will be separated by a dividing line + +```ts +items: [['collapse'], ['bold', 'italic']]; +``` + +All the display forms of the existing plug-ins have been configured in the Toolbar component, and we can directly pass in the plug-in name to use these configurations. Of course, we can also pass in an object to cover part of the configuration + +```ts +items: [ + ['collapse'], + [ + { + name: 'bold', + icon: 'icon', + title: 'Prompt text', + }, + 'italic', + ], +]; +``` + +If the default configuration is found through the `name` attribute, the `type` attribute will not be overwritten. If the configured `name` is not part of the default configuration, it will be processed according to the custom button + +## Collapse + +Usually used to configure the card drop-down box + +Need to specify `type` as `collapse` + +### className + +Custom style name + +### icon + +Optional + +The button icon, which can be a React component, or a string of html in Vue + +### content + +Optional + +Button display content, will be displayed together with icon + +It can be a React component, or it can be a string of html in Vue. Or a method, and return React component or html string + +### onSelect + +List item selected event, return `false`, the default command of list item configuration will not be executed + +```ts +onSelect?: (event: React.MouseEvent, name: string) => void | boolean; +``` + +### groups + +Group display + +The `groups` property can be set to classify cards for different purposes as needed + +If `title` is not filled in, the grouping style will not appear + +```ts +// Display group information +items: [ + [ + { + type: 'collapse', + groups: [ + { + title: 'File', + items: ['image-uploader', 'file-uploader'], + }, + ], + }, + ], +]; + +// or do not display group information + +items: [ + [ + { + type: 'collapse', + groups: [ + { + items: ['bold', 'underline'], + }, + ], + }, + ], +]; +``` + +### items + +Configure `items` of `collapse` + +The following cards have been configured by default + +```ts +'image-uploader', +'codeblock', +'table', +'file-uploader', +'video-uploader', +'math', +'status', +``` + +We can specify `name` as the name of an existing card, and configure other options to override the default configuration. + +Of course, we can also specify other names to complete custom `item` + +```ts +items: [ + [ + { + type: 'collapse', + groups: [ + { + items: [{ name: 'codeblock', content: 'I am CodeBlock' }], + }, + ], + }, + ], +]; +``` + +The basic properties are the same as the `button` properties, which can be viewed in the following part of the article, here are the special properties relative to the `button` + +#### search + +To query characters, in the toolbar plug-in, we can use `/` to call up shortcut options in the editor, and search for related cards, so you can specify a combination of related keywords and characters here + +#### description + +List item description, can return a `React` component, or `Vue` can return `html` string + +#### prompt + +The content that needs to be rendered when the mouse is moved into the list item can return a `React` component, or `Vue` can return a `html` string + +The effect is similar to the `table` card item. After the input is moved in, a table with selected columns and rows will be displayed + +#### onClick + +List item click event, return `false` will not execute the configured default command + +```ts +onClick?: (event: React.MouseEvent, name: string) => void | boolean; +``` + +## Button + +button configuration properties + +Configure in the toolbar items, you need to specify the `type` as `button` + +```ts +items:[ + [ + { + type:'button', + name:'test', + ... + } + ] +] +``` + +### name + +Button name + +If the button name is the same as the toolbar default configuration item name, then the default configuration will be overwritten, otherwise it will be used as a custom button + +### icon + +Optional + +The button icon, which can be a React component, or a string of html in Vue + +### content + +Optional + +Button display content, will be displayed together with icon + +It can be a React component, or it can be a string of html in Vue. Or a method, and return React component or html string + +### title + +The prompt message displayed when the mouse moves into the button + +### placement + +Set the location of the prompt message + +```ts +placement?: + |'right' + |'top' + |'left' + |'bottom' + |'topLeft' + |'topRight' + |'bottomLeft' + |'bottomRight' + |'leftTop' + |'leftBottom' + |'rightTop' + |'rightBottom'; +``` + +### hotkey + +Whether to display the hot key, or set the information of the hot key + +The default is to display the hotkey to the prompt message (`title`), and use the `name` information to find the hotkey set by the plug-in + +```ts +hotkey?: boolean | string; +``` + +### autoExecute + +When the button is clicked, whether to automatically execute the plug-in command, it is enabled by default + +### command + +Plug-in command or parameter + +If this parameter is configured and the `autoExecute` property is enabled, when the button is clicked, this configuration is called to execute the plug-in command + +If `name` is configured, execute the plugin corresponding to `name`, otherwise execute the plugin corresponding to `name` specified by `button` + +If there is a configuration of `args` or `command` as a pure array, it will be passed as a parameter to the command to execute the plugin + +```ts +command?: {name: string; args: Array} | Array; +``` + +### className + +Configure the style name for the button + +### onClick + +Mouse click event + +If it returns false, the plugin command will not be executed automatically + +```ts +onClick?: (event: React.MouseEvent) => void | boolean; +``` + +### onMouseDown + +Mouse button press event + +```ts +onMouseDown?: (event: React.MouseEvent) => void; +``` + +### onMouseEnter + +Mouse in button event + +```ts +onMouseEnter?: (event: React.MouseEvent) => void; +``` + +### onMouseLeave + +Mouse off button event + +```ts +onMouseLeave?: (event: React.MouseEvent) => void; +``` + +### onActive + +The custom button is activated and selected, and the plug-in `engine.command.queryState` method is called by default + +```ts +onActive?: () => boolean; +``` + +### onDisabled + +The custom button is disabled, and the plugin `engine.command.queryEnabled` is called by default + +```ts +onDisabled?: () => boolean; +``` + +## Dropdown + +dropdown configuration properties + +Configure in the toolbar items, you need to specify `type` as `dropdown` + +```ts +items:[ + [ + { + type:'dropdown', + name:'test', + items: [ + { + key:'item1', + content:'item1' + } + ] + ... + } + ] +] +``` + +### items + +Drop-down list items, similar to buttons + +```ts +items:[{ + key: string; + icon?: React.ReactNode; + content?: React.ReactNode | (() => React.ReactNode); + hotkey?: boolean | string; + isDefault?: boolean; + title?: string; + placement?: + |'right' + |'top' + |'left' + |'bottom' + |'topLeft' + |'topRight' + |'bottomLeft' + |'bottomRight' + |'leftTop' + |'leftBottom' + |'rightTop' + |'rightBottom'; + className?: string; + disabled?: boolean; + command?: {name: string; args: Array} | Array; + autoExecute?: boolean; +}] +``` + +### name + +Drop-down list name + +If the name is the same as the toolbar default configuration item name, then the default existing configuration will be overwritten, otherwise it will be used as a custom drop-down list + +### icon + +Optional + +The button icon, which can be a React component, or a string of html in Vue + +### content + +Optional + +Button display content, will be displayed together with icon + +It can be a React component, or it can be a string of html in Vue. Or a method, and return React component or html string + +### title + +The prompt message displayed when the mouse moves into the button + +### values + +The selected value in the drop-down list is obtained by `engine.command.queryState` by default. If there is a configuration of `onActive`, the value will be obtained from the custom `onActive` + +```ts +values?: string | Array; +``` + +### single + +Single selection or multiple selection + +```ts +single?: boolean; +``` + +### className + +Drop-down list style + +### direction + +Arrangement direction `vertical` | `horizontal` + +```ts +direction?:'vertical' |'horizontal'; +``` + +### onSelect + +List item selection event, return `false` will not automatically execute the command configured for the selected item + +```ts +onSelect?: (event: React.MouseEvent, key: string) => void | boolean; +``` + +### hasArrow + +Whether to show the drop-down arrow + +```ts +hasArrow?: boolean; +``` + +### hasDot + +Whether to display the check effect after the selected value + +```ts +hasDot?: boolean; +``` + +### renderContent + +Custom render the content displayed after the drop-down list is selected, the default is the `icon` or `content` configured by the drop-down list + +Can return React components or Vue can return html strings + +```ts +renderContent?: (item: DropdownListItem) => React.ReactNode; +``` + +### onActive + +The custom button is activated and selected, and the plug-in `engine.command.queryState` method is called by default + +```ts +onActive?: () => boolean; +``` + +### onDisabled + +The custom button is disabled, and the plugin `engine.command.queryEnabled` is called by default + +```ts +onDisabled?: () => boolean; +``` + +## Default configuration of all plugins + +```ts +[ + ['collapse'], + ['undo', 'redo', 'paintformat', 'removeformat'], + ['heading', 'fontfamily', 'fontsize'], + ['bold', 'italic', 'strikethrough', 'underline', 'moremark'], + ['fontcolor', 'backcolor'], + ['alignment'], + ['unorderedlist', 'orderedlist', 'tasklist', 'indent', 'line-height'], + ['link', 'quote', 'hr'], +]; +``` + +These default configuration details can be found here: + +React: [https://github.com/yanmao-cc/am-editor/blob/master/packages/toolbar/src/config/toolbar/index.tsx](https://github.com/yanmao-cc/am-editor/blob/master/packages/toolbar/src/config/toolbar/index.tsx) + +Vue3: [https://github.com/yanmao-cc/am-editor/blob/master/packages/toolbar-vue/src/config/index.ts](https://github.com/yanmao-cc/am-editor/blob/master/packages/toolbar-vue/src/config/index.ts) + +Vue2: [https://github.com/zb201307/am-editor-vue2/blob/main/packages/toolbar/src/config/index.ts](https://github.com/zb201307/am-editor-vue2/blob/main/packages/toolbar/src/config/index.ts) diff --git a/docs/config/toolbar.zh-CN.md b/docs/config/toolbar.zh-CN.md new file mode 100644 index 00000000..92d53db8 --- /dev/null +++ b/docs/config/toolbar.zh-CN.md @@ -0,0 +1,513 @@ +# 工具栏配置 + +引入工具栏 + +```ts +//vue3 请使用 @aomao/toolbar-vue +//vue2 请使用 am-editor-toolbar-vue2 +import Toolbar, { ToolbarPlugin, ToolbarComponent } from '@aomao/toolbar'; +``` + +- Toolbar 工具栏组件 +- ToolbarPlugin 提供给引擎的插件 +- ToolbarComponent 提供给引擎的卡片组件 + +除了 `Toolbar` 组件,后两者都是实现在编辑器按下 `/` 出现工具栏卡片插件选项的快捷方式 + +## 类型 + +工具栏现在有四种展现方式 + +- `button` 按钮 +- `downdrop` 下拉框 +- `color` 颜色板 +- `collapse` 下拉面板,工具栏的第一个按钮出现的下拉框,卡片形式的组件基本上都放在这里 + +## 属性 + +Toolbar 组件需要传入的属性: + +- `editor` 编辑器实例,可以用于自动调用插件执行 +- `items` 插件展示配置列表 + +## 配置项 + +items 是一个二维数组,我们可以把相同概念的插件放在一个组里面,便于寻找。渲染出来后,每个组都会有分割线分开 + +```ts +items: [['collapse'], ['bold', 'italic']]; +``` + +在 Toolbar 组件里面已经配置好了现有插件的所有展现形式,我们可以直接传入插件名称使用这些配置。当然,我们也可以传入一个对象覆盖部分配置 + +```ts +items: [ + ['collapse'], + [ + { + name: 'bold', + icon: '图标', + title: '提示文字', + }, + 'italic', + ], +]; +``` + +如果通过 `name` 属性找到了默认配置,那么 `type` 属性是不会被覆盖的。如果配置的`name`不属于默认配置的一部分,就按照自定义按钮处理 + +## Collapse + +通常用于配置卡片下拉框 + +需要指定 `type` 为 `collapse` + +### className + +自定义样式名称 + +### icon + +可选 + +按钮图标,可以是 React 组件,在 Vue 中也可以是一段字符串的 html + +### content + +可选 + +按钮显示内容,会与 icon 一起显示 + +可以是 React 组件,在 Vue 中也可以是一段字符串的 html。或者是一个方法,并且返回 React 组件或者 html 字符串 + +### onSelect + +列表项选中事件,返回 `false` 不会执行列表项配置的默认命令 + +```ts +onSelect?: (event: React.MouseEvent, name: string) => void | boolean; +``` + +### groups + +分组显示 + +通过 `groups` 属性可以设置按需要把不同用途的卡片分类 + +不填写 `title` 将不会出现分组样式 + +```ts +// 显示分组信息 +items: [ + [ + { + type: 'collapse', + groups: [ + { + title: '文件', + items: ['image-uploader', 'file-uploader'], + }, + ], + }, + ], +]; + +// or 不显示分组信息 + +items: [ + [ + { + type: 'collapse', + groups: [ + { + items: ['bold', 'underline'], + }, + ], + }, + ], +]; +``` + +### items + +配置 `collapse` 的 `items` + +默认情况下已经配置了以下卡片 + +```ts +'image-uploader', +'codeblock', +'table', +'file-uploader', +'video-uploader', +'math', +'status', +``` + +我们可以指定 `name` 为已存在的卡片名称,并且配置其它选项覆盖默认配置。 + +当然我们也可以指定其它名称,完成自定义`item` + +```ts +items: [ + [ + { + type: 'collapse', + groups: [ + { + items: [{ name: 'codeblock', content: '我是CodeBlock' }], + }, + ], + }, + ], +]; +``` + +基本属性与 `button` 属性一样,可以在文章以下部分查看,这里列出了相对于 `button` 外的特俗属性 + +#### search + +查询字符,在工具栏插件中我们可以使用 `/` 在编辑器唤出快捷选项,并且可以搜索相关卡片,所以这里可以指定相关关键字字符组合 + +#### description + +列表项描述,可以返回一个 `React` 组件,或者 `Vue` 可以返回 `html` 字符串 + +#### prompt + +鼠标移入到列表项时需要渲染的内容,可以返回一个 `React` 组件,或者 `Vue` 可以返回 `html` 字符串 + +效果类似于 `table` 卡片项,输入移入后展示一个选择列和行数的表格 + +#### onClick + +列表项单击事件,返回 `false` 将不会执行配置的默认命令 + +```ts +onClick?: (event: React.MouseEvent, name: string) => void | boolean; +``` + +## Button + +button 配置属性 + +在工具栏 items 里面配置,需要指定 `type` 为 `button` + +```ts +items:[ + [ + { + type: 'button', + name: 'test', + ... + } + ] +] +``` + +### name + +按钮名称 + +如果按钮名称与工具栏默认配置项名称相同,那么会覆盖默认已有配置,否则将作为自定义按钮 + +### icon + +可选 + +按钮图标,可以是 React 组件,在 Vue 中也可以是一段字符串的 html + +### content + +可选 + +按钮显示内容,会与 icon 一起显示 + +可以是 React 组件,在 Vue 中也可以是一段字符串的 html。或者是一个方法,并且返回 React 组件或者 html 字符串 + +### title + +鼠标移入按钮时显示的提示信息 + +### placement + +设置提示信息的位置 + +```ts +placement?: + | 'right' + | 'top' + | 'left' + | 'bottom' + | 'topLeft' + | 'topRight' + | 'bottomLeft' + | 'bottomRight' + | 'leftTop' + | 'leftBottom' + | 'rightTop' + | 'rightBottom'; +``` + +### hotkey + +是否显示热键,或者设置热键的信息 + +默认为显示热键到提示信息(`title`),并且通过 `name` 信息找到插件设置的热键 + +```ts +hotkey?: boolean | string; +``` + +### autoExecute + +按钮单击时,是否自动执行插件命令,默认启用 + +### command + +插件命令或参数 + +如果有配置此参数,并且 `autoExecute` 属性为启用状态,在按钮单击时,调用此配置执行插件命令 + +如果有配置 `name` 就执行`name` 对应的插件,否则就执行 `button` 指定的 `name` 对应的插件 + +如果有配置 `args` 或者 `command` 为纯数组,会作为参数传入执行插件的命令 + +```ts +command?: { name: string; args: Array } | Array; +``` + +### className + +为按钮配置样式名称 + +### onClick + +鼠标单击事件 + +如果返回 `false` 将不会自动执行插件命令 + +```ts +onClick?: (event: React.MouseEvent) => void | boolean; +``` + +### onMouseDown + +鼠标按下按钮事件 + +```ts +onMouseDown?: (event: React.MouseEvent) => void; +``` + +### onMouseEnter + +鼠标移入按钮事件 + +```ts +onMouseEnter?: (event: React.MouseEvent) => void; +``` + +### onMouseLeave + +鼠标移开按钮事件 + +```ts +onMouseLeave?: (event: React.MouseEvent) => void; +``` + +### onActive + +自定义按钮激活选中,默认调用插件 `engine.command.queryState` 方法 + +```ts +onActive?: () => boolean; +``` + +### onDisabled + +自定义按钮禁用,默认调用插件 `engine.command.queryEnabled` + +```ts +onDisabled?: () => boolean; +``` + +## Dropdown + +dropdown 配置属性 + +在工具栏 items 里面配置,需要指定 `type` 为 `dropdown` + +```ts +items:[ + [ + { + type: 'dropdown', + name: 'test', + items: [ + { + key: 'item1', + content: 'item1' + } + ] + ... + } + ] +] +``` + +### items + +下拉列表项,与按钮类似 + +```ts +items:[{ + key: string; + icon?: React.ReactNode; + content?: React.ReactNode | (() => React.ReactNode); + hotkey?: boolean | string; + isDefault?: boolean; + title?: string; + placement?: + | 'right' + | 'top' + | 'left' + | 'bottom' + | 'topLeft' + | 'topRight' + | 'bottomLeft' + | 'bottomRight' + | 'leftTop' + | 'leftBottom' + | 'rightTop' + | 'rightBottom'; + className?: string; + disabled?: boolean; + command?: { name: string; args: Array } | Array; + autoExecute?: boolean; +}] +``` + +### name + +下拉列表名称 + +如果名称与工具栏默认配置项名称相同,那么会覆盖默认已有配置,否则将作为自定义下拉列表 + +### icon + +可选 + +按钮图标,可以是 React 组件,在 Vue 中也可以是一段字符串的 html + +### content + +可选 + +按钮显示内容,会与 icon 一起显示 + +可以是 React 组件,在 Vue 中也可以是一段字符串的 html。或者是一个方法,并且返回 React 组件或者 html 字符串 + +### title + +鼠标移入按钮时显示的提示信息 + +### values + +下拉列表选中值,默认通过 `engine.command.queryState` 获取,如果有配置 `onActive` 将会从自定义 `onActive` 中获取值 + +```ts +values?: string | Array; +``` + +### single + +单选还是可以多选 + +```ts +single?: boolean; +``` + +### className + +下拉列表样式 + +### direction + +排列方向 `vertical` | `horizontal` + +```ts +direction?: 'vertical' | 'horizontal'; +``` + +### onSelect + +列表项选中事件,返回 `false` 将不自动执行选中项配置的命令 + +```ts +onSelect?: (event: React.MouseEvent, key: string) => void | boolean; +``` + +### hasArrow + +是否显示下拉箭头 + +```ts +hasArrow?: boolean; +``` + +### hasDot + +是否显示选中值后的勾选效果 + +```ts +hasDot?: boolean; +``` + +### renderContent + +自定义渲染下拉列表选中后显示的内容,默认为下拉列表配置的 `icon` 或者 `content` + +可以返回 React 组件或者 Vue 可以返回 html 字符串 + +```ts +renderContent?: (item: DropdownListItem) => React.ReactNode; +``` + +### onActive + +自定义按钮激活选中,默认调用插件 `engine.command.queryState` 方法 + +```ts +onActive?: () => boolean; +``` + +### onDisabled + +自定义按钮禁用,默认调用插件 `engine.command.queryEnabled` + +```ts +onDisabled?: () => boolean; +``` + +## 所有插件的默认配置 + +```ts +[ + ['collapse'], + ['undo', 'redo', 'paintformat', 'removeformat'], + ['heading', 'fontfamily', 'fontsize'], + ['bold', 'italic', 'strikethrough', 'underline', 'moremark'], + ['fontcolor', 'backcolor'], + ['alignment'], + ['unorderedlist', 'orderedlist', 'tasklist', 'indent', 'line-height'], + ['link', 'quote', 'hr'], +]; +``` + +这些默认配置详细信息可以在这里找到定义: + +React: [https://github.com/yanmao-cc/am-editor/blob/master/packages/toolbar/src/config/toolbar/index.tsx](https://github.com/yanmao-cc/am-editor/blob/master/packages/toolbar/src/config/toolbar/index.tsx) + +Vue3: [https://github.com/yanmao-cc/am-editor/blob/master/packages/toolbar-vue/src/config/index.ts](https://github.com/yanmao-cc/am-editor/blob/master/packages/toolbar-vue/src/config/index.ts) + +Vue2: [https://github.com/zb201307/am-editor-vue2/blob/main/packages/toolbar/src/config/index.ts](https://github.com/zb201307/am-editor-vue2/blob/main/packages/toolbar/src/config/index.ts) diff --git a/docs/config/upload.md b/docs/config/upload.md new file mode 100644 index 00000000..ba328dca --- /dev/null +++ b/docs/config/upload.md @@ -0,0 +1,428 @@ +--- +toc: menu +--- + +# Upload configuration + +The editor implements upload logic by default + +We can access it in `request.upload` in the engine instance + +`request.upload` internally uses `XMLHttpRequest` to upload files, the advantage is that you can get the upload progress + +```ts +engine.request.upload(options: UploaderOptions, files: Array, name?: string) +// Upload optional type +export type UploaderOptions = { + // Upload address + url: string; + // Request type, default json + type?: string; + // content type + contentType?: string; + // additional data + data?: {}; + // cross domain + crossOrigin?: boolean; + // request header + headers?: {[key: string]: string }; + // Before uploading, you can judge the file size limit + onBefore?: (file: File) => Promise; + // Start upload + onReady?: (fileInfo: FileInfo, file: File) => Promise; + // uploading + onUploading?: (file: File, progress: {percent: number }) => void; + // upload error + onError?: (error: Error, file: File) => void; + // Upload successfully + onSuccess?: (response: any, file: File) => void; +}; +// FileInfo type +export type FileInfo = { + uid: string; + src: string | ArrayBuffer | null; + name: string; + size: number; + type: string; + ext: string; +}; +``` + +In addition to upload, there is a utility method called `getFiles(options?: OpenDialogOptions)` that can pop up a local file selector + +```ts +export type OpenDialogOptions = { + event?: MouseEvent; + accept?: string; + multiple?: boolean | number; +}; +``` + +The following plugins all rely on `engine.request.upload` to achieve upload + +We only need to follow the instructions of the corresponding plug-in and simply configure it to upload. + +- ImageUploader +- FileUploader +- VideoUploader + +## Custom upload + +### Single plugin upload + +Take ImageUploader as an example + +```ts +import { + getExtensionName, + FileInfo, + File, + isAndroid, + isEngine, +} from '@aomao/engine'; +import { ImageComponent, ImageUploader } from '@aomao/plugin-image'; +import { ImageValue } from 'plugins/image/dist/component'; +// Inherit the original ImageUploader class and override the execute method +class CustomizeImageUploader extends ImageUploader { + // The card instance currently being uploaded + private imageComponents: Record = {}; + // Process the picture before uploading, and the base64 of the obtained picture will be displayed in the editor while the upload is waiting + handleBefore(uid: string, file: File) { + const { type, name, size } = file; + // Get the file extension + const ext = getExtensionName(file); + // read files asynchronously + return new Promise( + (resolve, reject) => { + const fileReader = new FileReader(); + fileReader.addEventListener( + 'load', + () => { + resolve({ + file, + info: { + // unique number + uid, + // Blob + src: fileReader.result, + // file name + name, + // File size + size, + // file type + type, + // File suffix + ext, + }, + }); + }, + false, + ); + fileReader.addEventListener('error', () => { + reject(false); + }); + fileReader.readAsDataURL(file); + }, + ); + } + // Insert the editor before uploading + onReady(fileInfo: FileInfo) { + // If the ImageComponent instance of the current picture exists, it will not be processed + if (!isEngine(this.editor) || !!this.imageComponents[fileInfo.uid]) + return; + // Insert ImageComponent card + const component = this.editor.card.insert(ImageComponent.cardName, { + // Set the status to uploading + status: 'uploading', + // Display the base64 image obtained in handleBefore, so as not to cause the editor area to be blank + src: fileInfo.src, + }) as ImageComponent; + // Record the card instance of the currently uploaded file + this.imageComponents[fileInfo.uid] = component; + } + // uploading + onUploading(uid: string, { percent }: { percent: number }) { + // Get the ImageComponent instance corresponding to file + const component = this.imageComponents[uid]; + if (!component) return; + // Set the current upload progress percentage + component.setProgressPercent(percent); + } + // Upload successfully + onSuccess(response: any, uid: string) { + // Get the ImageComponent instance corresponding to file + const component = this.imageComponents[uid]; + if (!component) return; + // Get the image address after the upload is successful + let src = ''; + // Process the response returned by the server, and update the status value of the ImageComponent instance corresponding to the file if there is an error in the upload + if (!response.result) { + // Update the value of the card + this.editor.card.update(component.id, { + status: 'error', + message: + response.message || + this.editor.language.get('image', 'uploadError'), + }); + } else { + // Upload successfully + src = response.data; + } + // Set the status value of the ImageComponent instance corresponding to file to done + const value: ImageValue = { + status: 'done', + src, + }; + // There is a url after the uploaded image is obtained + if (src) { + // Call the method of the current instance of ImageUploader to load the url image. If the loading fails, set the status to error and display that it cannot be loaded, otherwise the image will be loaded normally + this.loadImage(component.id, value); + } + // Delete the current temporary record + delete this.imageComponents[uid]; + } + + // upload error + onError(error: Error, uid: string) { + const component = this.imageComponents[uid]; + if (!component) return; + // Update the card status to error and display the error message + this.editor.card.update(component.id, { + status: 'error', + message: + error.message || + this.editor.language.get('image', 'uploadError'), + }); + // Delete the current temporary record + delete this.imageComponents[uid]; + } + async execute(files?: Array | string | MouseEvent) { + // It is the reader View that will not handle it + if (!isEngine(this.editor)) return; + // Get the currently passed in optional value + const { request, language } = this.editor; + const { multiple } = this.options.file; + // Upload size limit + const limitSize = this.options.file.limitSize || 5 * 1024 * 1024; + // The incoming files is not an array to get a picture address, that is, MouseEvent pops up the file selector + if (!Array.isArray(files) && typeof files !== 'string') { + // A file selector pops up, allowing the user to select a file + files = await request.getFiles({ + // Click event of user target + event: files, + // Selectable file suffix name. this.extensionNames is the combined value of the suffixes supported by default in the ImageUploader plugin and the suffixes passed in by the options + accept: isAndroid + ? 'image/*' + : this.extensionNames.length > 0 + ? '.' + this.extensionNames.join(',.') + : '', + // The maximum number can be selected + multiple, + }); + } + // If the file address is passed in, then upload the image address. If insertRemote judges that it is a third-party website image address, it will request the api to download from the server, and then the server will store it before returning the new image address. + // Because the pictures of third-party websites that are not on this site may be cross-domain or inaccessible, it is recommended to perform back-end download processing + else if (typeof files === 'string') { + this.insertRemote(files); + return; + } + // don't process if there is no file + if (files.length === 0) return; + const promiseList = []; + for (let f = 0; f < files.length; f++) { + const file = files[f]; + // The unique identifier of the currently uploaded file + const uid = Date.now() + '-' + f; + // Determine the file size + if (file.size > limitSize) { + // Display error + this.editor.messageError( + language + .get('image', 'uploadLimitError') + .replace( + '$size', + (limitSize / 1024 / 1024).toFixed(0) + 'M', + ), + ); + return; + } + promiseList.push(this.handleBefore(uid, file)); + } + //After all the pictures are read, insert the editor + Promise.all(promiseList).then((values) => { + if (values.some((value) => value === false)) { + this.editor.messageError('read image failed'); + return; + } + const files = values as { file: File; info: FileInfo }[]; + files.forEach((v) => { + // insert editor + this.onReady(v.info); + }); + // Process upload + this.handleUpload(files); + }); + } + + /** + * Process file upload + * @param values + */ + handleUpload(values: { file: File; info: FileInfo }[]) { + const files = values.map((v) => { + v.file.uid = v.info.uid; + return v.file; + }); + // Custom upload method + this.editor.request.upload( + { + url: this.options.file.action, + onUploading: (file, percent) => { + this.onUploading(file.uid || '', percent); + }, + onSuccess: (response, file) => { + this.onSuccess(response, file.uid || ''); + }, + onError: (error, file) => { + this.onError(error, file.uid || ''); + }, + }, + files, + ); + } +} + +export default CustomizeImageUploader; +``` + +### Global upload + +Override the editor `engine.request.upload` method + +```ts +import Engine, { + EngineInterface, + FileInfo, + File, + getExtensionName, + UploaderOptions, +} from '@aomao/engine'; + +export default class { + // Process the picture before uploading, and the blob of the file will be displayed in the editor while waiting for upload + handleBefore(uid: string, file: File) { + const { type, name, size } = file; + // Get the file extension + const ext = getExtensionName(file); + // read files asynchronously + return new Promise( + (resolve, reject) => { + const fileReader = new FileReader(); + fileReader.addEventListener( + 'load', + () => { + resolve({ + file, + info: { + // unique number + uid, + // Blob format + src: fileReader.result, + // file name + name, + // File size + size, + // file type + type, + // File suffix + ext, + }, + }); + }, + false, + ); + fileReader.addEventListener('error', () => { + reject(false); + }); + fileReader.readAsDataURL(file); + }, + ); + } + + setGlobalUpload(engine: EngineInterface = new Engine('.container')) { + // Override the upload method in the editor + engine.request.upload = async (options, files, name) => { + const { onBefore, onReady } = options; + // do not process if there is no file + if (files.length === 0) return; + const promiseList = []; + for (let f = 0; f < files.length; f++) { + const file = files[f]; + // The unique identifier of the currently uploaded file + const uid = Date.now() + '-' + f; + file.uid = uid; + if (onBefore && (await onBefore(file)) === false) return; + promiseList.push(this.handleBefore(uid, file)); + } + //Insert the editor after reading all files + Promise.all(promiseList).then(async (values) => { + if (values.some((value) => value === false)) { + engine.messageError('read image failed'); + return; + } + const files = values as { file: File; info: FileInfo }[]; + Promise.all([ + ...files.map(async (v) => { + return new Promise(async (resolve) => { + if (onReady) { + await onReady(v.info, v.file); + } + resolve(true); + }); + }), + ]).then(() => { + files.forEach(async (file) => { + // Process upload + this.handleUpload(file.file, options, name); + }); + }); + }); + }; + } + /** + * Process upload + * @param url upload address + * @param name formData parameter name + * @param file file + */ + handleUpload(file: File, options: UploaderOptions, name: string = 'file') { + // form data + const formData = new FormData(); + formData.append(name, file, file.name); + if (file.data) { + Object.keys(file.data).forEach((key) => { + formData.append(key, file.data![key]); + }); + } + const { + // Upload address + url, + // additional data + data, + // Progress callback during upload + onUploading, + // Upload successful callback + onSuccess, + // Upload error callback + onError, + } = options; + if (data) { + Object.keys(data).forEach((key) => { + formData.append(key, data![key]); + }); + } + + // Custom upload and call onUploading onSuccess onError callback method + } +} +``` diff --git a/docs/config/upload.zh-CN.md b/docs/config/upload.zh-CN.md new file mode 100644 index 00000000..5f9a9458 --- /dev/null +++ b/docs/config/upload.zh-CN.md @@ -0,0 +1,429 @@ +--- +toc: menu +--- + +# 上传配置 + +编辑器默认实现了上传逻辑 + +我们可以在引擎实例中的 `request.upload` 访问到 + +`request.upload` 内部实用 `XMLHttpRequest` 上传文件,好处是可以获取到上传进度 + +```ts +engine.request.upload(options: UploaderOptions, files: Array, name?: string) +// 上传可选项类型 +export type UploaderOptions = { + // 上传地址 + url: string; + // 请求类型,默认 json + type?: string; + // 内容类型 + contentType?: string; + // 额外数据 + data?: {}; + // 跨域 + crossOrigin?: boolean; + // 请求头 + headers?: { [key: string]: string }; + // 上传前,可以做文件大小限制判断 + onBefore?: (file: File) => Promise; + // 开始上传 + onReady?: (fileInfo: FileInfo, file: File) => Promise; + // 上传中 + onUploading?: (file: File, progress: { percent: number }) => void; + // 上传错误 + onError?: (error: Error, file: File) => void; + // 上传成功 + onSuccess?: (response: any, file: File) => void; +}; +// FileInfo 类型 +export type FileInfo = { + uid: string; + src: string | ArrayBuffer | null; + name: string; + size: number; + type: string; + ext: string; +}; +``` + +除了 upload 外,还有 `getFiles(options?: OpenDialogOptions)` 实用方法,可以弹出本地文件选择器 + +```ts +export type OpenDialogOptions = { + event?: MouseEvent; + accept?: string; + multiple?: boolean | number; +}; +``` + +下面的插件都是依赖 `engine.request.upload` 实现上传的 + +我们只需要按照对应插件的说明简单配置后就可以实现上传 + +- ImageUploader +- FileUploader +- VideoUploader + +## 自定义上传 + +### 单个插件上传 + +以 ImageUploader 为例 + +```ts +import { + getExtensionName, + FileInfo, + File, + isAndroid, + isEngine, +} from '@aomao/engine'; +import { ImageComponent, ImageUploader } from '@aomao/plugin-image'; +import { ImageValue } from 'plugins/image/dist/component'; +// 继承原 ImageUploader 类,重写 execute 方法 +class CustomizeImageUploader extends ImageUploader { + // 当前上传中的卡片实例 + private imageComponents: Record = {}; + // 上传前处理图片,获取图片的base64在上传等待中显示在编辑器中 + handleBefore(uid: string, file: File) { + const { type, name, size } = file; + // 获取文件后缀名 + const ext = getExtensionName(file); + // 异步读取文件 + return new Promise( + (resolve, reject) => { + const fileReader = new FileReader(); + fileReader.addEventListener( + 'load', + () => { + resolve({ + file, + info: { + // 唯一编号 + uid, + // Blob + src: fileReader.result, + // 文件名称 + name, + // 文件大小 + size, + // 文件类型 + type, + // 文件后缀名 + ext, + }, + }); + }, + false, + ); + fileReader.addEventListener('error', () => { + reject(false); + }); + fileReader.readAsDataURL(file); + }, + ); + } + // 上传前插入编辑器 + onReady(fileInfo: FileInfo) { + // 如果当前图片的 ImageComponent 实例存在就不处理 + if (!isEngine(this.editor) || !!this.imageComponents[fileInfo.uid]) + return; + // 插入ImageComponent 卡片 + const component = this.editor.card.insert(ImageComponent.cardName, { + // 设置状态为上传中 + status: 'uploading', + // 显示在 handleBefore 中获取的 base64 图片,这样不会导致编辑器区域空白 + src: fileInfo.src, + }) as ImageComponent; + // 记录当前上传文件的 卡片实例 + this.imageComponents[fileInfo.uid] = component; + } + // 上传中 + onUploading(uid: string, { percent }: { percent: number }) { + // 获取file 对应的 ImageComponent 实例 + const component = this.imageComponents[uid]; + if (!component) return; + // 设置当前上传进度百分比 + component.setProgressPercent(percent); + } + // 上传成功 + onSuccess(response: any, uid: string) { + // 获取file 对应的 ImageComponent 实例 + const component = this.imageComponents[uid]; + if (!component) return; + // 获取上传成功后的图片地址 + let src = ''; + // 处理服务端返回的 response,如果上传出错就更新对应 file 对应的 ImageComponent 实例的状态值 + if (!response.result) { + // 更新卡片的值 + this.editor.card.update(component.id, { + status: 'error', + message: + response.message || + this.editor.language.get('image', 'uploadError'), + }); + } else { + // 上传成功 + src = response.data; + } + // 设置为file 对应的 ImageComponent 实例的状态值为 done + const value: ImageValue = { + status: 'done', + src, + }; + // 有获取的上传图片后的url + if (src) { + // 调用 ImageUploader 当前实例的方法去加载这个 url 图片,如果加载失败,就设置状态为error并显示无法加载,否则就正常加载图片 + this.loadImage(component.id, value); + } + // 删除当前的临时记录 + delete this.imageComponents[uid]; + } + + // 上传出错 + onError(error: Error, uid: string) { + const component = this.imageComponents[uid]; + if (!component) return; + // 更新卡片状态为 error,并显示错误信息 + this.editor.card.update(component.id, { + status: 'error', + message: + error.message || + this.editor.language.get('image', 'uploadError'), + }); + // 删除当前的临时记录 + delete this.imageComponents[uid]; + } + + async execute(files?: Array | string | MouseEvent) { + // 是阅读器View就不处理 + if (!isEngine(this.editor)) return; + // 获取当前传入的可选项值 + const { request, language } = this.editor; + const { multiple } = this.options.file; + // 上传大小限制 + const limitSize = this.options.file.limitSize || 5 * 1024 * 1024; + // 传入的files不是数组获取不是图片地址,那就是 MouseEvent 弹出文件选择器 + if (!Array.isArray(files) && typeof files !== 'string') { + // 弹出文件选择器,让用户选择文件 + files = await request.getFiles({ + // 用户目标的单击事件 + event: files, + // 可选取的文件后缀名称。this.extensionNames 是 ImageUploader 插件内默认支持的后缀和可选项传进来的后缀合并后的值 + accept: isAndroid + ? 'image/*' + : this.extensionNames.length > 0 + ? '.' + this.extensionNames.join(',.') + : '', + // 最多可选取数量 + multiple, + }); + } + // 如果传入的文件地址,那就执行图片地址的上传,insertRemote 如果判断是非本站第三方网站图片地址就会请求api到服务端下载然后服务端存储后再返回新的图片地址 + // 因为非本站第三方网站的图片可能存在跨域或者无法访问的情况,建议进行后端下载处理 + else if (typeof files === 'string') { + this.insertRemote(files); + return; + } + // 如果没有任何文件就不处理 + if (files.length === 0) return; + const promiseList = []; + for (let f = 0; f < files.length; f++) { + const file = files[f]; + // 当前上传文件唯一标识 + const uid = Date.now() + '-' + f; + // 判断文件大小 + if (file.size > limitSize) { + // 显示错误 + this.editor.messageError( + language + .get('image', 'uploadLimitError') + .replace( + '$size', + (limitSize / 1024 / 1024).toFixed(0) + 'M', + ), + ); + return; + } + promiseList.push(this.handleBefore(uid, file)); + } + //全部图片读取完成后再插入编辑器 + Promise.all(promiseList).then((values) => { + if (values.some((value) => value === false)) { + this.editor.messageError('read image failed'); + return; + } + const files = values as { file: File; info: FileInfo }[]; + files.forEach((v) => { + // 插入编辑器 + this.onReady(v.info); + }); + // 处理上传 + this.handleUpload(files); + }); + } + + /** + * 处理文件上传 + * @param values + */ + handleUpload(values: { file: File; info: FileInfo }[]) { + const files = values.map((v) => { + v.file.uid = v.info.uid; + return v.file; + }); + // 自定义上传方法 + this.editor.request.upload( + { + url: this.options.file.action, + onUploading: (file, percent) => { + this.onUploading(file.uid || '', percent); + }, + onSuccess: (response, file) => { + this.onSuccess(response, file.uid || ''); + }, + onError: (error, file) => { + this.onError(error, file.uid || ''); + }, + }, + files, + ); + } +} + +export default CustomizeImageUploader; +``` + +### 全局上传 + +重写编辑器 `engine.request.upload` 方法 + +```ts +import Engine, { + EngineInterface, + FileInfo, + File, + getExtensionName, + UploaderOptions, +} from '@aomao/engine'; + +export default class { + // 上传前处理图片,获取文件的Blob在上传等待中显示在编辑器中 + handleBefore(uid: string, file: File) { + const { type, name, size } = file; + // 获取文件后缀名 + const ext = getExtensionName(file); + // 异步读取文件 + return new Promise( + (resolve, reject) => { + const fileReader = new FileReader(); + fileReader.addEventListener( + 'load', + () => { + resolve({ + file, + info: { + // 唯一编号 + uid, + // Blob格式 + src: fileReader.result, + // 文件名称 + name, + // 文件大小 + size, + // 文件类型 + type, + // 文件后缀名 + ext, + }, + }); + }, + false, + ); + fileReader.addEventListener('error', () => { + reject(false); + }); + fileReader.readAsDataURL(file); + }, + ); + } + + setGlobalUpload(engine: EngineInterface = new Engine('.container')) { + // 重写编辑器中 upload 方法 + engine.request.upload = async (options, files, name) => { + const { onBefore, onReady } = options; + // 如果没有任何文件就不处理 + if (files.length === 0) return; + const promiseList = []; + for (let f = 0; f < files.length; f++) { + const file = files[f]; + // 当前上传文件唯一标识 + const uid = Date.now() + '-' + f; + file.uid = uid; + if (onBefore && (await onBefore(file)) === false) return; + promiseList.push(this.handleBefore(uid, file)); + } + //全部文件读取完成后再插入编辑器 + Promise.all(promiseList).then(async (values) => { + if (values.some((value) => value === false)) { + engine.messageError('read image failed'); + return; + } + const files = values as { file: File; info: FileInfo }[]; + Promise.all([ + ...files.map(async (v) => { + return new Promise(async (resolve) => { + if (onReady) { + await onReady(v.info, v.file); + } + resolve(true); + }); + }), + ]).then(() => { + files.forEach(async (file) => { + // 处理上传 + this.handleUpload(file.file, options, name); + }); + }); + }); + }; + } + /** + * 处理上传 + * @param url 上传地址 + * @param name formData 参数名称 + * @param file 文件 + */ + handleUpload(file: File, options: UploaderOptions, name: string = 'file') { + // 表单数据 + const formData = new FormData(); + formData.append(name, file, file.name); + if (file.data) { + Object.keys(file.data).forEach((key) => { + formData.append(key, file.data![key]); + }); + } + const { + // 上传地址 + url, + // 额外数据 + data, + // 上传中的进度回调 + onUploading, + // 上传成功回调 + onSuccess, + // 上传错误回调 + onError, + } = options; + if (data) { + Object.keys(data).forEach((key) => { + formData.append(key, data![key]); + }); + } + + // 自定义上传,并调用 onUploading onSuccess onError 回调方法 + } +} +``` diff --git a/docs/config/view.md b/docs/config/view.md new file mode 100644 index 00000000..0b204f2b --- /dev/null +++ b/docs/config/view.md @@ -0,0 +1,75 @@ +--- +toc: menu +--- + +# View configuration + +The reader is mainly used for draft mode editing or simple content display. It needs real-time collaborative display and is set to be non-editable. You can use the engine's readonly attribute + +Passed in when instantiating the reader + +```ts +import {View} from'@aomao/engine'; +//Instantiate the view +const view = new View(render node, { +... configuration items, +}); +``` + +### lang + +- Type: `string` +- Default value: `zh-CN` +- Detailed: Language configuration, temporarily supports `zh-CN`, `en-US`. Can use `locale` configuration + +```ts +const view = new View(render node, { + lang:'zh-CN', +}); +``` + +### locale + +- Type: `object` +- Default value: `zh-CN` +- Detailed: Configure additional language packs + +Language pack, default language pack [https://github.com/yanmao-cc/am-editor/blob/master/locale](https://github.com/yanmao-cc/am-editor/tree/master/ locale) + +```ts +const view = new View(render node, { + locale: { + 'zh-CN': { + test:'Test', + a: { + b: "B" + } + }, + } +}); +console.log(view.language.get('test')); +``` + +### root + +- Type: `Node` +- Default value: the parent node of the current reader render node +- Detailed: Reader root node + +### plugins + +- Type: `Array` +- Default value: `[]` +- Detailed: A collection of plugins that implement the abstract class of `Plugin` + +### cards + +- Type: `Array` +- Default value: `[]` +- Detailed: Implement the card collection of the `Card` abstract class + +### config + +- Type: `{ [key: string]: PluginOptions }` +- Default value: `{}` +- Detailed: the configuration item of each plug-in, the key is the name of the plug-in, please refer to the description of each plug-in for detailed configuration diff --git a/docs/config/view.zh-CN.md b/docs/config/view.zh-CN.md new file mode 100644 index 00000000..0d564c33 --- /dev/null +++ b/docs/config/view.zh-CN.md @@ -0,0 +1,75 @@ +--- +toc: menu +--- + +# 阅读器配置 + +阅读器主要用于草稿模式编辑或单纯的内容显示,需要实时协同显示并且设置为不可编辑,可以使用引擎的 `readonly` 属性 + +在实例化阅读器时传入 + +```ts +import { View } from '@aomao/engine'; +//实例化引擎 +const view = new View(渲染节点, { + ...配置项, +}); +``` + +### lang + +- 类型: `string` +- 默认值:`zh-CN` +- 详细:语言配置,暂时支持 `zh-CN`、`en-US`。可使用 `locale` 配置 + +```ts +const view = new View(渲染节点, { + lang: 'zh-CN', +}); +``` + +### locale + +- 类型: `object` +- 默认值:`zh-CN` +- 详细:配置额外语言包 + +语言包,默认语言包 [https://github.com/yanmao-cc/am-editor/blob/master/locale](https://github.com/yanmao-cc/am-editor/blob/master/locale) + +```ts +const view = new View(渲染节点, { + locale: { + 'zh-CN': { + test: '测试', + a: { + b: 'B', + }, + }, + }, +}); +console.log(view.language.get('test')); +``` + +### root + +- 类型: `Node` +- 默认值:当前阅读器渲染节点父节点 +- 详细:阅读器根节点 + +### plugins + +- 类型: `Array` +- 默认值:`[]` +- 详细:实现 `Plugin` 抽象类的插件集合 + +### cards + +- 类型: `Array` +- 默认值:`[]` +- 详细:实现 `Card` 抽象类的卡片集合 + +### config + +- 类型: `{ [key: string]: PluginOptions }` +- 默认值:`{}` +- 详细:每个插件的配置项,key 为插件名称,详细配置请参考每个插件的说明 diff --git a/docs/docs/README.md b/docs/docs/README.md new file mode 100644 index 00000000..b0ed4597 --- /dev/null +++ b/docs/docs/README.md @@ -0,0 +1,31 @@ +--- +title: Introduction +--- + +## What is it? + +> Thanks to Google Translate + +Use the `contenteditable` attribute provided by the browser to make a DOM node editable. + +The engine takes over most of the browser's default behaviors such as cursors and events. + +The nodes in the editor area have four types of combined nodes of `mark`, `inline`, `block` and `card` through the `schema` rule. They are composed of different attributes, styles or `html` structures. Certain constraints are imposed on nesting. + +Use the `MutationObserver` to monitor the changes of the `html` structure in the editing area, and generate a `json0` type data format to interact with the [ShareDB](https://github.com/share/sharedb) library to meet the needs of collaborative editing . + +**`Vue2`** example [https://github.com/zb201307/am-editor-vue2](https://github.com/zb201307/am-editor-vue2) + +**`Vue3`** example [https://github.com/yanmao-cc/am-editor/tree/master/examples/vue](https://github.com/yanmao-cc/am-editor/tree/master/examples/vue) + +**`React`** example [https://github.com/yanmao-cc/am-editor/tree/master/examples/react](https://github.com/yanmao-cc/am-editor/tree/master/examples/react) + +## Features + +- Out of the box, it provides dozens of rich plug-ins to meet most needs +- High extensibility, in addition to the basic plug-in of `mark`, inline`and`block`type, we also provide`card`component combined with`React`, `Vue` and other front-end libraries to render the plug-in UI +- Rich multimedia support, not only supports pictures, audio and video, but also supports insertion of embedded multimedia content +- Support Markdown syntax +- The engine is written in pure JavaScript and does not rely on any front-end libraries. Plug-ins can be rendered using front-end libraries such as `React` and `Vue`. Easily cope with complex architecture +- Built-in collaborative editing program, ready to use with lightweight configuration +- Compatible with most of the latest mobile browsers diff --git a/docs/docs/README.zh-CN.md b/docs/docs/README.zh-CN.md new file mode 100644 index 00000000..9b021baa --- /dev/null +++ b/docs/docs/README.zh-CN.md @@ -0,0 +1,33 @@ +--- +title: 介绍 +--- + +## 是什么? + +一个富文本协同编辑器框架,可以使用ReactVue自定义插件 + +`广告`:[科学上网,方便、快捷的上网冲浪](https://xiyou4you.us/r/?s=18517120) 稳定、可靠,访问 Github 或者其它外网资源很方便。 + +使用浏览器提供的 `contenteditable` 属性让一个 DOM 节点具有可编辑能力。 + +引擎接管了浏览器大部分光标、事件等默认行为。 + +可编辑器区域内的节点通过 `schema` 规则,制定了 `mark` `inline` `block` `card` 4 种组合节点,他们由不同的属性、样式或 `html` 结构组成,并对它们的嵌套进行了一定的约束。 + +通过 `MutationObserver` 监听编辑区域内的 `html` 结构的改变,并生成 `json0` 类型的数据格式与 [ShareDB](https://github.com/share/sharedb) 库进行交互达到协同编辑的需要。 + +**`Vue2`** 案例 [https://github.com/zb201307/am-editor-vue2](https://github.com/zb201307/am-editor-vue2) + +**`Vue3`** 案例 [https://github.com/yanmao-cc/am-editor/tree/master/examples/vue](https://github.com/yanmao-cc/am-editor/tree/master/examples/vue) + +**`React`** 案例 [https://github.com/yanmao-cc/am-editor/tree/master/examples/react](https://github.com/yanmao-cc/am-editor/tree/master/examples/react) + +## 特性 + +- 开箱即用,提供几十种丰富的插件来满足大部分需求 +- 高扩展性,除了 `mark` `inline` `block` 类型基础插件外,我们还提供 `card` 组件结合`React` `Vue`等前端库渲染插件 UI +- 丰富的多媒体支持,不仅支持图片和音视频,更支持插入嵌入式多媒体内容 +- 支持 Markdown 语法 +- 引擎纯 JavaScript 编写,不依赖任何前端库,插件可以使用 `React` `Vue` 等前端库渲染。复杂架构轻松应对 +- 内置协同编辑方案,轻量配置即可使用 +- 兼容大部分最新移动端浏览器 diff --git a/docs/docs/concepts-editor.md b/docs/docs/concepts-editor.md new file mode 100644 index 00000000..014ffaca --- /dev/null +++ b/docs/docs/concepts-editor.md @@ -0,0 +1,53 @@ +# editor + +In am-editor, the editor is a separate mode for reading and writing. Editing mode and reading mode need to be rendered through different modules. Because of the existence of `card` mode, the content output by the editor may also be interactive. In reading mode, we can also let the plug-in use `React` `Vue`Wait for the front-end framework to render some interactive components. Simply put, all the effects that can be achieved under the front-end framework can be placed in the plug-in. For example: you can make a voting plug-in, you can set voting options in edit mode, you can choose to vote in reading mode, show the number of votes after voting, and so on. These functions may be very interesting. Unlike traditional editors that output fixed html or json data, of course we also support the output of pure html and present non-interactive content. + +Instantiate engine + +```ts +import Engine from'@aomao/engine'; +... +//initialization +const engine = new Engine("Editor root node", { + plugins: [], + cards: [], +}); +``` + +Although it is a read-write separation mode, most of the logic for rendering content in reading mode is the same as in editing mode, so the engine provides the View module to render the reading mode + +The instantiation method is roughly the same as the engine + +```ts +import {View} from'@aomao/engine'; +... +//initialization +const view = new View("Renderer root node", { + plugins: [], + cards: [], +}); +``` + +Inside the plug-in, we may need to control the reading mode, we can use `isEngine` to determine + +```ts +import {isEngine} from'@aomao/engine'; + +... +if(isEngine(this.editor)) { + //Edit mode +} else { + //Reading mode +} + +... + +``` + +## Edit mode + +In the editing mode, we need to control the DOM tree, cursor, events, etc., so that the user's input can achieve the best expected value and experience, all of which will be done by the engine `@aomao/engine` + +## Reading Mode + +The reading mode is much simpler than the editing mode. There is no need to change the DOM tree, and the cursor can hardly be controlled. The interaction of the `card` plugin is exactly the same as writing the components of the front-end framework such as `React` and `Vue` under normal circumstances. diff --git a/docs/docs/concepts-editor.zh-CN.md b/docs/docs/concepts-editor.zh-CN.md new file mode 100644 index 00000000..a71fb7f1 --- /dev/null +++ b/docs/docs/concepts-editor.zh-CN.md @@ -0,0 +1,53 @@ +# 编辑器 + +在 am-editor 中编辑器是读写分离的模式。编辑模式和阅读模式需要通过不同的模块呈现渲染,因为有`card`模式的存在,编辑器输出的内容也可能是存在交互的,在阅读模式下,我们也可以让插件借助`React` `Vue`等前端框架渲染一些交互组件,简单的说在前端框架下可以实现的效果都可以放在插件里面。例如:可以做一个投票插件,编辑模式下我们可以设置投票选项,阅读模式下可以选择投票,投票后展现投票数量等等。这些功能可能会非常有意思,不像传统编辑器那样输出固定的 html 或者 json 数据,当然我们也支持输出纯 html,呈现无交互内容。 + +实例化引擎 + +```ts +import Engine from '@aomao/engine'; +... +//初始化 +const engine = new Engine("编辑器根节点", { + plugins: [], + cards: [], +}); +``` + +虽然是读写分离的模式,但是阅读模式渲染内容的大部分逻辑与编辑模式下相同,所以由引擎提供 View 模块来渲染阅读模式 + +实例化方式与引擎大致相同 + +```ts +import { View } from '@aomao/engine'; +... +//初始化 +const view = new View("渲染器根节点", { + plugins: [], + cards: [], +}); +``` + +在插件内部,我们可能需要对阅读模式做一些控制,我们可以通过 `isEngine` 来判定 + +```ts +import { isEngine } from '@aomao/engine'; + +... +if(isEngine(this.editor)) { + //编辑模式 +} else { + //阅读模式 +} + +... + +``` + +## 编辑模式 + +编辑模式我们需要控制 DOM 树、光标、事件等等让用户的输入达到最好的预期值与体验,这些都将由引擎`@aomao/engine`来完成 + +## 阅读模式 + +阅读模式相对于编辑模式简单得多,不需要改变 DOM 树,光标几乎可以不用控制。`card`插件的交互完全和正常情况下写`React` `Vue`等前端框架的组件一样。 diff --git a/docs/docs/concepts-event.md b/docs/docs/concepts-event.md new file mode 100644 index 00000000..49d25331 --- /dev/null +++ b/docs/docs/concepts-event.md @@ -0,0 +1,504 @@ +# Incident + +In the engine, we handle many events by default, such as: text input, delete, copy, paste, left and right arrow keys, markdown syntax input monitoring, plug-in shortcut keys, and so on. These events may have different processing logic at different cursor positions. Most operations are to modify the DOM tree structure and repair the cursor position. In addition, we also expose these events to the plug-in to handle by itself. + +Method signature + +```ts +/** +* Bind event +* @param eventType event type +* @param listener event callback +* @param rewrite whether to rewrite +*/ +on(eventType: string, listener: EventListener, rewrite?: boolean): void; +/** + * Remove bound event + * @param eventType event type + * @param listener event callback + */ +off(eventType: string, listener: EventListener): void; +/** + * trigger event + * @param eventType event name + * @param args trigger parameters + */ +trigger(eventType: string, ...args: any): any; +``` + +### Element events + +In javascript, we usually use document.addEventListener document.removeEventListener to bind DOM element events. In the engine, we abstract an `EventInterface` type interface, and elements of the `NodeInterface` type are bound to an attribute event of the `EventInterface` type. So as long as the element of type `NodeInterface` can be bound, removed, and triggered by on off trigger. Not only can bind DOM native events, but also custom events + +```ts +const node = $('
'); +//Native event +node.on('click', () => alert('click')); +//Custom event +node.on('customer', () => alert('customer')); +node.trigger('customer'); +``` + +### Editor events + +We have processed the specific combination of keys. The following are some of the events we exposed, which are effective in both editing mode and reading mode. + +```ts +//engine +engine.on('event name', 'processing method'); +//read +view.on('event name', 'processing method'); +``` + +### `keydown:all` + +Select all ctrl+a key press, if it returns false, stop processing other monitors + +```ts +/** + * @param event key event + * */ +(event: KeyboardEvent) => boolean | void +``` + +### `card:minimiz` + +Triggered when the card is minimized + +```ts +/** + * @param card card instance + * */ +(card: CardInterface) => void +``` + +### `card:maximize` + +Triggered when the card is maximized + +```ts +/** + * @param card card instance + * */ +(card: CardInterface) => void +``` + +### `parse:value-before` + +Triggered before parsing DOM nodes and generating standard editor values + +```ts +/** +* @param root DOM root node +*/ +(root: NodeInterface) => void +``` + +### `parse:value` + +Parse the DOM node, generate the editor value that meets the standard, and trigger when it traverses the child nodes. Return false to skip the current node + +```ts +/** +* @param node The node currently traversed +* @param attributes The filtered attributes of the current node +* @param styles The filtered style of the current node +* @param value The currently generated editor value collection +*/ +( + node: NodeInterface, + attributes: {[key: string]: string }, + styles: {[key: string]: string }, + value: Array, +) => boolean | void +``` + +### `parse:value-after` + +Analyze DOM nodes and generate editor values ​​that conform to the standard. Triggered after generating xml code + +```ts +/** +* @param value xml code +*/ +(value: Array) => void +``` + +### `parse:html-before` + +Triggered before conversion to HTML code + +```ts +/** +* @param root The root node to be converted +*/ +(root: NodeInterface) => void +``` + +### `parse:html` + +Convert to HTML code + +```ts +/** +* @param root The root node to be converted +*/ +(root: NodeInterface) => void +``` + +### `parse:html-after` + +Triggered after conversion to HTML code + +```ts +/** +* @param root The root node to be converted +*/ +(root: NodeInterface) => void +``` + +### `copy` + +Triggered when DOM node is copied + +```ts +/** +* @param node The child node currently traversed +*/ +(root: NodeInterface) => void +``` + +## Engine events + +### `change` + +Editor value change event + +```ts +/** + * @param value Editor value + * */ +(value: string) => void +``` + +### `select` + +Editor cursor selection trigger + +```ts +() => void +``` + +### `focus` + +Triggered when the editor is focused + +```ts +() => void +``` + +### `blur` + +Triggered when the editor loses focus + +```ts +() => void +``` + +### `beforeCommandExecute` + +Triggered before the editor executes the command + +```ts +/** + * @param name Execute plug-in command name + * @param args command execution parameters + * */ +(name: string, ...args: any) => void +``` + +### `afterCommandExecute` + +Triggered after the editor executes a command + +```ts +/** + * @param name Execute plug-in command name + * @param args command execution parameters + * */ +(name: string, ...args: any) => void +``` + +### `drop:files` + +Triggered when a file is dragged to the editor + +```ts +/** + * @param files file collection + * */ +(files: Array) => void +``` + +### `beforeSetValue` + +Triggered before assigning a value to the editor + +```ts +/** + * @param value Editor value + * */ +(value: string) => void +``` + +### `afterSetValue` + +Triggered after assigning a value to the editor + +```ts +/** + * @param value Editor value + * */ +(value: string) => void +``` + +### `readonly` + +Triggered when the editor's read-only attribute is changed + +```ts +/** + * @param readonly is read-only + * */ +(readonly: boolean) => void +``` + +### `paste:event` + +Triggered when the paste to editor event occurs, if it returns false, the paste will not be processed + +```ts +/** + * @param data Pasteboard related data + * @param source pasted rich text + * */ +(data: ClipboardData & {isPasteText: boolean }, source: string) => boolean | void +``` + +### `paste:schema` + +Set the structural rules of the DOM elements that need to be retained for this pasting, and the structural rules that the attributes need to retain + +```ts +/** + * @param schema Schema object, you can add, modify, delete and other operations to the structure rules + * */ +(schema: SchemaInterface) => void +``` + +### `paste:origin` + +Parse the pasted data, and trigger before generating a fragment that matches the editor data + +```ts +/** + * @param root pasted DOM node + * */ +(root: NodeInterface) => void +``` + +### `paste:each` + +Analyze the pasted data, generate a fragment that matches the editor data, and then cyclically organize the sub-elements to trigger + +```ts +/** + * @param node Paste the element child nodes traversed by the fragment + * */ +(root: NodeInterface) => void, +``` + +### `paste:each-after` + +Analyze the pasted data, generate a fragment that matches the editor data, and then cycle through the sub-element stage to trigger + +```ts +/** + * @param node Paste the element child nodes traversed by the fragment + * */ +(root: NodeInterface) => void +``` + +### `paste:before` + +After the DOM fragment is generated from the pasted data, it is triggered before it is written to the editor + +```ts +/** + * @param fragment pasted fragment + * */ +(fragment: DocumentFragment) => void +``` + +### `paste:insert` + +Triggered after inserting the currently pasted fragment, the card has not been rendered yet + +```ts +/** + * @param range cursor instance after current insertion + * */ +(range: RangeInterface) => void +``` + +### `paste:after` + +Triggered after the paste action is completed + +```ts +() => void +``` + +### `ops` + +Triggered by DOM changes, these operational changes are usually sent to the collaborative server for interaction + +```ts +/** + * @param ops operation item + * */ +(ops: Op[]) => void +``` + +### `keydown:enter` + +Press the enter key, if it returns false, stop processing other monitors + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:backspace` + +The delete key is pressed, if it returns false, the processing of other monitors is terminated + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:tab` + +Tab key is pressed, if it returns false, stop processing other monitors + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:shift-tab` + +Press the Shift-Tab key, if it returns false, stop processing other monitors + +```ts +(event: KeyboardEvent) => boolean |void +``` + +### `keydown:at` + +@ The corresponding key is pressed, if it returns false, the processing of other monitors will be terminated + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:space` + +Press the space bar, if it returns false, stop processing other monitors + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:slash` + +Press the backslash key to call out the Toolbar, if it returns false, stop processing other monitors + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:left` + +Press the left arrow key, if it returns false, stop processing other monitors + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:right` + +Press the right arrow key, if it returns false, stop processing other monitors + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:up` + +Press the up arrow key, if it returns false, stop processing other monitors + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:down` + +Press the down arrow key, if it returns false, stop processing other monitors + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keyup:enter` + +Press the enter key to bounce up, if it returns false, stop processing other monitors + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keyup:backspace` + +Press the delete button to pop up, if it returns false, terminate the processing of other monitors + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keyup:tab` + +Tab key presses and pops up, if it returns false, terminate the processing of other monitors + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keyup:space` + +Press the space bar to pop up, if it returns false, terminate the processing of other monitors + +```ts +(event: KeyboardEvent) => boolean | void +``` + +## Reader events + +### `render` + +Triggered after the reader has finished rendering + +```ts +/** + * @param node render root node + * */ +(node: NodeInterface) => void +``` diff --git a/docs/docs/concepts-event.zh-CN.md b/docs/docs/concepts-event.zh-CN.md new file mode 100644 index 00000000..f6df2cec --- /dev/null +++ b/docs/docs/concepts-event.zh-CN.md @@ -0,0 +1,504 @@ +# 事件 + +在引擎中我们默认处理了很多事件,例如:文字输入、删除、复制、粘贴、左右方向键、markdown 语法输入监听、插件快捷键等等。这些事件在不同光标位置可能会有不同的处理逻辑,大多数操作都是修改 DOM 树结构、修复光标位置。另外,我们还把这些事件暴露给插件自行处理。 + +方法签名 + +```ts +/** +* 绑定事件 +* @param eventType 事件类型 +* @param listener 事件回调 +* @param rewrite 是否重写 +*/ +on(eventType: string, listener: EventListener, rewrite?: boolean): void; +/** + * 移除绑定事件 + * @param eventType 事件类型 + * @param listener 事件回调 + */ +off(eventType: string, listener: EventListener): void; +/** + * 触发事件 + * @param eventType 事件名称 + * @param args 触发参数 + */ +trigger(eventType: string, ...args: any): any; +``` + +### 元素事件 + +在 javascript 中我们通常使用 document.addEventListener document.removeEventListener 绑定 DOM 元素事件。在引擎中,我们抽象了一个 `EventInterface` 类型接口,并且 `NodeInterface` 类型的元素绑定了`EventInterface`类型的属性 event。所以只要是 `NodeInterface` 类型的元素都可以通过 on off trigger,绑定、移除、触发事件。不仅可以绑定 DOM 原生事件,还可以绑定自定义事件 + +```ts +const node = $('
'); +//原生事件 +node.on('click', () => alert('click')); +//自定义事件 +node.on('customer', () => alert('customer')); +node.trigger('customer'); +``` + +### 编辑器事件 + +我们对特定的组合按键进行了处理,以下是我们暴露出来的一些事件,在编辑模式和阅读模式都有效 + +```ts +//引擎 +engine.on('事件名称', '处理方法'); +//阅读 +view.on('事件名称', '处理方法'); +``` + +### `keydown:all` + +全选 ctrl+a 键按下,如果返回 false,终止处理其它监听 + +```ts +/** + * @param event 按键事件 + * */ +(event: KeyboardEvent) => boolean | void +``` + +### `card:minimiz` + +卡片最小化时触发 + +```ts +/** + * @param card 卡片实例 + * */ +(card: CardInterface) => void +``` + +### `card:maximize` + +卡片最大化时触发 + +```ts +/** + * @param card 卡片实例 + * */ +(card: CardInterface) => void +``` + +### `parse:value-before` + +解析 DOM 节点,生成符合标准的编辑器值之前触发 + +```ts +/** +* @param root DOM根节点 +*/ +(root: NodeInterface) => void +``` + +### `parse:value` + +解析 DOM 节点,生成符合标准的编辑器值,遍历子节点时触发。返回 false 跳过当前节点 + +```ts +/** +* @param node 当前遍历的节点 +* @param attributes 当前节点已过滤后的属性 +* @param styles 当前节点已过滤后的样式 +* @param value 当前已经生成的编辑器值集合 +*/ +( + node: NodeInterface, + attributes: { [key: string]: string }, + styles: { [key: string]: string }, + value: Array, +) => boolean | void +``` + +### `parse:value-after` + +解析 DOM 节点,生成符合标准的编辑器值。生成 xml 代码结束后触发 + +```ts +/** +* @param value xml代码 +*/ +(value: Array) => void +``` + +### `parse:html-before` + +转换为 HTML 代码之前触发 + +```ts +/** +* @param root 需要转换的根节点 +*/ +(root: NodeInterface) => void +``` + +### `parse:html` + +转换为 HTML 代码 + +```ts +/** +* @param root 需要转换的根节点 +*/ +(root: NodeInterface) => void +``` + +### `parse:html-after` + +转换为 HTML 代码之后触发 + +```ts +/** +* @param root 需要转换的根节点 +*/ +(root: NodeInterface) => void +``` + +### `copy` + +复制 DOM 节点时触发 + +```ts +/** +* @param node 当前遍历的子节点 +*/ +(root: NodeInterface) => void +``` + +## 引擎事件 + +### `change` + +编辑器值改变事件 + +```ts +/** + * @param value 编辑器值 + * */ +(value: string) => void +``` + +### `select` + +编辑器光标选中触发 + +```ts +() => void +``` + +### `focus` + +编辑器聚焦点时触发 + +```ts +() => void +``` + +### `blur` + +编辑器失去焦点时触发 + +```ts +() => void +``` + +### `beforeCommandExecute` + +在编辑器执行命令之前触发 + +```ts +/** + * @param name 执行插件命令名称 + * @param args 命令执行参数 + * */ +(name: string, ...args: any) => void +``` + +### `afterCommandExecute` + +在编辑器执行命令之后触发 + +```ts +/** + * @param name 执行插件命令名称 + * @param args 命令执行参数 + * */ +(name: string, ...args: any) => void +``` + +### `drop:files` + +拖动文件到编辑器时触发 + +```ts +/** + * @param files 文件集合 + * */ +(files: Array) => void +``` + +### `beforeSetValue` + +在给编辑器赋值前触发 + +```ts +/** + * @param value 编辑器值 + * */ +(value: string) => void +``` + +### `afterSetValue` + +在给编辑器赋值后触发 + +```ts +/** + * @param value 编辑器值 + * */ +(value: string) => void +``` + +### `readonly` + +编辑器只读属性变更后触发 + +```ts +/** + * @param readonly 是否只读 + * */ +(readonly: boolean) => void +``` + +### `paste:event` + +当粘贴到编辑器事件发生时触发,如果返回 false,将不在处理粘贴 + +```ts +/** + * @param data 粘贴板相关数据 + * @param source 粘贴的富文本 + * */ +(data: ClipboardData & { isPasteText: boolean }, source: string) => boolean | void +``` + +### `paste:schema` + +设置本次粘贴所需保留 DOM 元素的结构规则,以及属性所需保留的结构规则 + +```ts +/** + * @param schema Schema对象,可以对结构规则增加修改删除等操作 + * */ +(schema: SchemaInterface) => void +``` + +### `paste:origin` + +解析粘贴数据,还未生成符合编辑器数据的片段之前触发 + +```ts +/** + * @param root 粘贴的DOM节点 + * */ +(root: NodeInterface) => void +``` + +### `paste:each` + +解析粘贴数据,生成符合编辑器数据的片段之后循环整理子元素阶段触发 + +```ts +/** + * @param node 粘贴片段遍历的元素子节点 + * */ +(root: NodeInterface) => void, +``` + +### `paste:each-after` + +解析粘贴数据,生成符合编辑器数据的片段之后循环整理子元素阶段后触发 + +```ts +/** + * @param node 粘贴片段遍历的元素子节点 + * */ +(root: NodeInterface) => void +``` + +### `paste:before` + +由粘贴数据生成 DOM 片段后,还未写入到编辑器之前触发 + +```ts +/** + * @param fragment 粘贴的片段 + * */ +(fragment: DocumentFragment) => void +``` + +### `paste:insert` + +插入当前粘贴的片段后触发,此时还未渲染卡片 + +```ts +/** + * @param range 当前插入后的光标实例 + * */ +(range: RangeInterface) => void +``` + +### `paste:after` + +粘贴动作完成后触发 + +```ts +() => void +``` + +### `ops` + +DOM 改变触发,这些操作改变通常用于发送到协同服务端交互 + +```ts +/** + * @param ops 操作项 + * */ +(ops: Op[]) => void +``` + +### `keydown:enter` + +回车键按下,如果返回 false,终止处理其它监听 + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:backspace` + +删除键按下,如果返回 false,终止处理其它监听 + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:tab` + +Tab 键按下,如果返回 false,终止处理其它监听 + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:shift-tab` + +Shift-Tab 键按下,如果返回 false,终止处理其它监听 + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:at` + +@ 符合键按下,如果返回 false,终止处理其它监听 + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:space` + +空格键按下,如果返回 false,终止处理其它监听 + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:slash` + +反斜杠键按下,唤出 Toolbar,如果返回 false,终止处理其它监听 + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:left` + +左方向键按下,如果返回 false,终止处理其它监听 + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:right` + +右方向键按下,如果返回 false,终止处理其它监听 + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:up` + +上方向键按下,如果返回 false,终止处理其它监听 + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keydown:down` + +下方向键按下,如果返回 false,终止处理其它监听 + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keyup:enter` + +回车键按下弹起,如果返回 false,终止处理其它监听 + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keyup:backspace` + +删除键按下弹起,如果返回 false,终止处理其它监听 + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keyup:tab` + +Tab 键按下弹起,如果返回 false,终止处理其它监听 + +```ts +(event: KeyboardEvent) => boolean | void +``` + +### `keyup:space` + +空格键按下弹起,如果返回 false,终止处理其它监听 + +```ts +(event: KeyboardEvent) => boolean | void +``` + +## 阅读器事件 + +### `render` + +在阅读器渲染完成后触发 + +```ts +/** + * @param node 渲染根节点 + * */ +(node: NodeInterface) => void +``` diff --git a/docs/docs/concepts-history.md b/docs/docs/concepts-history.md new file mode 100644 index 00000000..0e66d80b --- /dev/null +++ b/docs/docs/concepts-history.md @@ -0,0 +1,5 @@ +# History + +In the engine, the observe method of [`MutationObserver`](https://dom.spec.whatwg.org/#mutationobserver) is used to monitor the changes of the DOM tree. The observe method is not triggered immediately after every change, but in Triggered when there is no subsequent editing behavior. This interval is very short, so short that we can think of it as changing from time to time. This interval depends on the implementation of `MutationObserver` and we cannot control it. + +For each change, we will provide the DOM node data (similar to addition, deletion and modification operations) provided to us by `MutationObserver`, including attribute changes. Converting to `Ops` is equivalent to a set of description operations, and secondly, we also record before the change The cursor position. We treat these data as a kind of snapshot in the memory in the form of a stack. When executing undo and redo commands, we will search and restore by index from this stack. At the same time, these `ops` will also be transmitted to our collaborative server to notify each client of the changes in the DOM tree diff --git a/docs/docs/concepts-history.zh-CN.md b/docs/docs/concepts-history.zh-CN.md new file mode 100644 index 00000000..b325f1c9 --- /dev/null +++ b/docs/docs/concepts-history.zh-CN.md @@ -0,0 +1,5 @@ +# 历史 + +在引擎中会使用[`MutationObserver`](https://dom.spec.whatwg.org/#mutationobserver)的 observe 方法监听 DOM 树的变更,并不是每次变更后都会立马触发 observe 方法,而是在没有后续编辑行为时触发,这个间隔非常短,短到能让我们认为是时时变更的。这个间隔时间取决于`MutationObserver`的实现,我们无法控制。 + +每次的变更,我们会将`MutationObserver`提供给我们的 DOM 节点数据(类似增删改的操作)包括属性的变更,转换为`Ops`相当于是一个描述操作的集合,其次我们还有记录变更前的光标位置。这些数据我们都把它当作一种快照按堆栈形式存在内存中。在执行撤销、重做命令时,我们会从这个堆栈中按索引查找并还原。同时这些`Ops`还会传输到我们的协同服务端上,以此来通知各个客户端 DOM 树的变更 diff --git a/docs/docs/concepts-node.md b/docs/docs/concepts-node.md new file mode 100644 index 00000000..e51717fe --- /dev/null +++ b/docs/docs/concepts-node.md @@ -0,0 +1,118 @@ +# Node + +The DOM node is the most important object in the editor, and the editor data structure is a DOM tree. According to functions and characteristics, we can be divided into + +- `mark` style node, we can add color, bold, font size and other effects to the text, and can nest effects in each other +- `inline` Inline nodes, for example, links. Add special attributes or style effects to a paragraph of text, not nested. +- `block` block-level node, can occupy a line alone, and can have multiple `mark` `inline` style nodes as child nodes +- `card` is a single area, which can be in-line nodes or block-level nodes. In this area, unless there is a specific area that can be edited, it will be handed over to the developer to customize + +This is a simple plain text value: + +```html +

This is a paragraph

+``` + +Nodes usually consist of html tags and some style attributes. In order to facilitate the distinction, the composition of each style node should be unique. + +For example, to have a unique label name: + +```html +Bold Italic +``` + +Or modified by attributes and styles: + +```html +Bold +Italic +``` + +They all have the same effect, but the engine judges that they all belong to different plug-ins. + +## Style node + +The style node is usually used to describe the text size, bold, italic, color and other styles of the text. + +The child nodes of a style node can only be a text node or a style node. The style node must have a parent node (inline node or block-level node) and cannot exist in the editor alone. + +```html +

+ This is a red text +

+``` + +## In-line node + +Inline nodes have all the characteristics of style nodes, but inline nodes cannot be nested, and the child nodes of inline nodes can only be style nodes or text nodes. Similarly, inline nodes must have a parent node (only block-level nodes), and cannot exist alone in the editor. + +```html +

+ This is a link +

+``` + +## Block node + +The block-level node occupies a line in the editor. Except for explicitly specifying the nesting relationship with `schema`, it can only be under `$root` (editor root node) by default. The child node can be any other node, unless it has been specified that the plug-in cannot contain certain style node classes. For example, bolding and adjusting the font size cannot be used in the title. + +```html + +

This is a title

+``` + +The p tag belongs to the block-level node required by default in the engine and is used to indicate a paragraph. In custom nodes, it is not recommended to use the p tag. + +## Card + +We can divide a separate area in the editor to display a complex editing module. This area is like a piece of white paper, you can sway freely on it. His structure looks like this: + +```html +
+
+ +
+ +
+ +
+
+``` + +### Attributes + +`data-card-type` indicates the card type, there are two types of cards: + +- Inline `inline` can be embedded in a block-level label as a child node, which can be displayed at the same level as text, style nodes, and other inline nodes +- `block` as a block-level node on its own line + +`data-card-value` card custom value, which can be dynamically rendered with the help of the value during rendering + +`data-card-key` card name identification + +### Child node + +`data-card-element` card sub-fixed node identification attribute + +- `body` The main node of the card, which contains all the content of the card +- `left` `right` The user controls the cursor on both sides of the card, which is also a fixed node and cannot store any content +- The `center` card content node is also a custom rendering node. All your nodes should be placed here. + +## Node selector + +To manipulate the complex DOM tree, it seems more troublesome to use the document.createElement related function that comes with the browser. It would be very convenient if there is a javascript library like `JQuery`, so we encapsulated a "simple version of the jquery library". + +```ts +import { $ } from '@aomao/engine'; + +//Select node +const node = $('CSS selector'); +//Create node +const divNode = $('
'); +``` + +Using \$ to create or select a node will return a `NodeInterface` type object, which can better help you manage DOM `Node` nodes. Please check the API for specific properties and methods diff --git a/docs/docs/concepts-node.zh-CN.md b/docs/docs/concepts-node.zh-CN.md new file mode 100644 index 00000000..60114382 --- /dev/null +++ b/docs/docs/concepts-node.zh-CN.md @@ -0,0 +1,118 @@ +# 节点 + +DOM 节点在编辑器中是最重要的对象,编辑器数据结构就是一个 DOM 树。按照功能和特性我们可以划分为 + +- `mark` 样式节点,我们可以给文本加上颜色、加粗、字体大小等效果,并且可以互相嵌套效果 +- `inline` 行内节点,例如,链接。给一段文字添加特殊属性或者样式效果,不可嵌套。 +- `block` 块级节点,可以独占一行,并且可以有多个 `mark` `inline` 样式节点作为子节点 +- `card` 一个单独区域,可以是行内节点也可以是块级节点。在这个区域内,除非有指定特定区域可编辑,否则都将交由开发者自定义 + +这是一个简单的纯文本值: + +```html +

这是一个段落

+``` + +节点通常由 html 标签和一些样式属性组成。为了有利于区分,每个样式节点的组成都应唯一。 + +例如,拥有一个独特的标签名称: + +```html +加粗 斜体 +``` + +或者通过属性以及样式来修饰: + +```html +加粗 +斜体 +``` + +他们都有一样的效果,但引擎在判定上,他们都属于不同插件。 + +## 样式节点 + +样式节点通常用来描述文本的文字大小、粗体、斜体、颜色等样式。 + +样式节点的子节点只能是文本节点或者样式节点,样式节点必须有父节点(行内节点或块级节点),不能单独存在于编辑器中。 + +```html +

+ This is a red text +

+``` + +## 行内节点 + +行内节点拥有样式节点的所有的特质,但是行内节点不可以嵌套,行内节点的子节点只能是样式节点或者文本节点。同样,行内节点必须有父节点(只能是块级节点),不能单独存在于编辑器中。 + +```html +

+ This is a link +

+``` + +## 块级节点 + +块级节点在编辑器中独占一行,除了使用 `schema` 明确指定嵌套关系外,默认只能在 `$root` (编辑器根节点)下。子节点可以是其它任意节点,除非已指定不能包含某些样式节点类的插件。例如,标题中不能使用加粗、调整字体大小。 + +```html + +

This is a title

+``` + +p 标签在引擎中属于默认所需的块级节点,用于表明一个段落。在自定义节点中,不建议再使用 p 标签。 + +## 卡片 + +我们可以在编辑器中划分一个单独区域,用于展示一个复杂的编辑模块。该区域就像一张白纸,你可以在上面挥洒自如。他的结构看起来像这样: + +```html +
+
+ +
+ +
+ +
+
+``` + +### 属性 + +`data-card-type` 表示卡片类型,卡片有两种类型: + +- `inline` 行内,可以嵌入一个块级标签中作为子节点,可以和文本、样式节点、其它行内节点同级别展示 +- `block` 作为一个块级节点独占一行 + +`data-card-value` 卡片自定义值,在渲染时可以借助值动态渲染 + +`data-card-key` 卡片名称标识 + +### 子节点 + +`data-card-element` 卡片子固定节点标识属性 + +- `body` 卡片主体节点,包含卡片所有的内容 +- `left` `right` 用户控制卡片两侧光标,也是固定的节点,不能存任何内容 +- `center` 卡片内容节点,也是自定义渲染节点。你的所有节点要放在这里。 + +## 节点选择器 + +要操作复杂的 DOM 树,使用浏览器自带的 document.createElement 相关函数看起来比较麻烦。如果有像`JQuery`的 javascript 库则会很方便,因此我们封装了一个"简易版的 jquery 库"。 + +```ts +import { $ } from '@aomao/engine'; + +//选择节点 +const node = $('CSS选择器'); +//创建节点 +const divNode = $('
'); +``` + +使用 \$ 创建或选择节点后会返回一个 `NodeInterface` 类型对象,能更好的帮助你管理 DOM `Node` 节点。具体属性和方法请查看 API diff --git a/docs/docs/concepts-plugin.md b/docs/docs/concepts-plugin.md new file mode 100644 index 00000000..b0ea4bd9 --- /dev/null +++ b/docs/docs/concepts-plugin.md @@ -0,0 +1,48 @@ +# Plugin + +It is relatively simple to develop a plug-in on the engine. The engine provides the following abstract classes: + +- `Plugin` The most basic abstract plug-in class +- `ElementPlugin` node plugin, inherited from the `Plugin` abstract class +- `BlockPlugin` block-level node plug-in, inherited from the abstract class of `ElementPlugin` +- `MarkPlugin` style node plugin, inherited from `ElementPlugin` abstract class +- `InlinePlugin` inline node plugin, inherited from `ElementPlugin` abstract class +- `ListPlugin` list plugin, inherited from `BlockPlugin` abstract class + +In more complex plug-ins, we need to manipulate the DOM tree and cursor, so just inheritance is not enough. We also need to cooperate with the node API to make a more complete plug-in. + +Here we only need to understand the basic knowledge about plug-ins. If you need to develop a complete tutorial for plug-ins, please view it in the "Plugins" menu. + +We have provided enough basic plug-ins. Basically, you may no longer need to define basic plug-ins. Most likely, you will need to use `card` combined with front-end libraries such as `React` or `Vue` to define a complex plug-in, such as surveys. Questionnaires, drawing, multiple choice questions, `card` components are all competent + +## Use + +The plugin is initialized when the editor is instantiated. So we need to pass the plugin to the engine at the beginning + +```ts +const engine = new Engine(render node, { +plugins: [...plugin list], +}); +``` + +## Command + +All plug-ins inherit from the abstract class `Plugin` and must implement the `execute` method. The engine will add them to the list of executable commands, and when executing plug-in commands, the engine will help deal with the cursor position, history, etc. + +```ts +/** + * Execute plugin + * @param args parameters required by the plug-in + */ +abstract execute(...args: any): void; +``` + +We can execute a plugin command in the form of `engine.command.execute("plugin name", ...plugin parameter)` + +## Card + +In addition to the previous imperative and fixed operation node type plug-ins without UI rendering, we can also combine `Card` to complete custom content rendering plug-ins. Similarly, `Card` is also an abstract class, and we need to inherit it. It also has a method `render` (card rendering method) that must be implemented. How to render the nodes in the card is entirely up to you. + +## Extension + +In addition to the troublesome method of customizing the plug-in, we can also inherit it from the existing basic plug-in, and then rewrite some methods of the plug-in, get the node addition event of the plug-in, and so on. However, you must first have a certain understanding of the logic and definition of the plug-in. diff --git a/docs/docs/concepts-plugin.zh-CN.md b/docs/docs/concepts-plugin.zh-CN.md new file mode 100644 index 00000000..2d3c707b --- /dev/null +++ b/docs/docs/concepts-plugin.zh-CN.md @@ -0,0 +1,48 @@ +# 插件 + +在引擎上开发一个插件是相对比较简单的。引擎提供了以下几个抽象类: + +- `Plugin` 最基础的插件抽象类 +- `ElementPlugin` 节点插件,继承自`Plugin`抽象类 +- `BlockPlugin` 块级节点插件,继承自`ElementPlugin`抽象类 +- `MarkPlugin` 样式节点插件,继承自`ElementPlugin`抽象类 +- `InlinePlugin` 行内节点插件,继承自`ElementPlugin`抽象类 +- `ListPlugin` 列表插件,继承自`BlockPlugin`抽象类 + +在比较复杂的插件里面,我们需要操作 DOM 树还有光标,所以仅仅继承还是不够的,我们还需要配合节点 API 来制作一个比较完善的插件。 + +在这里我们只需要了解有关插件的基本知识,如果需要开发插件的完整教程请在"插件"菜单里面查看。 + +我们已经提供了足够多的基础插件,基本上你可能不再需要定义基础插件,大概率会需要使用 `card` 结合 `React` 或者 `Vue` 等前端库来定义一个复杂的插件,例如:调查问卷、画图、选择题,`card` 组件都能胜任 + +## 使用 + +插件在编辑器实例化时,就会初始化插件。所以我们得在一开始就需要把插件传入引擎 + +```ts +const engine = new Engine(渲染节点, { + plugins: [...插件列表], +}); +``` + +## 命令 + +插件都继承自`Plugin`抽象类,必须实现 `execute` 方法。引擎会把他们加入到可执行命令列表里,并且在执行插件命令时,引擎会帮助处理好光标位置、历史记录等等 + +```ts +/** + * 执行插件 + * @param args 插件需要的参数 + */ +abstract execute(...args: any): void; +``` + +我们可以通过 `engine.command.execute("插件名称", ...插件参数)` 这种形式来执行一个插件命令 + +## 卡片 + +除了前面的无 UI 渲染的命令式和固定操作节点类型的插件外,我们还可以结合 `Card` 来完成自定义内容渲染的插件。同样的, `Card` 也是一个抽象类,我们需要继承它,它也有一个必须实现的方法 `render`(卡片渲染方法),如何渲染卡片内的节点节点完全取决于你。 + +## 扩展 + +除了自定义插件比较麻烦的方法外,我们还可以在现有基础插件上去继承它,然后重写插件的部分方法,获取到插件的节点添加事件等等。不过,你首先要对插件的逻辑和定义有一定的了解。 diff --git a/docs/docs/concepts-range.md b/docs/docs/concepts-range.md new file mode 100644 index 00000000..4a54e1ab --- /dev/null +++ b/docs/docs/concepts-range.md @@ -0,0 +1,52 @@ +# Range + +In the editor, in addition to dealing with the DOM tree, the second thing is to control our range. After we click the left mouse button in the editing area, the range will flicker, and there is the editing position, including the selection of a range by sliding the mouse after pressing the left button. The range information will indicate the position of the DOM node that the current user wants to operate. + +After knowing the position of the DOM to be manipulated, any other user operations may need to change the DOM tree structure, such as inputting characters or pressing the delete key. What we need to do is to correctly apply the user feedback to the DOM tree. After up, let the range range fall on the correct position, the user does not want to see the range jump randomly. + +The browser has provided us with a rich API [Range](https://developer.mozilla.org/zh-CN/docs/Web/API/Range/Range). Different browser vendors may have some subtle differences in implementation, including differences in the location of selected nodes. But these have been handled well in our engine. + +## Meet Range + +There are five important attributes in the `Range` object. If you need to understand other detailed attributes and methods, please visit the browser API [Range](https://developer.mozilla.org/zh-CN/docs/Web/API/Range/Range) + +- `startContainer` range start position node +- `startOffset` the offset under the node where the range starts +- `endContainer` node at the end of the range +- `endOffset` the offset under the node where the range ends +- `collapsed` indicates whether the start position and end position of the range are at the same position + +Example 1: + +Here we use anchor to indicate the start position, and focus to indicate the end position. + +```html +

abcd

+``` + +Below the p node is a paragraph of `abcd` text, the type is `Text` in the DOM tree, which is a text node. Both startContainer and endContainer point to `Text`, offset is the character length startOffset=1, endOffset=3, although the pointing nodes are all Text, the offset is inconsistent, collapsed is false + +Example 2: + +Here we use range to indicate that the start position and end position of the range are in a coincident state + +```html +

+ abcd +

+``` + +Below the p node is a span node, and below the span node is the text node `Text`. Here, there are two ways of `Range` object + +- startContainer and endContainer both point to `Text`, startOffset and endOffset are both 0 +- startContainer and endContainer both point to the span node, startOffset and endOffset are both 0 + +When the pointed node is not a `Text` text node, offset represents the index value of the child node relative to the parent node + +The meanings of these two methods are the same here, and in more complex DOM structures, there will be more complex expressions. In this case, we use the Range object to determine the position of the node and perform many operations. Uncertainty. So we extend the `RangeInterface` type on the basis of Range to help us better control the `Range` object. For more information, please see API + +## Zero-width characters + +A zero-width character is a character that is not printed in the browser, and it has no width. + +When the range position cannot be set, or there is a repair to the default browser default range position, the `zero-width character` will be used to select the range next to the zero-width character. For example: , we want the range to focus on the span node, but there is no node in the span node, then we can add a zero-width character to the span node , and let the range select before or after the zero-width character, we can enter content in the span node. diff --git a/docs/docs/concepts-range.zh-CN.md b/docs/docs/concepts-range.zh-CN.md new file mode 100644 index 00000000..1af2752e --- /dev/null +++ b/docs/docs/concepts-range.zh-CN.md @@ -0,0 +1,52 @@ +# 光标 + +在编辑器中,除了需要和 DOM 树打交道外,其次就是控制好我们的光标。在我们鼠标左键在编辑区域单击后,会有光标闪烁,那里就是我们编辑的位置,包括左键按下后滑动鼠标选取一段范围。这些光标信息都会表明当前用户要操作的 DOM 节点位置。 + +在知道要操作的 DOM 位置后,用户的其它任何操作都可能需要去改变 DOM 树结构,例如输入字符、或者按下删除键,我们需要做的是就是把用户的这些反馈正确的应用到 DOM 树上后让光标范围落在正确的位置上,用户并不想看到光标乱跳。 + +浏览器已经为我们提供了丰富的 API [Range](https://developer.mozilla.org/zh-CN/docs/Web/API/Range/Range)。不同的浏览器厂商可能在实现上会有些细微的差别,包括所选节点的位置也有差别。不过这些在我们的引擎中已经很好的处理过了。 + +## 认识 Range + +`Range` 对象中主要有五个重要属性,需要了解其它详细属性和方法,请访问浏览器 API[Range](https://developer.mozilla.org/zh-CN/docs/Web/API/Range/Range) + +- `startContainer` 光标开始位置节点 +- `startOffset` 光标开始位置节点下的偏移量 +- `endContainer` 光标结束位置节点 +- `endOffset` 光标结束位置节点下的偏移量 +- `collapsed` 表示光标开始位置和结束位置是否处于同一个位置 + +例子 1: + +这里我们使用 anchor 表示开始位置,focus 表示结束位置。 + +```html +

abcd

+``` + +p 节点下面是一段 `abcd` 文本,在 DOM 树中类型是 `Text` ,是一个文本节点。startContainer 和 endContainer 都指向 `Text`, offset 就是字符长度 startOffset=1, endOffset=3 ,虽然指向节点都是 Text,但是 offset 不一致,collapsed 为 false + +例子 2: + +这里我们使用 cursor 表示光标开始位置和结束位置处于重合状态 + +```html +

+ abcd +

+``` + +p 节点下是一个 span 节点,span 节点下是 `Text` 文本节点。此处表示 `Range` 对象有两种方式 + +- startContainer 和 endContainer 都指向 `Text`,startOffset 和 endOffset 都为 0 +- startContainer 和 endContainer 都指向 span 节点,startOffset 和 endOffset 都为 0 + +在所指节点非 `Text` 文本节点时,offset 表示子节点相对于父节点的索引值 + +此处这两种方式表达的意思都是一样的,而且在更复杂的 DOM 结构中,还会有更多复杂的表述,在这种情况下我们借助 Range 对象来判定节点位置执行操作会有很多的不确定性。所以我们在 Range 基础上扩展了`RangeInterface`类型来帮助我们更好的把控`Range`对象。更多的信息请查看 API + +## 零宽字符 + +零宽字符是一种在浏览器中不打印的字符,它也没有宽度。 + +在无法设置光标位置,或者有修复默认浏览器默认光标位置时,会使用到`零宽字符`,让光标选择到零宽字符旁边。例如:,我们想让光标聚焦到 span 节点内,但是 span 节点内没有任何节点,这时我们可以给 span 节点内添加一个零宽字符 ,并让光标选择在零宽字符前或后,我们就可以在 span 节点内输入内容了。 diff --git a/docs/docs/concepts-schema.md b/docs/docs/concepts-schema.md new file mode 100644 index 00000000..b00aae77 --- /dev/null +++ b/docs/docs/concepts-schema.md @@ -0,0 +1,279 @@ +# Schema + +For a complex DOM tree, we need to use a set of rules to constrain the DOM tree structure, including node nesting, node attributes, and some specific behaviors. + +We can make rules for a single node, of course, we can also make global rules for the three node types `mark` `inline` `block`. `card` belongs to a special type of us, in essence, they can also be classified as `inline` and `block` types + +If a node is not in the constraint rule, it will be filtered out, including attributes and styles. If you need this attribute, then it must appear in the rule, otherwise it will not be retained + +## Settings + +Single rule type: `SchemaRule` Global rule type: `SchemaGlobal` + +A rule contains the following attributes: + +- `name` DOM node name, optional value +- `type` type, `mark` `inline` `block`. In the case of no node name, global rules will be set according to type. Must value +- The `attributes` attribute, which sets the node attribute rules, is an object. Optional value +- Whether `isVoid` is an empty node, like br, img and other tags, it is impossible to set child nodes, including text. Optional value + +example: + +```ts +//Single node rule +{ + name:'p', + type:'block', +}, +{ + name:'span', + type:'mark', + attributes: { + style: { + color: "@color" + } + } +} +//Global rules by type +{ + type: "block", + attributes: { + id: "*" + } +} +``` + +## Additional rules for block-level nodes + +In addition to the general rules, we have also customized two additional attributes for block-level nodes + +```ts +{ + ... + allowIn?: Array; +canMerge?: boolean; +} +``` + +- `allowIn` allows the name of the block-level node that the node can be put into, by default their value is `$root` (editor root node). This is usually used in nested nodes, for example: ul li unordered list has a li child node, which is also a block-level node on its own line. If a block-level node does not specify a block-level node that can be placed, it will be filtered out +- `canMerge` Whether two adjacent block-level nodes can be merged. For example: quoting the plug-in blockquote, when two blockquote nodes are adjacent, their child nodes will be merged into one blockquote node, because it is meaningless for them to exist separately next to each other, but it will increase the complexity of the document. + +Type: `SchemaBlock` + +## attributes value + +The attribute value type `SchemaValue`, which consists of `SchemaValueObject` and `SchemaValueBase` + +```ts +export type SchemaValueBase = + | RegExp + | Array + | string + | ((propValue: string) => boolean) + | '@number' + | '@length' + | '@color' + | '@url' + | '*'; + +export type SchemaValueObject = { + required: boolean; + value: SchemaValueBase; +}; +``` + +We can see that the attribute value can be configured very flexibly and supports: + +- Regular expression +- Array +- Single character +- Function custom verification +- `@number` number, number +- `@length` length, including the pixel value of the pixel band unit, for example: 10px +- `@color` can determine whether the attribute value is a "color". For example: #ffffff rgb(0,0,0,0) +- `@ulr` determines whether the attribute value is a link +- Any value of `*`, including undefined, null and other empty values ​​can pass the validation + +In addition to the value determination, by default, these attributes are optional after they are set. We may also need to formulate necessary attributes for nodes to distinguish the difference between the nodes with the same name and the type of plugin they belong to, for example: + +A style node representing the foreground color + +```html +Hello +``` + +```ts +{ + name: "span", + type: "mark", + attributes: { + style:{ + color: "@color" + } + } +} +``` + +Style nodes representing foreground and background colors + +```html +Hello +``` + +```ts +{ + name: "span", + type: "mark", + attributes: { + style:{ + color: "@color", + "background-color": "@color" + } + } +} +``` + +The names of these two style nodes are span and both contain color styles. Because the default attributes are optional attributes, we will ignore these optional attributes when determining a node. The rest of the names are also the same, which will cause Logic errors, many unexpected situations occurred. + +So here we need to use the value of the `SchemaValueObject` type to show the uniqueness of these two nodes. These marked attributes are also the most important feature points of the node. + +```ts +{ + name: "span", + type: "mark", + attributes: { + style:{ + color: { + required: true, + value:"@color" + } + } + } +} + +{ + name: "span", + type: "mark", + attributes: { + style:{ + color: { + required: true, + value:"@color" + }, + "background-color": { + required: true, + value:"@color" + } + } + } +} +``` + +## Default rules + +The engine divides the nodes according to functions and characteristics `mark` `inline` `block` `card`, in order to meet the normal operation of these divided nodes and the needs of the engine, we have formulated some default rules, which will be customized with us The rules are combined and used together, so it is not recommended to customize the rules to overwrite them + +```ts +import { SchemaGlobal, SchemaRule } from '../types'; +import { CARD_KEY, CARD_TYPE_KEY, CARD_VALUE_KEY } from './card'; +import { ANCHOR, CURSOR, FOCUS } from './selection'; + +const defualtSchema: Array = [ + { + name: 'p', + type: 'block', + }, + { + name: 'br', + type: 'inline', + isVoid: true, + }, + { + name: ANCHOR, + type: 'inline', + isVoid: true, + }, + { + name: FOCUS, + type: 'inline', + isVoid: true, + }, + { + name: CURSOR, + type: 'inline', + isVoid: true, + }, + { + type: 'block', + attributes: { + 'data-id': '*', + }, + }, + { + name: 'card', + type: 'inline', + attributes: { + name: { + required: true, + value: /\w+/, + }, + type: { + required: true, + value: 'inline', + }, + value: '*', + }, + }, + { + name: 'span', + type: 'inline', + attributes: { + [CARD_KEY]: { + required: true, + value: /\w+/, + }, + [CARD_TYPE_KEY]: { + required: true, + value: 'inline', + }, + [CARD_VALUE_KEY]: '*', + class: '*', + contenteditable: '*', + }, + }, + { + name: 'card', + type: 'block', + attributes: { + name: { + required: true, + value: /\w+/, + }, + type: { + required: true, + value: 'block', + }, + value: '*', + }, + }, + { + name: 'div', + type: 'block', + attributes: { + [CARD_KEY]: { + required: true, + value: /\w+/, + }, + [CARD_TYPE_KEY]: { + required: true, + value: 'block', + }, + [CARD_VALUE_KEY]: '*', + class: '*', + contenteditable: '*', + }, + }, +]; + +export default defualtSchema; +``` diff --git a/docs/docs/concepts-schema.zh-CN.md b/docs/docs/concepts-schema.zh-CN.md new file mode 100644 index 00000000..2452cb8c --- /dev/null +++ b/docs/docs/concepts-schema.zh-CN.md @@ -0,0 +1,279 @@ +# Schema + +对于复杂的 DOM 树,我们需要使用一套规则来约束 DOM 树结构,包括节点嵌套、节点属性、以及一些特定的行为。 + +我们可以对单个节点制定规则,当然也可以针对三种节点类型`mark` `inline` `block`制定全局规则。`card` 属于我们一种特殊类型,本质上他们也可以归纳为 `inline` 和 `block` 类型 + +如果一个节点不在约束规则中,那么它将会被过滤掉,包括属性、样式,如果你需要这个属性,那么它一定要出现在规则中,否则都不会被保留 + +## 设置 + +单个规则类型:`SchemaRule` 全局规则类型:`SchemaGlobal` + +一个规则的包含以下属性: + +- `name` DOM 节点名称,可选值 +- `type` 类型,`mark` `inline` `block`。在不制定节点名称情况下,将根据 type 设置全局规则。必须值 +- `attributes` 属性,设置节点属性规则,是一个对象。可选值 +- `isVoid` 是否是空节点,类似 br、img 等标签,是无法设置子节点的,包括文本。可选值 + +例子: + +```ts +//单个节点规则 +{ + name: 'p', + type: 'block', +}, +{ + name: 'span', + type: 'mark', + attributes: { + style: { + color: "@color" + } + } +} +//按类型全局规则 +{ + type: "block", + attributes: { + id: "*" + } +} +``` + +## 块级节点额外规则 + +在通用规则之外,我们还为块级节点额外定制了两个属性 + +```ts +{ + ... + allowIn?: Array; + canMerge?: boolean; +} +``` + +- `allowIn` 允许节点可以放入的块级节点名称,默认他们的值为 `$root`(编辑器根节点)。这通常在嵌套节点中使用,例如:ul li 无序列表下有 li 子节点,它也是独占一行的属于块级节点。如果一个块级节点没有指定可放入的块级节点,那么它将会被过滤掉 +- `canMerge` 相邻的两个块级节点是否可以合并。例如:引用插件 blockquote ,在两个 blockquote 节点处于相邻状态时,它们的子节点会被合并到一个 blockquote 节点下,因为它们相邻单独存在是没有意义的,反而还会增加文档的复杂性 + +类型:`SchemaBlock` + +## attributes 值 + +属性值类型 `SchemaValue`,它由 `SchemaValueObject` 和 `SchemaValueBase` 组成 + +```ts +export type SchemaValueBase = + | RegExp + | Array + | string + | ((propValue: string) => boolean) + | '@number' + | '@length' + | '@color' + | '@url' + | '*'; + +export type SchemaValueObject = { + required: boolean; + value: SchemaValueBase; +}; +``` + +我们可以看到属性值是可以很灵活配置的,支持: + +- 正则表达式 +- 数组 +- 单个字符 +- 函数自定义验证 +- `@number` 数量,数字 +- `@length` 长度,包括像素带单位的像素值,例如:10px +- `@color` 可以判断该属性值是否是一个“颜色”。例如:#ffffff rgb(0,0,0,0) +- `@ulr` 判断该属性值是否是一个链接 +- `*` 任意值,包括 undefined、null 等空值都可以通过效验 + +除了值的判定外,默认情况下,这些属性设置后都是可选属性,我们还可能需要为节点制定必要的属性,以区分相通名称节点之间的差别和所属插件类型判别,例如: + +表示前景色的样式节点 + +```html +Hello +``` + +```ts +{ + name: "span", + type: "mark", + attributes: { + style:{ + color: "@color" + } + } +} +``` + +表示前景色和背景色的样式节点 + +```html +Hello +``` + +```ts +{ + name: "span", + type: "mark", + attributes: { + style:{ + color: "@color", + "background-color": "@color" + } + } +} +``` + +这两个样式节点名称都是 span 而且都包含 color 样式,因为默认属性都是可选属性,所以我们在判定一个节点时会忽略这些可选属性,剩下的名称也是一样的,这样就会造成逻辑错误,出现很多意外情况。 + +所以这里我们需要使用 `SchemaValueObject` 类型的值,来表明这两个节点的唯一性,这些标明的属性也是节点最主要的特征点 + +```ts +{ + name: "span", + type: "mark", + attributes: { + style:{ + color: { + required: true, + value:"@color" + } + } + } +} + +{ + name: "span", + type: "mark", + attributes: { + style:{ + color: { + required: true, + value:"@color" + }, + "background-color": { + required: true, + value:"@color" + } + } + } +} +``` + +## 默认规则 + +引擎按照功能和特性对节点进行了划分 `mark` `inline` `block` `card`,为了满足这些划分后的节点正常工作以及引擎需要,我们制定了一些默认规则,这些规则会和我们自定义规则合并后一起使用,所以不建议自定义规则去覆盖它们 + +```ts +import { SchemaGlobal, SchemaRule } from '../types'; +import { CARD_KEY, CARD_TYPE_KEY, CARD_VALUE_KEY } from './card'; +import { ANCHOR, CURSOR, FOCUS } from './selection'; + +const defualtSchema: Array = [ + { + name: 'p', + type: 'block', + }, + { + name: 'br', + type: 'inline', + isVoid: true, + }, + { + name: ANCHOR, + type: 'inline', + isVoid: true, + }, + { + name: FOCUS, + type: 'inline', + isVoid: true, + }, + { + name: CURSOR, + type: 'inline', + isVoid: true, + }, + { + type: 'block', + attributes: { + 'data-id': '*', + }, + }, + { + name: 'card', + type: 'inline', + attributes: { + name: { + required: true, + value: /\w+/, + }, + type: { + required: true, + value: 'inline', + }, + value: '*', + }, + }, + { + name: 'span', + type: 'inline', + attributes: { + [CARD_KEY]: { + required: true, + value: /\w+/, + }, + [CARD_TYPE_KEY]: { + required: true, + value: 'inline', + }, + [CARD_VALUE_KEY]: '*', + class: '*', + contenteditable: '*', + }, + }, + { + name: 'card', + type: 'block', + attributes: { + name: { + required: true, + value: /\w+/, + }, + type: { + required: true, + value: 'block', + }, + value: '*', + }, + }, + { + name: 'div', + type: 'block', + attributes: { + [CARD_KEY]: { + required: true, + value: /\w+/, + }, + [CARD_TYPE_KEY]: { + required: true, + value: 'block', + }, + [CARD_VALUE_KEY]: '*', + class: '*', + contenteditable: '*', + }, + }, +]; + +export default defualtSchema; +``` diff --git a/docs/docs/contributing.md b/docs/docs/contributing.md new file mode 100644 index 00000000..51747e17 --- /dev/null +++ b/docs/docs/contributing.md @@ -0,0 +1,77 @@ +# Contribution + +## Contributing plugins + +Refer to the plugin tutorial document + +## Contribute Engine Code + +Clone the am-editor repository on GitHub + +### Installation dependencies + +```bash +$ yarn + +//or + +$ npm install +``` + +### Startup project + +```bash +//Ordinary start +$ yarn start + +//or +//The server-side rendering mode is started and site-ssr will be called. To fully use ssr mode, you need to actively access port 7001 after startup +$ yarn ssr +``` + +### Start collaborative service + +```bash +$ cd ot-server +$ yarn start +``` + +### Compile + +Because we have multiple packages, we use lerna management mode + +In the compilation configuration, we used [father-build](https://github.com/umijs/father) + +One command can compile all packages + +```bash +$ yarn build +``` + +Site packaging + +```bash +$ yarn docs:build +``` + +## Contributing documents + +am-editor uses [dumi](https://d.umijs.org/) as a document site tool, + +1. There is "Edit this document on GitHub" at the bottom left of each document, you can modify the document here +2. Open the docs directory on Github, use the file editor to create, modify, and preview files, and then submit a PR +3. You can also clone the am-editor warehouse and modify the files in the docs directory. After the local documentation is debugged, the PR will be unified. + +## Financial support + +### Alipay + +![alipay](https://cdn-object.yanmao.cc/contribution/alipay.png?x-oss-process=image/resize,w_200) + +### WeChat Pay + +![wechat](https://cdn-object.yanmao.cc/contribution/weichat.png?x-oss-process=image/resize,w_200) + +### PayPal + +https://paypal.me/aomaocom diff --git a/docs/docs/contributing.zh-CN.md b/docs/docs/contributing.zh-CN.md new file mode 100644 index 00000000..2467dd87 --- /dev/null +++ b/docs/docs/contributing.zh-CN.md @@ -0,0 +1,77 @@ +# 贡献 + +## 贡献插件 + +参考插件教程文档 + +## 贡献 Engine 代码 + +在 GitHub 上 clone am-editor 仓库 + +### 安装依赖 + +```bash +$ yarn + +//or + +$ npm install +``` + +### 启动项目 + +```bash +//普通启动 +$ yarn start + +//or +//服务端渲染模式启动,将调用 site-ssr 。要完全使用ssr模式,启动后需要主动访问 7001 端口 +$ yarn ssr +``` + +### 启动协同服务 + +```bash +$ cd ot-server +$ yarn start +``` + +### 编译 + +因为我们有多个 package,使用的是 lerna 管理模式 + +在编译配置上,我们使用了 [father-build](https://github.com/umijs/father) + +一个命令就可以编译所有的包 + +```bash +$ yarn build +``` + +站点打包 + +```bash +$ yarn docs:build +``` + +## 贡献文档 + +am-editor 使用 [dumi](https://d.umijs.org/) 作为文档站点工具, + +1. 每篇文档左下方有 “在 GitHub 上编辑这篇文档”,你可以通过这里进行文档修改 +2. 打开 Github 上的 docs 目录,用文件编辑器新建、修改、预览文件,然后提 PR +3. 你还可以 clone am-editor 仓库,修改 docs 目录下的文件,本地文档调试完成后统一提 PR + +## 经济支持 + +### 支付宝 + +![alipay](https://cdn-object.yanmao.cc/contribution/alipay.png?x-oss-process=image/resize,w_200) + +### 微信支付 + +![wechat](https://cdn-object.yanmao.cc/contribution/weichat.png?x-oss-process=image/resize,w_200) + +### PayPal + +https://paypal.me/aomaocom diff --git a/docs/docs/faq.md b/docs/docs/faq.md new file mode 100644 index 00000000..57c482b4 --- /dev/null +++ b/docs/docs/faq.md @@ -0,0 +1,34 @@ +# FAQ + +## Does am-editor support Vue2? + +The engine library `@aomao/engine` itself is written in javascript and does not involve the front-end framework. Mainly because some plugins we use front-end frame rendering + +The following three plugins are different + +- `@aomao/toolbar-vue` editor toolbar. Buttons, icons, drop-down boxes, color pickers, etc. are all complex UIs + +- `@aomao/plugin-codeblock-vue` The drop-down box for selecting the code language has a search function. It is a better choice to use the existing UI of the front-end library + +- `@aomao/plugin-link-vue` link input, text input, using the existing UI of the front-end library is a better choice + +These three plugins all have vue3 dependencies and use the antv UI library. Other plugins do not rely on any front-end framework + +[Vue2 Plugins](https://github.com/zb201307/am-editor-vue2/tree/main/packages) + +## window is not defined, document is not defined, navigator is not defined + +SSR will execute the render method on the server side, and the server side does not have DOM/BOM variables and methods + +In the editing mode, there is basically no need for server-side rendering. Mainly lies in the view rendering. If pure html is used, the dynamic interaction of the content of `Card` will be lacking. + +1. Use the built-in window object of jsdom. You can use the getWindow object to get this \_\_amWindow object inside the engine or plug-in. But it cannot solve the problem of third-party packages relying on the window object + +```ts +const { JSDOM } = require('jsdom'); + +const { window } = new JSDOM(``); +global.__amWindow = window; +``` + +2. Introduce third-party packages dynamically or use `isServer` to determine whether there is a window object. This can solve the problem of no errors when running, but the content cannot be completely rendered on the server side. You can output html on the server to meet the needs of seo. Re-render the view reader after loading into the browser diff --git a/docs/docs/faq.zh-CN.md b/docs/docs/faq.zh-CN.md new file mode 100644 index 00000000..0e936d64 --- /dev/null +++ b/docs/docs/faq.zh-CN.md @@ -0,0 +1,34 @@ +# FAQ + +## am-editor 支持 Vue2 吗? + +引擎库 `@aomao/engine` 本身是 javascript 编写的,不涉及到前端框架。主要在于一些插件我们使用了前端框架渲染 + +下面这三个插件有区别 + +- `@aomao/toolbar-vue` 编辑器工具栏。按钮、图标、下拉框、颜色选择器等都是复杂的 UI + +- `@aomao/plugin-codeblock-vue` 选择代码语言的下拉框具有搜索功能,使用前端库现有的 UI 是比较好的选择 + +- `@aomao/plugin-link-vue` 链接输入、文本输入,使用前端库现有的 UI 是比较好的选择 + +这三个插件都有 vue3 的依赖,并且使用的是 antd UI 库。其它插件没有依赖任何前端框架 + +[Vue2 插件](https://github.com/zb201307/am-editor-vue2/tree/main/packages) + +## window is not defined, document is not defined, navigator is not defined + +SSR 因为会在服务端执行 render 渲染方法,而服务端没有 DOM/BOM 变量和方法 + +在编辑模式下,基本上没有服务端渲染的需求。主要在于视图渲染,如果使用纯 html 呈现将缺少`Card`内容的动态交互。 + +1. 使用 jsdom 内置 window 对象。在引擎或插件内部可以使用 getWindow 对象获取这个 \_\_amWindow 对象。但是无法解决第三方包依赖 window 对象的问题 + +```ts +const { JSDOM } = require('jsdom'); + +const { window } = new JSDOM(``); +global.__amWindow = window; +``` + +2. 将第三方包动态引入 或者 使用 `isServer` 判定是否有 window 对象。这样能解决运行不会出错的问题,但是在服务端还是无法完整的渲染出内容。可以在服务端输出 html,满足 seo 需求。加载到浏览器后重新渲染 view 阅读器 diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md new file mode 100644 index 00000000..f16d2359 --- /dev/null +++ b/docs/docs/getting-started.md @@ -0,0 +1,362 @@ +--- +title: Quick start +--- + +## Get started quickly + +In addition to the pure `javascript` writing of the engine library, a small part of the plug-ins we provide have more complex UI, and it is a relatively easy task to use the front-end library to render the UI. + +The following three plugins are different + +- `@aomao/toolbar` editor toolbar. Buttons, icons, drop-down boxes, color pickers, etc. are all complex UIs + +- `@aomao/plugin-codeblock` The drop-down box for selecting the code language has a search function. It is a better choice to use the existing UI of the front-end library + +- `@aomao/plugin-link` link input, text input, using the existing UI of the front-end library is a better choice + +[React case](https://github.com/yanmao-cc/am-editor/blob/master/docs/demo/engine.tsx) + +[Vue case](https://github.com/yanmao-cc/am-editor/tree/master/examples/vue) + +### Installation + +Use npm or yarn to install the editing engine + +```bash +$ npm install @aomao/engine +# or +$ yarn add @aomao/engine +``` + +### Use + +Let's start by outputting a `Hello word!`. Now you can edit it below. + +```tsx +/** + * defaultShowCode: true + */ +import React, { useEffect, useRef, useState } from 'react'; +import Engine, { EngineInterface } from '@aomao/engine'; + +const EngineDemo = () => { + //Editor container + const ref = useRef(null); + //Engine instance + const [engine, setEngine] = useState(); + //Editor content + const [content, setContent] = useState('Hello word!'); + + useEffect(() => { + if (!ref.current) return; + //Instantiate the engine + const engine = new Engine(ref.current); + //Set the editor value + engine.setValue(content); + //Listen to the editor value change event + engine.on('change', (value) => { + setContent(value); + console.log(`value:${value}`); + }); + //Set the engine instance + setEngine(engine); + }, []); + + return
; +}; +export default EngineDemo; +``` + +### Plugins + +Now, on the basis of the appeal code, we introduce the `@aomao/plugin-bold` bold plug-in + +```tsx | pure +import Bold from '@aomao/plugin-bold'; +``` + +Then add the `Bold` plugin to the engine + +```tsx | pure +//Instantiate the engine +const engine = new Engine(ref.current, { + plugins: [Bold], +}); +``` + +The default shortcut of the `Bold` plugin is windows `ctrl+b` or mac `⌘+b`, now try the bold effect + +```tsx +import React, { useEffect, useRef, useState } from 'react'; +import Engine, { EngineInterface } from '@aomao/engine'; +import Bold from '@aomao/plugin-bold'; + +const EngineDemo = () => { + //Editor container + const ref = useRef(null); + //Engine instance + const [engine, setEngine] = useState(); + //Editor content + const [content, setContent] = useState( + 'Hello word!', + ); + + useEffect(() => { + if (!ref.current) return; + //Instantiate the engine + const engine = new Engine(ref.current, { + plugins: [Bold], + }); + //Set the editor value + engine.setValue(content); + //Listen to the editor value change event + engine.on('change', (value) => { + setContent(value); + console.log(`value:${value}`); + }); + //Set the engine instance + setEngine(engine); + }, []); + + return
; +}; +export default EngineDemo; +``` + +### Card + +A card is a separate area in the editor. The UI of this area can be customized to render content using front-end frameworks such as React and Vue, and finally mounted on the editor. + +Introduce the `@aomao/plugin-codeblock` code block plug-in. Part of the plug-in UI is rendered by the front-end framework, so there is a distinction. `vue3` developers use `@aomao/plugin-codeblock-vue` `vue2` developers use `am-editor-codeblock-vue2` + +```tsx | pure +import CodeBlock, { CodeBlockComponent } from '@aomao/plugin-codeblock'; +``` + +Add `CodeBlock` plugin and `CodeBlockComponent` card component to the engine + +```tsx | pure +//Instantiate the engine +const engine = new Engine(ref.current, { + plugins: [CodeBlock], + cards: [CodeBlockComponent], +}); +``` + +The `CodeBlock` plugin supports `markdown` by default. Enter the code block syntax ```javascript` at the beginning of a line in the editor, and then see the effect. + +```tsx +import React, { useEffect, useRef, useState } from 'react'; +import Engine, { EngineInterface } from '@aomao/engine'; +import CodeBlock, { CodeBlockComponent } from '@aomao/plugin-codeblock'; + +const EngineDemo = () => { + //Editor container + const ref = useRef(null); + //Engine instance + const [engine, setEngine] = useState(); + //Editor content + const [content, setContent] = useState( + 'Hello word!', + ); + + useEffect(() => { + if (!ref.current) return; + //Instantiate the engine + const engine = new Engine(ref.current, { + plugins: [CodeBlock], + cards: [CodeBlockComponent], + }); + //Set the editor value + engine.setValue(content); + //Listen to the editor value change event + engine.on('change', (value) => { + setContent(value); + console.log(`value:${value}`); + }); + //Set the engine instance + setEngine(engine); + }, []); + + return
; +}; +export default EngineDemo; +``` + +### toolbar + +Introduce the `@aomao/toolbar` toolbar, the toolbar UI is more complicated, all of which are rendered by using the front-end framework, `vue3` developers use `@aomao/toolbar-vue` `vue2` developers use `am-editor-codeblock-vue2` + +```tsx | pure +import Toolbar, { ToolbarPlugin, ToolbarComponent } from '@aomao/toolbar'; +``` + +Add the `ToolbarPlugin` plugin and the `ToolbarComponent` card component to the engine, it will allow us to use the shortcut key `/` to wake up the toolbar in the editor + +```tsx | pure +//Instantiate the engine +const engine = new Engine(ref.current, { + plugins: [ToolbarPlugin], + cards: [ToolbarComponent], +}); +``` + +Rendering toolbar, the toolbar has been configured with all plug-ins, here we only need to pass in the plug-in name + +```tsx | pure +return ( + ... + { + engine && ( + + ) + } + ... +) +``` + +```tsx +/** + * transform: true + */ +import React, { useEffect, useRef, useState } from 'react'; +import Engine, { EngineInterface } from '@aomao/engine'; +import Bold from '@aomao/plugin-bold'; +import CodeBlock, { CodeBlockComponent } from '@aomao/plugin-codeblock'; +import Toolbar, { ToolbarPlugin, ToolbarComponent } from '@aomao/toolbar'; + +const EngineDemo = () => { + //Editor container + const ref = useRef(null); + //Engine instance + const [engine, setEngine] = useState(); + //Editor content + const [content, setContent] = useState( + 'Hello word!', + ); + + useEffect(() => { + if (!ref.current) return; + //Instantiate the engine + const engine = new Engine(ref.current, { + plugins: [CodeBlock, Bold, ToolbarPlugin], + cards: [CodeBlockComponent, ToolbarComponent], + }); + //Set the editor value + engine.setValue(content); + //Listen to the editor value change event + engine.on('change', (value) => { + setContent(value); + console.log(`value:${value}`); + }); + //Set the engine instance + setEngine(engine); + }, []); + + return ( + <> + {engine && } +
+ + ); +}; +export default EngineDemo; +``` + +#### Develop your own toolbar + +`@aomao/toolbar` is more to provide a toolbar UI display, the essence is to call `engine.command.execute` to execute plug-in commands + +```tsx +/** + * transform: true + */ +import React, { useEffect, useRef, useState } from 'react'; +import Engine, { EngineInterface } from '@aomao/engine'; +import Bold from '@aomao/plugin-bold'; + +const EngineDemo = () => { + //Editor container + const ref = useRef(null); + //Engine instance + const [engine, setEngine] = useState(); + //Editor content + const [content, setContent] = useState( + 'Hello word!', + ); + // button state + const [btnActive, setBtnActive] = useState(false); + + useEffect(() => { + if (!ref.current) return; + //Instantiate the engine + const engine = new Engine(ref.current, { + plugins: [Bold], + }); + //Set the editor value + engine.setValue(content); + //Listen to the editor value change event + engine.on('change', (value) => { + setContent(value); + console.log(`value:${value}`); + }); + // listen for cursor changes + engine.on('select', () => { + // Query the selected state of the bold plugin + setBtnActive(engine.command.queryState('bold')); + }); + //Set the engine instance + setEngine(engine); + }, []); + + const handleMouseDown = (event: React.MouseDown) => { + // Click the button to avoid losing the editor cursor + event.preventDefault(); + }; + + const handleBoldClick = () => { + // execute the bold command + engine?.command.execute('bold'); + }; + + return ( + <> + {engine && ( + + )} +
+ + ); +}; +export default EngineDemo; +``` + +### Collaborative editing + +Collaborative editing is based on [ShareDB](https://github.com/share/sharedb). Each editor acts as [client](https://github.com/yanmao-cc/am-editor/blob/master/docs/demo/ot-client.ts) through `WebSocket` and [server](https://github.com/yanmao-cc/am-editor/tree/master/ot-server) to exchange data. The editor processes and renders data. + +We need to set up the server and then configure the client. [View full example](https://github.com/yanmao-cc/am-editor/blob/master/docs/demo/engine.tsx) + +```tsx | pure +//Instantiate the collaborative editing client and pass in the current editor engine instance +const otClient = new OTClient(engine); +//Connect to the collaboration server, uid will do a simple authentication demonstration here, and authentication information such as token should be required in normal business. `demo` is the unique number of the document +otClient.connect( + `ws://127.0.0.1:8080${currentMember ? '?uid=' + currentMember.id : ''}`, + 'demo', +); +``` diff --git a/docs/docs/getting-started.zh-CN.md b/docs/docs/getting-started.zh-CN.md new file mode 100644 index 00000000..93abc86d --- /dev/null +++ b/docs/docs/getting-started.zh-CN.md @@ -0,0 +1,362 @@ +--- +title: 快速上手 +--- + +## 快速上手 + +除引擎库纯`javascript`编写外,我们所提供的插件中,小部分插件 UI 比较复杂,使用前端库来渲染 UI 是一项比较轻松的工作。 + +下面这三个插件有区别 + +- `@aomao/toolbar` 编辑器工具栏。按钮、图标、下拉框、颜色选择器等都是复杂的 UI + +- `@aomao/plugin-codeblock` 选择代码语言的下拉框具有搜索功能,使用前端库现有的 UI 是比较好的选择 + +- `@aomao/plugin-link` 链接输入、文本输入,使用前端库现有的 UI 是比较好的选择 + +[React 案例](https://github.com/yanmao-cc/am-editor/blob/master/docs/demo/engine.tsx) + +[Vue 案例](https://github.com/yanmao-cc/am-editor/tree/master/examples/vue) + +### 安装 + +使用 npm 或者 yarn 安装编辑引擎 + +```bash +$ npm install @aomao/engine +# or +$ yarn add @aomao/engine +``` + +### 使用 + +我们按从输出一个`Hello word!`入手。现在你可以在下方编辑了。 + +```tsx +/** + * defaultShowCode: true + */ +import React, { useEffect, useRef, useState } from 'react'; +import Engine, { EngineInterface } from '@aomao/engine'; + +const EngineDemo = () => { + //编辑器容器 + const ref = useRef(null); + //引擎实例 + const [engine, setEngine] = useState(); + //编辑器内容 + const [content, setContent] = useState('Hello word!'); + + useEffect(() => { + if (!ref.current) return; + //实例化引擎 + const engine = new Engine(ref.current); + //设置编辑器值 + engine.setValue(content); + //监听编辑器值改变事件 + engine.on('change', (value) => { + setContent(value); + console.log(`value:${value}`); + }); + //设置引擎实例 + setEngine(engine); + }, []); + + return
; +}; +export default EngineDemo; +``` + +### 插件 + +现在我们在上述代码基础上,引入`@aomao/plugin-bold`加粗插件 + +```tsx | pure +import Bold from '@aomao/plugin-bold'; +``` + +然后将`Bold`插件加入引擎 + +```tsx | pure +//实例化引擎 +const engine = new Engine(ref.current, { + plugins: [Bold], +}); +``` + +`Bold`插件的默认快捷键为 windows `ctrl+b` 或 mac `⌘+b`,现在试试加粗效果吧 + +```tsx +import React, { useEffect, useRef, useState } from 'react'; +import Engine, { EngineInterface } from '@aomao/engine'; +import Bold from '@aomao/plugin-bold'; + +const EngineDemo = () => { + //编辑器容器 + const ref = useRef(null); + //引擎实例 + const [engine, setEngine] = useState(); + //编辑器内容 + const [content, setContent] = useState( + 'Hello word!', + ); + + useEffect(() => { + if (!ref.current) return; + //实例化引擎 + const engine = new Engine(ref.current, { + plugins: [Bold], + }); + //设置编辑器值 + engine.setValue(content); + //监听编辑器值改变事件 + engine.on('change', (value) => { + setContent(value); + console.log(`value:${value}`); + }); + //设置引擎实例 + setEngine(engine); + }, []); + + return
; +}; +export default EngineDemo; +``` + +### 卡片 + +卡片是编辑器中单独划分的一个区域,该区域的 UI 可以使用 React、Vue 等前端框架自定义渲染内容,最后再挂载到编辑器上。 + +引入`@aomao/plugin-codeblock`代码块插件,这个插件部分 UI 使用前端框架渲染,所以有区分。 `vue3`开发者使用 `@aomao/plugin-codeblock-vue` `vue2`开发者使用 `am-editor-codeblock-vue2` + +```tsx | pure +import CodeBlock, { CodeBlockComponent } from '@aomao/plugin-codeblock'; +``` + +将`CodeBlock`插件和`CodeBlockComponent`卡片组件加入引擎 + +```tsx | pure +//实例化引擎 +const engine = new Engine(ref.current, { + plugins: [CodeBlock], + cards: [CodeBlockComponent], +}); +``` + +`CodeBlock`插件默认支持`markdown`,在编辑器一行开头位置输入代码块语法` ```javascript `回车后,看看效果吧 + +```tsx +import React, { useEffect, useRef, useState } from 'react'; +import Engine, { EngineInterface } from '@aomao/engine'; +import CodeBlock, { CodeBlockComponent } from '@aomao/plugin-codeblock'; + +const EngineDemo = () => { + //编辑器容器 + const ref = useRef(null); + //引擎实例 + const [engine, setEngine] = useState(); + //编辑器内容 + const [content, setContent] = useState( + 'Hello word!', + ); + + useEffect(() => { + if (!ref.current) return; + //实例化引擎 + const engine = new Engine(ref.current, { + plugins: [CodeBlock], + cards: [CodeBlockComponent], + }); + //设置编辑器值 + engine.setValue(content); + //监听编辑器值改变事件 + engine.on('change', (value) => { + setContent(value); + console.log(`value:${value}`); + }); + //设置引擎实例 + setEngine(engine); + }, []); + + return
; +}; +export default EngineDemo; +``` + +### 工具栏 + +引入`@aomao/toolbar`工具栏,工具栏 UI 比较复杂,都是借助使用前端框架渲染,`vue3`开发者使用 `@aomao/toolbar-vue` `vue2`开发者使用 `am-editor-toolbar-vue2` + +```tsx | pure +import Toolbar, { ToolbarPlugin, ToolbarComponent } from '@aomao/toolbar'; +``` + +将`ToolbarPlugin`插件和`ToolbarComponent`卡片组件加入引擎,它将让我们在编辑器中可以使用快捷键`/`唤醒出工具栏 + +```tsx | pure +//实例化引擎 +const engine = new Engine(ref.current, { + plugins: [ToolbarPlugin], + cards: [ToolbarComponent], +}); +``` + +渲染工具栏,工具栏已配置好所有插件,这里我们只需要传入插件名称即可 + +```tsx | pure +return ( + ... + { + engine && ( + + ) + } + ... +) +``` + +```tsx +/** + * transform: true + */ +import React, { useEffect, useRef, useState } from 'react'; +import Engine, { EngineInterface } from '@aomao/engine'; +import Bold from '@aomao/plugin-bold'; +import CodeBlock, { CodeBlockComponent } from '@aomao/plugin-codeblock'; +import Toolbar, { ToolbarPlugin, ToolbarComponent } from '@aomao/toolbar'; + +const EngineDemo = () => { + //编辑器容器 + const ref = useRef(null); + //引擎实例 + const [engine, setEngine] = useState(); + //编辑器内容 + const [content, setContent] = useState( + 'Hello word!', + ); + + useEffect(() => { + if (!ref.current) return; + //实例化引擎 + const engine = new Engine(ref.current, { + plugins: [CodeBlock, Bold, ToolbarPlugin], + cards: [CodeBlockComponent, ToolbarComponent], + }); + //设置编辑器值 + engine.setValue(content); + //监听编辑器值改变事件 + engine.on('change', (value) => { + setContent(value); + console.log(`value:${value}`); + }); + //设置引擎实例 + setEngine(engine); + }, []); + + return ( + <> + {engine && } +
+ + ); +}; +export default EngineDemo; +``` + +#### 自己开发工具栏 + +`@aomao/toolbar` 更多的是提供了一个工具栏的 UI 展示,本质是调用 `engine.command.execute` 执行插件命令 + +```tsx +/** + * transform: true + */ +import React, { useEffect, useRef, useState } from 'react'; +import Engine, { EngineInterface } from '@aomao/engine'; +import Bold from '@aomao/plugin-bold'; + +const EngineDemo = () => { + //编辑器容器 + const ref = useRef(null); + //引擎实例 + const [engine, setEngine] = useState(); + //编辑器内容 + const [content, setContent] = useState( + 'Hello word!', + ); + // 按钮状态 + const [btnActive, setBtnActive] = useState(false); + + useEffect(() => { + if (!ref.current) return; + //实例化引擎 + const engine = new Engine(ref.current, { + plugins: [Bold], + }); + //设置编辑器值 + engine.setValue(content); + //监听编辑器值改变事件 + engine.on('change', (value) => { + setContent(value); + console.log(`value:${value}`); + }); + // 监听光标改变 + engine.on('select', () => { + // 查询bold插件的选中状态 + setBtnActive(engine.command.queryState('bold')); + }); + //设置引擎实例 + setEngine(engine); + }, []); + + const handleMouseDown = (event: React.MouseDown) => { + // 点击按钮避免编辑器光标丢失 + event.preventDefault(); + }; + + const handleBoldClick = () => { + // 执行加粗命令 + engine?.command.execute('bold'); + }; + + return ( + <> + {engine && ( + + )} +
+ + ); +}; +export default EngineDemo; +``` + +### 协同编辑 + +协同编辑基于[ShareDB](https://github.com/share/sharedb)实现。每位编辑者作为[客户端](https://github.com/yanmao-cc/am-editor/blob/master/docs/demo/ot-client.ts)通过`WebSocket`与[服务端](https://github.com/yanmao-cc/am-editor/tree/master/ot-server)通信交换数据。编辑器处理数据、渲染数据。 + +我们需要把服务端搭建好,然后配置客户端。[查看完整示例](https://github.com/yanmao-cc/am-editor/blob/master/docs/demo/engine.tsx) + +```tsx | pure +//实例化协作编辑客户端,传入当前编辑器引擎实例 +const otClient = new OTClient(engine); +//连接到协作服务端,uid这里做一个简单的身份验证演示,正常业务中应该需要token等身份验证信息。`demo` 是文档的唯一编号 +otClient.connect( + `ws://127.0.0.1:8080${currentMember ? '?uid=' + currentMember.id : ''}`, + 'demo', +); +``` diff --git a/docs/docs/resources-icon.md b/docs/docs/resources-icon.md new file mode 100644 index 00000000..2c32f3e8 --- /dev/null +++ b/docs/docs/resources-icon.md @@ -0,0 +1,3 @@ +# Use icons + +All the icons of the project are in [Iconfont](https://at.alicdn.com/t/project/1456030/0cbd04d3-3ca1-4898-b345-e0a9150fcc80.html?spm=a313x.7781069.1998910419.35) diff --git a/docs/docs/resources-icon.zh-CN.md b/docs/docs/resources-icon.zh-CN.md new file mode 100644 index 00000000..0752a1a2 --- /dev/null +++ b/docs/docs/resources-icon.zh-CN.md @@ -0,0 +1,3 @@ +# 使用图标 + +项目的所有图标都在[Iconfont](https://at.alicdn.com/t/project/1456030/0cbd04d3-3ca1-4898-b345-e0a9150fcc80.html?spm=a313x.7781069.1998910419.35) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..4d142372 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,8 @@ +--- +title: am-editor multiplayer collaborative editor +sidemenu: false +showFooter: false +mobile: false +--- + + diff --git a/docs/index.zh-CN.md b/docs/index.zh-CN.md new file mode 100644 index 00000000..ec6db26f --- /dev/null +++ b/docs/index.zh-CN.md @@ -0,0 +1,8 @@ +--- +title: am-editor 多人协同编辑器 +sidemenu: false +showFooter: false +mobile: false +--- + + diff --git a/docs/plugin/plugin-alignment.md b/docs/plugin/plugin-alignment.md new file mode 100644 index 00000000..1167c678 --- /dev/null +++ b/docs/plugin/plugin-alignment.md @@ -0,0 +1,64 @@ +# @aomao/plugin-alignment + +Alignment: left, center, right, justified + +## Installation + +```bash +$ yarn add @aomao/plugin-alignment +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Alignment from'@aomao/plugin-alignment'; + +new Engine(...,{ plugins:[Alignment] }) +``` + +## Optional + +### hot key + +The default shortcut key is + +Left alignment: `mod+shift+l` + +Center alignment: `mod+shift+c` + +Right alignment: `mod+shift+r` + +Justification: `mod+shift+j` + +```ts +//hot key +hotkey?: { + left?: string;//Left alignment, default mod+shift+l + center?: string;//Center alignment, default mod+shift+c + right?: string;//Right alignment, default mod+shift+r + justify?: string;//Justify at both ends, default mod+shift+j +}; +//Use configuration +new Engine(...,{ + config:{ + "alignment":{ + //Modify left-aligned shortcut key + hotkey:{ + left: "shortcut key" + } + } + } + }) +``` + +## Command + +Optional parameters of the alignment plugin, `left` | `center` | `right` | `justify`, respectively indicate left-justified, center-justified, right-justified, and justified at both ends + +```ts +//Use command to execute the plug-in and pass in the required parameters +engine.command.execute('alignment', 'left' | 'center' | 'right' | 'justify'); +//Use command to execute the query current status, return string | undefined, the alignment style of the node where the cursor is located "left" | "center" | "right" | "justify" +engine.command.queryState('alignment'); +``` diff --git a/docs/plugin/plugin-alignment.zh-CN.md b/docs/plugin/plugin-alignment.zh-CN.md new file mode 100644 index 00000000..c5ebf4ea --- /dev/null +++ b/docs/plugin/plugin-alignment.zh-CN.md @@ -0,0 +1,64 @@ +# @aomao/plugin-alignment + +对齐方式:左对齐、居中对齐、右对齐、两端对齐 + +## 安装 + +```bash +$ yarn add @aomao/plugin-alignment +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Alignment from '@aomao/plugin-alignment'; + +new Engine(...,{ plugins:[Alignment] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 + +左对齐: `mod+shift+l` + +居中对齐: `mod+shift+c` + +右对齐: `mod+shift+r` + +两端对齐: `mod+shift+j` + +```ts +//快捷键 +hotkey?: { + left?: string;//左对齐,默认 mod+shift+l + center?: string;//居中对齐,默认 mod+shift+c + right?: string;//右对齐,默认 mod+shift+r + justify?: string;//两端对齐,默认 mod+shift+j +}; +//使用配置 +new Engine(...,{ + config:{ + "alignment":{ + //修改 左对齐 快捷键 + hotkey:{ + left:"快捷键" + } + } + } + }) +``` + +## 命令 + +对齐插件可选参数,`left` | `center` | `right` | `justify`,分别表示 左对齐、居中对齐、右对齐、两端对齐 + +```ts +//使用 command 执行插件、并传入所需参数 +engine.command.execute('alignment', 'left' | 'center' | 'right' | 'justify'); +//使用 command 执行查询当前状态,返回 string | undefined,光标所在处节点对齐样式 "left" | "center" | "right" | "justify" +engine.command.queryState('alignment'); +``` diff --git a/docs/plugin/plugin-backcolor.md b/docs/plugin/plugin-backcolor.md new file mode 100644 index 00000000..e00fc691 --- /dev/null +++ b/docs/plugin/plugin-backcolor.md @@ -0,0 +1,51 @@ +# @aomao/plugin-backcolor + +Background color plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-backcolor +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Backcolor from'@aomao/plugin-backcolor'; + +new Engine(...,{ plugins:[Backcolor] }) +``` + +## Optional + +### hot key + +No shortcut keys by default + +```ts +//Shortcut keys, key combination keys, args, execution parameters, [color,defaultColor?], color is required, defaultColor is optional +hotkey?:{key:string,args:Array};//default none + +//Use configuration +new Engine(...,{ + config:{ + "backcolor":{ + //Modify shortcut keys + hotkey:{ + key:"mod+b", + args:["#000000","#ffffff"] + } + } + } + }) +``` + +## Command + +```ts +//color: the changed background color, defaultColor: the default background color to keep, modify the background color when the defaultColor is not passed in or the color is different from the defaultColor value +engine.command.execute('backcolor', color, defaultColor); +//Use command to query the current state, return Array | undefined, the background color value set where the cursor is currently located +engine.command.queryState('backcolor'); +``` diff --git a/docs/plugin/plugin-backcolor.zh-CN.md b/docs/plugin/plugin-backcolor.zh-CN.md new file mode 100644 index 00000000..6bb9d9c6 --- /dev/null +++ b/docs/plugin/plugin-backcolor.zh-CN.md @@ -0,0 +1,51 @@ +# @aomao/plugin-backcolor + +背景颜色插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-backcolor +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Backcolor from '@aomao/plugin-backcolor'; + +new Engine(...,{ plugins:[Backcolor] }) +``` + +## 可选项 + +### 快捷键 + +默认无快捷键 + +```ts +//快捷键,key 组合键,args,执行参数,[color,defaultColor?] , color 必须,defaultColor 可选 +hotkey?:{key:string,args:Array};//默认无 + +//使用配置 +new Engine(...,{ + config:{ + "backcolor":{ + //修改快捷键 + hotkey:{ + key:"mod+b", + args:["#000000","#ffffff"] + } + } + } + }) +``` + +## 命令 + +```ts +//color:更改的背景颜色,defaultColor:保持的默认背景色,在没有传入 defaultColor 或者 color 与 defaultColor 值不同时执行背景色修改 +engine.command.execute('backcolor', color, defaultColor); +//使用 command 执行查询当前状态,返回 Array | undefined,当前光标所在处背景色值集合 +engine.command.queryState('backcolor'); +``` diff --git a/docs/plugin/plugin-bold.md b/docs/plugin/plugin-bold.md new file mode 100644 index 00000000..aff0c041 --- /dev/null +++ b/docs/plugin/plugin-bold.md @@ -0,0 +1,66 @@ +# @aomao/plugin-bold + +Bold style plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-bold +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Bold from'@aomao/plugin-bold'; + +new Engine(...,{ plugins:[Bold] }) +``` + +## Optional + +### hot key + +The default shortcut key is `mod+b`, and multiple shortcut keys are passed in as an array + +```ts +//hot key, +hotkey?: string | Array; + +//Use configuration +new Engine(...,{ + config:{ + "bold":{ + //Modify shortcut keys + hotkey: "shortcut key" + } + } + }) +``` + +### Markdown + +Support markdown by default, pass in `false` to close + +The markdown syntax of the Bold plugin is `**` + +```ts +markdown?: boolean;//enabled by default, false off +//Use configuration +new Engine(...,{ + config:{ + "bold":{ + //Close markdown + markdown:false + } + } + }) +``` + +## Command + +```ts +engine.command.execute('bold'); +//Use command to execute query current status, return boolean | undefined +engine.command.queryState('bold'); +``` diff --git a/docs/plugin/plugin-bold.zh-CN.md b/docs/plugin/plugin-bold.zh-CN.md new file mode 100644 index 00000000..03de5ed7 --- /dev/null +++ b/docs/plugin/plugin-bold.zh-CN.md @@ -0,0 +1,66 @@ +# @aomao/plugin-bold + +加粗样式插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-bold +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Bold from '@aomao/plugin-bold'; + +new Engine(...,{ plugins:[Bold] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+b`,以数组形式传入多个快捷键 + +```ts +//快捷键, +hotkey?: string | Array; + +//使用配置 +new Engine(...,{ + config:{ + "bold":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Bold 插件 markdown 语法为`**` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "bold":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +```ts +engine.command.execute('bold'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('bold'); +``` diff --git a/docs/plugin/plugin-code.md b/docs/plugin/plugin-code.md new file mode 100644 index 00000000..6e10703d --- /dev/null +++ b/docs/plugin/plugin-code.md @@ -0,0 +1,66 @@ +# @aomao/plugin-code + +Inline code style plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-code +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Code from'@aomao/plugin-code'; + +new Engine(...,{ plugins:[Code] }) +``` + +## Optional + +### hot key + +The default shortcut key is `mod+e`, and multiple shortcut keys are passed in as an array + +```ts +//hot key, +hotkey?: string | Array; + +//Use configuration +new Engine(...,{ + config:{ + "code":{ + //Modify shortcut keys + hotkey: "shortcut key" + } + } + }) +``` + +### Markdown + +Support markdown by default, pass in `false` to close + +Code plugin markdown syntax is `` ` + +```ts +markdown?: boolean;//enabled by default, false off +//Use configuration +new Engine(...,{ + config:{ + "code":{ + //Close markdown + markdown:false + } + } + }) +``` + +## Command + +```ts +engine.command.execute('code'); +//Use command to execute query current status, return boolean | undefined +engine.command.queryState('code'); +``` diff --git a/docs/plugin/plugin-code.zh-CN.md b/docs/plugin/plugin-code.zh-CN.md new file mode 100644 index 00000000..22e6a1c1 --- /dev/null +++ b/docs/plugin/plugin-code.zh-CN.md @@ -0,0 +1,66 @@ +# @aomao/plugin-code + +行内代码样式插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-code +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Code from '@aomao/plugin-code'; + +new Engine(...,{ plugins:[Code] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+e`,以数组形式传入多个快捷键 + +```ts +//快捷键, +hotkey?: string | Array; + +//使用配置 +new Engine(...,{ + config:{ + "code":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Code 插件 markdown 语法为`` ` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "code":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +```ts +engine.command.execute('code'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('code'); +``` diff --git a/docs/plugin/plugin-codelock.md b/docs/plugin/plugin-codelock.md new file mode 100644 index 00000000..ed79bcdd --- /dev/null +++ b/docs/plugin/plugin-codelock.md @@ -0,0 +1,80 @@ +# @aomao/plugin-codeblock + +Code block plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-codeblock +``` + +`Vue3` use + +```bash +$ yarn add @aomao/plugin-codeblock-vue +``` + +`Vue2` use + +```bash +$ yarn add am-editor-codeblock-vue2 +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import CodeBlock, {CodeBlockComponent} from'@aomao/plugin-codeblock'; + +new Engine(...,{ plugins:[CodeBlock], cards:[CodeBlockComponent]}) +``` + +## Optional + +### hot key + +No shortcut keys by default + +```ts +//Shortcut keys, key combination keys, args, execution parameters, [mode?: string, value?: string] Language mode: optional, code text: optional +hotkey?:string | {key:string,args:Array};//default none + +//Use configuration +new Engine(...,{ + config:{ + "codeblock":{ + //Modify shortcut keys + hotkey:{ + key:"mod+b", + args:["javascript","const test = 123;"] + } + } + } + }) +``` + +### Markdown + +Support markdown by default, pass in `false` to close + +CodeBlock plugin markdown syntax is ``` + +```ts +markdown?: boolean;//enabled by default, false off +//Use configuration +new Engine(...,{ + config:{ + "codeblock":{ + //Close markdown + markdown:false + } + } + }) +``` + +## Command + +```ts +//Can carry two parameters, language type, default text, all are optional +engine.command.execute('codeblock', 'javascript', 'const test = 123;'); +``` diff --git a/docs/plugin/plugin-codelock.zh-CN.md b/docs/plugin/plugin-codelock.zh-CN.md new file mode 100644 index 00000000..b839f827 --- /dev/null +++ b/docs/plugin/plugin-codelock.zh-CN.md @@ -0,0 +1,80 @@ +# @aomao/plugin-codeblock + +代码块插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-codeblock +``` + +`Vue3` 使用 + +```bash +$ yarn add @aomao/plugin-codeblock-vue +``` + +`Vue2` 使用 + +```bash +$ yarn add am-editor-codeblock-vue2 +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import CodeBlock , { CodeBlockComponent } from '@aomao/plugin-codeblock'; + +new Engine(...,{ plugins:[CodeBlock] , cards:[CodeBlockComponent]}) +``` + +## 可选项 + +### 快捷键 + +默认无快捷键 + +```ts +//快捷键,key 组合键,args,执行参数,[mode?: string, value?: string] 语言模式:可选,代码文本:可选 +hotkey?:string | {key:string,args:Array};//默认无 + +//使用配置 +new Engine(...,{ + config:{ + "codeblock":{ + //修改快捷键 + hotkey:{ + key:"mod+b", + args:["javascript","const test = 123;"] + } + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +CodeBlock 插件 markdown 语法为` ``` ` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "codeblock":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +```ts +//可携带两个参数,语言类型,默认文本,都是可选的 +engine.command.execute('codeblock', 'javascript', 'const test = 123;'); +``` diff --git a/docs/plugin/plugin-file.md b/docs/plugin/plugin-file.md new file mode 100644 index 00000000..4b70c376 --- /dev/null +++ b/docs/plugin/plugin-file.md @@ -0,0 +1,163 @@ +# @aomao/plugin-file + +File plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-file +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import File, {FileComponent, FileUploader} from'@aomao/plugin-file'; + +new Engine(...,{ plugins:[ File, FileUploader], cards:[ FileComponent ]}) +``` + +`FileUploader` plug-in main functions: select files, upload files + +## `File` optional + +`onBeforeRender` can modify the address when previewing nearby or downloading attachments + +```ts +onBeforeRender?: (action:'download' |'preview', url: string) => string; +``` + +## `FileUploader` optional + +```ts +//Use configuration +new Engine(...,{ + config:{ + [FileUploader.pluginName]:{ + //...Related configuration + } + } + }) +``` + +### File Upload + +`action`: upload address, always use `POST` request + +`crossOrigin`: Whether to cross-origin + +`headers`: request header + +`contentType`: File upload is uploaded in `multipart/form-data;` type by default + +`accept`: Restrict the file types selected by the user's file selection box, default `*` all + +`limitSize`: Limit the file size selected by the user. If the file size exceeds the limit, no upload will be requested. Default: `1024 * 1024 * 5` 5M + +`multiple`: `false` can only upload one file at a time, `true` defaults to a maximum of 100 files at a time. You can specify the specific number, but the file selection box cannot be limited, only the first number of uploads can be limited when uploading + +`data`: POST these data together to the server when the file is uploaded + +`name`: When file upload request, the name of the request parameter in `FormData`, the default is `file` + +```ts +/** + * File upload address + */ +action:string +/** + * Whether cross-domain + */ +crossOrigin?: boolean; +/** +* Request header +*/ +headers?: {[key: string]: string} | (() => {[key: string]: string }); +/** + * Data return type, default json + */ +type?:'*' |'json' |'xml' |'html' |'text' |'js'; +/** + * The name of the FormData when the file is uploaded, the default is file + */ +name?: string +/** + * Additional data upload + */ +data?: {}; +/** + * Request type, default multipart/form-data; + */ +contentType?:string +/** + * The format of file reception, default "*" all + */ +accept?: string | Array; +/** + * File selection limit + */ +multiple?: boolean | number; +/** + * Upload size limit, default 1024 * 1024 * 5 is 5M + */ +limitSize?: number; + +``` + +### Analyze server response data + +Will find by default + +File address: response.url || response.data && response.data.url +Preview address: response.preview || response.data && response.data.preview After the back-end conversion, you can preview some complex files, and return the address if available +Download address: response.download || response.data && response.data.download The download address of the file, you can add permissions, time restrictions, etc., if you have one, you can return the address + +`result`: true upload is successful, data is the file address. false upload failed, data is an error message + +```ts +/** + * Parse the uploaded Respone and return result: whether it is successful or not, data: success: file address, failure: error message + */ +parse?: ( + response: any, +) => { + result: boolean; + data: string; +}; +``` + +## Command + +### `File` plugin command + +Insert a file + +Parameter 1: File status `uploading` | `done` | `error` uploading, uploading completed, uploading error + +Parameter 2: When the status is not `error`, the file is displayed, otherwise an error message is displayed + +```ts +//'uploading' |'done' |'error' +engine.command.execute( + File.pluginName, + 'done', + 'File address', + 'File name', //optional + 'File size', //optional + 'Preview address', //optional + 'Download address', //optional +); +``` + +### `FileUploader` plugin command + +Pop up the file selection box and perform upload + +Optional parameter 1: Pass in the file list, these files will be uploaded. Otherwise, the file selection box will pop up and upload it after selecting the file + +```ts +//Method signature +async execute(files?: Array | MouseEvent):void +//Excuting an order +engine.command.execute(FileUploader.pluginName); +``` diff --git a/docs/plugin/plugin-file.zh-CN.md b/docs/plugin/plugin-file.zh-CN.md new file mode 100644 index 00000000..98e6079f --- /dev/null +++ b/docs/plugin/plugin-file.zh-CN.md @@ -0,0 +1,163 @@ +# @aomao/plugin-file + +文件插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-file +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import File , { FileComponent , FileUploader } from '@aomao/plugin-file'; + +new Engine(...,{ plugins:[ File , FileUploader ] , cards:[ FileComponent ]}) +``` + +`FileUploader` 插件主要功能:选择文件、上传文件 + +## `File` 可选项 + +`onBeforeRender` 预览附近或者下载附件时可对地址修改 + +```ts +onBeforeRender?: (action: 'download' | 'preview', url: string) => string; +``` + +## `FileUploader` 可选项 + +```ts +//使用配置 +new Engine(...,{ + config:{ + [FileUploader.pluginName]:{ + //...相关配置 + } + } + }) +``` + +### 文件上传 + +`action`: 上传地址,始终使用 `POST` 请求 + +`crossOrigin`: 是否跨域 + +`headers`: 请求头 + +`contentType`: 文件上传默认以 `multipart/form-data;` 类型上传 + +`accept`: 限制用户文件选择框选择的文件类型,默认 `*` 所有的 + +`limitSize`: 限制用户选择的文件大小,超过限制将不请求上传。默认:`1024 * 1024 * 5` 5M + +`multiple`: `false` 一次只能上传一个文件,`true` 默认一次最多 100 个文件。可以指定具体数量,但是文件选择框无法限制,只能上传的时候限制上传最前面的张数 + +`data`: 文件上传时同时将这些数据一起`POST`到服务端 + +`name`: 文件上传请求时,请求参数在 `FormData` 中的名称,默认 `file` + +```ts +/** + * 文件上传地址 + */ +action:string +/** + * 是否跨域 + */ +crossOrigin?: boolean; +/** +* 请求头 +*/ +headers?: { [key: string]: string } | (() => { [key: string]: string }); +/** + * 数据返回类型,默认 json + */ +type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js'; +/** + * 文件上传时 FormData 的名称,默认 file + */ +name?: string +/** + * 额外携带数据上传 + */ +data?: {}; +/** + * 请求类型,默认 multipart/form-data; + */ +contentType?:string +/** + * 文件接收的格式,默认 "*" 所有的 + */ +accept?: string | Array; +/** + * 文件选择限制数量 + */ +multiple?: boolean | number; +/** + * 上传大小限制,默认 1024 * 1024 * 5 就是5M + */ +limitSize?: number; + +``` + +### 解析服务端响应数据 + +默认会查找 + +文件地址:response.url || response.data && response.data.url +预览地址:response.preview || response.data && response.data.preview 后端转换后可以预览一些复杂的文件,如果有可以返回地址 +下载地址:response.download || response.data && response.data.download 文件的下载地址,可以加权限、时间限制等等,如果有可以返回地址 + +`result`: true 上传成功,data 为文件地址。false 上传失败,data 为错误消息 + +```ts +/** + * 解析上传后的Respone,返回 result:是否成功,data:成功:文件地址,失败:错误信息 + */ +parse?: ( + response: any, +) => { + result: boolean; + data: string; +}; +``` + +## 命令 + +### `File` 插件命令 + +插入一个文件 + +参数 1:文件状态`uploading` | `done` | `error` 上传中、上传完成、上传错误 + +参数 2:在状态非 `error` 下,为展示文件,否则展示错误消息 + +```ts +//'uploading' | 'done' | 'error' +engine.command.execute( + File.pluginName, + 'done', + '文件地址', + '文件名称', //可选、默认为url地址 + '文件大小', //可选 + '预览地址', //可选 + '下载地址', //可选 +); +``` + +### `FileUploader` 插件命令 + +弹出文件选择框,并执行上传 + +可选参数 1:传入文件列表,将上传这些文件。否则弹出文件选择框并,选择文件后执行上传 + +```ts +//方法签名 +async execute(files?: Array | MouseEvent):void +//执行命令 +engine.command.execute(FileUploader.pluginName); +``` diff --git a/docs/plugin/plugin-fontcolor.md b/docs/plugin/plugin-fontcolor.md new file mode 100644 index 00000000..72aa5906 --- /dev/null +++ b/docs/plugin/plugin-fontcolor.md @@ -0,0 +1,51 @@ +# @aomao/plugin-fontcolor + +Foreground plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-fontcolor +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Fontcolor from'@aomao/plugin-fontcolor'; + +new Engine(...,{ plugins:[Fontcolor] }) +``` + +## Optional + +### hot key + +No shortcut keys by default + +```ts +//Shortcut keys, key combination keys, args, execution parameters, [color,defaultColor?], color is required, defaultColor is optional +hotkey?:{key:string,args:Array};//default none + +//Use configuration +new Engine(...,{ + config:{ + "fontcolor":{ + //Modify shortcut keys + hotkey:{ + key:"mod+b", + args:["#000000","#ffffff"] + } + } + } + }) +``` + +## Command + +```ts +//color: the changed foreground color, defaultColor: the default foreground color to be maintained, the foreground color modification is performed when the defaultColor is not passed in or the color is different from the defaultColor value +engine.command.execute('fontcolor', color, defaultColor); +//Use command to query the current state, return Array | undefined, the foreground color value set where the cursor is currently located +engine.command.queryState('fontcolor'); +``` diff --git a/docs/plugin/plugin-fontcolor.zh-CN.md b/docs/plugin/plugin-fontcolor.zh-CN.md new file mode 100644 index 00000000..6d6acbac --- /dev/null +++ b/docs/plugin/plugin-fontcolor.zh-CN.md @@ -0,0 +1,51 @@ +# @aomao/plugin-fontcolor + +前景色插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-fontcolor +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Fontcolor from '@aomao/plugin-fontcolor'; + +new Engine(...,{ plugins:[Fontcolor] }) +``` + +## 可选项 + +### 快捷键 + +默认无快捷键 + +```ts +//快捷键,key 组合键,args,执行参数,[color,defaultColor?] , color 必须,defaultColor 可选 +hotkey?:{key:string,args:Array};//默认无 + +//使用配置 +new Engine(...,{ + config:{ + "fontcolor":{ + //修改快捷键 + hotkey:{ + key:"mod+b", + args:["#000000","#ffffff"] + } + } + } + }) +``` + +## 命令 + +```ts +//color:更改的前景颜色,defaultColor:保持的默认前景色,在没有传入 defaultColor 或者 color 与 defaultColor 值不同时执行前景色修改 +engine.command.execute('fontcolor', color, defaultColor); +//使用 command 执行查询当前状态,返回 Array | undefined,当前光标所在处前景色值集合 +engine.command.queryState('fontcolor'); +``` diff --git a/docs/plugin/plugin-fontfamily.md b/docs/plugin/plugin-fontfamily.md new file mode 100644 index 00000000..076db885 --- /dev/null +++ b/docs/plugin/plugin-fontfamily.md @@ -0,0 +1,258 @@ +# @aomao/plugin-fontfamily + +Font plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-fontfamily +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Fontfamily from'@aomao/plugin-fontfamily'; + +new Engine(...,{ plugins:[Fontfamily] }) +``` + +## Optional + +### Paste and filter custom fonts + +Supports filtering of fonts that do not conform to the definition + +```ts +/** + * @param fontFamily current font + * @returns returns string to modify the current value, false is removed, true is retained + * */ +filter?: (fontFamily: string) => string | boolean +//Configuration +new Engine(...,{ + config:{ + [Fontfamily.pluginName]: { + //Configure the font to be filtered after pasting + filter: (fontfamily: string) => { + // fontFamilyDefaultData The default font data exported from the toolbar package + const item = fontFamilyDefaultData.find(item => fontfamily.split(",").some(name => item.value.toLowerCase().indexOf(name.replace(/"/,"").toLowerCase()) > -1)) + return item? item.value: false + } + } + } +} +``` + +### hot key + +No shortcut keys by default + +```ts +//Shortcut keys, key combination keys, args, execution parameters, [font], font must +hotkey?:{key:string,args:Array};//default none + +//Use configuration +new Engine(...,{ + config:{ + "fontfamily":{ + //Modify shortcut keys + hotkey:{ + key:"mod+b", + args:["Microsoft Yahei"] + } + } + } + }) +``` + +### Custom font + +Part of the font list is built in the toolbar, which can be obtained by the following methods + +```ts +import { fontFamilyDefaultData, fontfamily } from '@aomao/toolbar'; // or @aomao/toolbar-vue +``` + +#### `fontfamily` Convert available drop-down list data + +```ts +/** + * Generate font drop-down list items + * @param data key-value key-value pair data, key name, if there is a language, it is the key of the language key-value pair, otherwise it will be displayed directly + * @param language language, optional + */ +fontfamily( +data: Array<{ key: string; value: string }>, language?: {[key: string]: string }): Array +``` + +#### `fontFamilyDefaultData` Default font list + +```ts +[ + { + key: 'default', + value: '', + }, + { + key: 'arial', + value: 'Arial', + }, + { + key: 'comicSansMS', + value: '"Comic Sans MS"', + }, + { + key: 'courierNew', + value: '"Courier New"', + }, + { + key: 'georgia', + value: 'Georgia', + }, + { + key: 'helvetica', + value: 'Helvetica', + }, + { + key: 'impact', + value: 'Impact', + }, + { + key: 'timesNewRoman', + value: '"Times New Roman"', + }, + { + key: 'trebuchetMS', + value: '"Trebuchet MS"', + }, + { + key: 'verdana', + value: 'Verdana', + }, + { + key: 'fangSong', + value: 'FangSong, Imitation Song, FZFangSong-Z02S, STFangsong, fangsong', + }, + { + key: 'stFangsong', + value: 'STFangsong, Chinese imitation Song, FangSong, FZFangSong-Z02S, fangsong', + }, + { + key: 'stSong', + value: 'STSong, Chinese Song Ti, SimSun, "Songti SC", NSimSun, serif', + }, + { + key: 'stKaiti', + value: 'STKaiti, KaiTi, KaiTi, "Kaiti SC", cursive', + }, + { + key: 'simSun', + value: 'SimSun, Song Ti, "Songti SC", NSimSun, STSong, serif', + }, + { + key: 'microsoftYaHei', + value: '"Microsoft YaHei", Microsoft YaHei, "PingFang SC", SimHei, STHeiti, sans-serif;', + }, + { + key: 'kaiTi', + value: 'KaiTi, Kaiti, STKaiti, "Kaiti SC", cursive', + }, + { + key: 'kaitiSC', + value: '"Kaiti SC"', + }, + { + key: 'simHei', + value: 'SimHei, boldface, "Microsoft YaHei", "PingFang SC", STHeiti, sans-serif', + }, + { + key: 'heitiSC', + value: '"Heiti SC"', + }, + { + key: 'fzHei', + value: 'FZHei-B01S', + }, + { + key: 'fzKai', + value: 'FZKai-Z03S', + }, + { + key: 'fzFangSong', + value: 'FZFangSong-Z02S', + }, +]; +``` + +We can organize the data according to the default data format, and then use the `fontfamily` method to generate the data needed for the drop-down list, and finally overwrite the configuration of the toolbar + +```ts +items: [ + ['collapse'], + [ + { + name: 'fontfamily', + items: fontfamily(fontFamilyDefaultData), + }, + ], +]; +``` + +## Command + +```ts +//font: changed font +engine.command.execute('fontfamily', color); +//Use command to query the current state, return Array | undefined, the font value collection where the cursor is currently located +engine.command.queryState('fontfamily'); +``` + +## Other + +Whether the font is available is judged by setting different fonts on the HTML tags, and then detecting the change in the width of the HTML tags and comparing them with the default font + +```ts +/** + * Whether to support fonts + * @param font font name + * @returns + */ +export const isSupportFontFamily = (font: string) => { + if (typeof font !== 'string') { + console.log('Font name is not legal !'); + return false; + } + + let width; + const body = document.body; + + const container = document.createElement('span'); + container.innerHTML = Array(10).join('wi'); + container.style.cssText = [ + 'position:absolute', + 'width:auto', + 'font-size:128px', + 'left:-99999px', + ].join(' !important;'); + + const getWidth = (fontFamily: string) => { + container.style.fontFamily = fontFamily; + body.appendChild(container); + width = container.clientWidth; + body.removeChild(container); + + return width; + }; + + const monoWidth = getWidth('monospace'); + const serifWidth = getWidth('serif'); + const sansWidth = getWidth('sans-serif'); + + return ( + monoWidth !== getWidth(font + ',monospace') || + sansWidth !== getWidth(font + ',sans-serif') || + serifWidth !== getWidth(font + ',serif') + ); +}; +``` diff --git a/docs/plugin/plugin-fontfamily.zh-CN.md b/docs/plugin/plugin-fontfamily.zh-CN.md new file mode 100644 index 00000000..60cc2c0b --- /dev/null +++ b/docs/plugin/plugin-fontfamily.zh-CN.md @@ -0,0 +1,260 @@ +# @aomao/plugin-fontfamily + +字体插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-fontfamily +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Fontfamily from '@aomao/plugin-fontfamily'; + +new Engine(...,{ plugins:[Fontfamily] }) +``` + +## 可选项 + +### 粘贴过滤自定义字体 + +支持过滤不符合自定义的字体 + +```ts +/** + * @param fontFamily 当前字体 + * @returns 返回 string 修改当前值,false 移除,true 保留 + * */ +filter?: (fontFamily: string) => string | boolean +//配置 +new Engine(...,{ + config:{ + [Fontfamily.pluginName]: { + //配置粘贴后需要过滤的字体 + filter: (fontfamily: string) => { + // fontFamilyDefaultData 从toolbar包中导出的默认字体数据 + const item = fontFamilyDefaultData.find(item => fontfamily.split(",").some(name => item.value.toLowerCase().indexOf(name.replace(/"/,"").toLowerCase()) > -1)) + return item ? item.value : false + } + } + } +} +``` + +### 快捷键 + +默认无快捷键 + +```ts +//快捷键,key 组合键,args,执行参数,[font] , font 必须 +hotkey?:{key:string,args:Array};//默认无 + +//使用配置 +new Engine(...,{ + config:{ + "fontfamily":{ + //修改快捷键 + hotkey:{ + key:"mod+b", + args:["微软雅黑"] + } + } + } + }) +``` + +### 自定义字体 + +工具栏中内置了部分字体列表,可以通过以下方法获取 + +```ts +import { fontFamilyDefaultData, fontfamily } from '@aomao/toolbar'; // 或 @aomao/toolbar-vue +``` + +#### `fontfamily` 转换可用的下拉列表数据 + +```ts +/** + * 生成字体下拉列表项 + * @param data key-value 键值对数据,key 名称,如果有传语言则是语言键值对的key否则就直接显示 + * @param language 语言,可选 + */ +fontfamily( + data: Array<{ key: string; value: string }>, + language?: { [key: string]: string }, +): Array +``` + +#### `fontFamilyDefaultData` 默认字体列表 + +```ts +[ + { + key: 'default', + value: '', + }, + { + key: 'arial', + value: 'Arial', + }, + { + key: 'comicSansMS', + value: '"Comic Sans MS"', + }, + { + key: 'courierNew', + value: '"Courier New"', + }, + { + key: 'georgia', + value: 'Georgia', + }, + { + key: 'helvetica', + value: 'Helvetica', + }, + { + key: 'impact', + value: 'Impact', + }, + { + key: 'timesNewRoman', + value: '"Times New Roman"', + }, + { + key: 'trebuchetMS', + value: '"Trebuchet MS"', + }, + { + key: 'verdana', + value: 'Verdana', + }, + { + key: 'fangSong', + value: 'FangSong, 仿宋, FZFangSong-Z02S, STFangsong, fangsong', + }, + { + key: 'stFangsong', + value: 'STFangsong, 华文仿宋, FangSong, FZFangSong-Z02S, fangsong', + }, + { + key: 'stSong', + value: 'STSong, 华文宋体, SimSun, "Songti SC", NSimSun, serif', + }, + { + key: 'stKaiti', + value: 'STKaiti, 华文楷体, KaiTi, "Kaiti SC", cursive', + }, + { + key: 'simSun', + value: 'SimSun, 宋体, "Songti SC", NSimSun, STSong, serif', + }, + { + key: 'microsoftYaHei', + value: '"Microsoft YaHei", 微软雅黑, "PingFang SC", SimHei, STHeiti, sans-serif;', + }, + { + key: 'kaiTi', + value: 'KaiTi, 楷体, STKaiti, "Kaiti SC", cursive', + }, + { + key: 'kaitiSC', + value: '"Kaiti SC"', + }, + { + key: 'simHei', + value: 'SimHei, 黑体, "Microsoft YaHei", "PingFang SC", STHeiti, sans-serif', + }, + { + key: 'heitiSC', + value: '"Heiti SC"', + }, + { + key: 'fzHei', + value: 'FZHei-B01S', + }, + { + key: 'fzKai', + value: 'FZKai-Z03S', + }, + { + key: 'fzFangSong', + value: 'FZFangSong-Z02S', + }, +]; +``` + +我们可以按照默认数据的格式整理好数据,然后使用 `fontfamily` 方法生成下拉列表所需要的数据,最后覆盖工具栏的配置 + +```ts +items: [ + ['collapse'], + [ + { + name: 'fontfamily', + items: fontfamily(fontFamilyDefaultData), + }, + ], +]; +``` + +## 命令 + +```ts +//font:更改的字体 +engine.command.execute('fontfamily', color); +//使用 command 执行查询当前状态,返回 Array | undefined,当前光标所在处字体值集合 +engine.command.queryState('fontfamily'); +``` + +## 其它 + +字体是否可用,是通过设置不同字体到 HTML 标签上,然后检测 HTML 标签的宽度变化与默认字体对比来判断的 + +```ts +/** + * 是否支持字体 + * @param font 字体名称 + * @returns + */ +export const isSupportFontFamily = (font: string) => { + if (typeof font !== 'string') { + console.log('Font name is not legal !'); + return false; + } + + let width; + const body = document.body; + + const container = document.createElement('span'); + container.innerHTML = Array(10).join('wi'); + container.style.cssText = [ + 'position:absolute', + 'width:auto', + 'font-size:128px', + 'left:-99999px', + ].join(' !important;'); + + const getWidth = (fontFamily: string) => { + container.style.fontFamily = fontFamily; + body.appendChild(container); + width = container.clientWidth; + body.removeChild(container); + + return width; + }; + + const monoWidth = getWidth('monospace'); + const serifWidth = getWidth('serif'); + const sansWidth = getWidth('sans-serif'); + + return ( + monoWidth !== getWidth(font + ',monospace') || + sansWidth !== getWidth(font + ',sans-serif') || + serifWidth !== getWidth(font + ',serif') + ); +}; +``` diff --git a/docs/plugin/plugin-fontsize.md b/docs/plugin/plugin-fontsize.md new file mode 100644 index 00000000..cd2a80c0 --- /dev/null +++ b/docs/plugin/plugin-fontsize.md @@ -0,0 +1,80 @@ +# @aomao/plugin-fontsize + +Font size plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-fontsize +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Fontsize from'@aomao/plugin-fontsize'; + +new Engine(...,{ plugins:[Fontsize] }) +``` + +## Optional + +### Paste filter custom font size + +Supports filtering of font sizes that do not meet the custom + +```ts +/** + * @param fontSize current font size + * @returns returns string to modify the current value, false is removed, true is retained + * */ +filter?: (fontSize: string) => string | boolean +//Configuration +new Engine(...,{ + config:{ + [Fontsize.pluginName]: { + //Configure the font size to be filtered after pasting + filter: (fontSize: string) => { + return ["12px","13px","14px","15px","16px","19px","22px","24px","29px","32px","40px","48px"] .indexOf(fontSize)> -1 + } + } + } +} +``` + +### Default font size + +```ts +defaultSize?:string //The default is 14px +``` + +### hot key + +No shortcut keys by default + +```ts +//Shortcut keys, key combination keys, args, execution parameters, [size,defaultSize?], size is required, defaultSize is optional +hotkey?:{key:string,args:Array};//default none + +//Use configuration +new Engine(...,{ + config:{ + "fontsize":{ + //Modify shortcut keys + hotkey:{ + key:"mod+b", + args:["12px","14px"] + } + } + } + }) +``` + +## Command + +```ts +//size: the font size to be changed, defaultSize: the default font size to be maintained, the foreground color modification is performed when the defaultSize is not passed in or the size is different from the defaultSize value +engine.command.execute('fontsize', size, defaultSize); +//Use command to query the current state, return Array | undefined, the font size value collection where the cursor is currently located +engine.command.queryState('fontsize'); +``` diff --git a/docs/plugin/plugin-fontsize.zh-CN.md b/docs/plugin/plugin-fontsize.zh-CN.md new file mode 100644 index 00000000..9e5284bb --- /dev/null +++ b/docs/plugin/plugin-fontsize.zh-CN.md @@ -0,0 +1,80 @@ +# @aomao/plugin-fontsize + +字体大小插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-fontsize +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Fontsize from '@aomao/plugin-fontsize'; + +new Engine(...,{ plugins:[Fontsize] }) +``` + +## 可选项 + +### 粘贴过滤自定义字体大小 + +支持过滤不符合自定义的字体大小 + +```ts +/** + * @param fontSize 当前字体大小 + * @returns 返回 string 修改当前值,false 移除,true 保留 + * */ +filter?: (fontSize: string) => string | boolean +//配置 +new Engine(...,{ + config:{ + [Fontsize.pluginName]: { + //配置粘贴后需要过滤的字体大小 + filter: (fontSize: string) => { + return ["12px","13px","14px","15px","16px","19px","22px","24px","29px","32px","40px","48px"].indexOf(fontSize) > -1 + } + } + } +} +``` + +### 默认字体大小 + +```ts +defaultSize?:string //默认为14px +``` + +### 快捷键 + +默认无快捷键 + +```ts +//快捷键,key 组合键,args,执行参数,[size,defaultSize?] , size 必须,defaultSize 可选 +hotkey?:{key:string,args:Array};//默认无 + +//使用配置 +new Engine(...,{ + config:{ + "fontsize":{ + //修改快捷键 + hotkey:{ + key:"mod+b", + args:["12px","14px"] + } + } + } + }) +``` + +## 命令 + +```ts +//size:更改的字体大小,defaultSize:保持的默认字体大小,在没有传入 defaultSize 或者 size 与 defaultSize 值不同时执行前景色修改 +engine.command.execute('fontsize', size, defaultSize); +//使用 command 执行查询当前状态,返回 Array | undefined,当前光标所在处字体大小值集合 +engine.command.queryState('fontsize'); +``` diff --git a/docs/plugin/plugin-heading.md b/docs/plugin/plugin-heading.md new file mode 100644 index 00000000..ebbf873f --- /dev/null +++ b/docs/plugin/plugin-heading.md @@ -0,0 +1,253 @@ +# @aomao/plugin-heading + +Heading style plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-heading +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Heading from'@aomao/plugin-heading'; + +new Engine(...,{ plugins:[Heading] }) +``` + +## Optional + +### Anchor + +A copyable anchor button appears on the left side of the title after it is turned on + +```ts +showAnchor?: boolean; +``` + +Triggered when the copy anchor is clicked, the id value of the current title is passed in, the returned content will be written to the user's pasteboard, and the current url+id will be returned by default + +```ts +anchorCopy?:(id:string) => string +``` + +### hot key + +```ts +//hot key +hotkey?: { + h1?: string;//Title 1, default mod+opt+1 + h2?: string;//Title 2, default mod+opt+2 + h3?: string;//Title 3, default mod+opt+3 + h4?: string;//Title 4, default mod+opt+4 + h5?: string;//Title 5, default mod+opt+5 + h6?: string;//Title 6, default mod+opt+6 +}; +//Use configuration +new Engine(...,{ + config:{ + "heading":{ + //Modify shortcut keys + hotkey:{ + h1: "shortcut key" + } + } + } + }) +``` + +### Markdown + +Support markdown by default, pass in `false` to close + +The heading plugin markdown syntax is `#` `##` `###` `####` `#####` `######` + +```ts +markdown?: boolean;//enabled by default, false off +//Use configuration +new Engine(...,{ + config:{ + "heading":{ + //Close markdown + markdown:false + } + } + }) +``` + +### Disable mark plugin style effect + +You can disable the mark plug-in effect under the title, ['fontsize','bold'] is disabled by default, and filter out these plug-in styles in the case of splitting, pasting, etc. + +```ts +disableMark?: Array //mark plugin name collection +``` + +### Type to be enabled (h1 h2 h3 h4 h5 h6) + +Can define the node types required by h1-h6, if not defined, all are supported + +Markdown will also be invalid after setting + +```ts +enableTypes?: Array +``` + +In addition, you may also need to configure the heading plugin of the items attribute in the toolbar + +```ts +{ + type:'dropdown', + name:'heading', + items: [ + { + key: "p", + className:'heading-item-p', + content: "Body" + }, + { + key: "h1", + className:'heading-item-h1', + content: "Title 1" + } + ] + } +``` + +## Command + +When `p` is passed in or the current heading style is consistent with the current passed value, the heading will be canceled + +```ts +//Use command to execute the plug-in and pass in the required parameters +engine.command.execute( + 'heading', + 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p', +); +//Use command to execute query current status, return string | undefined, return "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" +engine.command.queryState('heading'); +``` + +## Outline + +Generate headline outline data + +Need to import the `Outline` class from `@aomao/plugin-heading` + +```ts +import { Outline } from '@aomao/plugin-heading'; +``` + +### `normalize` + +Normalize the title node data into a structure with depth levels + +```ts +/** + * Normalize heading data into a structure with depth levels + * After normalization, the structure of each element is: + * { + * id: string, // id + * title: string, // title + * level: number, // Title level + * domNode: Node, // dom node + * depth: number // display depth + *} + * The depth algorithm is consistent with that of Google Docs + *-Effect: h1 -> h4 assign a fixed depth to ensure that the final level depth of the title of the same level is the same + *-Algorithm: Find out the title level of the document; assign the indentation depth in descending order of level; + * @param {Element[]}headings heading standard DOM node array + * + * @return {Array} title node array + */ +normalize(headings: Array): OutlineData[]; +``` + +### `getFromDom` + +Extract outline from DOM node + +```ts +/** + * Extract outline from DOM node + * @param {Element} rootNode root node + * @return {Array} + */ +getFromDom(rootNode: Element): OutlineData[]; +``` + +### Examples + +### 例子 + +```ts +import React, { useRef, useEffect, useState, useCallback } from 'react'; +import { $, EditorInterface } from '@aomao/engine'; +import { Outline, OutlineData } from '@aomao/plugin-heading'; + +type Props = { + editor: EditorInterface; +}; + +const outline = new Outline(); + +const Toc: React.FC = ({ editor }) => { + const rootRef = useRef(null); + const [datas, setDatas] = useState>([]); + + useEffect(() => { + const onChange = () => { + //Get outline data + const data = getTocData(); + setDatas(data); + }; + //Binding editor value change event + editor.on('change', onChange); + setTimeout(() => { + onChange(); + }, 50); + return () => editor.off('change', onChange); + }, [editor]); + + const getTocData = useCallback(() => { + // Extract the title Dom node that meets the structural requirements + let nodes: Array = []; + const { card } = editor; + editor.container.find('h1,h2,h3,h4,h5,h6').each((child) => { + const node = $(child); + // The title in the Card is not included in the outline + if (card.closest(node)) { + return; + } + // Non-first-level in-depth titles, not included in the outline + if (!node.parent()?.isRoot()) { + return; + } + nodes.push(node.get()!); + }); + return outline.normalize(nodes); + }, []); + + return ( +
+
大纲
+
+ {datas.map((data, index) => { + return ( + + {data.text} + + ); + })} +
+
+ ); +}; +export default Toc; +``` diff --git a/docs/plugin/plugin-heading.zh-CN.md b/docs/plugin/plugin-heading.zh-CN.md new file mode 100644 index 00000000..67bc107b --- /dev/null +++ b/docs/plugin/plugin-heading.zh-CN.md @@ -0,0 +1,251 @@ +# @aomao/plugin-heading + +标题样式插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-heading +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Heading from '@aomao/plugin-heading'; + +new Engine(...,{ plugins:[Heading] }) +``` + +## 可选项 + +### 锚点 + +开启后在标题左边出现可复制锚点按钮 + +```ts +showAnchor?: boolean; +``` + +当点击复制锚点的时候触发,传入当前标题的 id 值,返回的内容将写入到用户的粘贴板上,默认将返回当前 url+id + +```ts +anchorCopy?:(id:string) => string +``` + +### 快捷键 + +```ts +//快捷键 +hotkey?: { + h1?: string;//标题1,默认 mod+opt+1 + h2?: string;//标题2,默认 mod+opt+2 + h3?: string;//标题3,默认 mod+opt+3 + h4?: string;//标题4,默认 mod+opt+4 + h5?: string;//标题5,默认 mod+opt+5 + h6?: string;//标题6,默认 mod+opt+6 +}; +//使用配置 +new Engine(...,{ + config:{ + "heading":{ + //修改快捷键 + hotkey:{ + h1:"快捷键" + } + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Heading 插件 markdown 语法为`#` `##` `###` `####` `#####` `######` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "heading":{ + //关闭markdown + markdown:false + } + } + }) +``` + +### 禁用 mark 插件样式效果 + +可以在标题下禁用 mark 插件效果,默认禁用 ['fontsize', 'bold'] ,在分割、粘贴等情况下过滤掉这些插件样式 + +```ts +disableMark?: Array //mark插件名称集合 +``` + +### 要启用的类型(h1 h2 h3 h4 h5 h6) + +可以定义 h1 - h6 所需要的节点类型,如果不定义则支持全部 + +设置后 markdown 也会失效 + +```ts +enableTypes?: Array +``` + +另外可能还需要配置 toolbar 中 items 属性的 heading 插件 + +```ts +{ + type: 'dropdown', + name: 'heading', + items: [ + { + key: "p", + className: 'heading-item-p', + content: "正文" + }, + { + key: "h1", + className: 'heading-item-h1', + content: "标题1" + } + ] + } +``` + +## 命令 + +传入 `p` 或当前标题样式与当前传入值一致 时将取消标题 + +```ts +//使用 command 执行插件、并传入所需参数 +engine.command.execute( + 'heading', + 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p', +); +//使用 command 执行查询当前状态,返回 string | undefined,返回 "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" +engine.command.queryState('heading'); +``` + +## 大纲 + +生成标题的大纲数据 + +需要从 `@aomao/plugin-heading` 导入 `Outline` 类 + +```ts +import { Outline } from '@aomao/plugin-heading'; +``` + +### `normalize` + +将标题节点数据归一化为带深度层级的结构 + +```ts +/** + * 将 heading 数据归一化为带深度层级的结构 + * 归一化后,每个元素的结构为: + * { + * id: string, // id + * title: string, // 标题 + * level: number , // 标题层级 + * domNode: Node, // dom 节点 + * depth: number // 展示深度 + * } + * depth 的算法和 Google Docs 的一致 + * - 效果:h1 -> h4 分配固定的 depth,保证相同 level 的标题最终的层级深度是一样的 + * - 算法:找出文档存在的标题层级;按层级由大到小依次分别分配缩进深度; + * @param {Element[]}headings heading 的标准 DOM 节点数组 + * + * @return {Array} 标题节点数组 + */ +normalize(headings: Array): OutlineData[]; +``` + +### `getFromDom` + +从 DOM 节点提取大纲 + +```ts +/** + * 从 DOM 节点提取大纲 + * @param {Element} rootNode 根节点 + * @return {Array} + */ +getFromDom(rootNode: Element): OutlineData[]; +``` + +### 例子 + +```ts +import React, { useRef, useEffect, useState, useCallback } from 'react'; +import { $, EditorInterface } from '@aomao/engine'; +import { Outline, OutlineData } from '@aomao/plugin-heading'; + +type Props = { + editor: EditorInterface; +}; + +const outline = new Outline(); + +const Toc: React.FC = ({ editor }) => { + const rootRef = useRef(null); + const [datas, setDatas] = useState>([]); + + useEffect(() => { + const onChange = () => { + //获取大纲数据 + const data = getTocData(); + setDatas(data); + }; + //绑定编辑器值改变事件 + editor.on('change', onChange); + setTimeout(() => { + onChange(); + }, 50); + return () => editor.off('change', onChange); + }, [editor]); + + const getTocData = useCallback(() => { + // 从编辑区域提取符合结构要求的标题 Dom 节点 + let nodes: Array = []; + const { card } = editor; + editor.container.find('h1,h2,h3,h4,h5,h6').each((child) => { + const node = $(child); + // Card 里的标题,不纳入大纲 + if (card.closest(node)) { + return; + } + // 非一级深度标题,不纳入大纲 + if (!node.parent()?.isRoot()) { + return; + } + nodes.push(node.get()!); + }); + return outline.normalize(nodes); + }, []); + + return ( +
+
大纲
+
+ {datas.map((data, index) => { + return ( + + {data.text} + + ); + })} +
+
+ ); +}; +export default Toc; +``` diff --git a/docs/plugin/plugin-hr.md b/docs/plugin/plugin-hr.md new file mode 100644 index 00000000..5986230f --- /dev/null +++ b/docs/plugin/plugin-hr.md @@ -0,0 +1,64 @@ +# @aomao/plugin-hr + +Split line plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-hr +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Hr, {HrComponent} from'@aomao/plugin-hr'; + +new Engine(...,{ plugins:[Hr], cards:[HrComponent]}) +``` + +## Optional + +### hot key + +Default shortcut key `mod+shift+e` + +```ts +hotkey?:string;//default mod+shift+e + +//Use configuration +new Engine(...,{ + config:{ + "hr":{ + //Modify shortcut keys + hotkey: "shortcut key" + } + } + }) +``` + +### Markdown + +Support markdown by default, pass in `false` to close + +Hr plugin markdown syntax is `---` + +```ts +markdown?: boolean;//enabled by default, false off +//Use configuration +new Engine(...,{ + config:{ + "hr":{ + //Close markdown + markdown:false + } + } + }) +``` + +## Command + +```ts +//Can carry two parameters, language type, default text, all are optional +engine.command.execute('hr'); +``` diff --git a/docs/plugin/plugin-hr.zh-CN.md b/docs/plugin/plugin-hr.zh-CN.md new file mode 100644 index 00000000..78996e95 --- /dev/null +++ b/docs/plugin/plugin-hr.zh-CN.md @@ -0,0 +1,64 @@ +# @aomao/plugin-hr + +分割线插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-hr +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Hr , { HrComponent } from '@aomao/plugin-hr'; + +new Engine(...,{ plugins:[Hr] , cards:[HrComponent]}) +``` + +## 可选项 + +### 快捷键 + +默认快捷键 `mod+shift+e` + +```ts +hotkey?:string;//默认mod+shift+e + +//使用配置 +new Engine(...,{ + config:{ + "hr":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Hr 插件 markdown 语法为`---` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "hr":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +```ts +//可携带两个参数,语言类型,默认文本,都是可选的 +engine.command.execute('hr'); +``` diff --git a/docs/plugin/plugin-image.md b/docs/plugin/plugin-image.md new file mode 100644 index 00000000..38827f98 --- /dev/null +++ b/docs/plugin/plugin-image.md @@ -0,0 +1,226 @@ +# @aomao/plugin-image + +Image plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-image +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Image, {ImageComponent, ImageUploader} from'@aomao/plugin-image'; + +new Engine(...,{ plugins:[ Image, ImageUploader], cards:[ ImageComponent ]}) +``` + +The main functions of the `ImageUploader` plugin: select images, upload images, upload third-party image addresses when pasting or using markdown + +## `Image` optional + +`onBeforeRender` Modify the image address before the image is rendered + +```ts +onBeforeRender?: (status:'uploading' |'done', src: string) => string; +``` + +## `ImageUploader` optional + +```ts +//Use configuration +new Engine(...,{ + config:{ + [ImageUploader.pluginName]:{ + //...Related configuration + } + } + }) +``` + +### File Upload + +`action`: upload address, always use `POST` request + +`crossOrigin`: Whether to cross-origin + +`headers`: request header + +`contentType`: Image file upload is uploaded in `multipart/form-data;` type by default + +`accept`: Restrict the file type selected by the user's file selection box, the default is `svg`, `png`,`bmp`, `jpg`, `jpeg`,`gif`,`tif`,`tiff`,`emf` ,`webp` + +`limitSize`: Limit the file size selected by the user. If the file size exceeds the limit, no upload will be requested. Default: `1024 * 1024 * 5` 5M + +`multiple`: `false` can only upload one picture at a time, `true` defaults to a maximum of 100 pictures at a time. You can specify the specific number, but the file selection box cannot be limited, only the first number of uploads can be limited when uploading + +`data`: When files are uploaded or third-party image addresses are uploaded, these data will be `POST` to the server at the same time + +`name`: When file upload request, the name of the request parameter in `FormData`, the default is `file` + +```ts +/** + * File upload configuration + */ +file:{ + /** + * File upload address + */ + action:string + /** + * Whether cross-domain + */ + crossOrigin?: boolean; + /** + * Request header + */ + headers?: {[key: string]: string} | (() => {[key: string]: string }); + /** + * Data return type, default json + */ + type?:'*' |'json' |'xml' |'html' |'text' |'js'; + /** + * Additional data upload + */ + data?: {}; + /** + * The name of the FormData when the image file is uploaded, the default file + */ + name?: string + /** + * Request type, default multipart/form-data; + */ + contentType?:string + /** + * The format of the picture received, the default is "svg","png","bmp","jpg","jpeg","gif","tif","tiff","emf","webp" + */ + accept?: string | Array; + /** + * File selection limit + */ + multiple?: boolean | number; + /** + * Upload size limit, default 1024 * 1024 * 5 is 5M + */ + limitSize?: number; +} +``` + +### Third-party image upload + +Determine whether the image address belongs to a third-party image + +Third-party pictures may have some access restrictions such as anti-hotlinking, or the picture display has an expiration date + +If it is a third-party picture, you need to pass the address to the server to download the picture and save it, otherwise the upload will not be executed, and the current address will be used to display the picture + +The request parameter is `{ url:string }` + +```ts +/** + * Whether it is a third-party picture address, if it is, then the address will upload the server to download the picture and save it, and then return to the new address + */ +isRemote?: (src: string) => boolean; +/** + * Upload configuration + */ +remote:{ + /** + * Upload address + */ + action:string + /** + * Whether cross-domain + */ + crossOrigin?: boolean; + /** + * Request header + */ + headers?: {[key: string]: string} | (() => {[key: string]: string }); + /** + * Data return type, default json + */ + type?:'*' |'json' |'xml' |'html' |'text' |'js'; + /** + * Additional data upload + */ + data?: {}; + /** + * The name of the request parameter when the image file is lost when uploading, the default url + */ + name?: string + /** + * Request type, default application/json + */ + contentType?:string +} +``` + +### Analyze server response data + +By default, it will find response.url || response.data && response.data.url || response.src || response.data && response.data.src + +`result`: true upload is successful, data is the image address. false upload failed, data is an error message + +```ts +/** + * Parse the uploaded Respone and return result: whether it is successful or not, data: success: image address, failure: error message + */ +parse?: ( + response: any, +) => { + result: boolean; + data: string; +}; +``` + +### Markdown + +Support markdown by default, pass in `false` to close + +ImageUploader plug-in markdown syntax is `/^!\[([^\]]{0,})\]\((https?:\/\/[^\)]{5,})\)$/` + +After obtaining the image address, it will use the `remote` configuration to `POST` the image address to the server, the request parameter is `url`, and the server will use the image address to download and save the new image address and return it + +```ts +markdown?: boolean;//enabled by default, false off +//Use configuration +new Engine(...,{ + config:{ + [ImageUploader.pluginName]:{ + //Close markdown + markdown:false + } + } + }) +``` + +## Command + +### `Image` plugin command + +Insert a picture + +Parameter 1: Image status `uploading` | `done` | `error` uploading, uploading completed, uploading error + +Parameter 2: When the status is not `error`, it is the display picture, otherwise it displays the error message + +```ts +//'uploading' |'done' |'error' +engine.command.execute(Image.pluginName, 'done', 'Image address'); +``` + +### `ImageUploader` plugin command + +Pop up the file selection box and perform upload + +Optional parameter 1: Pass in the file list, these files will be uploaded. Pass in the picture address, insert the picture, and upload it if it is a third-party picture. Otherwise, the file selection box will pop up and upload it after selecting the file + +```ts +//Method signature +async execute(files?: Array | string | MouseEvent):void +//Excuting an order +engine.command.execute(ImageUploader.pluginName); +``` diff --git a/docs/plugin/plugin-image.zh-CN.md b/docs/plugin/plugin-image.zh-CN.md new file mode 100644 index 00000000..8e842ad3 --- /dev/null +++ b/docs/plugin/plugin-image.zh-CN.md @@ -0,0 +1,228 @@ +# @aomao/plugin-image + +图片插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-image +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Image , { ImageComponent , ImageUploader } from '@aomao/plugin-image'; + +new Engine(...,{ plugins:[ Image , ImageUploader ] , cards:[ ImageComponent ]}) +``` + +`ImageUploader` 插件主要功能:选择图片、上传图片、在粘贴或者使用 markdown 时上传第三方图片地址 + +## `Image` 可选项 + +`onBeforeRender` 图片渲染前对图片地址进行修改 + +```ts +onBeforeRender?: (status: 'uploading' | 'done', src: string) => string; +``` + +无可选项 + +## `ImageUploader` 可选项 + +```ts +//使用配置 +new Engine(...,{ + config:{ + [ImageUploader.pluginName]:{ + //...相关配置 + } + } + }) +``` + +### 文件上传 + +`action`: 上传地址,始终使用 `POST` 请求 + +`crossOrigin`: 是否跨域 + +`headers`: 请求头 + +`contentType`: 图片文件上传默认以 `multipart/form-data;` 类型上传 + +`accept`: 限制用户文件选择框选择的文件类型,默认 `svg`,`png`,`bmp`,`jpg`,`jpeg`,`gif`,`tif`,`tiff`,`emf`,`webp` + +`limitSize`: 限制用户选择的文件大小,超过限制将不请求上传。默认:`1024 * 1024 * 5` 5M + +`multiple`: `false` 一次只能上传一张图片,`true` 默认一次最多 100 张图。可以指定具体数量,但是文件选择框无法限制,只能上传的时候限制上传最前面的张数 + +`data`: 文件上传或第三方图片地址上传时同时将这些数据一起`POST`到服务端 + +`name`: 文件上传请求时,请求参数在 `FormData` 中的名称,默认 `file` + +```ts +/** + * 文件上传配置 + */ +file:{ + /** + * 文件上传地址 + */ + action:string + /** + * 是否跨域 + */ + crossOrigin?: boolean; + /** + * 请求头 + */ + headers?: { [key: string]: string } | (() => { [key: string]: string }); + /** + * 数据返回类型,默认 json + */ + type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js'; + /** + * 额外携带数据上传 + */ + data?: {}; + /** + * 图片文件上传时 FormData 的名称,默认 file + */ + name?: string + /** + * 请求类型,默认 multipart/form-data; + */ + contentType?:string + /** + * 图片接收的格式,默认 "svg","png","bmp","jpg","jpeg","gif","tif","tiff","emf","webp" + */ + accept?: string | Array; + /** + * 文件选择限制数量 + */ + multiple?: boolean | number; + /** + * 上传大小限制,默认 1024 * 1024 * 5 就是5M + */ + limitSize?: number; +} +``` + +### 第三方图片上传 + +判断图片地址是否属于第三方图片 + +第三方图片可能存在防盗链等一些访问限制,或者图片展示有有效期限 + +如果是第三方图片需要将地址传入服务端下载图片保存,否则将不会执行上传,使用当前地址展现图片 + +请求参数为 `{ url:string }` + +```ts +/** + * 是否是第三方图片地址,如果是,那么地址将上传服务器下载图片保存后,返回新地址 + */ +isRemote?: (src: string) => boolean; +/** + * 上传配置 + */ +remote:{ + /** + * 上传地址 + */ + action:string + /** + * 是否跨域 + */ + crossOrigin?: boolean; + /** + * 请求头 + */ + headers?: { [key: string]: string } | (() => { [key: string]: string }); + /** + * 数据返回类型,默认 json + */ + type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js'; + /** + * 额外携带数据上传 + */ + data?: {}; + /** + * 图片文件丢之上传时请求参数的名称,默认 url + */ + name?: string + /** + * 请求类型,默认 application/json + */ + contentType?:string +} +``` + +### 解析服务端响应数据 + +默认会查找 response.url || response.data && response.data.url || response.src || response.data && response.data.src + +`result`: true 上传成功,data 为图片地址。false 上传失败,data 为错误消息 + +```ts +/** + * 解析上传后的Respone,返回 result:是否成功,data:成功:图片地址,失败:错误信息 + */ +parse?: ( + response: any, +) => { + result: boolean; + data: string; +}; +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +ImageUploader 插件 markdown 语法为`/^!\[([^\]]{0,})\]\((https?:\/\/[^\)]{5,})\)$/` + +获取到图片地址后,会使用 `remote` 配置将图片地址 `POST` 到服务端,请求参数为 `url`,服务端使用图片地址下载保存后将新的图片地址返回 + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + [ImageUploader.pluginName]:{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +### `Image` 插件命令 + +插入一张图片 + +参数 1:图片状态`uploading` | `done` | `error` 上传中、上传完成、上传错误 + +参数 2:在状态非 `error` 下,为展示图片,否则展示错误消息 + +```ts +//'uploading' | 'done' | 'error' +engine.command.execute(Image.pluginName, 'done', '图片地址'); +``` + +### `ImageUploader` 插件命令 + +弹出文件选择框,并执行上传 + +可选参数 1:传入文件列表,将上传这些文件。传入图片地址,插入图片,如果为第三方图片就执行上传。否则弹出文件选择框并,选择文件后执行上传 + +```ts +//方法签名 +async execute(files?: Array | string | MouseEvent):void +//执行命令 +engine.command.execute(ImageUploader.pluginName); +``` diff --git a/docs/plugin/plugin-indent.md b/docs/plugin/plugin-indent.md new file mode 100644 index 00000000..68310278 --- /dev/null +++ b/docs/plugin/plugin-indent.md @@ -0,0 +1,67 @@ +# @aomao/plugin-indent + +Indentation plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-indent +``` + +Add to engine + +This plug-in is recommended to be added first to prevent other plug-ins from intercepting the event and making it unable to take effect + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Indent from'@aomao/plugin-indent'; + +new Engine(...,{ plugins:[Indent] }) +``` + +## Optional + +### hot key + +Default indentation shortcut `mod+]` + +Delete indentation shortcut key `mod+[` by default + +```ts +//hot key, +hotkey?: { + in?:string //Indentation shortcut key, default mod+] + out?:string //Delete indentation shortcut key, default mod+[ +}; + +//Use configuration +new Engine(...,{ + config:{ + "indent":{ + //Modify shortcut keys + hotkey:{ + "in":"shortcut key", + "out": "shortcut key" + } + } + } + }) +``` + +### Maximum padding + +Maximum padding, each indentation is 2 + +```ts +maxPadding?:number +``` + +## Command + +One parameter defaults to `in`, optional value is `in` to increase indentation, and `out` to decrease indentation + +```ts +engine.command.execute('indent'); +//Use command to execute query current status, return numbber, current indentation value +engine.command.queryState('indent'); +``` diff --git a/docs/plugin/plugin-indent.zh-CN.md b/docs/plugin/plugin-indent.zh-CN.md new file mode 100644 index 00000000..6b5b005d --- /dev/null +++ b/docs/plugin/plugin-indent.zh-CN.md @@ -0,0 +1,67 @@ +# @aomao/plugin-indent + +缩进插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-indent +``` + +添加到引擎 + +此插件建议放在第一个增加,以免其它插件拦截了事件,使其无法生效 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Indent from '@aomao/plugin-indent'; + +new Engine(...,{ plugins:[Indent] }) +``` + +## 可选项 + +### 快捷键 + +默认缩进快捷键 `mod+]` + +默认删除缩进快捷键 `mod+[` + +```ts +//快捷键, +hotkey?: { + in?:string //缩进快捷键,默认 mod+] + out?:string //删除缩进快捷键,默认 mod+[ +}; + +//使用配置 +new Engine(...,{ + config:{ + "indent":{ + //修改快捷键 + hotkey:{ + "in":"快捷键", + "out":"快捷键" + } + } + } + }) +``` + +### 最大 padding + +最大 padding,每次缩进为 2 + +```ts +maxPadding?:number +``` + +## 命令 + +有一个参数 默认为 `in` ,可选值为 `in` 增加缩进,`out` 减少缩进 + +```ts +engine.command.execute('indent'); +//使用 command 执行查询当前状态,返回 numbber,当前缩进值 +engine.command.queryState('indent'); +``` diff --git a/docs/plugin/plugin-italic.md b/docs/plugin/plugin-italic.md new file mode 100644 index 00000000..210ad141 --- /dev/null +++ b/docs/plugin/plugin-italic.md @@ -0,0 +1,66 @@ +# @aomao/plugin-italic + +Italic style plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-italic +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Italic from'@aomao/plugin-italic'; + +new Engine(...,{ plugins:[Italic] }) +``` + +## Optional + +### hot key + +The default shortcut key is `mod+i`, and multiple shortcut keys are passed in as an array + +```ts +//hot key, +hotkey?: string | Array; + +//Use configuration +new Engine(...,{ + config:{ + "italic":{ + //Modify shortcut keys + hotkey: "shortcut key" + } + } + }) +``` + +### Markdown + +Support markdown by default, pass in `false` to close + +Italic plugin markdown syntax is `_` + +```ts +markdown?: boolean;//enabled by default, false off +//Use configuration +new Engine(...,{ + config:{ + "italic":{ + //Close markdown + markdown:false + } + } + }) +``` + +## Command + +```ts +engine.command.execute('italic'); +//Use command to execute query current status, return boolean | undefined +engine.command.queryState('italic'); +``` diff --git a/docs/plugin/plugin-italic.zh-CN.md b/docs/plugin/plugin-italic.zh-CN.md new file mode 100644 index 00000000..0ee8d2df --- /dev/null +++ b/docs/plugin/plugin-italic.zh-CN.md @@ -0,0 +1,66 @@ +# @aomao/plugin-italic + +斜体样式插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-italic +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Italic from '@aomao/plugin-italic'; + +new Engine(...,{ plugins:[Italic] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+i`,以数组形式传入多个快捷键 + +```ts +//快捷键, +hotkey?: string | Array; + +//使用配置 +new Engine(...,{ + config:{ + "italic":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Italic 插件 markdown 语法为`_` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "italic":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +```ts +engine.command.execute('italic'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('italic'); +``` diff --git a/docs/plugin/plugin-line-height.md b/docs/plugin/plugin-line-height.md new file mode 100644 index 00000000..fdc84709 --- /dev/null +++ b/docs/plugin/plugin-line-height.md @@ -0,0 +1,80 @@ +# @aomao/plugin-line-height + +Row height plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-line-height +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Lineheight from'@aomao/plugin-line-height'; + +new Engine(...,{ plugins:[Lineheight] }) +``` + +## Optional + +### Paste filter custom line height + +Supports filtering the row height that does not meet the custom + +```ts +/** + * @param lineHeight current line height + * @returns returns string to modify the current value, false is removed, true is retained + * */ +filter?: (lineHeight: string) => string | boolean +//Configuration +new Engine(...,{ + config:{ + [LineHeihgt.pluginName]: { + //Configure the row height to be filtered after pasting + 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 + } + } + } +} +``` + +### hot key + +No shortcut keys by default + +```ts +//Shortcut keys, key combination keys, args, execution parameters, [lineHeight], lineHeight are optional, delete the line height at the current cursor position without passing a value +hotkey?:{key:string,args:Array};//default none + +//Use configuration +new Engine(...,{ + config:{ + "line-height":{ + //Modify shortcut keys + hotkey:{ + key:"mod+b", + args:["2"] + } + } + } + }) +``` + +## Command + +```ts +//lineHeight: changed line height +engine.command.execute('line-height', lineHeight); +//Use command to query the current state, return Array | undefined, the set of high values ​​at the current cursor position +engine.command.queryState('line-height'); +``` diff --git a/docs/plugin/plugin-line-height.zh-CN.md b/docs/plugin/plugin-line-height.zh-CN.md new file mode 100644 index 00000000..e7f1e1e5 --- /dev/null +++ b/docs/plugin/plugin-line-height.zh-CN.md @@ -0,0 +1,80 @@ +# @aomao/plugin-line-height + +行高插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-line-height +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Lineheight from '@aomao/plugin-line-height'; + +new Engine(...,{ plugins:[Lineheight] }) +``` + +## 可选项 + +### 粘贴过滤自定义行高 + +支持过滤不符合自定义的行高 + +```ts +/** + * @param lineHeight 当前行高 + * @returns 返回 string 修改当前值,false 移除,true 保留 + * */ +filter?: (lineHeight: string) => string | boolean +//配置 +new Engine(...,{ + config:{ + [LineHeihgt.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 + } + } + } +} +``` + +### 快捷键 + +默认无快捷键 + +```ts +//快捷键,key 组合键,args,执行参数,[lineHeight] , lineHeight 可选,不传值删除当前光标位置的行高 +hotkey?:{key:string,args:Array};//默认无 + +//使用配置 +new Engine(...,{ + config:{ + "line-height":{ + //修改快捷键 + hotkey:{ + key:"mod+b", + args:["2"] + } + } + } + }) +``` + +## 命令 + +```ts +//lineHeight:更改的行高 +engine.command.execute('line-height', lineHeight); +//使用 command 执行查询当前状态,返回 Array | undefined,当前光标所在处行高值集合 +engine.command.queryState('line-height'); +``` diff --git a/docs/plugin/plugin-link.md b/docs/plugin/plugin-link.md new file mode 100644 index 00000000..6db0da85 --- /dev/null +++ b/docs/plugin/plugin-link.md @@ -0,0 +1,97 @@ +# @aomao/plugin-link + +Link plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-link +``` + +`Vue3` use + +```bash +$ yarn add @aomao/plugin-link-vue +``` + +`Vue2` use + +```bash +$ yarn add am-editor-link-vue2 +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Link from'@aomao/plugin-link'; + +new Engine(...,{ plugins:[Link] }) +``` + +## Optional + +### hot key + +The default shortcut key is `mod+k`, and the default parameter is ["_blank"] + +```ts +//Shortcut keys, key combination keys, args, execution parameters, [target?:string,href?:string,text?:string] Open mode: optional, default link: optional, default text: optional +hotkey?:string | {key:string,args:Array}; + +//Use configuration +new Engine(...,{ + config:{ + "link":{ + //Modify shortcut keys + hotkey:{ + key:"mod+k", + args:["_balnk_","https://www.yanmao.cc","ITELLYOU"] + } + } + } + }) +``` + +### Markdown + +Support markdown by default, pass in `false` to close + +Link plug-in markdown syntax is `[text](link address)` and it is triggered after pressing enter + +```ts +markdown?: boolean;//enabled by default, false off +//Use configuration +new Engine(...,{ + config:{ + "link":{ + //Close markdown + markdown:false + } + } + }) +``` + +### onConfirm + +The url or text to be modified can be modified + +Confirm execution after editing text and url + +```ts +onConfirm?: ( + text: string, + link: string, +) => Promise<{ text: string; link: string }>; +``` + +## Command + +Three parameters can be passed in [target?:string,href?:string,text?:string] Open mode: optional, default link: optional, default text: optional + +```ts +//target:'_blank','_parent','_top','_self', href: link, text: text +engine.command.execute('link', '_blank', 'https://www.yanmao.cc', 'ITELLYOU'); +//Use command to execute query current status, return boolean | undefined +engine.command.queryState('link'); +``` diff --git a/docs/plugin/plugin-link.zh-CN.md b/docs/plugin/plugin-link.zh-CN.md new file mode 100644 index 00000000..2543fbc3 --- /dev/null +++ b/docs/plugin/plugin-link.zh-CN.md @@ -0,0 +1,97 @@ +# @aomao/plugin-link + +链接插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-link +``` + +`Vue3` 使用 + +```bash +$ yarn add @aomao/plugin-link-vue +``` + +`Vue2` 使用 + +```bash +$ yarn add am-editor-link-vue2 +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Link from '@aomao/plugin-link'; + +new Engine(...,{ plugins:[Link] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+k`,默认参数为 ["_blank"] + +```ts +//快捷键,key 组合键,args,执行参数,[target?:string,href?:string,text?:string] 打开模式:可选,默认链接:可选,默认文本:可选 +hotkey?:string | {key:string,args:Array}; + +//使用配置 +new Engine(...,{ + config:{ + "link":{ + //修改快捷键 + hotkey:{ + key:"mod+k", + args:["_balnk_","https://www.yanmao.cc","ITELLYOU"] + } + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Link 插件 markdown 语法为`[文本](链接地址)` 回车后触发 + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "link":{ + //关闭markdown + markdown:false + } + } + }) +``` + +### onConfirm + +可对要修改的 url 或者 文本 进行修改 + +在编辑文本和 url 后确认执行 + +```ts +onConfirm?: ( + text: string, + link: string, +) => Promise<{ text: string; link: string }>; +``` + +## 命令 + +可传入三个参数[target?:string,href?:string,text?:string] 打开模式:可选,默认链接:可选,默认文本:可选 + +```ts +//target:'_blank', '_parent', '_top', '_self',href:链接,text:文字 +engine.command.execute('link', '_blank', 'https://www.yanmao.cc', 'ITELLYOU'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('link'); +``` diff --git a/docs/plugin/plugin-mark-range.md b/docs/plugin/plugin-mark-range.md new file mode 100644 index 00000000..1796c25b --- /dev/null +++ b/docs/plugin/plugin-mark-range.md @@ -0,0 +1,203 @@ +# @aomao/plugin-mark-range + +Cursor area marking plugin + +Can be used to cooperate with development similar to comments, crossed comments + +[Annotation/Comment Case](https://github.com/yanmao-cc/am-editor/blob/master/docs/demo/comment/index.tsx) + +## Installation + +```bash +$ yarn add @aomao/plugin-mark-range +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import MarkRange from'@aomao/plugin-mark-range'; + +new Engine(...,{ plugins:[MarkRange] }) +``` + +## Optional + +```ts +//Use configuration +new Engine(...,{ + config:{ + "mark-range":{ + //Modify shortcut keys + hotkey:..., + //Other options + ... + } + } + }) +``` + +### Mark Type Collection + +At least one type must be specified for the tag plugin. If there are multiple tags, multiple types can be specified + +```ts +keys: Array + +//For example, comments keys = ["comment"] +``` + +### Mark node change callback + +In collaborative editing, this callback will be triggered after other authors add tags, or edit or delete some nodes that contain tagged nodes + +This callback will also be triggered when using undo and redo related operations + +addIds: Newly added mark node number collection + +removeIds: a collection of deleted marker node numbers + +ids: a collection of all valid marked node numbers + +```ts +onChange?: (addIds: {[key: string]: Array},removeIds: {[key: string]: Array},ids: {[key:string]: Array }) = > void +``` + +### Callback when the marked section is selected + +Triggered when the cursor changes. If selectInfo has a value, it will carry the nearest cursor position. If it is a nested relationship, then it will return the innermost mark number + +```ts +onSelect?: (range: RangeInterface, selectInfo?: {key: string, id: string}) => void +``` + +### hot key + +No shortcut keys by default + +```ts +//Shortcut keys, key combination keys, args, execution parameters, [mode?: string, value?: string] Language mode: optional, code text: optional +hotkey?:string | {key:string,args:Array};//default none +``` + +## Command + +All commands need to specify the specified key passed in in the options `keys` + +```ts +engine.command.execute('mark-range', 'mark key'); +``` + +### Preview + +Preview the effect of a mark or the current cursor position + +If you do not pass in the edit id parameter, then preview the effect of the current cursor selection + +This operation will not participate in collaborative synchronization + +This operation will not generate historical records, and cannot undo and redo operations + +When the cursor changes, the current preview effect will be cancelled automatically + +If it is to preview the effect of the cursor, the command will return all the text splicing in the area selected by the cursor. The cards will be spliced ​​in the format of [card:card name, card number]. If you need to convert it, you have to deal with it yourself + +```ts +engine.command.execute('mark-range', key: string,'preview', id?:string): string | undefined; +``` + +### Apply the preview effect to the editor + +Apply the preview effect to the editor and synchronize to the collaboration server + +This operation will not generate historical records, and cannot undo and redo operations + +A tag number must be passed in, which can be a string. The number should be unique relative to the key + +```ts +engine.command.execute('mark-range', key: string,'apply', id:string); +``` + +### Cancel the preview effect + +If you do not pass in the mark number, cancel all currently ongoing preview items + +```ts +engine.command.execute('mark-range', key: string,'revoke', id?:string); +``` + +### Find Node + +Find out all the corresponding dom node objects in the editor according to the tag number + +```ts +engine.command.execute('mark-range', key: string,'find', id: string): Array; +``` + +### Remove mark effect + +Remove the mark effect of the specified mark number + +This operation will not generate historical records, and cannot undo and redo operations + +```ts +engine.command.execute('mark-range', key: string,'remove', id: string) +``` + +### Filter tags + +Filter all tags in the editor value, and return the filtered value and the number and corresponding path of all tags + +value Gets the html in the root node of the current editor as the value by default + +It is useful when we need to store the mark and the editor value separately or conditionally display the mark + +```ts +engine.command.execute('mark-range', key: string,'filter', value?: string): {value: string, paths: Array<{ id: Array, path: Array} >} +``` + +### Restore mark + +Use the tag path and the filtered editor value for tag restoration + +value Gets the html in the root node of the current editor as the value by default + +```ts +engine.command.execute('mark-range', key: string,'wrap', paths: Array<{ id: Array, path: Array}>, value?: string): string +``` + +## Style definition + +```css +/** The comment in the mark style -comment- in the editor refers to the key configured in the mark ---- start **/ +[data-comment-preview], +[data-comment-id] { + position: relative; +} + +span[data-comment-preview], +span[data-comment-id] { + display: inline-block; +} + +[data-comment-preview]::before, +[data-comment-id]::before { + content: ''; + position: absolute; + width: 100%; + bottom: 0px; + left: 0; + height: 2px; + border-bottom: 2px solid #f8e1a1 !important; +} + +[data-comment-preview] { + background: rgb(250, 241, 209) !important; +} + +[data-card-key][data-comment-id]::before, +[data-card-key][data-comment-preview]::before { + bottom: -2px; +} +/** Mark style in the editor ---- end **/ +``` diff --git a/docs/plugin/plugin-mark-range.zh-CN.md b/docs/plugin/plugin-mark-range.zh-CN.md new file mode 100644 index 00000000..6b9eef63 --- /dev/null +++ b/docs/plugin/plugin-mark-range.zh-CN.md @@ -0,0 +1,203 @@ +# @aomao/plugin-mark-range + +光标区域标记插件 + +可用来配合开发类似于批注、划线评论 + +[批注/评论案例](https://github.com/yanmao-cc/am-editor/blob/master/docs/demo/comment/index.tsx) + +## 安装 + +```bash +$ yarn add @aomao/plugin-mark-range +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import MarkRange from '@aomao/plugin-mark-range'; + +new Engine(...,{ plugins:[MarkRange] }) +``` + +## 可选项 + +```ts +//使用配置 +new Engine(...,{ + config:{ + "mark-range":{ + //修改快捷键 + hotkey:..., + //其它可选项 + ... + } + } + }) +``` + +### 标记类型集合 + +必须为标记插件指定至少一个类型。如果有多种标记可指定多个类型 + +```ts +keys: Array + +//例如评论 keys = ["comment"] +``` + +### 标记节点改变回调 + +在协同编辑时,其它作者添加标记后,或者在编辑、删除一些节点中包含标记节点时都会触发此回调 + +在使用 撤销、重做 相关操作时,也会触发此回调 + +addIds: 新增的标记节点编号集合 + +removeIds: 删除的标记节点编号集合 + +ids: 所有有效的标记节点编号集合 + +```ts +onChange?: (addIds: { [key: string]: Array},removeIds: { [key: string]: Array},ids: { [key:string] : Array }) => void +``` + +### 选中标记节时点回调 + +在光标改变时触发,selectInfo 有值的情况下将携带光标所在最近,如果是嵌套关系,那么就返回最里层的标记编号 + +```ts +onSelect? : (range: RangeInterface, selectInfo?: { key: string, id: string}) => void +``` + +### 快捷键 + +默认无快捷键 + +```ts +//快捷键,key 组合键,args,执行参数,[mode?: string, value?: string] 语言模式:可选,代码文本:可选 +hotkey?:string | {key:string,args:Array};//默认无 +``` + +## 命令 + +所有命令都需要指定在可选项中 `keys` 中传入的指定 key + +```ts +engine.command.execute('mark-range', '标记key'); +``` + +### 预览 + +对一个标记或当前做在光标位置进行效果预览 + +如果不传入编辑 id 参数,那么就对当前光标所选进行效果预览 + +此操作不会参与协同同步 + +此操作不会产生历史记录,无法做 撤销 和 重做 操作 + +光标改变时,将自动取消当前预览效果 + +如果是对光标进行效果预览,命令将返回光标选中区域的所有文本拼接。卡片将使用 [card:卡片名称,卡片编号] 这种格式拼接,需要转换则要自行处理 + +```ts +engine.command.execute('mark-range', key: string, 'preview', id?:string): string | undefined; +``` + +### 将预览效果应用到编辑器 + +将预览效果应用到编辑器,并同步到协同服务器 + +此操作不会产生历史记录,无法做 撤销 和 重做 操作 + +必须传入一个标记编号,可以是字符串。编号相对于 key 应是唯一的 + +```ts +engine.command.execute('mark-range', key: string, 'apply', id:string); +``` + +### 取消预览效果 + +如果不传入标记编号,则取消所有的当前正在进行的预览项 + +```ts +engine.command.execute('mark-range', key: string, 'revoke', id?:string); +``` + +### 查找节点 + +根据标记编号找出其在编辑器中所有相对应的 dom 节点对象 + +```ts +engine.command.execute('mark-range', key: string, 'find', id: string): Array; +``` + +### 移除标记效果 + +移除指定标记编号的标记效果 + +此操作不会产生历史记录,无法做 撤销 和 重做 操作 + +```ts +engine.command.execute('mark-range', key: string, 'remove', id: string) +``` + +### 过滤标记 + +对编辑器值中的所有标记过滤,并返回过滤后的值和所有标记的编号和对应路径 + +value 默认获取当前编辑器根节点中的 html 作为值 + +在我们需要将标记和编辑器值分开存储或有条件展现标记时很有用 + +```ts +engine.command.execute('mark-range', key: string, 'filter', value?: string): { value: string, paths: Array<{ id: Array, path: Array}>} +``` + +### 还原标记 + +使用标记路径和过滤后的编辑器值进行标记还原 + +value 默认获取当前编辑器根节点中的 html 作为值 + +```ts +engine.command.execute('mark-range', key: string, 'wrap', paths: Array<{ id: Array, path: Array}>, value?: string): string +``` + +## 样式定义 + +```css +/** 编辑器中标记样式 -comment- 中的 comment 都是代指标记中配置的 key ---- 开始 **/ +[data-comment-preview], +[data-comment-id] { + position: relative; +} + +span[data-comment-preview], +span[data-comment-id] { + display: inline-block; +} + +[data-comment-preview]::before, +[data-comment-id]::before { + content: ''; + position: absolute; + width: 100%; + bottom: 0px; + left: 0; + height: 2px; + border-bottom: 2px solid #f8e1a1 !important; +} + +[data-comment-preview] { + background: rgb(250, 241, 209) !important; +} + +[data-card-key][data-comment-id]::before, +[data-card-key][data-comment-preview]::before { + bottom: -2px; +} +/** 编辑器中标记样式 ---- 结束 **/ +``` diff --git a/docs/plugin/plugin-mark.md b/docs/plugin/plugin-mark.md new file mode 100644 index 00000000..2bdbbaed --- /dev/null +++ b/docs/plugin/plugin-mark.md @@ -0,0 +1,201 @@ +# @aomao/plugin-mark-range + +Cursor area marking plugin + +Can be used to cooperate with development similar to comments, crossed comments + +## Install + +```bash +$ yarn add @aomao/plugin-mark-range +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import MarkRange from'@aomao/plugin-mark-range'; + +new Engine(...,{ plugins:[MarkRange] }) +``` + +## Optional + +```ts +//Use configuration +new Engine(...,{ + config:{ + "mark-range":{ + //Modify shortcut keys + hotkey:..., + //Other options + ... + } + } + }) +``` + +### Mark Type Collection + +At least one type must be specified for the tag plugin. If there are multiple tags, multiple types can be specified + +```ts +keys: Array + +//For example, comments keys = ["comment"] +``` + +### Mark node change callback + +In collaborative editing, this callback will be triggered after other authors add tags, or edit or delete some nodes that contain tagged nodes + +This callback will also be triggered when using undo and redo related operations + +addIds: Newly added mark node number collection + +removeIds: a collection of deleted marker node numbers + +ids: a collection of all valid marked node numbers + +```ts +onChange?: (addIds: {[key: string]: Array},removeIds: {[key: string]: Array},ids: {[key:string]: Array }) = > void +``` + +### Callback when the marked section is selected + +Triggered when the cursor changes. If selectInfo has a value, it will carry the nearest cursor position. If it is a nested relationship, then it will return the innermost mark number + +```ts +onSelect?: (range: RangeInterface, selectInfo?: {key: string, id: string}) => void +``` + +### hot key + +No shortcut keys by default + +```ts +//Shortcut keys, key combination keys, args, execution parameters, [mode?: string, value?: string] Language mode: optional, code text: optional +hotkey?:string | {key:string,args:Array};//default none +``` + +## Plug-in method + +All commands need to specify the specified key passed in in the options `keys` + +```ts +engine.command.executeMethod('mark-range', 'action', 'mark key'); +``` + +### Preview + +Preview the effect of a mark or the current cursor position + +If you do not pass in the edit id parameter, then preview the effect of the current cursor selection + +This operation will not participate in collaborative synchronization + +This operation will not generate historical records, and cannot undo and redo operations + +When the cursor changes, the current preview effect will be cancelled automatically + +If it is to preview the effect of the cursor, the command will return all the text splicing in the area selected by the cursor. The cards will be spliced ​​in the format of [card:card name, card number]. If you need to convert it, you have to deal with it yourself + +```ts +engine.command.executeMethod('mark-range','action', key: string,'preview', id?:string): string | undefined; +``` + +### Apply the preview effect to the editor + +Apply the preview effect to the editor and synchronize to the collaboration server + +This operation will not generate historical records, and cannot undo and redo operations + +A tag number must be passed in, which can be a string. The number should be unique relative to the key + +```ts +engine.command.executeMethod('mark-range','action', key: string,'apply', id:string); +``` + +### Cancel the preview effect + +If you do not pass in the mark number, cancel all currently ongoing preview items + +```ts +engine.command.executeMethod('mark-range','action', key: string,'revoke', id?:string); +``` + +### Find Node + +Find out all the corresponding dom node objects in the editor according to the tag number + +```ts +engine.command.executeMethod('mark-range','action', key: string,'find', id: string): Array; +``` + +### Remove mark effect + +Remove the mark effect of the specified mark number + +This operation will not generate historical records, and cannot undo and redo operations + +```ts +engine.command.executeMethod('mark-range','action', key: string,'remove', id: string) +``` + +### Filter tags + +Filter all tags in the editor value, and return the filtered value and the number and corresponding path of all tags + +value Gets the html in the root node of the current editor as the value by default + +It is useful when we need to store the mark and the editor value separately or conditionally display the mark + +```ts +engine.command.executeMethod('mark-range','action', key: string,'filter', value?: string): {value: string, paths: Array<{ id: Array, path: Array }>} +``` + +### Restore mark + +Use the tag path and the filtered editor value for tag restoration + +value Gets the html in the root node of the current editor as the value by default + +```ts +engine.command.executeMethod('mark-range','action', key: string,'wrap', paths: Array<{ id: Array, path: Array}>, value?: string) : string +``` + +## Style definition + +```css +/** The comment in the mark style -comment- in the editor refers to the key configured in the mark ---- start **/ +[data-comment-preview], +[data-comment-id] { + position: relative; +} + +span[data-comment-preview], +span[data-comment-id] { + display: inline-block; +} + +[data-comment-preview]::before, +[data-comment-id]::before { + content: ''; + position: absolute; + width: 100%; + bottom: 0px; + left: 0; + height: 2px; + border-bottom: 2px solid #f8e1a1 !important; +} + +[data-comment-preview] { + background: rgb(250, 241, 209) !important; +} + +[data-card-key][data-comment-id]::before, +[data-card-key][data-comment-preview]::before { + bottom: -2px; +} +/** Mark style in the editor ---- end **/ +``` diff --git a/docs/plugin/plugin-mark.zh-CN.md b/docs/plugin/plugin-mark.zh-CN.md new file mode 100644 index 00000000..1e78f8fc --- /dev/null +++ b/docs/plugin/plugin-mark.zh-CN.md @@ -0,0 +1,201 @@ +# @aomao/plugin-mark-range + +光标区域标记插件 + +可用来配合开发类似于批注、划线评论 + +## 安装 + +```bash +$ yarn add @aomao/plugin-mark-range +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import MarkRange from '@aomao/plugin-mark-range'; + +new Engine(...,{ plugins:[MarkRange] }) +``` + +## 可选项 + +```ts +//使用配置 +new Engine(...,{ + config:{ + "mark-range":{ + //修改快捷键 + hotkey:..., + //其它可选项 + ... + } + } + }) +``` + +### 标记类型集合 + +必须为标记插件指定至少一个类型。如果有多种标记可指定多个类型 + +```ts +keys: Array + +//例如评论 keys = ["comment"] +``` + +### 标记节点改变回调 + +在协同编辑时,其它作者添加标记后,或者在编辑、删除一些节点中包含标记节点时都会触发此回调 + +在使用 撤销、重做 相关操作时,也会触发此回调 + +addIds: 新增的标记节点编号集合 + +removeIds: 删除的标记节点编号集合 + +ids: 所有有效的标记节点编号集合 + +```ts +onChange?: (addIds: { [key: string]: Array},removeIds: { [key: string]: Array},ids: { [key:string] : Array }) => void +``` + +### 选中标记节时点回调 + +在光标改变时触发,selectInfo 有值的情况下将携带光标所在最近,如果是嵌套关系,那么就返回最里层的标记编号 + +```ts +onSelect? : (range: RangeInterface, selectInfo?: { key: string, id: string}) => void +``` + +### 快捷键 + +默认无快捷键 + +```ts +//快捷键,key 组合键,args,执行参数,[mode?: string, value?: string] 语言模式:可选,代码文本:可选 +hotkey?:string | {key:string,args:Array};//默认无 +``` + +## 插件方法 + +所有命令都需要指定在可选项中 `keys` 中传入的指定 key + +```ts +engine.command.executeMethod('mark-range', 'action', '标记key'); +``` + +### 预览 + +对一个标记或当前做在光标位置进行效果预览 + +如果不传入编辑 id 参数,那么就对当前光标所选进行效果预览 + +此操作不会参与协同同步 + +此操作不会产生历史记录,无法做 撤销 和 重做 操作 + +光标改变时,将自动取消当前预览效果 + +如果是对光标进行效果预览,命令将返回光标选中区域的所有文本拼接。卡片将使用 [card:卡片名称,卡片编号] 这种格式拼接,需要转换则要自行处理 + +```ts +engine.command.executeMethod('mark-range', 'action', key: string, 'preview', id?:string): string | undefined; +``` + +### 将预览效果应用到编辑器 + +将预览效果应用到编辑器,并同步到协同服务器 + +此操作不会产生历史记录,无法做 撤销 和 重做 操作 + +必须传入一个标记编号,可以是字符串。编号相对于 key 应是唯一的 + +```ts +engine.command.executeMethod('mark-range', 'action', key: string, 'apply', id:string); +``` + +### 取消预览效果 + +如果不传入标记编号,则取消所有的当前正在进行的预览项 + +```ts +engine.command.executeMethod('mark-range', 'action', key: string, 'revoke', id?:string); +``` + +### 查找节点 + +根据标记编号找出其在编辑器中所有相对应的 dom 节点对象 + +```ts +engine.command.executeMethod('mark-range', 'action', key: string, 'find', id: string): Array; +``` + +### 移除标记效果 + +移除指定标记编号的标记效果 + +此操作不会产生历史记录,无法做 撤销 和 重做 操作 + +```ts +engine.command.executeMethod('mark-range', 'action', key: string, 'remove', id: string) +``` + +### 过滤标记 + +对编辑器值中的所有标记过滤,并返回过滤后的值和所有标记的编号和对应路径 + +value 默认获取当前编辑器根节点中的 html 作为值 + +在我们需要将标记和编辑器值分开存储或有条件展现标记时很有用 + +```ts +engine.command.executeMethod('mark-range', 'action', key: string, 'filter', value?: string): { value: string, paths: Array<{ id: Array, path: Array}>} +``` + +### 还原标记 + +使用标记路径和过滤后的编辑器值进行标记还原 + +value 默认获取当前编辑器根节点中的 html 作为值 + +```ts +engine.command.executeMethod('mark-range', 'action', key: string, 'wrap', paths: Array<{ id: Array, path: Array}>, value?: string): string +``` + +## 样式定义 + +```css +/** 编辑器中标记样式 -comment- 中的 comment 都是代指标记中配置的 key ---- 开始 **/ +[data-comment-preview], +[data-comment-id] { + position: relative; +} + +span[data-comment-preview], +span[data-comment-id] { + display: inline-block; +} + +[data-comment-preview]::before, +[data-comment-id]::before { + content: ''; + position: absolute; + width: 100%; + bottom: 0px; + left: 0; + height: 2px; + border-bottom: 2px solid #f8e1a1 !important; +} + +[data-comment-preview] { + background: rgb(250, 241, 209) !important; +} + +[data-card-key][data-comment-id]::before, +[data-card-key][data-comment-preview]::before { + bottom: -2px; +} +/** 编辑器中标记样式 ---- 结束 **/ +``` diff --git a/docs/plugin/plugin-math.md b/docs/plugin/plugin-math.md new file mode 100644 index 00000000..0285d04b --- /dev/null +++ b/docs/plugin/plugin-math.md @@ -0,0 +1,126 @@ +# @aomao/plugin-math + +Mathematical formula + +## Installation + +```bash +$ yarn add @aomao/plugin-math +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Math, {MathComponent} from'@aomao/plugin-math'; + +new Engine(...,{ plugins:[ Math], cards:[ MathComponent ]}) +``` + +## `Math` optional + +```ts +//Use configuration +new Engine(...,{ + config:{ + [Math.pluginName]:{ + //...Related configuration + } + } + }) +``` + +### Request to generate formula code as picture or SVG + +`action`: request address, always use `POST` request + +`type`: default is `json` + +`contentType`: By default, the request is initiated in the `application/json` type + +`data`: POST these data together to the server when requesting + +```ts +/** + * Request to generate formula svg address + */ +action: string; +/** + * Data return type, default json + */ +type?:'*' |'json' |'xml' |'html' |'text' |'js'; +/** + * Additional data upload + */ +data?: {}; +/** + * Request type, default application/json; + */ +contentType?: string; +``` + +After configuration, the plug-in will use the `content` field to POST to the specified `action` address, which contains the formula code + +### Analyze server response data + +Will find by default + +The formula corresponds to the image address or `SVG` code: response.url || response.data && response.data.url + +`result`: true is generated successfully, and data is the image address corresponding to the formula or the `SVG` code. false Failed to generate, data is the error message + +```ts +/** + * Parse the generated Respone and return result: whether it is successful or not, data: success: the formula corresponds to the image address or `SVG` code, failure: error message + */ +parse?: ( + response: any, +) => { + result: boolean; + data: string; +}; +``` + +### Drawing interface + +You can use the `https://g.yanmao.cc/latex` address to generate the `SVG` code corresponding to the formula. This project uses [mathjax](https://www.mathjax.org/) to generate `SVG` code + +Demo site: [https://drawing.yanmao.cc/](https://drawing.yanmao.cc/) + +Configuration: + +```ts +[Math.pluginName]: { + action: `https://g.yanmao.cc/latex`, + parse: (res: any) => { + if(res.success) return {result: true, data: res.svg} + return {result: false} + } +} +``` + +## Command + +### Insert formula code + +Parameter 1: Formula code + +Parameter 2: The formula corresponds to the image address or `SVG` code + +```ts +engine.command.execute( + Math.pluginName, + 'Formula code', //optional + 'The formula corresponds to the image address or `SVG` code', //optional +); +``` + +### Request to generate formula code image or SVG + +Parameter 1: fixed as `query` +Parameter 2: callback after success +Parameter 3: Callback after failure. Optional + +```ts +engine.command.execute(Math.pluginName, "query", success:(url: string) => void, failed: (message: string) => void); +``` diff --git a/docs/plugin/plugin-math.zh-CN.md b/docs/plugin/plugin-math.zh-CN.md new file mode 100644 index 00000000..78efd6d4 --- /dev/null +++ b/docs/plugin/plugin-math.zh-CN.md @@ -0,0 +1,126 @@ +# @aomao/plugin-math + +数学公式 + +## 安装 + +```bash +$ yarn add @aomao/plugin-math +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Math , { MathComponent } from '@aomao/plugin-math'; + +new Engine(...,{ plugins:[ Math ] , cards:[ MathComponent ]}) +``` + +## `Math` 可选项 + +```ts +//使用配置 +new Engine(...,{ + config:{ + [Math.pluginName]:{ + //...相关配置 + } + } + }) +``` + +### 请求生成公式代码为图片或 SVG + +`action`: 请求地址,始终使用 `POST` 请求 + +`type`: 默认为 `json` + +`contentType`: 默认以 `application/json` 类型发起请求 + +`data`: 请求时将这些数据一起`POST`到服务端 + +```ts +/** + * 请求生成公式svg地址 + */ +action: string; +/** + * 数据返回类型,默认 json + */ +type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js'; +/** + * 额外携带数据上传 + */ +data?: {}; +/** + * 请求类型,默认 application/json; + */ +contentType?: string; +``` + +配置后,插件会使用 `content` 字段 POST 到指定的 `action` 地址,里面包含了公式代码 + +### 解析服务端响应数据 + +默认会查找 + +公式对应图片地址或`SVG`代码:response.url || response.data && response.data.url + +`result`: true 生成成功,data 为公式对应图片地址或`SVG`代码。false 生成失败,data 为错误消息 + +```ts +/** + * 解析生成后的Respone,返回 result:是否成功,data:成功:公式对应图片地址或`SVG`代码,失败:错误信息 + */ +parse?: ( + response: any, +) => { + result: boolean; + data: string; +}; +``` + +### 画图接口 + +可以使用 `https://g.yanmao.cc/latex` 地址生成公式对应的 `SVG` 代码。该项目使用[mathjax](https://www.mathjax.org/) 生成 `SVG` 代码 + +演示站点:[https://drawing.yanmao.cc/](https://drawing.yanmao.cc/) + +配置: + +```ts +[Math.pluginName]: { + action: `https://g.yanmao.cc/latex`, + parse: (res: any) => { + if(res.success) return { result: true, data: res.svg} + return { result: false} + } +} +``` + +## 命令 + +### 插入公式代码 + +参数 1:公式代码 + +参数 2:公式对应图片地址或`SVG`代码 + +```ts +engine.command.execute( + Math.pluginName, + '公式代码', //可选 + '公式对应图片地址或`SVG`代码', //可选 +); +``` + +### 请求生成公式代码图片或 SVG + +参数 1:固定为 `query` +参数 2:成功后的回调 +参数 3:失败后的回调。可选 + +```ts +engine.command.execute(Math.pluginName, "query", success:(url: string) => void, failed: (message: string) => void); +``` diff --git a/docs/plugin/plugin-mention.md b/docs/plugin/plugin-mention.md new file mode 100644 index 00000000..e5860e7d --- /dev/null +++ b/docs/plugin/plugin-mention.md @@ -0,0 +1,126 @@ +# @aomao/plugin-mention + +Mention plugin + +## Install + +```bash +$ yarn add @aomao/plugin-mention +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Mention, {MentionComponent} from'@aomao/plugin-mention'; + +new Engine(...,{ plugins:[Mention], cards: [MentionComponent] }) +``` + +## Optional + +```ts +//Use configuration +new Engine(...,{ + config:{ + "mention":{ + //Other options + ... + } + } + }) +``` + +`defaultData`: Default drop-down query list display data + +`onSearch`: the method to query, or configure the action, choose one of the two + +`onSelect`: Call back after selecting an item in the list, here you can return a custom value combined with key and name to form a new value and store it in cardValue. And it will return together after executing the getList command + +`onClick`: Triggered when clicking on the "mention" + +`onMouseEnter`: Triggered when the mouse moves over the "mention" + +`onRender`: custom rendering list + +`onRenderItem`: custom rendering list item + +`onLoading`: custom rendering loading status + +`onEmpty`: custom render empty state + +`action`: query address, always use `GET` request, parameter `keyword` + +`data`: When querying, these data will be sent to the server at the same time + +```ts +//List data displayed by default +defaultData?: Array<{ key: string, name: string, avatar?: string}> +//Method for query, or configure action, choose one of the two +onSearch?:(keyword: string) => Promise> +//Call back after selecting an item in the list, here you can return a custom value combined with key and name to form a new value and store it in cardValue. And it will return together after executing the getList command +onSelect?: (data: {[key:string]: string}) => void | {[key: string]: string} +//Click event on "mention" +onClick?:(data: {[key:string]: string}) => void +// Triggered when the mouse moves over the "mention" +onMouseEnter?:(node: NodeInterface, data: {[key:string]: string}) => void +//Customize the rendering list, bindItem can bind the required properties and events for the list item +onRender?: (data: MentionItem, root: NodeInterface, bindItem: (node: NodeInterface, data: {[key:string]: string}) => NodeInterface) => Promise; +//Custom rendering list items +onRenderItem?: (item: MentionItem, root: NodeInterface) => string | NodeInterface | void +// Customize the rendering loading status +onLoading?: (root: NodeInterface) => string | NodeInterface | void +// Custom render empty state +onEmpty?: (root: NodeInterface) => string | NodeInterface | void +/** + * look for the address + */ +action?: string; +/** + * Data return type, default json + */ +type?:'*' |'json' |'xml' |'html' |'text' |'js'; +/** + * Additional data upload + */ +data?: {}; +/** + * Request type, default multipart/form-data; + */ +contentType?: string; +/** + * Parse the uploaded Respone and return result: whether it is successful or not, data: success: file address, failure: error message + */ +parse?: ( + response: any, +) => { + result: boolean; + data: Array<{ key: string, name: string, avatar?: string}>; +}; + +``` + +### Analyze server response data + +`result`: true upload is successful, data data collection. false upload failed, data is an error message + +```ts +/** + * Parse the uploaded Respone and return result: whether it is successful or not, data: success: file address, failure: error message + */ +parse?: ( + response: any, +) => { + result: boolean; + data: Array<{ key: string, name: string, avatar?: string}>; +}; +``` + +## Plug-in method + +Get all mentions in the document + +```ts +//Return Array<{ key: string, name: string}> +engine.command.executeMethod('mention', 'getList'); +``` diff --git a/docs/plugin/plugin-mention.zh-CN.md b/docs/plugin/plugin-mention.zh-CN.md new file mode 100644 index 00000000..deed8e70 --- /dev/null +++ b/docs/plugin/plugin-mention.zh-CN.md @@ -0,0 +1,126 @@ +# @aomao/plugin-mention + +提及插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-mention +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Mention, { MentionComponent } from '@aomao/plugin-mention'; + +new Engine(...,{ plugins:[Mention], cards: [MentionComponent] }) +``` + +## 可选项 + +```ts +//使用配置 +new Engine(...,{ + config:{ + "mention":{ + //其它可选项 + ... + } + } + }) +``` + +`defaultData`: 默认下拉查询列表展示数据 + +`onSearch`: 查询时的方法,或者配置 action,二选其一 + +`onSelect`: 选中列表中的一项后回调,这里可以返回一个自定义值与 key、name 一起组合成新的值存在 cardValue 里面。并且执行 getList 命令后会一起返回来 + +`onClick`: 在“提及”上单击时触发 + +`onMouseEnter`: 鼠标移入“提及”上时触发 + +`onRender`: 自定义渲染列表 + +`onRenderItem`: 自定义渲染列表项 + +`onLoading`: 自定渲染加载状态 + +`onEmpty`: 自定渲染空状态 + +`action`: 查询地址,始终使用 `GET` 请求,参数 `keyword` + +`data`: 查询时同时将这些数据一起传到到服务端 + +```ts +//默认展示的列表数据 +defaultData?: Array<{ key: string, name: string, avatar?: string}> +//查询时的方法,或者配置 action,二选其一 +onSearch?:(keyword: string) => Promise> +//选中列表中的一项后回调,这里可以返回一个自定义值与 key、name 一起组合成新的值存在 cardValue 里面。并且执行 getList 命令后会一起返回来 +onSelect?: (data: {[key:string]: string}) => void | {[key: string]: string} +//在“提及”上单击事件 +onClick?:(data: {[key:string]: string}) => void +//鼠标移入“提及”上时触发 +onMouseEnter?:(node: NodeInterface, data: {[key:string]: string}) => void +//自定义渲染列表,bindItem 可以为列表项绑定需要的属性和事件 +onRender?: (data: MentionItem, root: NodeInterface, bindItem: (node: NodeInterface, data: {[key:string]: string}) => NodeInterface) => Promise; +//自定义渲染列表项 +onRenderItem?: (item: MentionItem, root: NodeInterface) => string | NodeInterface | void +// 自定渲染加载状态 +onLoading?: (root: NodeInterface) => string | NodeInterface | void +// 自定渲染空状态 +onEmpty?: (root: NodeInterface) => string | NodeInterface | void +/** + * 查询地址 + */ +action?: string; +/** + * 数据返回类型,默认 json + */ +type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js'; +/** + * 额外携带数据上传 + */ +data?: {}; +/** + * 请求类型,默认 multipart/form-data; + */ +contentType?: string; +/** + * 解析上传后的Respone,返回 result:是否成功,data:成功:文件地址,失败:错误信息 + */ +parse?: ( + response: any, +) => { + result: boolean; + data: Array<{ key: string, name: string, avatar?: string}>; +}; + +``` + +### 解析服务端响应数据 + +`result`: true 上传成功,data 数据集合。false 上传失败,data 为错误消息 + +```ts +/** + * 解析上传后的Respone,返回 result:是否成功,data:成功:文件地址,失败:错误信息 + */ +parse?: ( + response: any, +) => { + result: boolean; + data: Array<{ key: string, name: string, avatar?: string}>; +}; +``` + +## 插件方法 + +获取文档中所有的提及 + +```ts +//返回 Array<{ key: string, name: string}> +engine.command.executeMethod('mention', 'getList'); +``` diff --git a/docs/plugin/plugin-orderedlist.md b/docs/plugin/plugin-orderedlist.md new file mode 100644 index 00000000..335fc121 --- /dev/null +++ b/docs/plugin/plugin-orderedlist.md @@ -0,0 +1,68 @@ +# @aomao/plugin-orderedlist + +Ordered list plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-orderedlist +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Orderedlist from'@aomao/plugin-orderedlist'; + +new Engine(...,{ plugins:[Orderedlist] }) +``` + +## Optional + +### hot key + +Default shortcut key `mod+shift+7` + +```ts +//hot key +hotkey?: string | Array;//default mod+shift+7 +//Use configuration +new Engine(...,{ + config:{ + "orderedlist":{ + //Modify shortcut keys + hotkey: "shortcut key" + } + } + }) +``` + +### Markdown + +Support markdown by default, pass in `false` to close + +The orderedlist plugin markdown syntax is `1.` serial number + dot + +```ts +markdown?: boolean;//enabled by default, false off +//Use configuration +new Engine(...,{ + config:{ + "orderedlist":{ + //Close markdown + markdown:false + } + } + }) +``` + +## Command + +There is a parameter `start:number` which defaults to 1, which indicates the starting number of the list + +```ts +//Use command to execute the plug-in and pass in the required parameters +engine.command.execute('orderedlist', 1); +//Use command to execute query current status, return false or current list plug-in name orderedlist tasklist unorderedlist +engine.command.queryState('orderedlist'); +``` diff --git a/docs/plugin/plugin-orderedlist.zh-CN.md b/docs/plugin/plugin-orderedlist.zh-CN.md new file mode 100644 index 00000000..4894a575 --- /dev/null +++ b/docs/plugin/plugin-orderedlist.zh-CN.md @@ -0,0 +1,68 @@ +# @aomao/plugin-orderedlist + +有序列表插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-orderedlist +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Orderedlist from '@aomao/plugin-orderedlist'; + +new Engine(...,{ plugins:[Orderedlist] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键`mod+shift+7` + +```ts +//快捷键 +hotkey?: string | Array;//默认mod+shift+7 +//使用配置 +new Engine(...,{ + config:{ + "orderedlist":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Orderedlist 插件 markdown 语法为`1.` 序号+点 + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "orderedlist":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +有一个参数 `start:number` 默认为 1,表示列表开始序号 + +```ts +//使用 command 执行插件、并传入所需参数 +engine.command.execute('orderedlist', 1); +//使用 command 执行查询当前状态,返回 false 或者当前列表插件名称 orderedlist tasklist unorderedlist +engine.command.queryState('orderedlist'); +``` diff --git a/docs/plugin/plugin-paintformat.md b/docs/plugin/plugin-paintformat.md new file mode 100644 index 00000000..5764f5fe --- /dev/null +++ b/docs/plugin/plugin-paintformat.md @@ -0,0 +1,53 @@ +# @aomao/plugin-paintformat + +Format brush plug-in + +Support all mark tag plugins + +The block supports the following plugins: `@aomao/plugin-heading` `@aomao/plugin-orderlist` `@aomao/plugin-unorderedlist` + +## Installation + +```bash +$ yarn add @aomao/plugin-paintformat +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Paintformat from'@aomao/plugin-paintformat'; + +new Engine(...,{ plugins:[Paintformat] }) +``` + +## Optional + +### Remove + +Remove the style command, or provide a method. The default is removeformat, you need to add the `@aomao/plugin-removeformat` plugin + +```ts +removeCommand?:string | ((range:RangeInterface) => void); +``` + +### Draw + +How to draw a block node, return false, do not perform built-in drawing, including not copying the css style of the block node + +```ts +/** + * @param currentBlock The node that currently needs to be drawn + * @param block needs to be copied format node + * */ +paintBlock?:(currentBlock:NodeInterface,block:NodeInterface) => boolean | void +``` + +## Command + +```ts +//Use command to execute the plugin +engine.command.execute('paintformat'); +//Use command to execute query current state, return boolean +engine.command.queryState('paintformat'); +``` diff --git a/docs/plugin/plugin-paintformat.zh-CN.md b/docs/plugin/plugin-paintformat.zh-CN.md new file mode 100644 index 00000000..929700a8 --- /dev/null +++ b/docs/plugin/plugin-paintformat.zh-CN.md @@ -0,0 +1,53 @@ +# @aomao/plugin-paintformat + +格式刷插件 + +支持所有 mark 标签插件 + +block 支持以下插件:`@aomao/plugin-heading` `@aomao/plugin-orderlist` `@aomao/plugin-unorderedlist` + +## 安装 + +```bash +$ yarn add @aomao/plugin-paintformat +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Paintformat from '@aomao/plugin-paintformat'; + +new Engine(...,{ plugins:[Paintformat] }) +``` + +## 可选项 + +### 移除 + +移除样式命令,或提供方法。默认为 removeformat ,需要添加 `@aomao/plugin-removeformat` 插件 + +```ts +removeCommand?:string | ((range:RangeInterface) => void); +``` + +### 绘制 + +如何绘制 block 节点,返回 false,不执行内置绘制,包括不复制 block 节点的 css 样式 + +```ts +/** + * @param currentBlock 当前需要绘制的节点 + * @param block 需要被复制格式的节点 + * */ +paintBlock?:(currentBlock:NodeInterface,block:NodeInterface) => boolean | void +``` + +## 命令 + +```ts +//使用 command 执行插件 +engine.command.execute('paintformat'); +//使用 command 执行查询当前状态,返回 boolean +engine.command.queryState('paintformat'); +``` diff --git a/docs/plugin/plugin-quote.md b/docs/plugin/plugin-quote.md new file mode 100644 index 00000000..6e8f453b --- /dev/null +++ b/docs/plugin/plugin-quote.md @@ -0,0 +1,66 @@ +# @aomao/plugin-quote + +Quote style plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-quote +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Quote from'@aomao/plugin-quote'; + +new Engine(...,{ plugins:[Quote] }) +``` + +## Optional + +### hot key + +The default shortcut key is `mod+shift+u` + +```ts +//hot key +hotkey?: string | Array; +//Use configuration +new Engine(...,{ + config:{ + "quote":{ + //Modify shortcut keys + hotkey: "shortcut key" + } + } + }) +``` + +### Markdown + +Support markdown by default, pass in `false` to close + +The markdown syntax of the Quote plug-in is `>` and it is triggered after the carriage return. + +```ts +markdown?: boolean;//enabled by default, false off +//Use configuration +new Engine(...,{ + config:{ + "quote":{ + //Close markdown + markdown:false + } + } + }) +``` + +## Command + +```ts +//Use command to execute the plug-in and pass in the required parameters +engine.command.execute('quote'); +//Use command to execute query current status, return boolean | undefined +engine.command.queryState('quote'); +``` diff --git a/docs/plugin/plugin-quote.zh-CN.md b/docs/plugin/plugin-quote.zh-CN.md new file mode 100644 index 00000000..b72befd2 --- /dev/null +++ b/docs/plugin/plugin-quote.zh-CN.md @@ -0,0 +1,66 @@ +# @aomao/plugin-quote + +引用样式插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-quote +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Quote from '@aomao/plugin-quote'; + +new Engine(...,{ plugins:[Quote] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+shift+u` + +```ts +//快捷键 +hotkey?: string | Array; +//使用配置 +new Engine(...,{ + config:{ + "quote":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Quote 插件 markdown 语法为`>`回车后触发 + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "quote":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +```ts +//使用 command 执行插件、并传入所需参数 +engine.command.execute('quote'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('quote'); +``` diff --git a/docs/plugin/plugin-redo.md b/docs/plugin/plugin-redo.md new file mode 100644 index 00000000..53ec7c48 --- /dev/null +++ b/docs/plugin/plugin-redo.md @@ -0,0 +1,47 @@ +# @aomao/plugin-redo + +Redo history plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-redo +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Redo from'@aomao/plugin-redo'; + +new Engine(...,{ plugins:[Redo] }) +``` + +## Optional + +### hot key + +The default shortcut key is `mod+y` `shift+mod+y` + +```ts +//hot key +hotkey?: string | Array; +//Use configuration +new Engine(...,{ + config:{ + "redo":{ + //Modify shortcut keys + hotkey: "shortcut key" + } + } + }) +``` + +## Command + +```ts +//Use command to execute the plug-in and pass in the required parameters +engine.command.execute('redo'); +//Use command to execute query current status, return boolean | undefined +engine.command.queryState('redo'); +``` diff --git a/docs/plugin/plugin-redo.zh-CN.md b/docs/plugin/plugin-redo.zh-CN.md new file mode 100644 index 00000000..fa575658 --- /dev/null +++ b/docs/plugin/plugin-redo.zh-CN.md @@ -0,0 +1,47 @@ +# @aomao/plugin-redo + +重做历史插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-redo +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Redo from '@aomao/plugin-redo'; + +new Engine(...,{ plugins:[Redo] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+y` `shift+mod+y` + +```ts +//快捷键 +hotkey?: string | Array; +//使用配置 +new Engine(...,{ + config:{ + "redo":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +## 命令 + +```ts +//使用 command 执行插件、并传入所需参数 +engine.command.execute('redo'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('redo'); +``` diff --git a/docs/plugin/plugin-removeformat.md b/docs/plugin/plugin-removeformat.md new file mode 100644 index 00000000..5de9f5e1 --- /dev/null +++ b/docs/plugin/plugin-removeformat.md @@ -0,0 +1,49 @@ +# @aomao/plugin-removeformat + +Remove style plugin + +Remove all mark tag plugins + +Remove all block styles + +## Installation + +```bash +$ yarn add @aomao/plugin-removeformat +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Removeformat from'@aomao/plugin-removeformat'; + +new Engine(...,{ plugins:[Removeformat] }) +``` + +## Optional + +### hot key + +The default shortcut key is `mod+\` + +```ts +//hot key +hotkey?: string | Array; +//Use configuration +new Engine(...,{ + config:{ + "redo":{ + //Modify shortcut keys + hotkey: "shortcut key" + } + } + }) +``` + +## Command + +```ts +//Use command to execute the plugin +engine.command.execute('removeformat'); +``` diff --git a/docs/plugin/plugin-removeformat.zh-CN.md b/docs/plugin/plugin-removeformat.zh-CN.md new file mode 100644 index 00000000..96c51495 --- /dev/null +++ b/docs/plugin/plugin-removeformat.zh-CN.md @@ -0,0 +1,49 @@ +# @aomao/plugin-removeformat + +移除样式插件 + +移除所有 mark 标签插件 + +移除所有 block 样式 + +## 安装 + +```bash +$ yarn add @aomao/plugin-removeformat +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Removeformat from '@aomao/plugin-removeformat'; + +new Engine(...,{ plugins:[Removeformat] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+\` + +```ts +//快捷键 +hotkey?: string | Array; +//使用配置 +new Engine(...,{ + config:{ + "redo":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +## 命令 + +```ts +//使用 command 执行插件 +engine.command.execute('removeformat'); +``` diff --git a/docs/plugin/plugin-selectall.md b/docs/plugin/plugin-selectall.md new file mode 100644 index 00000000..1b4ecfa4 --- /dev/null +++ b/docs/plugin/plugin-selectall.md @@ -0,0 +1,29 @@ +# @aomao/plugin-selectall + +Select all plugins + +## Installation + +```bash +$ yarn add @aomao/plugin-selectall +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Selectall from'@aomao/plugin-selectall'; + +new Engine(...,{ plugins:[Selectall] }) +``` + +## hot key + +The shortcut key is `mod+a`, which cannot be modified + +## Command + +```ts +//Use command to execute the plugin +engine.command.execute('selectall'); +``` diff --git a/docs/plugin/plugin-selectall.zh-CN.md b/docs/plugin/plugin-selectall.zh-CN.md new file mode 100644 index 00000000..a6738b05 --- /dev/null +++ b/docs/plugin/plugin-selectall.zh-CN.md @@ -0,0 +1,29 @@ +# @aomao/plugin-selectall + +全选插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-selectall +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Selectall from '@aomao/plugin-selectall'; + +new Engine(...,{ plugins:[Selectall] }) +``` + +## 快捷键 + +快捷键为 `mod+a`,不可修改 + +## 命令 + +```ts +//使用 command 执行插件 +engine.command.execute('selectall'); +``` diff --git a/docs/plugin/plugin-status.md b/docs/plugin/plugin-status.md new file mode 100644 index 00000000..5f444a53 --- /dev/null +++ b/docs/plugin/plugin-status.md @@ -0,0 +1,98 @@ +# @aomao/plugin-status + +Status plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-status +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Status, {StatusComponent} from'@aomao/plugin-status'; + +new Engine(...,{ plugins:[Status], cards:[StatusComponent]}) +``` + +## Optional + +### hot key + +No shortcut keys by default + +```ts +hotkey?:string; + +//Use configuration +new Engine(...,{ + config:{ + "status":{ + //Modify shortcut keys + hotkey: "shortcut key" + } + } + }) +``` + +### Custom color + +Can be modified or added to the default color list through `StatusComponent.colors` + +`colors` is a static property of `StatusComponent`, and its type is as follows: + +```ts +static colors: Array<{ + background: string, + color: string, + border?: string +}> +``` + +- `background` background color +- `color` font color +- `border` is optional. You can set the border color in the color list. In addition to beautification, it may not be visible to the naked eye in a color block that is close to white, and you can also set the border + +```ts +//Default color list +[ + { + background: '#FFE8E6', + color: '#820014', + border: '#FF4D4F', + }, + { + background: '#FCFCCA', + color: '#614700', + border: '#FFEC3D', + }, + { + background: '#E4F7D2', + color: '#135200', + border: '#73D13D', + }, + { + background: '#E9E9E9', + color: '#595959', + border: '#E9E9E9', + }, + { + background: '#D4EEFC', + color: '#003A8C', + border: '#40A9FF', + }, + { + background: '#DEE8FC', + color: '#061178', + border: '#597EF7', + }, +]; +``` + +## Command + +```ts +engine.command.execute('status'); +``` diff --git a/docs/plugin/plugin-status.zh-CN.md b/docs/plugin/plugin-status.zh-CN.md new file mode 100644 index 00000000..5300c2cb --- /dev/null +++ b/docs/plugin/plugin-status.zh-CN.md @@ -0,0 +1,98 @@ +# @aomao/plugin-status + +状态插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-status +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Status , { StatusComponent } from '@aomao/plugin-status'; + +new Engine(...,{ plugins:[Status] , cards:[StatusComponent]}) +``` + +## 可选项 + +### 快捷键 + +默认无快捷键 + +```ts +hotkey?:string; + +//使用配置 +new Engine(...,{ + config:{ + "status":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### 自定义颜色 + +可以通过 `StatusComponent.colors` 修改或增加到默认颜色列表中 + +`colors` 是 `StatusComponent` 的静态属性,它的类型如下: + +```ts +static colors: Array<{ + background: string, + color: string, + border?: string +}> +``` + +- `background` 背景颜色 +- `color` 字体颜色 +- `border` 可选,在颜色列表中可以设置边框颜色,除了可以美化外,在比较接近白色的色块中可能肉眼不好观察到,也可以设置边框 + +```ts +//默认颜色列表 +[ + { + background: '#FFE8E6', + color: '#820014', + border: '#FF4D4F', + }, + { + background: '#FCFCCA', + color: '#614700', + border: '#FFEC3D', + }, + { + background: '#E4F7D2', + color: '#135200', + border: '#73D13D', + }, + { + background: '#E9E9E9', + color: '#595959', + border: '#E9E9E9', + }, + { + background: '#D4EEFC', + color: '#003A8C', + border: '#40A9FF', + }, + { + background: '#DEE8FC', + color: '#061178', + border: '#597EF7', + }, +]; +``` + +## 命令 + +```ts +engine.command.execute('status'); +``` diff --git a/docs/plugin/plugin-strikethrough.md b/docs/plugin/plugin-strikethrough.md new file mode 100644 index 00000000..681ad489 --- /dev/null +++ b/docs/plugin/plugin-strikethrough.md @@ -0,0 +1,66 @@ +# @aomao/plugin-strikethrough + +Strikethrough style plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-strikethrough +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Strikethrough from'@aomao/plugin-strikethrough'; + +new Engine(...,{ plugins:[Strikethrough] }) +``` + +## Optional + +### hot key + +The default shortcut key is `mod+shift+x`, and multiple shortcut keys are passed in as an array + +```ts +//hot key, +hotkey?: string | Array; + +//Use configuration +new Engine(...,{ + config:{ + "strikethrough":{ + //Modify shortcut keys + hotkey: "shortcut key" + } + } + }) +``` + +### Markdown + +Support markdown by default, pass in `false` to close + +Strikethrough plugin markdown syntax is `~~` + +```ts +markdown?: boolean;//enabled by default, false off +//Use configuration +new Engine(...,{ + config:{ + "strikethrough":{ + //Close markdown + markdown:false + } + } + }) +``` + +## Command + +```ts +engine.command.execute('strikethrough'); +//Use command to execute query current status, return boolean | undefined +engine.command.queryState('strikethrough'); +``` diff --git a/docs/plugin/plugin-strikethrough.zh-CN.md b/docs/plugin/plugin-strikethrough.zh-CN.md new file mode 100644 index 00000000..0d3393e0 --- /dev/null +++ b/docs/plugin/plugin-strikethrough.zh-CN.md @@ -0,0 +1,66 @@ +# @aomao/plugin-strikethrough + +删除线样式插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-strikethrough +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Strikethrough from '@aomao/plugin-strikethrough'; + +new Engine(...,{ plugins:[Strikethrough] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+shift+x`,以数组形式传入多个快捷键 + +```ts +//快捷键, +hotkey?: string | Array; + +//使用配置 +new Engine(...,{ + config:{ + "strikethrough":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Strikethrough 插件 markdown 语法为`~~` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "strikethrough":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +```ts +engine.command.execute('strikethrough'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('strikethrough'); +``` diff --git a/docs/plugin/plugin-sub.md b/docs/plugin/plugin-sub.md new file mode 100644 index 00000000..73ce2978 --- /dev/null +++ b/docs/plugin/plugin-sub.md @@ -0,0 +1,66 @@ +# @aomao/plugin-sub + +Subscript style plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-sub +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Sub from'@aomao/plugin-sub'; + +new Engine(...,{ plugins:[Sub] }) +``` + +## Optional + +### hot key + +The default shortcut key is `mod+,`, multiple shortcut keys are passed in as an array + +```ts +//hot key, +hotkey?: string | Array; + +//Use configuration +new Engine(...,{ + config:{ + "sub":{ + //Modify shortcut keys + hotkey: "shortcut key" + } + } + }) +``` + +### Markdown + +Support markdown by default, pass in `false` to close + +Sub plugin markdown syntax is `~` + +```ts +markdown?: boolean;//enabled by default, false off +//Use configuration +new Engine(...,{ + config:{ + "sub":{ + //Close markdown + markdown:false + } + } + }) +``` + +## Command + +```ts +engine.command.execute('sub'); +//Use command to execute query current status, return boolean | undefined +engine.command.queryState('sub'); +``` diff --git a/docs/plugin/plugin-sub.zh-CN.md b/docs/plugin/plugin-sub.zh-CN.md new file mode 100644 index 00000000..20700b37 --- /dev/null +++ b/docs/plugin/plugin-sub.zh-CN.md @@ -0,0 +1,66 @@ +# @aomao/plugin-sub + +下标样式插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-sub +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Sub from '@aomao/plugin-sub'; + +new Engine(...,{ plugins:[Sub] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+,`,以数组形式传入多个快捷键 + +```ts +//快捷键, +hotkey?: string | Array; + +//使用配置 +new Engine(...,{ + config:{ + "sub":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Sub 插件 markdown 语法为`~` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "sub":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +```ts +engine.command.execute('sub'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('sub'); +``` diff --git a/docs/plugin/plugin-sup.md b/docs/plugin/plugin-sup.md new file mode 100644 index 00000000..57fc9d68 --- /dev/null +++ b/docs/plugin/plugin-sup.md @@ -0,0 +1,66 @@ +# @aomao/plugin-sup + +Superscript style plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-sup +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Sup from'@aomao/plugin-sup'; + +new Engine(...,{ plugins:[Sup] }) +``` + +## Optional + +### hot key + +The default shortcut key is `mod+.`, multiple shortcut keys are passed in as an array + +```ts +//hot key, +hotkey?: string | Array; + +//Use configuration +new Engine(...,{ + config:{ + "sup":{ + //Modify shortcut keys + hotkey: "shortcut key" + } + } + }) +``` + +### Markdown + +Support markdown by default, pass in `false` to close + +Sup plugin markdown syntax is `^` + +```ts +markdown?: boolean;//enabled by default, false off +//Use configuration +new Engine(...,{ + config:{ + "sup":{ + //Close markdown + markdown:false + } + } + }) +``` + +## Command + +```ts +engine.command.execute('sup'); +//Use command to execute query current status, return boolean | undefined +engine.command.queryState('sup'); +``` diff --git a/docs/plugin/plugin-sup.zh-CN.md b/docs/plugin/plugin-sup.zh-CN.md new file mode 100644 index 00000000..ad55b146 --- /dev/null +++ b/docs/plugin/plugin-sup.zh-CN.md @@ -0,0 +1,66 @@ +# @aomao/plugin-sup + +上标样式插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-sup +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Sup from '@aomao/plugin-sup'; + +new Engine(...,{ plugins:[Sup] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+.`,以数组形式传入多个快捷键 + +```ts +//快捷键, +hotkey?: string | Array; + +//使用配置 +new Engine(...,{ + config:{ + "sup":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Sup 插件 markdown 语法为`^` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "sup":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +```ts +engine.command.execute('sup'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('sup'); +``` diff --git a/docs/plugin/plugin-table.md b/docs/plugin/plugin-table.md new file mode 100644 index 00000000..63bfa7b7 --- /dev/null +++ b/docs/plugin/plugin-table.md @@ -0,0 +1,49 @@ +# @aomao/plugin-table + +Form plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-table +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Table, {TableComponent} from'@aomao/plugin-table'; + +new Engine(...,{ plugins:[Table], cards:[TableComponent]}) +``` + +## Optional + +### hot key + +No shortcut keys by default + +```ts +//Shortcut keys, key combination keys, args, execution parameters, [rows?: string, cols?: string] Number of rows: default 3 rows, number of columns: default 3 columns +hotkey?:string | {key:string,args:Array};//default none + +//Use configuration +new Engine(...,{ + config:{ + "table":{ + //Modify shortcut keys + hotkey:{ + key:"mod+t", + args:[5,5] + } + } + } + }) +``` + +## Command + +```ts +//Can carry two parameters, the number of rows and the number of columns, all are optional +engine.command.execute('table', 5, 5); +``` diff --git a/docs/plugin/plugin-table.zh-CN.md b/docs/plugin/plugin-table.zh-CN.md new file mode 100644 index 00000000..5461ea00 --- /dev/null +++ b/docs/plugin/plugin-table.zh-CN.md @@ -0,0 +1,49 @@ +# @aomao/plugin-table + +表格插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-table +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Table, { TableComponent } from '@aomao/plugin-table'; + +new Engine(...,{ plugins:[Table] , cards:[TableComponent]}) +``` + +## 可选项 + +### 快捷键 + +默认无快捷键 + +```ts +//快捷键,key 组合键,args,执行参数,[rows?: string, cols?: string] 行数:默认3行,列数:默认3列 +hotkey?:string | {key:string,args:Array};//默认无 + +//使用配置 +new Engine(...,{ + config:{ + "table":{ + //修改快捷键 + hotkey:{ + key:"mod+t", + args:[5,5] + } + } + } + }) +``` + +## 命令 + +```ts +//可携带两个参数,行数,列数,都是可选的 +engine.command.execute('table', 5, 5); +``` diff --git a/docs/plugin/plugin-tasklist.md b/docs/plugin/plugin-tasklist.md new file mode 100644 index 00000000..48bdfa03 --- /dev/null +++ b/docs/plugin/plugin-tasklist.md @@ -0,0 +1,68 @@ +# @aomao/plugin-tasklist + +Task list plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-tasklist +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Tasklist, {CheckboxComponent} from'@aomao/plugin-tasklist'; + +new Engine(...,{ plugins:[Tasklist], cards:[CheckboxComponent] }) +``` + +## Optional + +### hot key + +Default shortcut key `mod+shift+9` + +```ts +//hot key +hotkey?: string | Array;//default mod+shift+9 +//Use configuration +new Engine(...,{ + config:{ + "tasklist":{ + //Modify shortcut keys + hotkey: "shortcut key" + } + } + }) +``` + +### Markdown + +Support markdown by default, pass in `false` to close + +Tasklist plugin markdown syntax is `[]`, `[ ]`, `[x]` + +```ts +markdown?: boolean;//enabled by default, false off +//Use configuration +new Engine(...,{ + config:{ + "tasklist":{ + //Close markdown + markdown:false + } + } + }) +``` + +## Command + +You can pass in {checked:true} to indicate checked, optional parameters + +```ts +//Use command to execute the plug-in and pass in the required parameters +engine.command.execute('tasklist', { checked: boolean }); +//Use command to execute query current status, return false or current list plug-in name tasklist tasklist unorderedlist +engine.command.queryState('tasklist'); +``` diff --git a/docs/plugin/plugin-tasklist.zh-CN.md b/docs/plugin/plugin-tasklist.zh-CN.md new file mode 100644 index 00000000..24b3817d --- /dev/null +++ b/docs/plugin/plugin-tasklist.zh-CN.md @@ -0,0 +1,68 @@ +# @aomao/plugin-tasklist + +任务列表插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-tasklist +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Tasklist , { CheckboxComponent } from '@aomao/plugin-tasklist'; + +new Engine(...,{ plugins:[Tasklist] , cards:[CheckboxComponent] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键`mod+shift+9` + +```ts +//快捷键 +hotkey?: string | Array;//默认mod+shift+9 +//使用配置 +new Engine(...,{ + config:{ + "tasklist":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Tasklist 插件 markdown 语法为`[]`, `[ ]`, `[x]` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "tasklist":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +可传入 { checked:true } 表示选中,可选参数 + +```ts +//使用 command 执行插件、并传入所需参数 +engine.command.execute('tasklist', { checked: boolean }); +//使用 command 执行查询当前状态,返回 false 或者当前列表插件名称 tasklist tasklist unorderedlist +engine.command.queryState('tasklist'); +``` diff --git a/docs/plugin/plugin-underline.md b/docs/plugin/plugin-underline.md new file mode 100644 index 00000000..20bcf7a1 --- /dev/null +++ b/docs/plugin/plugin-underline.md @@ -0,0 +1,47 @@ +# @aomao/plugin-underline + +Underline style plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-underline +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Underline from'@aomao/plugin-underline'; + +new Engine(...,{ plugins:[Underline] }) +``` + +## Optional + +### hot key + +The default shortcut key is `mod+u`, and multiple shortcut keys are passed in as an array + +```ts +//hot key, +hotkey?: string | Array; + +//Use configuration +new Engine(...,{ + config:{ + "underline":{ + //Modify shortcut keys + hotkey: "shortcut key" + } + } + }) +``` + +## Command + +```ts +engine.command.execute('underline'); +//Use command to execute query current status, return boolean | undefined +engine.command.queryState('underline'); +``` diff --git a/docs/plugin/plugin-underline.zh-CN.md b/docs/plugin/plugin-underline.zh-CN.md new file mode 100644 index 00000000..2ed2bddf --- /dev/null +++ b/docs/plugin/plugin-underline.zh-CN.md @@ -0,0 +1,47 @@ +# @aomao/plugin-underline + +下划线样式插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-underline +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Underline from '@aomao/plugin-underline'; + +new Engine(...,{ plugins:[Underline] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+u`,以数组形式传入多个快捷键 + +```ts +//快捷键, +hotkey?: string | Array; + +//使用配置 +new Engine(...,{ + config:{ + "underline":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +## 命令 + +```ts +engine.command.execute('underline'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('underline'); +``` diff --git a/docs/plugin/plugin-undo.md b/docs/plugin/plugin-undo.md new file mode 100644 index 00000000..9a852754 --- /dev/null +++ b/docs/plugin/plugin-undo.md @@ -0,0 +1,47 @@ +# @aomao/plugin-undo + +Undo history plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-undo +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Undo from'@aomao/plugin-undo'; + +new Engine(...,{ plugins:[Undo] }) +``` + +## Optional + +### hot key + +The default shortcut key is `mod+z` `shift+mod+z` + +```ts +//hot key +hotkey?: string | Array; +//Use configuration +new Engine(...,{ + config:{ + "undo":{ + //Modify shortcut keys + hotkey: "shortcut key" + } + } + }) +``` + +## Command + +```ts +//Use command to execute the plug-in and pass in the required parameters +engine.command.execute('undo'); +//Use command to execute query current status, return boolean | undefined +engine.command.queryState('undo'); +``` diff --git a/docs/plugin/plugin-undo.zh-CN.md b/docs/plugin/plugin-undo.zh-CN.md new file mode 100644 index 00000000..2c3b8909 --- /dev/null +++ b/docs/plugin/plugin-undo.zh-CN.md @@ -0,0 +1,47 @@ +# @aomao/plugin-undo + +撤销历史插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-undo +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Undo from '@aomao/plugin-undo'; + +new Engine(...,{ plugins:[Undo] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+z` `shift+mod+z` + +```ts +//快捷键 +hotkey?: string | Array; +//使用配置 +new Engine(...,{ + config:{ + "undo":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +## 命令 + +```ts +//使用 command 执行插件、并传入所需参数 +engine.command.execute('undo'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('undo'); +``` diff --git a/docs/plugin/plugin-unorderedlist.md b/docs/plugin/plugin-unorderedlist.md new file mode 100644 index 00000000..2cbccc36 --- /dev/null +++ b/docs/plugin/plugin-unorderedlist.md @@ -0,0 +1,66 @@ +# @aomao/plugin-unorderedlist + +Unordered list plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-unorderedlist +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Unorderedlist from'@aomao/plugin-unorderedlist'; + +new Engine(...,{ plugins:[Unorderedlist] }) +``` + +## Optional + +### hot key + +Default shortcut key `mod+shift+8` + +```ts +//hot key +hotkey?: string | Array;//default mod+shift+8 +//Use configuration +new Engine(...,{ + config:{ + "unorderedlist":{ + //Modify shortcut keys + hotkey: "shortcut key" + } + } + }) +``` + +### Markdown + +Support markdown by default, pass in `false` to close + +Unorderedlist plugin markdown syntax is `*`, `-`, `+` + +```ts +markdown?: boolean;//enabled by default, false off +//Use configuration +new Engine(...,{ + config:{ + "unorderedlist":{ + //Close markdown + markdown:false + } + } + }) +``` + +## Command + +```ts +//Use command to execute the plug-in and pass in the required parameters +engine.command.execute('unorderedlist'); +//Use command to execute query current status, return false or current list plug-in name unorderedlist tasklist unorderedlist +engine.command.queryState('unorderedlist'); +``` diff --git a/docs/plugin/plugin-unorderedlist.zh-CN.md b/docs/plugin/plugin-unorderedlist.zh-CN.md new file mode 100644 index 00000000..012d884d --- /dev/null +++ b/docs/plugin/plugin-unorderedlist.zh-CN.md @@ -0,0 +1,66 @@ +# @aomao/plugin-unorderedlist + +无序列表插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-unorderedlist +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Unorderedlist from '@aomao/plugin-unorderedlist'; + +new Engine(...,{ plugins:[Unorderedlist] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键`mod+shift+8` + +```ts +//快捷键 +hotkey?: string | Array;//默认mod+shift+8 +//使用配置 +new Engine(...,{ + config:{ + "unorderedlist":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Unorderedlist 插件 markdown 语法为`*`, `-`, `+` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "unorderedlist":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +```ts +//使用 command 执行插件、并传入所需参数 +engine.command.execute('unorderedlist'); +//使用 command 执行查询当前状态,返回 false 或者当前列表插件名称 unorderedlist tasklist unorderedlist +engine.command.queryState('unorderedlist'); +``` diff --git a/docs/plugin/plugin-video.md b/docs/plugin/plugin-video.md new file mode 100644 index 00000000..05959d85 --- /dev/null +++ b/docs/plugin/plugin-video.md @@ -0,0 +1,209 @@ +# @aomao/plugin-video + +Video plugin + +## Installation + +```bash +$ yarn add @aomao/plugin-video +``` + +Add to engine + +```ts +import Engine, {EngineInterface} from'@aomao/engine'; +import Video, {VideoComponent, VideoUploader} from'@aomao/plugin-video'; + +new Engine(...,{ plugins:[ Video, VideoUploader], cards:[ VideoComponent ]}) +``` + +The main functions of the `VideoUploader` plug-in: select video files, upload video files + +## `Video` optional + +`onBeforeRender` can modify the address before setting the video address or when downloading the video. In addition, you can modify the address of the main image of the video. + +```ts +onBeforeRender?: (action:'download' |'query' |'cover', url: string) => string; +``` + +## `VideoUploader` optional + +```ts +//Use configuration +new Engine(...,{ + config:{ + [VideoUploader.pluginName]:{ + //...Related configuration + } + } + }) +``` + +### File Upload + +`action`: upload address, always use `POST` request + +`crossOrigin`: Whether to cross-origin + +`headers`: request header + +`contentType`: File upload is uploaded in `multipart/form-data;` type by default + +`accept`: Restrict the file type selected by the user's file selection box, the default `mp4` format + +`limitSize`: Limit the file size selected by the user. If the file size exceeds the limit, no upload will be requested. Default: `1024 * 1024 * 5` 5M + +`multiple`: `false` can only upload one file at a time, `true` defaults to a maximum of 100 files at a time. You can specify the specific number, but the file selection box cannot be limited, only the first number of uploads can be limited when uploading + +`data`: POST these data to the server at the same time when the file is uploaded + +`name`: When file upload request, the name of the request parameter in `FormData`, the default is `file` + +```ts +/** + * File upload address + */ +action:string +/** + * Whether cross-domain + */ +crossOrigin?: boolean; +/** +* Request header +*/ +headers?: {[key: string]: string} | (() => {[key: string]: string }); +/** + * Data return type, default json + */ +type?:'*' |'json' |'xml' |'html' |'text' |'js'; +/** + * The name of the FormData when the video file is uploaded, the default is file + */ +name?: string +/** + * Additional data upload + */ +data?: {}; +/** + * Request type, default multipart/form-data; + */ +contentType?:string +/** + * The format of the file reception, the default "*" all + */ +accept?: string | Array; +/** + * File selection limit + */ +multiple?: boolean | number; +/** + * Upload size limit, default 1024 * 1024 * 5 is 5M + */ +limitSize?: number; + +``` + +### Query video information + +This configuration may be required when there are playback permissions or restrictions on the video, other video files that cannot be played directly with html5 and need to be transcoded, and video files that require other processing of the video. + +The above video file upload processing flow: + +- After selecting the file to upload, you need to return the `status` field and mark the value as `transcoding`, and you need to return the unique identifier of the video file on the server side `id`, which can identify the video file in subsequent queries to obtain the video file Process the information, otherwise it will be regarded as `done` and directly transmitted to the video tag for playback +- When the plug-in gets the `status` field value as `transcoding`, it will display the message waiting for `transcoding...`, and call the query interface through the `id` parameter every 3 seconds to get the video file processing status until `status` Stop polling when the value is not `transcoding` + +In addition, after the `video information query interface` is configured, the `video information interface` query will be called every time a video is displayed, and the result returned by the interface will be used as a parameter for displaying the video information + +```ts +/** + * Query video information + */ +query?: { + /** + * look for the address + */ + action: string; + /** + * Data return type, default json + */ + type?:'*' |'json' |'xml' |'html' |'text' |'js'; + /** + * Additional data upload + */ + data?: {}; + /** + * Request type, default multipart/form-data; + */ + contentType?: string; +} +``` + +### Analyze server response data + +Will find by default + +Video file address: response.url || response.data && response.data.url is generally a playable mp4 address +Video file identification: response.id || response.data && response.data.id optional parameters, configured with `video query interface` must +Video file cover image address: response.cover || response.cover && response.data.cover Optional parameters +Video file processing status: response.status || response.status && response.data.status optional parameters, configured with `video query interface` required, otherwise it will be regarded as `done` +Video download address: response.download || response.data && response.data.download The download address of the video file, you can add permissions, time restrictions, etc., if you have one, you can return the address + +`result`: true upload is successful, data is video information. false upload failed, data is an error message + +```ts +/** + * Parse the uploaded Respone and return result: whether it is successful or not, data: success: video information, failure: error information + */ +parse?: ( + response: any, +) => { + result: boolean; + data: { + url: string, + id?: string, + cover?: string + status?: string + } | string; +}; +``` + +## Command + +### `Video` plugin command + +Insert a file + +Parameter 1: File status `uploading` | `done` | `transcoding` | `error` uploading, uploading completed, transcoding, uploading error + +Parameter 2: When the status is not `error`, the file is displayed, otherwise an error message is displayed + +```ts +//'uploading' |'done' | `transcoding` |'error' +engine.command.execute( + Video.pluginName, + 'done', + 'Video address', + 'Video name', //optional, the default is the video address + 'Video ID', //optional, equipped with the interface for querying video information must + 'Video cover', //optional + 'Video size', //optional + 'Download address', //optional +); +``` + +### `VideoUploader` plug-in command + +Pop up the file selection box and perform upload + +Optional parameter 1: Incoming file list, these files will be uploaded. Otherwise, a file selection box will pop up and upload it after selecting the file. Or pass in the `query` command to query the status of the video file +Optional parameter 2: Parameters for querying the information status of the video file, 0. Video file identification, 1. Callback after successful processing, 2. Callback after failed processing + +```ts +//Method signature +async execute(files?: Array | MouseEvent | string,...args:any):void +//Excuting an order +engine.command.execute(VideoUploader.pluginName,file); +//Inquire +engine.command.execute(VideoUploader.pluginName,"query","identification",success: (data?:{ url: string, name?: string, cover?: string, download?: string, status?: string }) => void, failed: (message: string) => void = () => ()); +``` diff --git a/docs/plugin/plugin-video.zh-CN.md b/docs/plugin/plugin-video.zh-CN.md new file mode 100644 index 00000000..d067adbb --- /dev/null +++ b/docs/plugin/plugin-video.zh-CN.md @@ -0,0 +1,209 @@ +# @aomao/plugin-video + +视频插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-video +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Video , { VideoComponent , VideoUploader } from '@aomao/plugin-video'; + +new Engine(...,{ plugins:[ Video , VideoUploader ] , cards:[ VideoComponent ]}) +``` + +`VideoUploader` 插件主要功能:选择视频文件、上传视频文件 + +## `Video` 可选项 + +`onBeforeRender` 设置视频地址前可或者下载视频时可对地址修改。另外还可以对视频的主图修改地址。 + +```ts +onBeforeRender?: (action: 'download' | 'query' | 'cover', url: string) => string; +``` + +## `VideoUploader` 可选项 + +```ts +//使用配置 +new Engine(...,{ + config:{ + [VideoUploader.pluginName]:{ + //...相关配置 + } + } + }) +``` + +### 文件上传 + +`action`: 上传地址,始终使用 `POST` 请求 + +`crossOrigin`: 是否跨域 + +`headers`: 请求头 + +`contentType`: 文件上传默认以 `multipart/form-data;` 类型上传 + +`accept`: 限制用户文件选择框选择的文件类型,默认 `mp4` 格式 + +`limitSize`: 限制用户选择的文件大小,超过限制将不请求上传。默认:`1024 * 1024 * 5` 5M + +`multiple`: `false` 一次只能上传一个文件,`true` 默认一次最多 100 个文件。可以指定具体数量,但是文件选择框无法限制,只能上传的时候限制上传最前面的张数 + +`data`: 文件上传时同时将这些数据一起`POST`到服务端 + +`name`: 文件上传请求时,请求参数在 `FormData` 中的名称,默认 `file` + +```ts +/** + * 文件上传地址 + */ +action:string +/** + * 是否跨域 + */ +crossOrigin?: boolean; +/** +* 请求头 +*/ +headers?: { [key: string]: string } | (() => { [key: string]: string }); +/** + * 数据返回类型,默认 json + */ +type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js'; +/** + * 视频文件上传时 FormData 的名称,默认 file + */ +name?: string +/** + * 额外携带数据上传 + */ +data?: {}; +/** + * 请求类型,默认 multipart/form-data; + */ +contentType?:string +/** + * 文件接收的格式,默认 "*" 所有的 + */ +accept?: string | Array; +/** + * 文件选择限制数量 + */ +multiple?: boolean | number; +/** + * 上传大小限制,默认 1024 * 1024 * 5 就是5M + */ +limitSize?: number; + +``` + +### 查询视频信息 + +在对视频有播放权限或限制、对其它无法使用 html5 直接播放需要转码后才能播放的视频文件、需要对视频进行其它处理的视频文件都可能需要这个配置 + +以上的视频文件上传处理流程: + +- 选择文件上传后需要返回 `status` 字段并标明值为 `transcoding`,并且需要返回这个视频文件在服务端的唯一标识 `id` ,这个标识能够在后续查询中辨别这个视频文件以或得视频文件处理信息,否则一律视为 `done` 直接传输给 video 标签播放 +- 插件获取到 `status` 字段值为 `transcoding` 时,会展示等待 `转码中...` 信息,并且每 3 秒通过 `id` 参数调用查询接口获取视频文件处理状态,直到 `status` 的值不为 `transcoding` 时终止轮询 + +除此之外,在有配置 `查询视频信息接口` 后,每次展示视频时都会调用 `查询视频信息接口` 查询一次,接口返回的结果将作为展示视频信息的参数 + +```ts +/** + * 查询视频信息 + */ +query?: { + /** + * 查询地址 + */ + action: string; + /** + * 数据返回类型,默认 json + */ + type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js'; + /** + * 额外携带数据上传 + */ + data?: {}; + /** + * 请求类型,默认 multipart/form-data; + */ + contentType?: string; +} +``` + +### 解析服务端响应数据 + +默认会查找 + +视频文件地址:response.url || response.data && response.data.url 一般为可播放的 mp4 地址 +视频文件标识:response.id || response.data && response.data.id 可选参数,配置了`视频查询接口`必须 +视频文件封面图片地址:response.cover || response.cover && response.data.cover 可选参数 +视频文件处理状态:response.status || response.status && response.data.status 可选参数,配置了`视频查询接口`必须,否则一律视为 `done` +视频下载地址:response.download || response.data && response.data.download 视频文件的下载地址,可以加权限、时间限制等等,如果有可以返回地址 + +`result`: true 上传成功,data 为视频信息。false 上传失败,data 为错误消息 + +```ts +/** + * 解析上传后的Respone,返回 result:是否成功,data:成功:视频信息,失败:错误信息 + */ +parse?: ( + response: any, +) => { + result: boolean; + data: { + url: string, + id?: string, + cover?: string + status?: string + } | string; +}; +``` + +## 命令 + +### `Video` 插件命令 + +插入一个文件 + +参数 1:文件状态`uploading` | `done` | `transcoding` | `error` 上传中、上传完成、转码中、上传错误 + +参数 2:在状态非 `error` 下,为展示文件,否则展示错误消息 + +```ts +//'uploading' | 'done' | `transcoding` | 'error' +engine.command.execute( + Video.pluginName, + 'done', + '视频地址', + '视频名称', //可选,默认为视频地址 + '视频标识', //可选,配置了 查询视频信息接口 必须 + '视频封面', //可选 + '视频大小', //可选 + '下载地址', //可选 +); +``` + +### `VideoUploader` 插件命令 + +弹出文件选择框,并执行上传 + +可选参数 1: 传入文件列表,将上传这些文件。否则弹出文件选择框并,选择文件后执行上传。或者传入 `query` 命令,查询视频文件状态 +可选参数 2: 查询视频文件信息状态的参数,0.视频文件标识,1.成功处理后的回调,2.失败处理后的回调 + +```ts +//方法签名 +async execute(files?: Array | MouseEvent | string,...args:any):void +//执行命令 +engine.command.execute(VideoUploader.pluginName,file); +//查询 +engine.command.execute(VideoUploader.pluginName,"query","标识",success: (data?:{ url: string, name?: string, cover?: string, download?: string, status?: string }) => void, failed: (message: string) => void = () => {}); +``` diff --git a/docs/plugin/tutorials-block.md b/docs/plugin/tutorials-block.md new file mode 100644 index 00000000..545b40af --- /dev/null +++ b/docs/plugin/tutorials-block.md @@ -0,0 +1,154 @@ +# Block plugin + +Block node plugin + +Usually used for block-level nodes on a single line, similar to titles, quotes + +For this type of plug-in, we need to inherit the `BlockPlugin` abstract class. The `BlockPlugin` abstract class extends some properties and methods on the basis of inheriting the `ElementPlugin` abstract class. So the plug-in that inherits `BlockPlugin` also has all the attributes and methods of the `ElementPlugin` abstract class + +## Inheritance + +Inherit the `BlockPlugin` abstract class + +```ts +import {BlockPlugin} from'@aomao/engine' + +export default class extends BlockPlugin { +... +} +``` + +## Attributes + +### `tagName` + +Label name, must + +Type: `string | Array` + +The tag name here is the same as the tag name in the parent class `ElementPlugin`, except that the tag name is one of the necessary attributes of the `BlockPlugin` plugin + +The `BlockPlugin` tag name can be an array. For example, title, h1, h2, h3, h4, h5, h6. When set as an array, these names will be combined with `style` and `attributes` into a `schema` rule. + +```ts +readonly tagName = ['h1','h2','h3','h4','h5','h6']; +``` + +### `allowIn` + +This node allows block nodes that can be placed, the default is `$root` editor root node + +Type: `Array` + +```ts +readonly allowIn = ['blockquote','$root'] +``` + +### `disableMark` + +Disabled mark plug-in style, the mark plug-in node style that cannot appear under the block node + +Type: `Array` + +```ts +//Pass in the mark plugin name +disableMark = ['fontsize', 'bold']; +``` + +### `canMerge` + +Can the same block nodes be merged, the default is false, optional + +Type: `boolean` + +## Method + +### `init` + +Initialization, optional + +The `BlockPlugin` plugin has implemented the `init` method, if you need to use it, you need to manually call it again. Otherwise there will be unexpected situations + +```ts +export default class extends BlockPlugin { +... + init(){ + super.init() + } +} +``` + +### `queryState` + +Query plug-in status command, optional + +```ts +queryState() { + //Not an engine + if (!isEngine(this.editor)) return; + const {change} = this.editor + //Get all block-level labels in the current cursor selection area + const blocks = change.blocks; + if (blocks.length === 0) { + return''; + } + //Check if there is a label name that contains the current plug-in settings. If there is an attribute style set, you also need to compare the attributes and styles + return this.tagName.indexOf(blocks[0].name) >= 0? blocks[0].name:''; +} +``` + +### `execute` + +Execute plug-in commands, need to be implemented + +Example of adding a block tag: + +```ts +execute(...args) { + //Not an engine + if (!isEngine(this.editor)) return; + const {change, block, node} = this.editor; + if (!this.queryState()) { + //Package block node + block.wrap(`<${this.tagName} />`); + } else { + //Get the cursor object + const range = change.range.get(); + //Get the first block-level node in the current cursor area and look up the node with the same name as the block-level node set by the current plugin + const blockquote = change.blocks[0].closest(this.tagName); + //Mark the cursor position before removing the package + const selection = range.createSelection(); + //Remove package + node.unwrap(blockquote); + //Restore the cursor position after removing the package + selection.move() + //Reset the cursor of the editor + change.range.select(range); + } +} +``` + +### `schema` + +Set the `schema` rule of this block plugin, optional + +The `BlockPlugin` plugin has implemented the `schema` method and will automatically set the rules according to the `tagName` `style` `attributes`. + +If you need to use it, you can override this method or use super.schema() to call this method again + +### `markdown` + +Parse `markdown` grammar, optional + +By default, after pressing the space, the engine will get the text characters in front of the space, and then call this method, we can find the `markdown` syntax of the text in the method + +```ts +/** + * Markdown processing + * @param event event + * @param text text + * @param block block-level node + * @param node trigger node + */ +markdown?(event: KeyboardEvent, text: string, block: NodeInterface, node: NodeInterface): boolean | void; +``` diff --git a/docs/plugin/tutorials-block.zh-CN.md b/docs/plugin/tutorials-block.zh-CN.md new file mode 100644 index 00000000..a16d0def --- /dev/null +++ b/docs/plugin/tutorials-block.zh-CN.md @@ -0,0 +1,154 @@ +# Block 插件 + +块级节点插件 + +通常用于独占一行的块级节点,类似于标题、引用 + +此类插件我们需要继承 `BlockPlugin` 抽象类,`BlockPlugin` 抽象类在继承 `ElementPlugin` 抽象类的基础上扩展了一些属性和方法。所以继承 `BlockPlugin` 的插件也同样拥有`ElementPlugin`抽象类的所有属性和方法 + +## 继承 + +继承 `BlockPlugin` 抽象类 + +```ts +import { BlockPlugin } from '@aomao/engine' + +export default class extends BlockPlugin { + ... +} +``` + +## 属性 + +### `tagName` + +标签名称,必须 + +类型:`string | Array` + +此处的标签名称与父类`ElementPlugin`中的标签名称作用是一致的,只不过标签名称是 `BlockPlugin` 插件必要的属性之一 + +`BlockPlugin` 标签名称可以是数组。例如标题,h1 h2 h3 h4 h5 h6 多种标签名称,设置为数组后,会把这些名称单独和 `style` `attributes`组合成 `schema` 规则 + +```ts +readonly tagName = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; +``` + +### `allowIn` + +该节点允许可以放入的 block 节点,默认为 `$root`编辑器根节点 + +类型:`Array` + +```ts +readonly allowIn = ['blockquote', '$root'] +``` + +### `disableMark` + +禁用的 mark 插件样式,该 block 节点下不可以出现的 mark 插件节点的样式 + +类型:`Array` + +```ts +//传入 mark插件 名称 +disableMark = ['fontsize', 'bold']; +``` + +### `canMerge` + +相同的 block 节点能否合并,默认 false,可选 + +类型:`boolean` + +## 方法 + +### `init` + +初始化,可选 + +`BlockPlugin` 插件已经实现了`init`方法,如果需要使用,需要手动再次调用。否则会出现意料外的情况 + +```ts +export default class extends BlockPlugin { + ... + init(){ + super.init() + } +} +``` + +### `queryState` + +查询插件状态命令,可选 + +```ts +queryState() { + //不是引擎 + if (!isEngine(this.editor)) return; + const { change } = this.editor + //获取当前光标选择区域内的所有块级标签 + const blocks = change.blocks; + if (blocks.length === 0) { + return ''; + } + //查看是否有包含当前插件设置的标签名称,如果有设置属性样式,还需要比较属性和样式 + return this.tagName.indexOf(blocks[0].name) >= 0 ? blocks[0].name : ''; +} +``` + +### `execute` + +执行插件命令,需要实现 + +添加一个 block 标签的例子: + +```ts +execute(...args) { + //不是引擎 + if (!isEngine(this.editor)) return; + const { change, block, node } = this.editor; + if (!this.queryState()) { + //包裹块级节点 + block.wrap(`<${this.tagName} />`); + } else { + //获取光标对象 + const range = change.range.get(); + //获取当前光标区域内的第一个块级节点并且向上查找与当前插件设置的块级节点名称相同的节点 + const blockquote = change.blocks[0].closest(this.tagName); + //标记移除包裹前光标位置 + const selection = range.createSelection(); + //移除包裹 + node.unwrap(blockquote); + //还原移除包裹后的光标所处位置 + selection.move() + //重新设置编辑器所处光标 + change.range.select(range); + } +} +``` + +### `schema` + +设置此 block 插件的`schema`规则,可选 + +`BlockPlugin` 插件已经实现了`schema`方法,会自动根据 `tagName` `style` `attributes` 设置规则。 + +如果需要使用,可以重写此方法或者使用 super.schema()再次调用此方法 + +### `markdown` + +解析`markdown`语法,可选 + +默认在按下空格后,引擎会获取到空格前面的文本字符,然后调用此方法,我们可以在方法中查找文本的`markdown`语法 + +```ts +/** + * Markdown 处理 + * @param event 事件 + * @param text 文本 + * @param block 块级节点 + * @param node 触发节点 + */ +markdown?(event: KeyboardEvent, text: string, block: NodeInterface, node: NodeInterface): boolean | void; +``` diff --git a/docs/plugin/tutorials-card.md b/docs/plugin/tutorials-card.md new file mode 100644 index 00000000..cf029aa1 --- /dev/null +++ b/docs/plugin/tutorials-card.md @@ -0,0 +1,1468 @@ +# Card component + +Card component + +Usually used for completely custom rendering content + +## Inheritance + +Inherit the `Card` abstract class + +```ts +import {Card} from'@aomao/engine' + +export default class extends Card { +... +} +``` + +## Example + +### `Rendering` + +Rendering a card needs to display the `render` method, which is an abstract method and must be implemented + +```ts +import { $, Card } from '@aomao/engine'; + +export default class extends Card { + static get cardName() { + return 'CardName'; + } + + static get cardType() { + return CardType.BLOCK; + } + + render() { + //Return the node, it will be automatically appended to the center position of the card + return $('
Card
'); + //Or take the initiative to append + this.getCenter().append($('
Card
')); + } +} +``` + +### React rendering + +React components + +```ts +import React from 'react'; + +export default () =>
React Commponent
; +``` + +Card components + +```ts +import ReactDOM from 'react-dom'; +import { $, Card, CardType } from '@aomao/engine'; +// import custom react components +import ReactCommponent from 'ReactCommponent'; + +export default class extends Card { + container?: NodeInterface; + + static get cardName() { + return 'CardName'; + } + + static get cardType() { + return CardType.BLOCK; + } + + /** + * After the card is rendered successfully, the empty div node has been loaded in the editor + * */ + didRender() { + if (!this.container) return; + // Get a node of type HTMLElement + const element = this.container.get()!; + //Use ReactDOM to render React components onto empty div nodes on the container + ReactDOM.render(, element); + } + + /** + * Render the card + * */ + render() { + // Render an empty div node + this.container = $('
'); + return this.container; + } + + /** + * Uninstall components + * */ + destroy() { + super.destroy(); + const element = this.container.get(); + if (element) ReactDOM.unmountComponentAtNode(element); + } +} +``` + +### React card plugin example + +Card plug-in file, main function: insert card, convert/parse card + +`test/index.ts` + +```ts +import { + $, + Plugin, + NodeInterface, + CARD_KEY, + isEngine, + SchemaInterface, + PluginOptions, + decodeCardValue, + encodeCardValue, +} from '@aomao/engine'; +import TestComponent from './component'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; +} +export default class extends Plugin { + static get pluginName() { + return 'test'; + } + // Plug-in initialization + init() { + // listen to events parsed into html + this.editor.on('parse:html', (node) => this.parseHtml(node)); + // Set the entrance of the schema rule when monitoring and pasting + this.editor.on('paste:schema', (schema) => this.pasteSchema(schema)); + // monitor the node loop when pasting + this.editor.on('paste:each', (child) => this.pasteHtml(child)); + } + // execution method + execute() { + if (!isEngine(this.editor)) return; + const { card } = this.editor; + card.insert(TestComponent.cardName); + } + // hot key + hotkey() { + return this.options.hotkey || 'mod+shift+0'; + } + // Add the required schema when pasting + pasteSchema(schema: SchemaInterface) { + schema.add({ + type: 'block', + name: 'div', + attributes: { + 'data-type': { + required: true, + value: TestComponent.cardName, + }, + 'data-value': '*', + }, + }); + } + // parse the pasted html + pasteHtml(node: NodeInterface) { + if (!isEngine(this.editor)) return; + if (node.isElement()) { + const type = node.attributes('data-type'); + if (type === TestComponent.cardName) { + const value = node.attributes('data-value'); + const cardValue = decodeCardValue(value); + this.editor.card.replaceNode( + node, + TestComponent.cardName, + cardValue, + ); + node.remove(); + return false; + } + } + return true; + } + // parse into html + parseHtml(root: NodeInterface) { + root.find(`[${CARD_KEY}=${TestComponent.cardName}`).each((cardNode) => { + const node = $(cardNode); + const card = this.editor.card.find(node) as TestComponent; + const value = card?.getValue(); + if (value) { + node.empty(); + const div = $( + `
`, + ); + node.replaceWith(div); + } else node.remove(); + }); + } +} +export { TestComponent }; +``` + +react component, presents the view and interaction of the card + +`test/component/test.jsx` + +```tsx | pure +import { FC } from 'react'; +const TestComponent: FC = () =>
This is Test Plugin
; +export default TestComponent; +``` + +The card component, which mainly loads the react component into the editor + +`test/component/index.tsx` + +```tsx | pure +import { + $, + Card, + CardToolbarItemOptions, + CardType, + isEngine, + NodeInterface, + ToolbarItemOptions, +} from '@aomao/engine'; +import ReactDOM from 'react-dom'; +import TestComponent from './test'; + +class Test extends Card { + static get cardName() { + return 'test'; + } + + static get cardType() { + return CardType.BLOCK; + } + + #container?: NodeInterface; + + toolbar(): Array { + if (!isEngine(this.editor) || this.editor.readonly) return []; + return [ + { + type: 'dnd', + }, + { + type: 'copy', + }, + { + type: 'delete', + }, + { + type: 'node', + node: $('Test button'), + didMount: (node) => { + node.on('click', () => { + alert('test button'); + }); + }, + }, + ]; + } + + render() { + this.#container = $('
Loading
'); + return this.#container; // Or use this.getCenter().append(this.#container) to avoid returning this.#container + } + + didRender() { + ReactDOM.render(, this.#container?.get()); + } + + destroy() { + ReactDOM.unmountComponentAtNode(this.#container?.get()!); + } +} +export default Test; +``` + +Use card plugins + +```tsx | pure +import React, { useEffect, useRef, useState } from 'react'; +import Engine, { EngineInterface } from '@aomao/engine'; +// Import custom card plugins and card components test/index.ts +import Test, { TestComponent } from './test'; + +const EngineDemo = () => { + //Editor container + const ref = useRef(null); + //Engine instance + const [engine, setEngine] = useState(); + //Editor content + const [content, setContent] = useState('Hello card!'); + + useEffect(() => { + if (!ref.current) return; + //Instantiate the engine + const engine = new Engine(ref.current, { + plugins: [Test], + cards: [TestComponent], + }); + //Set the editor value + engine.setValue(content); + //Listen to the editor value change event + engine.on('change', (value) => { + setContent(value); + console.log(`value:${value}`); + }); + //Set the engine instance + setEngine(engine); + }, []); + + return
; +}; +export default EngineDemo; +``` + +Use the shortcut key `mod+shift+0` defined in `test/index.ts` to insert the card component just defined in the editor + +### Vue2 rendering + +Vue components + +```ts + + +``` + +Card components + +```ts +import Vue from 'vue'; +import { $, Card, CardType } from '@aomao/engine'; +// import custom vue components +import VueCommponent from 'VueCommponent'; + +export default class extends Card { + container?: NodeInterface; + private vm?: Vue; + + static get cardName() { + return 'CardName'; + } + + static get cardType() { + return CardType.BLOCK; + } + + /** + * After the card is rendered successfully, the empty div node has been loaded in the editor + * */ + didRender() { + if (!this.container) return; + // Get a node of type HTMLElement + const element = this.container.get()!; + //Use createApp to render the Vue component to the empty div node on the container + //Add a delay, otherwise it may not be rendered successfully + setTimeout(() => { + this.vm = new Vue({ + render: (h) => { + return h(VueComponent, { + props: {}, + }); + }, + }); + element.append(vm.$mount().$el); + }, 20); + } + + /** + * Render the card + * */ + render() { + // Render an empty div node + this.container = $('
'); + return this.container; + } + + /** + * Uninstall components + * */ + destroy() { + super.destroy(); + this.vm?.$destroy(); + this.vm = undefined; + } +} +``` + +### Vue3 rendering + +Vue components + +```ts + + +``` + +Card components + +```ts +import { createApp, App } from 'vue'; +import { $, Card, CardType } from '@aomao/engine'; +// import custom vue components +import VueCommponent from 'VueCommponent'; + +export default class extends Card { + container?: NodeInterface; + private vm?: App; + + static get cardName() { + return 'CardName'; + } + + static get cardType() { + return CardType.BLOCK; + } + + /** + * After the card is rendered successfully, the empty div node has been loaded in the editor + * */ + didRender() { + if (!this.container) return; + // Get a node of type HTMLElement + const element = this.container.get()!; + //Use createApp to render the Vue component to the empty div node on the container + //Add a delay, otherwise it may not be rendered successfully + setTimeout(() => { + this.vm = createApp(VueComponent); + this.vm.mount(element); + }, 20); + } + + /** + * Render the card + * */ + render() { + // Render an empty div node + this.container = $('
'); + return this.container; + } + + /** + * Uninstall components + * */ + destroy() { + super.destroy(); + this.vm?.unmount(); + this.vm = undefined; + } +} +``` + +### Vue card plugin example + +Card plug-in file, main function: insert card, convert/parse card + +`test/index.ts` + +```ts +import { + $, + Plugin, + NodeInterface, + CARD_KEY, + isEngine, + SchemaInterface, + PluginOptions, + decodeCardValue, + encodeCardValue, +} from '@aomao/engine'; +import TestComponent from './component'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; +} +export default class extends Plugin { + static get pluginName() { + return 'test'; + } + // Plug-in initialization + init() { + // listen to events parsed into html + this.editor.on('parse:html', (node) => this.parseHtml(node)); + // Set the entrance of the schema rule when monitoring and pasting + this.editor.on('paste:schema', (schema) => this.pasteSchema(schema)); + // monitor the node loop when pasting + this.editor.on('paste:each', (child) => this.pasteHtml(child)); + } + // execution method + execute() { + if (!isEngine(this.editor)) return; + const { card } = this.editor; + card.insert(TestComponent.cardName); + } + // hot key + hotkey() { + return this.options.hotkey || 'mod+shift+0'; + } + // Add the required schema when pasting + pasteSchema(schema: SchemaInterface) { + schema.add({ + type: 'block', + name: 'div', + attributes: { + 'data-type': { + required: true, + value: TestComponent.cardName, + }, + 'data-value': '*', + }, + }); + } + // parse the pasted html + pasteHtml(node: NodeInterface) { + if (!isEngine(this.editor)) return; + if (node.isElement()) { + const type = node.attributes('data-type'); + if (type === TestComponent.cardName) { + const value = node.attributes('data-value'); + const cardValue = decodeCardValue(value); + this.editor.card.replaceNode( + node, + TestComponent.cardName, + cardValue, + ); + node.remove(); + return false; + } + } + return true; + } + // parse into html + parseHtml(root: NodeInterface) { + root.find(`[${CARD_KEY}=${TestComponent.cardName}`).each((cardNode) => { + const node = $(cardNode); + const card = this.editor.card.find(node) as TestComponent; + const value = card?.getValue(); + if (value) { + node.empty(); + const div = $( + `
`, + ); + node.replaceWith(div); + } else node.remove(); + }); + } +} +export { TestComponent }; +``` + +vue component, presents the view and interaction of the card + +`test/component/test.vue` + +```ts + + + + +``` + +The card component, which mainly loads the vue component into the editor + +`test/component/index.ts` + +```ts +import { + $, + Card, + CardToolbarItemOptions, + CardType, + isEngine, + NodeInterface, + ToolbarItemOptions, +} from '@aomao/engine'; +import { App, createApp } from 'vue'; +import TestVue from './test.vue'; + +class Test extends Card { + static get cardName() { + return 'test'; + } + + static get cardType() { + return CardType.BLOCK; + } + + #container?: NodeInterface; + #vm?: App; + + toolbar(): Array { + if (!isEngine(this.editor) || this.editor.readonly) return []; + return [ + { + type: 'dnd', + }, + { + type: 'copy', + }, + { + type: 'delete', + }, + { + type: 'node', + node: $('Test button'), + didMount: (node) => { + node.on('click', () => { + alert('test button'); + }); + }, + }, + ]; + } + + render() { + this.#container = $('
Loading
'); + return this.#container; // Or use this.getCenter().append(this.#container) to avoid returning this.#container + } + + didRender() { + this.#vm = createApp(TestVue, {}); + this.#vm.mount(this.#container?.get()); + } + + destroy() { + this.#vm?.unmount(); + } +} +export default Test; +``` + +Use card plugins + +```ts + + + +``` + +Use the shortcut key `mod+shift+0` defined in `test/index.ts` to insert the card component just defined in the editor + +### `Toolbar` + +To implement the card toolbar, you need to rewrite the `toolbar` method + +The toolbar has implemented some default buttons and events, just pass in the name to use + +- `separator` dividing line +- `copy` copy, you can copy the content of the card containing the root node to the clipboard +- `delete` delete card +- `maximize` to maximize the card +- `more` more button, need additional configuration `items` property +- `dnd` is the draggable icon button on the left side of the card + +In addition, you can customize button properties or render `React` and `Vue` front-end framework components + +Customizable toolbar UI types are: + +- `button` button +- `dropdown` drop-down box +- `switch` radio button +- `input` input box +- `node` a node of type `NodeInterface` + +For the configuration of each type, please see its [Type Definition](https://github.com/yanmao-cc/am-editor/blob/master/packages/engine/src/types/toolbar.ts) + +```ts +import { + $, + Card, + CardToolbarItemOptions, + ToolbarItemOptions, +} from '@aomao/engine'; + +export default class extends Card { + static get cardName() { + return 'CardName'; + } + + static get cardType() { + return CardType.BLOCK; + } + + // Card Toolbar + toolbar(): Array { + return [ + // Drag the button on the left + { + type: 'dnd', + }, + // copy + { + type: 'copy', + }, + // delete + { + type: 'delete', + }, + // split line + { + type: 'separator', + }, + // Custom node + { + type: 'node', + node: $('
'), + didMount: (node) => { + //After loading, you can use the front-end framework to render components to the node node. Vue needs to add delay to use createApp + console.log(`The button is loaded, ${node}`); + }, + }, + ]; + } + + // render div + render() { + return $('
Card
'); + } +} +``` + +### Set card value + +The default type of card value `CardValue` + +Two values of `id` and `type` are provided by default, and the custom value cannot be the same as the default value + +- `id` unique card number +- `type` card type + +```ts +import {$, Card, CardType} from'@aomao/engine' + +export default class extends Card<{ count: number }> { + + container?: NodeInterface + + static get cardName() { + return'CardName'; + } + + static get cardType() { + return CardType.BLOCK; + } + + // click on the div + onClick = () => { + // Get card value + const value = this.getValue() || {count: 0} + // give count + 1 + const count = value.count + 1 + // Reset the card value, it will be saved to the data-card-value attribute on the root node of the card + this.setValue({ + count, + }); + // Set the content of the div + this.container?.html(count) + }; + + // Render the div node + render() { + // Get the value of the card + const value = this.getValue() || {count: 0} + // Create a div node + this.container = $(`
${value.count}
`) + // bind the click event + this.container.on("click" => this.onClick) + // Return the node to load the container + return this.container + } +} +``` + +### Combine with plugins + +```ts +import { Plugin, isEngine } from '@aomao/engine'; +// import cards +import CardComponent from './component'; + +type Options = { + defaultValue?: number; +}; + +export default class extends Plugin { + static get pluginName() { + return 'card-plugin'; + } + // The plugin executes the command, call engine.command.excute("card-plugin") to execute the current command + execute() { + // Reader does not execute + if (!isEngine(this.editor)) return; + const { card } = this.editor; + //Insert the card and pass in the count initialization parameter + card.insert(CardComponent.cardName, { + count: this.otpions.defaultValue || 0, + }); + } +} +export { CardComponent }; +``` + +## Static properties + +### `cardName` + +CardName, read-only static attribute, required + +Type: `string` + +The CardName is unique and cannot be repeated with all the CardNames passed into the engine + +```ts +export default class extends Plugin { + //Define the CardName, it is required + static get cardName() { + return 'CardName'; + } +} +``` + +### `cardType` + +Card type, read-only static property, required + +Type: `CardType` + +There are two types of `CardType`, `inline` and `block` + +```ts +export default class extends Plugin { + //Define the card type, it is required + static get cardType() { + return CardType.BLOCK; + } +} +``` + +### `autoActivate` + +Whether it can be activated automatically, the default is false + +### `autoSelected` + +Whether it can be selected automatically, the default is true + +### `singleSelectable` + +Whether it can be selected individually, the default is true + +### `collab` + +Whether you can participate in collaboration, when other authors edit the card, it will cover a layer of shadow + +### `focus` + +Can focus + +### `selectStyleType` + +The style of the selected yes, the default is the border change, optional values: + +- `border` border changes +- `background` background color change + +### `toolbarFollowMouse` + +Whether the card toolbar follows the mouse position, the default flase + +## Attributes + +### `editor` + +EditEditor example + +Type: `EditorInterface` + +When the plug-in is instantiated, the editor instance will be passed in. We can access it through `this` + +```ts +import {Card, isEngine} from'@aomao/engine' + +export default class extends Card { +... + +init() { +console.log(isEngine(this.editor)? "Engine": "Reader") +} +} +``` + +### `id` + +Read only + +Type: `string` + +Card id, each card has a unique ID, we can use this ID to find instances of card components + +### `type` + +The card type, the static property `cardType` of the card class is obtained by default. If there is a `type` value in `getValue()`, this value will be used as the `type` + +When setting a new `type` value to the card, the current card will be removed and the new `type` will be used to re-render the card at the current card position + +Type: `CardType` + +### `isEditable` + +Read only + +Type: `boolean` + +Whether the card is editable + +### `contenteditable` + +Editable node, optional + +One or more CSS selectors can be set, and these nodes will become editable + +The value of the editable area needs to be customized and saved. It is recommended to save it in the `value` of the card + +```ts +import {Card, isEngine} from'@aomao/engine' + +export default class extends Card { +... + + contenteditable = ["div.card-editor-container"] + +render(){ + return "
Thi is Card
Editable here
" + } +} +``` + +### `readonly` + +Is it read-only + +Type: `boolean` + +### `root` + +Card root node + +Type: `NodeInterface` + +### `activated` + +Activate now + +Type: `boolean` + +### `selected` + +Whether selected + +Type: `boolean` + +### `isMaximize` + +Whether to maximize + +Type: `boolean` + +### `activatedByOther` + +Activator, effective in cooperative state + +Type: `string | false` + +### `selectedByOther` + +Selected person, valid in collaboration state + +Type: `string | false` + +### `toolbarModel` + +Toolbar operation class + +Type: `CardToolbarInterface` + +### `resizeModel` + +Size adjustment operation class + +Type: `ResizeInterface` + +### `resize` + +Whether the card size can be changed or passed into the rendering node + +Type: `boolean | (() => NodeInterface);` + +If specified, the `resizeModel` attribute will be instantiated + +## Method + +### `init` + +Initialization, optional + +```ts +init?(): void; +``` + +### `find` + +Find the DOM node in the Card + +```ts +/** + * Find the DOM node in the Card + * @param selector + */ +find(selector: string): NodeInterface; +``` + +### `findByKey` + +Get the DOM node in the current Card through the value of data-card-element + +```ts +/** + * Get the DOM node in the current Card through the value of data-card-element + * @param key key + */ +findByKey(key: string): NodeInterface; +``` + +### `getCenter` + +Get the central node of the card, which is the outermost node of the custom content area of ​​the card + +```ts +/** + * Get the central node of the card + */ +getCenter(): NodeInterface; +``` + +### `isCenter` + +Determine whether the node belongs to the central node of the card + +```ts +/** + * Determine whether the node belongs to the central node of the card + * @param node node + */ +isCenter(node: NodeInterface): boolean; +``` + +### `isCursor` + +Determine whether the node is at the left and right cursors of the card + +```ts +/** + * Determine whether the node is at the left and right cursors of the card + * @param node node + */ +isCursor(node: NodeInterface): boolean; +``` + +### `isLeftCursor` + +Determine whether the node is at the left cursor of the card + +```ts +/** + * Determine whether the node is at the left cursor of the card + * @param node node + */ +isLeftCursor(node: NodeInterface): boolean; +``` + +### `isRightCursor` + +Determine whether the node is at the right cursor of the card + +```ts +/** + * Determine whether the node is at the right cursor of the card + * @param node node + */ +isRightCursor(node: NodeInterface): boolean; +``` + +### `focus` + +Focus card + +```ts +/** + * Focus card + * @param range cursor + * @param toStart is the starting position + */ +focus(range: RangeInterface, toStart?: boolean): void; +``` + +### `focusPrevBlock` + +Focus on the previous block-level node where the card is located + +```ts +/** + * Focus on the previous block-level node where the card is located + * @param range cursor + * @param hasModify When there is no node, whether to create an empty node and focus + */ +focusPrevBlock(range: RangeInterface, hasModify: boolean): void; +``` + +### `focusNextBlock` + +Focus on the next block-level node where the card is located + +```ts +/** + * Focus on the next block-level node where the card is located + * @param range cursor + * @param hasModify When there is no node, whether to create an empty node and focus + */ +focusNextBlock(range: RangeInterface, hasModify: boolean): void; +``` + +### `onFocus` + +Triggered when the card is focused + +```ts +/** + * Triggered when the card is focused + */ +onFocus?(): void; +``` + +### `activate` + +Activate Card + +```ts +/** + * Activate Card + * @param activated Whether to activate + */ +activate(activated: boolean): void; +``` + +### `select` + +Choose Card + +```ts +/** + * Choose Card + * @param selected is it selected + */ +select(selected: boolean): void; +``` + +### `onSelect` + +Triggered when the selected state changes + +```ts +/** + * Trigger when the selected state changes + * @param selected is it selected + */ +onSelect(selected: boolean): void; +``` + +### `onSelectByOther` + +In the cooperative state, trigger when the selected state changes + +```ts +/** + * In the cooperative state, trigger when the selected state changes + * @param selected is it selected + * @param value {color: collaborator color, rgb: color rgb format} + */ +onSelectByOther( + selected: boolean, + value?: { + color: string; + rgb: string; + }, +): NodeInterface | void; +``` + +### `onActivate` + +Triggered when the activation state changes + +```ts +/** + * Triggered when the activation status changes + * @param activated Whether to activate + */ +onActivate(activated: boolean): void; +``` + +### `onActivateByOther` + +In the cooperative state, trigger when the activation state changes + +```ts +/** + * In the cooperative state, trigger when the activation state changes + * @param activated Whether to activate + * @param value {color: collaborator color, rgb: color rgb format} + */ +onActivateByOther( + activated: boolean, + value?: { + color: string; + rgb: string; + }, +): NodeInterface | void; +``` + +### `onChange` + +Trigger when the editable area value changes + +```ts +/** + * Trigger when the editor area value changes + * @param node editable area node + */ +onChange?(node: NodeInterface): void; +``` + +### `setValue` + +Set card value + +```ts +/** + * Set card value + * @param value + */ +setValue(value: CardValue): void; +``` + +### `getValue` + +Get card value + +```ts +/** + * Get card value + */ +getValue(): (CardValue & {id: string }) | undefined; +``` + +### `toolbar` + +Toolbar configuration items + +```ts +/** + * Toolbar configuration items + */ +toolbar?(): Array; +``` + +### `maximize` + +Maximize card + +```ts +/** + * Maximize + */ +maximize(): void; +``` + +### `minimize` + +Minimize the card + +```ts +/** + * minimize + */ +minimize(): void; +``` + +### `render` + +Render the card + +```ts +/** + * Render the card + */ +render(): NodeInterface | string | void; +``` + +### `destroy` + +destroy + +```ts +/** + * Destroy + */ +destroy?(): void; +``` + +### `didInsert` + +Triggered after inserting a card into the editor + +```ts +/** + * Trigger after insertion + */ +didInsert?(): void; +``` + +### `didUpdate` + +Triggered after updating the card + +```ts +/** + * Triggered after update + */ +didUpdate?(): void; +``` + +### `didRender` + +Triggered after the card is successfully rendered + +```ts +/** + * Triggered after rendering + */ +didRender(): void; +``` + +### `updateBackgroundSelection` + +Update the editable card collaborative selection area + +```ts +/** + * Update the editable card collaborative selection area + * @param range cursor + */ +updateBackgroundSelection?(range: RangeInterface): void; +``` + +### `drawBackground` + +Render the editable card collaborative selection area + +```ts +/** + * Rendering the collaborative selection area of the editor card + * @param node background canvas + * @param range render cursor + */ +drawBackground?( + node: NodeInterface, + range: RangeInterface, + targetCanvas: TinyCanvasInterface, +): DOMRect | RangeInterface[] | void | false; +``` + +### `getSelectionNodes` + +```ts +/** + * Get all nodes selected in the editable area + */ +getSelectionNodes?(): Array +``` diff --git a/docs/plugin/tutorials-card.zh-CN.md b/docs/plugin/tutorials-card.zh-CN.md new file mode 100644 index 00000000..f93f4a38 --- /dev/null +++ b/docs/plugin/tutorials-card.zh-CN.md @@ -0,0 +1,1468 @@ +# Card 组件 + +卡片组件 + +通常用于完全自定义渲染内容 + +## 继承 + +继承 `Card` 抽象类 + +```ts +import { Card } from '@aomao/engine' + +export default class extends Card { + ... +} +``` + +## 案例 + +### `渲染` + +渲染一个卡片需要显示 `render` 方法,这是个抽象方法,必须要实现它 + +```ts +import { $, Card } from '@aomao/engine'; + +export default class extends Card { + static get cardName() { + return '卡片名称'; + } + + static get cardType() { + return CardType.BLOCK; + } + + render() { + //返回节点,会自动追加到卡片 center 位置 + return $('
Card
'); + //或者主动追加 + this.getCenter().append($('
Card
')); + } +} +``` + +### React 渲染 + +React 组件 + +```ts +import React from 'react'; + +export default () =>
React Commponent
; +``` + +卡片组件 + +```ts +import ReactDOM from 'react-dom'; +import { $, Card, CardType } from '@aomao/engine'; +// 引入自定义的 react 组件 +import ReactCommponent from 'ReactCommponent'; + +export default class extends Card { + container?: NodeInterface; + + static get cardName() { + return '卡片名称'; + } + + static get cardType() { + return CardType.BLOCK; + } + + /** + * 卡片渲染成功后,空的 div 节点已在编辑器中加载 + * */ + didRender() { + if (!this.container) return; + // 获取 HTMLElement 类型的节点 + const element = this.container.get()!; + //使用 ReactDOM 把 React 组件渲染到 container 上的空 div 节点上 + ReactDOM.render(, element); + } + + /** + * 渲染卡片 + * */ + render() { + // 渲染一个空的div节点 + this.container = $('
'); + return this.container; + } + + /** + * 卸载组件 + * */ + destroy() { + super.destroy(); + const element = this.container.get(); + if (element) ReactDOM.unmountComponentAtNode(element); + } +} +``` + +### React 卡片插件示例 + +卡片插件文件,主要作用:插入卡片、转换/解析卡片 + +`test/index.ts` + +```ts +import { + $, + Plugin, + NodeInterface, + CARD_KEY, + isEngine, + SchemaInterface, + PluginOptions, + decodeCardValue, + encodeCardValue, +} from '@aomao/engine'; +import TestComponent from './component'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; +} +export default class extends Plugin { + static get pluginName() { + return 'test'; + } + // 插件初始化 + init() { + // 监听解析成html的事件 + this.editor.on('parse:html', (node) => this.parseHtml(node)); + // 监听粘贴时候设置schema规则的入口 + this.editor.on('paste:schema', (schema) => this.pasteSchema(schema)); + // 监听粘贴时候的节点循环 + this.editor.on('paste:each', (child) => this.pasteHtml(child)); + } + // 执行方法 + execute() { + if (!isEngine(this.editor)) return; + const { card } = this.editor; + card.insert(TestComponent.cardName); + } + // 快捷键 + hotkey() { + return this.options.hotkey || 'mod+shift+0'; + } + // 粘贴的时候添加需要的 schema + pasteSchema(schema: SchemaInterface) { + schema.add({ + type: 'block', + name: 'div', + attributes: { + 'data-type': { + required: true, + value: TestComponent.cardName, + }, + 'data-value': '*', + }, + }); + } + // 解析粘贴过来的html + pasteHtml(node: NodeInterface) { + if (!isEngine(this.editor)) return; + if (node.isElement()) { + const type = node.attributes('data-type'); + if (type === TestComponent.cardName) { + const value = node.attributes('data-value'); + const cardValue = decodeCardValue(value); + this.editor.card.replaceNode( + node, + TestComponent.cardName, + cardValue, + ); + node.remove(); + return false; + } + } + return true; + } + // 解析成html + parseHtml(root: NodeInterface) { + root.find(`[${CARD_KEY}=${TestComponent.cardName}`).each((cardNode) => { + const node = $(cardNode); + const card = this.editor.card.find(node) as TestComponent; + const value = card?.getValue(); + if (value) { + node.empty(); + const div = $( + `
`, + ); + node.replaceWith(div); + } else node.remove(); + }); + } +} +export { TestComponent }; +``` + +react 组件,呈现卡片的视图和交互 + +`test/component/test.jsx` + +```tsx | pure +import { FC } from 'react'; +const TestComponent: FC = () =>
This is Test Plugin
; +export default TestComponent; +``` + +卡片组件,主要把 react 组件加载到编辑器中 + +`test/component/index.tsx` + +```tsx | pure +import { + $, + Card, + CardToolbarItemOptions, + CardType, + isEngine, + NodeInterface, + ToolbarItemOptions, +} from '@aomao/engine'; +import ReactDOM from 'react-dom'; +import TestComponent from './test'; + +class Test extends Card { + static get cardName() { + return 'test'; + } + + static get cardType() { + return CardType.BLOCK; + } + + #container?: NodeInterface; + + toolbar(): Array { + if (!isEngine(this.editor) || this.editor.readonly) return []; + return [ + { + type: 'dnd', + }, + { + type: 'copy', + }, + { + type: 'delete', + }, + { + type: 'node', + node: $('测试按钮'), + didMount: (node) => { + node.on('click', () => { + alert('test button'); + }); + }, + }, + ]; + } + + render() { + this.#container = $('
Loading
'); + return this.#container; // 或者使用 this.getCenter().append(this.#container) 就不用再返回 this.#container 了 + } + + didRender() { + ReactDOM.render(, this.#container?.get()); + } + + destroy() { + ReactDOM.unmountComponentAtNode(this.#container?.get()!); + } +} +export default Test; +``` + +使用卡片插件 + +```tsx | pure +import React, { useEffect, useRef, useState } from 'react'; +import Engine, { EngineInterface } from '@aomao/engine'; +// 导入自定义的卡片插件和卡片组件 test/index.ts +import Test, { TestComponent } from './test'; + +const EngineDemo = () => { + //编辑器容器 + const ref = useRef(null); + //引擎实例 + const [engine, setEngine] = useState(); + //编辑器内容 + const [content, setContent] = useState('Hello card!'); + + useEffect(() => { + if (!ref.current) return; + //实例化引擎 + const engine = new Engine(ref.current, { + plugins: [Test], + cards: [TestComponent], + }); + //设置编辑器值 + engine.setValue(content); + //监听编辑器值改变事件 + engine.on('change', (value) => { + setContent(value); + console.log(`value:${value}`); + }); + //设置引擎实例 + setEngine(engine); + }, []); + + return
; +}; +export default EngineDemo; +``` + +使用 `test/index.ts` 中定义的快捷键 `mod+shift+0` 就能在编辑器中插入刚才定义的卡片组件了 + +### Vue2 渲染 + +Vue 组件 + +```ts + + +``` + +卡片组件 + +```ts +import Vue from 'vue'; +import { $, Card, CardType } from '@aomao/engine'; +// 引入自定义的 vue 组件 +import VueCommponent from 'VueCommponent'; + +export default class extends Card { + container?: NodeInterface; + private vm?: Vue; + + static get cardName() { + return '卡片名称'; + } + + static get cardType() { + return CardType.BLOCK; + } + + /** + * 卡片渲染成功后,空的 div 节点已在编辑器中加载 + * */ + didRender() { + if (!this.container) return; + // 获取 HTMLElement 类型的节点 + const element = this.container.get()!; + //使用 createApp 把 Vue 组件渲染到 container 上的空 div 节点上 + //加个延时,不然可能无法渲染成功 + setTimeout(() => { + this.vm = new Vue({ + render: (h) => { + return h(VueComponent, { + props: {}, + }); + }, + }); + element.append(vm.$mount().$el); + }, 20); + } + + /** + * 渲染卡片 + * */ + render() { + // 渲染一个空的div节点 + this.container = $('
'); + return this.container; + } + + /** + * 卸载组件 + * */ + destroy() { + super.destroy(); + this.vm?.$destroy(); + this.vm = undefined; + } +} +``` + +### Vue3 渲染 + +Vue 组件 + +```ts + + +``` + +卡片组件 + +```ts +import { createApp, App } from 'vue'; +import { $, Card, CardType } from '@aomao/engine'; +// 引入自定义的 vue 组件 +import VueCommponent from 'VueCommponent'; + +export default class extends Card { + container?: NodeInterface; + private vm?: App; + + static get cardName() { + return '卡片名称'; + } + + static get cardType() { + return CardType.BLOCK; + } + + /** + * 卡片渲染成功后,空的 div 节点已在编辑器中加载 + * */ + didRender() { + if (!this.container) return; + // 获取 HTMLElement 类型的节点 + const element = this.container.get()!; + //使用 createApp 把 Vue 组件渲染到 container 上的空 div 节点上 + //加个延时,不然可能无法渲染成功 + setTimeout(() => { + this.vm = createApp(VueComponent); + this.vm.mount(element); + }, 20); + } + + /** + * 渲染卡片 + * */ + render() { + // 渲染一个空的div节点 + this.container = $('
'); + return this.container; + } + + /** + * 卸载组件 + * */ + destroy() { + super.destroy(); + this.vm?.unmount(); + this.vm = undefined; + } +} +``` + +### Vue3 卡片插件示例 + +卡片插件文件,主要作用:插入卡片、转换/解析卡片 + +`test/index.ts` + +```ts +import { + $, + Plugin, + NodeInterface, + CARD_KEY, + isEngine, + SchemaInterface, + PluginOptions, + decodeCardValue, + encodeCardValue, +} from '@aomao/engine'; +import TestComponent from './component'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; +} +export default class extends Plugin { + static get pluginName() { + return 'test'; + } + // 插件初始化 + init() { + // 监听解析成html的事件 + this.editor.on('parse:html', (node) => this.parseHtml(node)); + // 监听粘贴时候设置schema规则的入口 + this.editor.on('paste:schema', (schema) => this.pasteSchema(schema)); + // 监听粘贴时候的节点循环 + this.editor.on('paste:each', (child) => this.pasteHtml(child)); + } + // 执行方法 + execute() { + if (!isEngine(this.editor)) return; + const { card } = this.editor; + card.insert(TestComponent.cardName); + } + // 快捷键 + hotkey() { + return this.options.hotkey || 'mod+shift+0'; + } + // 粘贴的时候添加需要的 schema + pasteSchema(schema: SchemaInterface) { + schema.add({ + type: 'block', + name: 'div', + attributes: { + 'data-type': { + required: true, + value: TestComponent.cardName, + }, + 'data-value': '*', + }, + }); + } + // 解析粘贴过来的html + pasteHtml(node: NodeInterface) { + if (!isEngine(this.editor)) return; + if (node.isElement()) { + const type = node.attributes('data-type'); + if (type === TestComponent.cardName) { + const value = node.attributes('data-value'); + const cardValue = decodeCardValue(value); + this.editor.card.replaceNode( + node, + TestComponent.cardName, + cardValue, + ); + node.remove(); + return false; + } + } + return true; + } + // 解析成html + parseHtml(root: NodeInterface) { + root.find(`[${CARD_KEY}=${TestComponent.cardName}`).each((cardNode) => { + const node = $(cardNode); + const card = this.editor.card.find(node) as TestComponent; + const value = card?.getValue(); + if (value) { + node.empty(); + const div = $( + `
`, + ); + node.replaceWith(div); + } else node.remove(); + }); + } +} +export { TestComponent }; +``` + +vue 组件,呈现卡片的视图和交互 + +`test/component/test.vue` + +```ts + + + + +``` + +卡片组件,主要把 vue 组件加载到编辑器中 + +`test/component/index.ts` + +```ts +import { + $, + Card, + CardToolbarItemOptions, + CardType, + isEngine, + NodeInterface, + ToolbarItemOptions, +} from '@aomao/engine'; +import { App, createApp } from 'vue'; +import TestVue from './test.vue'; + +class Test extends Card { + static get cardName() { + return 'test'; + } + + static get cardType() { + return CardType.BLOCK; + } + + #container?: NodeInterface; + #vm?: App; + + toolbar(): Array { + if (!isEngine(this.editor) || this.editor.readonly) return []; + return [ + { + type: 'dnd', + }, + { + type: 'copy', + }, + { + type: 'delete', + }, + { + type: 'node', + node: $('测试按钮'), + didMount: (node) => { + node.on('click', () => { + alert('test button'); + }); + }, + }, + ]; + } + + render() { + this.#container = $('
Loading
'); + return this.#container; // 或者使用 this.getCenter().append(this.#container) 就不用再返回 this.#container 了 + } + + didRender() { + this.#vm = createApp(TestVue, {}); + this.#vm.mount(this.#container?.get()); + } + + destroy() { + this.#vm?.unmount(); + } +} +export default Test; +``` + +使用卡片插件 + +```ts + + + +``` + +使用 `test/index.ts` 中定义的快捷键 `mod+shift+0` 就能在编辑器中插入刚才定义的卡片组件了 + +### `工具栏` + +实现卡片工具栏,需要重写 `toolbar` 方法 + +工具栏已经实现了一些默认按钮和事件,传入名称即可使用 + +- `separator` 分割线 +- `copy` 复制,可以复制卡片包含根节点的内容到剪切板上 +- `delete` 删除卡片 +- `maximize` 最大化卡片 +- `more` 更多按钮,需要额外配置 `items` 属性 +- `dnd` 位于卡片左侧的可拖动图标按钮 + +另外,还可以自定义按钮属性,或者渲染`React` `Vue` 前端框架组件 + +可自定义工具栏 UI 类型有: + +- `button` 按钮 +- `dropdown` 下拉框 +- `switch` 单选按钮 +- `input` 输入框 +- `node` 一个类型为 `NodeInterface` 的节点 + +每个类型的配置请看它的[类型定义](https://github.com/yanmao-cc/am-editor/blob/master/packages/engine/src/types/toolbar.ts) + +```ts +import { + $, + Card, + CardToolbarItemOptions, + ToolbarItemOptions, +} from '@aomao/engine'; + +export default class extends Card { + static get cardName() { + return '卡片名称'; + } + + static get cardType() { + return CardType.BLOCK; + } + + // 卡片工具栏 + toolbar(): Array { + return [ + // 左边拖动按钮 + { + type: 'dnd', + }, + // 复制 + { + type: 'copy', + }, + // 删除 + { + type: 'delete', + }, + // 分割线 + { + type: 'separator', + }, + // 自定义节点 + { + type: 'node', + node: $('
'), + didMount: (node) => { + //加载完成后,可以使用前端框架渲染组件到 node 节点上。vue 使用 createApp 需要加延时 + console.log(`按钮加载好了,${node}`); + }, + }, + ]; + } + + // 渲染 div + render() { + return $('
Card
'); + } +} +``` + +### 设置卡片值 + +卡片值默认类型 `CardValue` + +默认提供 `id` `type` 两个值,自定义值不能与默认值相同 + +- `id` 卡片唯一编号 +- `type` 卡片类型 + +```ts +import { $, Card, CardType } from '@aomao/engine' + +export default class extends Card<{ count: number }> { + + container?: NodeInterface + + static get cardName() { + return '卡片名称'; + } + + static get cardType() { + return CardType.BLOCK; + } + + // 在 div 上面单击 + onClick = () => { + // 获取卡片值 + const value = this.getValue() || { count: 0} + // 给 count + 1 + const count = value.count + 1 + // 重新设置卡片值,会保存到卡片根节点上的 data-card-value 属性上面 + this.setValue({ + count, + }); + // 设置 div 的内容 + this.container?.html(count) + }; + + // 渲染 div 节点 + render() { + // 获取卡片的值 + const value = this.getValue() || { count: 0} + // 创建 div 节点 + this.container = $(`
${value.count}
`) + // 绑定 click 事件 + this.container.on("click" => this.onClick) + // 返回节点给容器加载 + return this.container + } +} +``` + +### 与插件结合 + +```ts +import { Plugin, isEngine } from '@aomao/engine'; +// 引入卡片 +import CardComponent from './component'; + +type Options = { + defaultValue?: number; +}; + +export default class extends Plugin { + static get pluginName() { + return 'card-plugin'; + } + // 插件执行命令,调用 engine.command.excute("card-plugin") 执行当前命令 + execute() { + // 阅读器不执行 + if (!isEngine(this.editor)) return; + const { card } = this.editor; + //插入卡片,并且传入 count 初始化参数 + card.insert(CardComponent.cardName, { + count: this.otpions.defaultValue || 0, + }); + } +} +export { CardComponent }; +``` + +## 静态属性 + +### `cardName` + +卡片名称,只读静态属性,必须 + +类型:`string` + +卡片名称是唯一的,不可与传入引擎的所有卡片名称重复 + +```ts +export default class extends Plugin { + //定义卡片名称,它是必须的 + static get cardName() { + return '卡片名称'; + } +} +``` + +### `cardType` + +卡片类型,只读静态属性,必须 + +类型:`CardType` + +`CardType` 有两种类型,`inline` 和 `block` + +```ts +export default class extends Plugin { + //定义卡片类型,它是必须的 + static get cardType() { + return CardType.BLOCK; + } +} +``` + +### `autoActivate` + +是否能自动激活,默认 false + +### `autoSelected` + +是否能自动选中,默认 true + +### `singleSelectable` + +是否能单独选中,默认 true + +### `collab` + +是否能参与协作,在其它作者编辑卡片时,会遮盖一层阴影 + +### `focus` + +是否能聚焦 + +### `selectStyleType` + +被选中是的样式,默认为边框变化,可选值: + +- `border` 边框变化 +- `background` 背景颜色变化 + +### `toolbarFollowMouse` + +卡片工具栏是否跟随鼠标位置,默认 flase + +## 属性 + +### `editor` + +编辑器实例 + +类型:`EditorInterface` + +在插件实例化的时候,会传入编辑器实例。我们可以通过 `this` 访问它 + +```ts +import { Card, isEngine } from '@aomao/engine' + +export default class extends Card { + ... + + init() { + console.log(isEngine(this.editor) ? "引擎" : "阅读器") + } +} +``` + +### `id` + +只读 + +类型:`string` + +卡片 id,每个卡片都有一个唯一 ID,我们可以用此 ID 来查找卡片组件实例 + +### `type` + +卡片类型,默认获取卡片类的静态属性 `cardType`,如果 `getValue()` 中有 `type` 值,将会使用这个值作为 `type` + +在给卡片设置新的 `type` 值时,会移除当前卡片并且使用新的 `type` 在当前卡片位置重新渲染卡片 + +类型:`CardType` + +### `isEditable` + +只读 + +类型:`boolean` + +卡片是否可编辑器 + +### `contenteditable` + +可编辑节点,可选 + +可设置一个或多个 CSS 选择器,这些节点将会变为可编辑的 + +可编辑区域的值需要自定义保存。推荐保存在卡片的 `value` 里面 + +```ts +import { Card, isEngine } from '@aomao/engine' + +export default class extends Card { + ... + + contenteditable = ["div.card-editor-container"] + + render(){ + return "
Thi is Card
这里可以编辑
" + } +} +``` + +### `readonly` + +是否是只读 + +类型:`boolean` + +### `root` + +卡片根节点 + +类型:`NodeInterface` + +### `activated` + +是否激活 + +类型:`boolean` + +### `selected` + +是否选中 + +类型:`boolean` + +### `isMaximize` + +是否最大化 + +类型:`boolean` + +### `activatedByOther` + +激活者,协同状态下有效 + +类型:`string | false` + +### `selectedByOther` + +选中者,协同状态下有效 + +类型:`string | false` + +### `toolbarModel` + +工具栏操作类 + +类型:`CardToolbarInterface` + +### `resizeModel` + +大小调整操作类 + +类型:`ResizeInterface` + +### `resize` + +是否可改变卡片大小,或者传入渲染节点 + +类型:`boolean | (() => NodeInterface);` + +如果有指定,将会实例化 `resizeModel` 属性 + +## 方法 + +### `init` + +初始化,可选 + +```ts +init?(): void; +``` + +### `find` + +查找 Card 内的 DOM 节点 + +```ts +/** + * 查找Card内的 DOM 节点 + * @param selector + */ +find(selector: string): NodeInterface; +``` + +### `findByKey` + +通过 data-card-element 的值,获取当前 Card 内的 DOM 节点 + +```ts +/** + * 通过 data-card-element 的值,获取当前Card内的 DOM 节点 + * @param key key + */ +findByKey(key: string): NodeInterface; +``` + +### `getCenter` + +获取卡片的中心节点,也就是卡片自定义内容区域的最外层节点 + +```ts +/** + * 获取卡片的中心节点 + */ +getCenter(): NodeInterface; +``` + +### `isCenter` + +判断节点是否属于卡片的中心节点 + +```ts +/** + * 判断节点是否属于卡片的中心节点 + * @param node 节点 + */ +isCenter(node: NodeInterface): boolean; +``` + +### `isCursor` + +判断节点是否在卡片的左右光标处 + +```ts +/** + * 判断节点是否在卡片的左右光标处 + * @param node 节点 + */ +isCursor(node: NodeInterface): boolean; +``` + +### `isLeftCursor` + +判断节点是否在卡片的左光标处 + +```ts +/** + * 判断节点是否在卡片的左光标处 + * @param node 节点 + */ +isLeftCursor(node: NodeInterface): boolean; +``` + +### `isRightCursor` + +判断节点是否在卡片的右光标处 + +```ts +/** + * 判断节点是否在卡片的右光标处 + * @param node 节点 + */ +isRightCursor(node: NodeInterface): boolean; +``` + +### `focus` + +聚焦卡片 + +```ts +/** + * 聚焦卡片 + * @param range 光标 + * @param toStart 是否开始位置 + */ +focus(range: RangeInterface, toStart?: boolean): void; +``` + +### `focusPrevBlock` + +聚焦卡片所在位置的前一个块级节点 + +```ts +/** + * 聚焦卡片所在位置的前一个块级节点 + * @param range 光标 + * @param hasModify 没有节点时,是否创建一个空节点并聚焦 + */ +focusPrevBlock(range: RangeInterface, hasModify: boolean): void; +``` + +### `focusNextBlock` + +聚焦卡片所在位置的下一个块级节点 + +```ts +/** + * 聚焦卡片所在位置的下一个块级节点 + * @param range 光标 + * @param hasModify 没有节点时,是否创建一个空节点并聚焦 + */ +focusNextBlock(range: RangeInterface, hasModify: boolean): void; +``` + +### `onFocus` + +当卡片聚焦时触发 + +```ts +/** + * 当卡片聚焦时触发 + */ +onFocus?(): void; +``` + +### `activate` + +激活 Card + +```ts +/** + * 激活Card + * @param activated 是否激活 + */ +activate(activated: boolean): void; +``` + +### `select` + +选择 Card + +```ts +/** + * 选择Card + * @param selected 是否选中 + */ +select(selected: boolean): void; +``` + +### `onSelect` + +选中状态变化时触发 + +```ts +/** + * 选中状态变化时触发 + * @param selected 是否选中 + */ +onSelect(selected: boolean): void; +``` + +### `onSelectByOther` + +协同状态下,选中状态变化时触发 + +```ts +/** + * 协同状态下,选中状态变化时触发 + * @param selected 是否选中 + * @param value { color:协同者颜色 , rgb:颜色rgb格式 } + */ +onSelectByOther( + selected: boolean, + value?: { + color: string; + rgb: string; + }, +): NodeInterface | void; +``` + +### `onActivate` + +激活状态变化时触发 + +```ts +/** + * 激活状态变化时触发 + * @param activated 是否激活 + */ +onActivate(activated: boolean): void; +``` + +### `onActivateByOther` + +协同状态下,激活状态变化时触发 + +```ts +/** + * 协同状态下,激活状态变化时触发 + * @param activated 是否激活 + * @param value { color:协同者颜色 , rgb:颜色rgb格式 } + */ +onActivateByOther( + activated: boolean, + value?: { + color: string; + rgb: string; + }, +): NodeInterface | void; +``` + +### `onChange` + +可编辑器区域值改变时触发 + +```ts +/** + * 可编辑器区域值改变时触发 + * @param node 可编辑器区域节点 + */ +onChange?(node: NodeInterface): void; +``` + +### `setValue` + +设置卡片值 + +```ts +/** + * 设置卡片值 + * @param value 值 + */ +setValue(value: CardValue): void; +``` + +### `getValue` + +获取卡片值 + +```ts +/** + * 获取卡片值 + */ +getValue(): (CardValue & { id: string }) | undefined; +``` + +### `toolbar` + +工具栏配置项 + +```ts +/** + * 工具栏配置项 + */ +toolbar?(): Array; +``` + +### `maximize` + +最大化卡片 + +```ts +/** + * 最大化 + */ +maximize(): void; +``` + +### `minimize` + +最小化卡片 + +```ts +/** + * 最小化 + */ +minimize(): void; +``` + +### `render` + +渲染卡片 + +```ts +/** + * 渲染卡片 + */ +render(): NodeInterface | string | void; +``` + +### `destroy` + +销毁 + +```ts +/** + * 销毁 + */ +destroy?(): void; +``` + +### `didInsert` + +插入卡片到编辑器后触发 + +```ts +/** + * 插入后触发 + */ +didInsert?(): void; +``` + +### `didUpdate` + +更新卡片后触发 + +```ts +/** + * 更新后触发 + */ +didUpdate?(): void; +``` + +### `didRender` + +卡片渲染成功后触发 + +```ts +/** + * 渲染后触发 + */ +didRender(): void; +``` + +### `updateBackgroundSelection` + +更新可编辑器卡片协同选择区域 + +```ts +/** + * 更新可编辑器卡片协同选择区域 + * @param range 光标 + */ +updateBackgroundSelection?(range: RangeInterface): void; +``` + +### `drawBackground` + +渲染可编辑器卡片协同选择区域 + +```ts +/** + * 渲染可编辑器卡片协同选择区域 + * @param node 背景画布 + * @param range 渲染光标 + */ +drawBackground?( + node: NodeInterface, + range: RangeInterface, + targetCanvas: TinyCanvasInterface, +): DOMRect | RangeInterface[] | void | false; +``` + +### `getSelectionNodes` + +```ts +/** + * 获取可编辑区域选中的所有节点 + */ +getSelectionNodes?(): Array +``` diff --git a/docs/plugin/tutorials-element.md b/docs/plugin/tutorials-element.md new file mode 100644 index 00000000..d05205ea --- /dev/null +++ b/docs/plugin/tutorials-element.md @@ -0,0 +1,276 @@ +# Node plugin + +It is usually used in scenes that use node wrapping text to be modified. For example, `abc` `

abc

` uses a set of tags to wrap text or nodes + +Or used to set all node styles. For example, indentation, `

`, `

`, each block-level node can be set Indentation style + +A tag is composed of `tag name`, `attribute`, and `style`. These three elements will eventually be added to the `schema` rule management. `schema` is a list of constraints of the DOM tree. If the tag, style, and attribute do not exist`In the schema` rules, all will be filtered and discarded + +The `ElementPlugin` plugin will automatically compose a node of the `tag name`, `attribute`, and `style` we set, and automatically add it to the `schema` rule + +For this type of plug-in, we need to inherit the `ElementPlugin` abstract class. The `ElementPlugin` abstract class extends some properties and methods on the basis of inheriting the `Plugin` abstract class. So the plugin that inherits `ElementPlugin` also has all the attributes and methods of the `Plugin` abstract class + +## Inheritance + +Inherit the `ElementPlugin` abstract class + +```ts +import {ElementPlugin} from'@aomao/engine' + +export default class extends ElementPlugin { +... +} +``` + +## Attributes + +Because `ElementPlugin` inherits the abstract class of `Plugin`, so don't forget `pluginName`~ + +### `tagName` + +Label name, optional + +The label name used to wrap the text or node + +If there is a setting, it will be automatically added to the `schema` rule management + +```ts +export default class extends ElementPlugin { +... + readonly tagName = "span" +} +``` + +### `style` + +Label style, optional + +It is used to set the style of the current label. If the plug-in does not set the label name, the style will be used as the style of the global label. The label `schema` with this style will be judged to be legal and the style will be retained. + +If there is a setting, it will be automatically added to the `schema` rule management + +Example: + +```ts +export default class extends ElementPlugin { +... + readonly style = { + "font-weight": "bold" + } +} +``` + +In some cases we need a dynamic value, such as font size, `` 14px is a fixed value, if you want to replace it with `every time you execute the plug-in The dynamic value passed in by editor.command.execute`, then it is necessary to use the variable representation + +This variable is derived from the parameters of the `editor.command.execute` command, and the variable name is determined by `@var` + the index of the location of the parameter. `editor.command.execute("plugin name","parameter 0","parameter 1","parameter 2",...)` The corresponding variable name is `@var0` `@var1` `@var2`. .. + +```ts +export default class extends ElementPlugin { +... + readonly style = { + "font-size": "@var0" + } +} +``` + +In addition to dynamically setting the value, we can also format the value obtained by the node. For example, when pasting some copied text, the font size unit is pt, and we need to convert it to the familiar px. In this way, when executing `editor.command.queryState`, we can easily get the expected value + +```ts +export default class extends ElementPlugin { +... + readonly style = { + 'font-size': { +value:'@var0', +format: (value: string) => { +value = this.convertToPX(value); +return value; +}, +}, + } +} +``` + +### `attributes` + +The attributes of the label, optional + +The setting and usage are the same as `style` + +```ts +export default class extends ElementPlugin { +... + readonly attributes = { + "data-attr": "@var0" + } +} +``` + +Strictly speaking, `style` is also an attribute on the attribute tag, but the `style` attribute is more commonly used, and it appears in the form of key-value pairs. It is easier to understand and manage if listed separately. Eventually, when adding the `schema`, the `style` attribute will be merged into the `attributes` field. + +### `variable` + +Variable rules, optional + +If dynamic variables are used in the values ​​of `style` or `attributes`, then the variables must be stated in rules. Otherwise, `@var0`, `@var1` will be treated as fixed values ​​in the `schema`, and non-conformities will be filtered out + +```ts +variable = { + '@var0': { + required: true, + value: /[\d\.]+(pt|px)$/, + }, +}; +``` + +For `@var0` we used the following rules to indicate + +- `required` required value +- `value` regular expression, you can use `pt` or `px` as the unit value + +For more information about the setting of `schema` rules, please read in `Document` -> `Basic` -> `Structure` + +## Method + +### `init` + +Initialization, optional + +The `ElementPlugin` plugin has implemented the `init` method, if you need to use it, you need to manually call it again. Otherwise there will be unexpected situations + +```ts +export default class extends ElementPlugin { +... + init(){ + super.init() + } +} +``` + +### `setStyle` + +Apply the current plug-in `style` attribute to a node + +```ts +/** + * Apply the current plug-in style attribute to the node + * @param node The node that needs to be set + * @param args If there is a dynamic value in `style`, pass it here as a parameter, and you need to pay attention to the order of the parameters + */ +setStyle(node: NodeInterface | Node, ...args: Array): void +``` + +### `setAttributes` + +Apply the current plugin `attributes` attribute to a node + +```ts +/** + * Apply the attributes of the current plugin to the node + * @param node node + * @param args If there is a dynamic value in `attributes`, pass it in as a parameter here, and you need to pay attention to the order of the parameters + */ +setAttributes(node: NodeInterface | Node, ...args: Array): void; +``` + +### `getStyle` + +Get the style of a node that meets the current plug-in rules + +```ts +/** + * Get the style of the node that meets the current plug-in rules + * @param node node + * @returns key-value pairs of style name and style value + */ +getStyle(node: NodeInterface | Node): {[key: string]: string }; +``` + +### `getAttributes` + +Get the attributes of the node that comply with the current plug-in rules + +```ts +/** + * Get the attributes of the node that comply with the current plugin rules + * @param node node + * @returns attribute name and attribute value key-value pair + */ +getAttributes(node: NodeInterface | Node): {[key: string]: string }; +``` + +### `isSelf` + +Check whether the current node meets the rules set by the current plug-in + +```ts +/** + * Check whether the current node meets the rules set by the current plug-in + * @param node node + * @returns true | false + */ +isSelf(node: NodeInterface | Node): boolean; +``` + +### `queryState` + +Query plug-in status command, optional + +```ts +queryState() { + //Not an engine + if (!isEngine(this.editor)) return; + const {change} = this.editor; + //If there are no attributes and style restrictions, directly query whether the current label name is included + if (!this.style && !this.attributes) + return change.marks.some(node ​​=> node.name === this.tagName); + //Get the value collection within the attribute and style limit + const values: Array = []; + change.marks.forEach(node ​​=> { + values.push(...Object.values(this.getStyle(node))); + values.push(...Object.values(this.getAttributes(node))); + }); + return values.length === 0? undefined: values; +} +``` + +### `execute` + +Execute plug-in commands, need to be implemented + +Example of adding a mark tag: + +```ts +execute(...args) { + //Not an engine + if (!isEngine(this.editor)) return; + const { change} = this.editor; + //Instantiate a label name node set by the current plugin + const markNode = $(`<${this.tagName} />`); + //Set the style set by the current plugin for the node. If there is a dynamic value, the dynamic parameter will be automatically combined + this.setStyle(markNode, ...args); + //Set the attributes set by the current plug-in to the node, if there are dynamic values, automatically combine dynamic parameters + this.setAttributes(markNode, ...args); + + const {mark} = this.editor; + //Query whether the current cursor position meets the settings of the current plug-in + const trigger = !this.queryState() + if (trigger) { + //Wrap the mark style label node set by the current plug-in at the cursor + mark.wrap(markNode); + } else { + //Remove the mark style label node set by the current plug-in at the cursor + mark.unwrap(markNode); + } +} +``` + +### `schema` + +Get the rules generated by the attributes and styles set by the plugin. These rules will be added to the `schema` object + +```ts +/** + * Get the rules generated by the attributes and styles set by the plugin + */ +schema(): SchemaRule | SchemaGlobal | Array; +``` diff --git a/docs/plugin/tutorials-element.zh-CN.md b/docs/plugin/tutorials-element.zh-CN.md new file mode 100644 index 00000000..b2427800 --- /dev/null +++ b/docs/plugin/tutorials-element.zh-CN.md @@ -0,0 +1,276 @@ +# 节点插件 + +通常用于使用节点包裹文本加以修饰的场景。例如,`abc` `

abc

` 使用一组标签包裹文本或者节点 + +或者用于设置所有的节点样式。例如缩进,`

`,`

`,每个块级节点都可以设置缩进样式 + +一个标签由 `标签名称`、`属性`、`样式` 组成,这三要素最终都会加入 `schema` 规则管理里面,`schema` 是 DOM 树的约束条件列表,标签、样式、属性如果不存在 `schema` 规则里面,都会被过滤遗弃 + +`ElementPlugin` 插件会把我们设置的`标签名称`、`属性`、`样式`自动组成一个节点,并自动加入到`schema`规则里面 + +此类插件我们需要继承 `ElementPlugin` 抽象类,`ElementPlugin` 抽象类在继承 `Plugin` 抽象类的基础上扩展了一些属性和方法。所以继承 `ElementPlugin` 的插件也同样拥有`Plugin`抽象类的所有属性和方法 + +## 继承 + +继承 `ElementPlugin` 抽象类 + +```ts +import { ElementPlugin } from '@aomao/engine' + +export default class extends ElementPlugin { + ... +} +``` + +## 属性 + +因为 `ElementPlugin` 继承了 `Plugin` 抽象类,所以 `pluginName` 不要忘记了~ + +### `tagName` + +标签名称,可选 + +用于包裹文本或节点的标签名称 + +如果有设置,会自动加入 `schema` 规则管理 + +```ts +export default class extends ElementPlugin { + ... + readonly tagName = "span" +} +``` + +### `style` + +标签的样式,可选 + +用于设置当前标签的样式,如果此插件没有设置标签名称,样式将作为全局标签的样式,拥有此样式的标签`schema`会判定为合法的,会把样式保留。 + +如果有设置,会自动加入 `schema` 规则管理 + +示例: + +```ts +export default class extends ElementPlugin { + ... + readonly style = { + "font-weight": "bold" + } +} +``` + +某些情况下我们需要一个动态值,例如字体大小,`` 14px 是一个固定的值,如果希望每次执行插件都能替换成`editor.command.execute`传进来的动态值,那么就要使用变量表示 + +这个变量来源于`editor.command.execute`命令的参数,变量名称由 `@var` + 参数所在位置索引决定。`editor.command.execute("插件名称","参数0","参数1","参数2",...)` 对应的变量名为 `@var0` `@var1` `@var2` ... + +```ts +export default class extends ElementPlugin { + ... + readonly style = { + "font-size": "@var0" + } +} +``` + +除了动态设置值以外,我们还可以对节点获取到的值进行格式化。例如,在粘贴某些复制过来的文本时,字体大小单位是 pt,我们需要转换成我们熟悉的 px。这样在执行`editor.command.queryState`时,我们能轻松的获取到预期内的值 + +```ts +export default class extends ElementPlugin { + ... + readonly style = { + 'font-size': { + value: '@var0', + format: (value: string) => { + value = this.convertToPX(value); + return value; + }, + }, + } +} +``` + +### `attributes` + +标签的属性,可选 + +设置和使用方式与 `style` 一样 + +```ts +export default class extends ElementPlugin { + ... + readonly attributes = { + "data-attr": "@var0" + } +} +``` + +严格意义上`style`也属性标签上的属性,不过`style`属性比较常用,并且它是以键值对形式出现的,单独罗列出来比较好理解和管理。最终在加入 `schema` 时 `style` 属性会被合并到 `attributes` 字段中。 + +### `variable` + +变量规则,可选 + +如果 `style` 或 `attributes` 的值有使用动态变量,那么必须要对变量进行规则说明。否则,`@var0`,`@var1` 在 `schema` 中会被当作固定值处理,不符合就会被过滤遗弃 + +```ts +variable = { + '@var0': { + required: true, + value: /[\d\.]+(pt|px)$/, + }, +}; +``` + +对于`@var0`我们使用了以下规则表明 + +- `required` 必需要有的值 +- `value` 正则表达式,可以用`pt` 或 `px`做单位的数值 + +更多关于 `schema` 规则的设置请在 `文档` -> `基础` -> `结构`阅读 + +## 方法 + +### `init` + +初始化,可选 + +`ElementPlugin` 插件已经实现了`init`方法,如果需要使用,需要手动再次调用。否则会出现意料外的情况 + +```ts +export default class extends ElementPlugin { + ... + init(){ + super.init() + } +} +``` + +### `setStyle` + +将当前插件 `style` 属性应用到一个节点 + +```ts +/** + * 将当前插件style属性应用到节点 + * @param node 需要设置的节点 + * @param args 如果有 `style` 中有动态值,在这里以参数的形式传入,需要注意参数顺序 + */ +setStyle(node: NodeInterface | Node, ...args: Array): void +``` + +### `setAttributes` + +将当前插件 `attributes` 属性应用到一个节点 + +```ts +/** + * 将当前插件attributes属性应用到节点 + * @param node 节点 + * @param args 如果有 `attributes` 中有动态值,在这里以参数的形式传入,需要注意参数顺序 + */ +setAttributes(node: NodeInterface | Node, ...args: Array): void; +``` + +### `getStyle` + +获取一个节点符合当前插件规则的样式 + +```ts +/** + * 获取节点符合当前插件规则的样式 + * @param node 节点 + * @returns 样式名称和样式值键值对 + */ +getStyle(node: NodeInterface | Node): { [key: string]: string }; +``` + +### `getAttributes` + +获取节点符合当前插件规则的属性 + +```ts +/** + * 获取节点符合当前插件规则的属性 + * @param node 节点 + * @returns 属性名称和属性值键值对 + */ +getAttributes(node: NodeInterface | Node): { [key: string]: string }; +``` + +### `isSelf` + +检测当前节点是否符合当前插件设置的规则 + +```ts +/** + * 检测当前节点是否符合当前插件设置的规则 + * @param node 节点 + * @returns true | false + */ +isSelf(node: NodeInterface | Node): boolean; +``` + +### `queryState` + +查询插件状态命令,可选 + +```ts +queryState() { + //不是引擎 + if (!isEngine(this.editor)) return; + const { change } = this.editor; + //如果没有属性和样式限制,直接查询是否包含当前标签名称 + if (!this.style && !this.attributes) + return change.marks.some(node => node.name === this.tagName); + //获取属性和样式限制内的值集合 + const values: Array = []; + change.marks.forEach(node => { + values.push(...Object.values(this.getStyle(node))); + values.push(...Object.values(this.getAttributes(node))); + }); + return values.length === 0 ? undefined : values; +} +``` + +### `execute` + +执行插件命令,需要实现 + +添加一个 mark 标签的例子: + +```ts +execute(...args) { + //不是引擎 + if (!isEngine(this.editor)) return; + const { change } = this.editor; + //实例化一个当前插件设定的标签名称节点 + const markNode = $(`<${this.tagName} />`); + //给节点设置当前插件设定的样式,如果有动态值,自动组合动态参数 + this.setStyle(markNode, ...args); + //给节点设置当前插件设定的属性,如果有动态值,自动组合动态参数 + this.setAttributes(markNode, ...args); + + const { mark } = this.editor; + //查询当前光标位置是否符合当前插件的设置 + const trigger = !this.queryState() + if (trigger) { + //在光标处包裹当前插件设置的mark样式标签节点 + mark.wrap(markNode); + } else { + //在光标处移除当前插件设置的mark样式标签节点 + mark.unwrap(markNode); + } +} +``` + +### `schema` + +获取插件设置的属性和样式所生成的规则,这些规则将添加到 `schema` 对象中 + +```ts +/** + * 获取插件设置的属性和样式所生成的规则 + */ +schema(): SchemaRule | SchemaGlobal | Array; +``` diff --git a/docs/plugin/tutorials-inline.md b/docs/plugin/tutorials-inline.md new file mode 100644 index 00000000..598eab60 --- /dev/null +++ b/docs/plugin/tutorials-inline.md @@ -0,0 +1,144 @@ +# Inline plugin + +In-line node plugin + +Usually used in scenarios where the text is individually styled and cannot be nested + +For this type of plug-in, we need to inherit the `InlinePlugin` abstract class. The `InlinePlugin` abstract class extends some properties and methods on the basis of inheriting the `ElementPlugin` abstract class. So the plugin that inherits `InlinePlugin` also has all the attributes and methods of the `ElementPlugin` abstract class + +Because `InlinePlugin` has implemented `markdown` syntax processing, `execute`, `queryState` commands, so we can easily configure an Inline plugin + +```ts +import { InlinePlugin } from '@aomao/engine'; + +export default class extends InlinePlugin { + static get pluginName() { + return 'inline-plugin'; + } + + readonly tagName = 'code'; + + readonly style = { + border: '1px solid #000000', + }; +} +``` + +After executing `editor.command.execute("inline-plugin")`, the text at the cursor position will be wrapped by a code label with a black border color + +## Inheritance + +Inherit the `InlinePlugin` abstract class + +```ts +import {InlinePlugin} from'@aomao/engine' + +export default class extends InlinePlugin { +... +} +``` + +## Attributes + +### `tagName` + +Label name, must + +The label name here is the same as the label name in the parent class `ElementPlugin`, except that the label name is one of the necessary attributes of the `InlinePlugin` plugin + +### `markdown` + +Markdown syntax, optional + +Type: `string` + +Because the grammar parsing of markdown has been implemented in the `InlinePlugin` plugin, we only need to pass in the markdown grammar of the plugin, for example: + +```ts +//Inline code syntax +readonly markdown = "`" +``` + +## Method + +### `init` + +Initialization, optional + +The `InlinePlugin` plugin has implemented the `init` method, if you need to use it, you need to manually call it again. Otherwise there will be unexpected situations + +```ts +export default class extends InlinePlugin { +... + init(){ + super.init() + } +} +``` + +### `execute` + +Execute plug-in commands, optional + +The `InlinePlugin` plugin has implemented the `execute` method, if you need to use it, you can override this method + +### `queryState` + +Query plug-in status command, optional + +The `InlinePlugin` plugin has implemented the `queryState` method, if you need to use it, you can override this method + +### `schema` + +Set the `schema` rule of this inline plugin, optional + +The `ElementPlugin` plugin has implemented the `schema` method, which will automatically set the rules according to the `tagName` `style` `attributes`. + +If you need to use it, you can override this method or use super.schema() to call this method again + +### `isTrigger` + +Whether to trigger the execution to add the current inline package, otherwise the package with the current inline label will be removed, optional + +By default, the `InlinePlugin` plugin will call `editor.command.queryState` to query the current plugin state (the node selected within the current cursor matches the node set by the current inline plugin) and the currently set `tagName` `style` `attributes` In comparison, if they are consistent, the effect of removing the current inline plug-in node will be executed, otherwise the effect of the current inline plug-in node will be added. + +If you implement the isTrigger method, you need to determine whether to cancel or add the effect of the current inline plug-in node. + +```ts +/** + * Whether to trigger the execution to increase the current inline label package, otherwise it will remove the current inline label package + * @param args is the parameter passed in when calling command.execute to execute the plugin + */ +isTrigger?(...args: any): boolean; +``` + +### `triggerMarkdown` + +Parse `markdown` grammar, optional + +We can override this method when `InlinePlugin` fails to meet the requirements after the default parsing + +```ts +/** + * Parse markdown + * @param event event + * @param text markdown text + * @param node trigger node + */ +triggerMarkdown(event: KeyboardEvent, text: string, node: NodeInterface): void +``` + +### `pasteMarkdown` + +Batch parsing of `markdown` syntax when pasting + +We can override this method when `InlinePlugin` fails to meet the requirements after the default parsing + +If a `markdown` syntax is detected during pasting, it will be converted into plain text and then passed in. You need to replace all the `markdown` syntax texts currently in line with the current plug-in with inline tags + +```ts +/** + * @param node contains a text node with markdown syntax + * */ +pasteMarkdown(node: NodeInterface): void +``` diff --git a/docs/plugin/tutorials-inline.zh-CN.md b/docs/plugin/tutorials-inline.zh-CN.md new file mode 100644 index 00000000..0d4cdf22 --- /dev/null +++ b/docs/plugin/tutorials-inline.zh-CN.md @@ -0,0 +1,144 @@ +# Inline 插件 + +行内节点插件 + +通常用于文本单独样式、不可嵌套的场景下 + +此类插件我们需要继承 `InlinePlugin` 抽象类,`InlinePlugin` 抽象类在继承 `ElementPlugin` 抽象类的基础上扩展了一些属性和方法。所以继承 `InlinePlugin` 的插件也同样拥有`ElementPlugin`抽象类的所有属性和方法 + +因为`InlinePlugin` 已经实现了`markdown`语法处理,`execute`,`queryState` 命令,所以我们很容易就能配置好一个 Inline 插件 + +```ts +import { InlinePlugin } from '@aomao/engine'; + +export default class extends InlinePlugin { + static get pluginName() { + return 'inline-plugin'; + } + + readonly tagName = 'code'; + + readonly style = { + border: '1px solid #000000', + }; +} +``` + +执行 `editor.command.execute("inline-plugin")` 后,光标位置的文本就会被一个边框颜色为黑色的 code 标签包裹了 + +## 继承 + +继承 `InlinePlugin` 抽象类 + +```ts +import { InlinePlugin } from '@aomao/engine' + +export default class extends InlinePlugin { + ... +} +``` + +## 属性 + +### `tagName` + +标签名称,必须 + +此处的标签名称与父类`ElementPlugin`中的标签名称作用是一致的,只不过标签名称是 `InlinePlugin` 插件必要的属性之一 + +### `markdown` + +Markdown 语法,可选 + +类型:`string` + +因为 `InlinePlugin` 插件中已经实现了对 markdown 的语法解析,所以我们只需要传入插件的 markdown 语法即可,例如: + +```ts +//行内代码语法 +readonly markdown = "`" +``` + +## 方法 + +### `init` + +初始化,可选 + +`InlinePlugin` 插件已经实现了`init`方法,如果需要使用,需要手动再次调用。否则会出现意料外的情况 + +```ts +export default class extends InlinePlugin { + ... + init(){ + super.init() + } +} +``` + +### `execute` + +执行插件命令,可选 + +`InlinePlugin` 插件已经实现了`execute`方法,如果需要使用,可以重写此方法 + +### `queryState` + +查询插件状态命令,可选 + +`InlinePlugin` 插件已经实现了`queryState`方法,如果需要使用,可以重写此方法 + +### `schema` + +设置此 inline 插件的`schema`规则,可选 + +`ElementPlugin` 插件已经实现了`schema`方法,会自动根据 `tagName` `style` `attributes` 设置规则。 + +如果需要使用,可以重写此方法或者使用 super.schema()再次调用此方法 + +### `isTrigger` + +是否触发执行增加当前 inline 包裹,否则将移除当前 inline 标签的包裹,可选 + +默认情况下,`InlinePlugin` 插件会调用 `editor.command.queryState` 查询当前插件状态(当前光标范围内选中的节点符合当前 inline 插件设置的节点)与当前设置的`tagName` `style` `attributes`比较,一致的情况下会执行移除当前 inline 插件节点的效果,否则会加上当前 inline 插件节点的效果。 + +如果有实现 `isTrigger` 方法就需要自己判定当前是取消还是加上当前 inline 插件节点的效果 + +```ts +/** + * 是否触发执行增加当前inline标签包裹,否则将移除当前inline标签的包裹 + * @param args 在调用 command.execute 执行插件传入时的参数 + */ +isTrigger?(...args: any): boolean; +``` + +### `triggerMarkdown` + +解析`markdown`语法,可选 + +在 `InlinePlugin` 默认解析后无法满足需求时,我们可以重写此方法 + +```ts +/** + * 解析markdown + * @param event 事件 + * @param text markdown文本 + * @param node 触发节点 + */ +triggerMarkdown(event: KeyboardEvent, text: string, node: NodeInterface): void +``` + +### `pasteMarkdown` + +粘贴时批量解析`markdown`语法 + +在 `InlinePlugin` 默认解析后无法满足需求时,我们可以重写此方法 + +在粘贴时如果有检测到是`markdown`语法,会转换为纯文本后传入,需要把当前符合当前插件的`markdown`语法文本全部替换为 inline 标签 + +```ts +/** + * @param node 含有markdown语法的文本节点 + * */ +pasteMarkdown(node: NodeInterface): void +``` diff --git a/docs/plugin/tutorials-list.md b/docs/plugin/tutorials-list.md new file mode 100644 index 00000000..af8c9199 --- /dev/null +++ b/docs/plugin/tutorials-list.md @@ -0,0 +1,132 @@ +# List plugin + +List node plugin + +Usually used for ordered lists, unordered lists, and custom lists. For example, the task list is a custom list, and the `checkbox` inside is an implementation of `card` of type `inline` + +For this type of plug-in, we need to inherit the `ListPlugin` abstract class. The `ListPlugin` abstract class extends some properties and methods on the basis of inheriting the `BlockPlugin` abstract class. So the plug-in that inherits `ListPlugin` also has all the attributes and methods of the `BlockPlugin` abstract class + +## Inheritance + +Inherit the `ListPlugin` abstract class + +```ts +import {ListPlugin} from'@aomao/engine' + +export default class extends ListPlugin { +... +} +``` + +## Attributes + +`ListPlugin` has all the attributes and methods of `BlockPlugin` `ElementPlugin` `Plugin` inherited + +### `cardName` + +Card name, optional. + +When we customize the list, `cardName` is necessary + +The `card` component corresponding to the card name must be of type `inline`, not of type `block` + +Type: `string` + +```ts +cardName = 'checkbox'; +``` + +## Method + +### `init` + +Initialization, optional + +The `ListPlugin` plugin has implemented the `init` method, if you need to use it, you need to manually call it again. Otherwise there will be unexpected situations + +```ts +export default class extends ListPlugin { +... + init(){ + super.init() + } +} +``` + +### `isCurrent` + +To determine whether the node is the node required by the current list, it must be implemented + +We need to use this method to determine which plug-in a list node attribute + +```ts +isCurrent(node: NodeInterface) { + //li node, must include the style of the `CUSTOMZIE_LI_CLASS` custom list, and the first child node under li should be a card, the card name corresponds to the cardName we set + if (node.name ==='li') + return ( + node.hasClass(this.editor.list.CUSTOMZIE_LI_CLASS) && + node.first()?.attributes(CARD_KEY) === this.cardName + ); + //ul node should contain `CUSTOMZIE_UI_CLASS` custom list style. And there are also our custom styles + return node.hasClass(this.editor.list.CUSTOMZIE_UI_CLASS) && node.hasClass('data-list-task'); +} +``` + +### `queryState` + +The `ListPlugin` plugin has implemented the `queryState` method, if you need to use it, you can override this method + +```ts +queryState() { + if (!isEngine(this.editor)) return false; + return ( + this.editor.list.getPluginNameByNodes(this.editor.change.blocks) === + (this.constructor as PluginEntryType).pluginName + ); +} +``` + +### `execute` + +Need to use API call method to realize the package of the list node, and remove + +```ts +//Non-engine +if (!isEngine(this.editor)) return; +const { change, list, block } = this.editor; +//First cut the list,
->
+list.split(); +//Get the current cursor +const range = change.range.get(); +//Get all current block nodes after the season +const activeBlocks = block.findBlocks(range); +if (activeBlocks) { + //Create a marker node at the cursor + const selection = range.createSelection(); + //Determine whether it belongs to the custom list node of the current plug-in type + if (list.isSpecifiedType(activeBlocks, 'ul', 'checkbox')) { + //Remove package + list.unwrap(activeBlocks); + } else { + //Convert all currently activated block nodes into a custom list + const listBlocks = list.toCustomize( + activeBlocks, + 'checkbox', + //The value of the checkbox card is determined by the value when the checkbox is defined. For example, whether checked is checked + { + checked: boolean, + }, + ) as Array; + //After the conversion is completed, add our custom styles in a loop + listBlocks.forEach((list) => { + if (this.editor.node.isList(list)) list.addClass('data-list-task'); + }); + } + //Remove the mark and restore the cursor + selection.move(); + //Reselect the new cursor position + change.range.select(range); + //Merge adjacent and identical list nodes, if any + list.merge(); +} +``` diff --git a/docs/plugin/tutorials-list.zh-CN.md b/docs/plugin/tutorials-list.zh-CN.md new file mode 100644 index 00000000..60ddd781 --- /dev/null +++ b/docs/plugin/tutorials-list.zh-CN.md @@ -0,0 +1,132 @@ +# List 插件 + +列表节点插件 + +通常用于有序列表、无序列表、自定义列表。例如,任务列表就属于自定义列表,里面的`checkbox`是 `inline` 类型的 `card` 实现 + +此类插件我们需要继承 `ListPlugin` 抽象类,`ListPlugin` 抽象类在继承 `BlockPlugin` 抽象类的基础上扩展了一些属性和方法。所以继承 `ListPlugin` 的插件也同样拥有`BlockPlugin`抽象类的所有属性和方法 + +## 继承 + +继承 `ListPlugin` 抽象类 + +```ts +import { ListPlugin } from '@aomao/engine' + +export default class extends ListPlugin { + ... +} +``` + +## 属性 + +`ListPlugin` 拥有继承的 `BlockPlugin` `ElementPlugin` `Plugin` 所有的属性和方法 + +### `cardName` + +卡片名称,可选。 + +在我们自定义列表时,`cardName` 是必须的 + +卡片名称所对应的 `card` 组件必须是 `inline` 类型的,不能是 `block` 类型 + +类型:`string` + +```ts +cardName = 'checkbox'; +``` + +## 方法 + +### `init` + +初始化,可选 + +`ListPlugin` 插件已经实现了`init`方法,如果需要使用,需要手动再次调用。否则会出现意料外的情况 + +```ts +export default class extends ListPlugin { + ... + init(){ + super.init() + } +} +``` + +### `isCurrent` + +判断节点是否是当前列表所需要的节点,必须实现它 + +我们需要通过此方法,判定一个列表节点属性哪个插件 + +```ts +isCurrent(node: NodeInterface) { + //li 节点,必须包含 `CUSTOMZIE_LI_CLASS` 自定义列表的样式,并且li下的第一个子节点,应是一个卡片,卡片名称与我们设置的 cardName 对应 + if (node.name === 'li') + return ( + node.hasClass(this.editor.list.CUSTOMZIE_LI_CLASS) && + node.first()?.attributes(CARD_KEY) === this.cardName + ); + //ul 节点应该必须包含 `CUSTOMZIE_UI_CLASS` 自定义列表的样式。并且还有我们自定义的样式 + return node.hasClass(this.editor.list.CUSTOMZIE_UI_CLASS) && node.hasClass('data-list-task'); +} +``` + +### `queryState` + +`ListPlugin` 插件已经实现了`queryState`方法,如果需要使用,可以重写此方法 + +```ts +queryState() { + if (!isEngine(this.editor)) return false; + return ( + this.editor.list.getPluginNameByNodes(this.editor.change.blocks) === + (this.constructor as PluginEntryType).pluginName + ); +} +``` + +### `execute` + +需用通过 API 调用方法,来实现对列表节点的包裹,与移除 + +```ts +//非引擎 +if (!isEngine(this.editor)) return; +const { change, list, block } = this.editor; +//先要切割列表,
->
+list.split(); +//获取当前光标 +const range = change.range.get(); +//获取当前所有季后的block节点 +const activeBlocks = block.findBlocks(range); +if (activeBlocks) { + //在光标处创建标记节点 + const selection = range.createSelection(); + //判定是否属于当前插件类型的自定义列表节点 + if (list.isSpecifiedType(activeBlocks, 'ul', 'checkbox')) { + //移除包裹 + list.unwrap(activeBlocks); + } else { + //把当前的所有激活的block节点转换为自定义列表 + const listBlocks = list.toCustomize( + activeBlocks, + 'checkbox', + //checkbox 卡片的值,由checkbox定义时的值决定。例如 checked 是否有选中 + { + checked: boolean, + }, + ) as Array; + //转换完成后,循环添加我们自定义的样式 + listBlocks.forEach((list) => { + if (this.editor.node.isList(list)) list.addClass('data-list-task'); + }); + } + //移除标记,并把光标复原 + selection.move(); + //重新选中新的光标位置 + change.range.select(range); + //合并相邻并且相同的列表节点,如果有 + list.merge(); +} +``` diff --git a/docs/plugin/tutorials-mark.md b/docs/plugin/tutorials-mark.md new file mode 100644 index 00000000..a9e22343 --- /dev/null +++ b/docs/plugin/tutorials-mark.md @@ -0,0 +1,178 @@ +# Mark plugin + +Style node plugin + +Usually used for text modification, for example, bold, italic, underline, background color, etc. + +For this type of plug-in, we need to inherit the `MarkPlugin` abstract class. The `MarkPlugin` abstract class extends some properties and methods on the basis of inheriting the `ElementPlugin` abstract class. So the plugin that inherits `MarkPlugin` also has all the attributes and methods of the `ElementPlugin` abstract class + +Because `MarkPlugin` has implemented `markdown` syntax processing, `execute`, `queryState` commands, so we can easily configure a Mark plugin + +```ts +import { MarkPlugin } from '@aomao/engine'; + +export default class extends MarkPlugin { + static get pluginName() { + return 'mark-plugin'; + } + + readonly tagName = 'span'; + + readonly style = { + 'font-size': '18px', + color: 'red', + }; +} +``` + +After executing `editor.command.execute("mark-plugin")`, the text at the cursor position will be wrapped by a span tag with a font size of 18px and a font color of red + +## Inheritance + +Inherit the `MarkPlugin` abstract class + +```ts +import {MarkPlugin} from'@aomao/engine' + +export default class extends MarkPlugin { +... +} +``` + +## Attributes + +### `tagName` + +Label name, must + +The tag name here is the same as the tag name in the parent class `ElementPlugin`, except that the tag name is one of the necessary attributes of the `MarkPlugin` plugin + +### `markdown` + +Markdown syntax, optional + +Type: `string` + +Because the markdown syntax analysis has been implemented in the `MarkPlugin` plugin, we only need to pass in the markdown syntax of the plugin, for example: + +```ts +//Bold grammar +readonly markdown = "**" +``` + +### `copyOnEnter` + +Whether to copy the mark effect after carriage return, the default is true, allowing + +Type: `string` + +E.g: +`

abc

` The cursor tag represents the current cursor position, and a new line will appear after pressing the Enter key: + +If copying is allowed: `

`, otherwise `

` + +### `followStyle` + +Whether to follow the style, the default is true, optional + +After setting to not follow, input after this label will no longer have the mark plug-in effect, and the mark plug-in cancel command will be executed when the cursor is overlapped. E.g: + +`abc` or `abc` The cursor tag represents the current cursor position + +Enter here, it will be entered after the strong node or before the strong node + +`abc` If the cursor is in the middle of the style tag, it will continue to follow the style effect + +`abc123` If there is a strong node style effect immediately after the style tag, then it will continue to follow the style. Complete the input after strong abc + +### `combineValueByWrap` + +When the package `schema` is judged to be the same mark plugin node, and the attribute names are the same, and the values ​​are inconsistent, whether to merge the former value to the new node or remove the former mark node, the default is false to remove, optional + +The value of mark node style (style) will always be overwritten + +`abc` When using `` to wrap the mark node of a=1. If the combined value is `abc` otherwise it is `abc` + +## Method + +### `init` + +Initialization, optional + +The `MarkPlugin` plugin has implemented the `init` method, if you need to use it, you need to manually call it again. Otherwise there will be unexpected situations + +```ts +export default class extends MarkPlugin { +... + init(){ + super.init() + } +} +``` + +### `execute` + +Execute plug-in commands, optional + +The `MarkPlugin` plugin has implemented the `execute` method, if you need to use it, you can override this method + +### `queryState` + +Query plug-in status command, optional + +The `MarkPlugin` plugin has implemented the `queryState` method, if you need to use it, you can override this method + +### `schema` + +Set the `schema` rule of this mark plugin, optional + +The `ElementPlugin` plugin has implemented the `schema` method, which will automatically set the rules according to the `tagName` `style` `attributes`. + +If you need to use it, you can override this method or use super.schema() to call this method again + +### `isTrigger` + +Whether to trigger the execution to add the current mark label package, otherwise it will remove the current mark label package, optional + +By default, the `MarkPlugin` plugin will call `editor.command.queryState` to query the current plugin state (the node selected within the current cursor area matches the node set by the current mark plugin) and the currently set `tagName` `style` `attributes` In comparison, if they are consistent, the effect of removing the current mark plug-in node will be executed, otherwise the effect of the current mark plug-in node will be added. + +If you implement the isTrigger method, you need to determine whether to cancel or add the effect of the current mark plug-in node. + +```ts +/** + * Whether to trigger the execution to increase the current mark label package, otherwise it will remove the current mark label package + * @param args is the parameter passed in when calling command.execute to execute the plugin + */ +isTrigger?(...args: any): boolean; +``` + +### `triggerMarkdown` + +Parse `markdown` grammar, optional + +We can override this method when the requirement cannot be met after the default parsing of `MarkPlugin` + +```ts +/** + * Parse markdown + * @param event event + * @param text markdown text + * @param node trigger node + */ +triggerMarkdown(event: KeyboardEvent, text: string, node: NodeInterface): void +``` + +### `pasteMarkdown` + +Batch parsing of `markdown` syntax when pasting + +We can override this method when the requirement cannot be met after the default parsing of `MarkPlugin` + +If the `markdown` syntax is detected during pasting, it will be converted into plain text and then passed in. You need to replace all the `markdown` syntax texts currently in line with the current plug-in with the mark tag + +```ts +/** + * @param node contains a text node with markdown syntax + * */ +pasteMarkdown(node: NodeInterface): void +``` diff --git a/docs/plugin/tutorials-mark.zh-CN.md b/docs/plugin/tutorials-mark.zh-CN.md new file mode 100644 index 00000000..9ae17498 --- /dev/null +++ b/docs/plugin/tutorials-mark.zh-CN.md @@ -0,0 +1,178 @@ +# Mark 插件 + +样式节点插件 + +通常用于文本修饰,例如,加粗、斜体、下划线、背景色等等 + +此类插件我们需要继承 `MarkPlugin` 抽象类,`MarkPlugin` 抽象类在继承 `ElementPlugin` 抽象类的基础上扩展了一些属性和方法。所以继承 `MarkPlugin` 的插件也同样拥有`ElementPlugin`抽象类的所有属性和方法 + +因为`MarkPlugin` 已经实现了`markdown`语法处理,`execute`,`queryState` 命令,所以我们很容易就能配置好一个 Mark 插件 + +```ts +import { MarkPlugin } from '@aomao/engine'; + +export default class extends MarkPlugin { + static get pluginName() { + return 'mark-plugin'; + } + + readonly tagName = 'span'; + + readonly style = { + 'font-size': '18px', + color: 'red', + }; +} +``` + +执行 `editor.command.execute("mark-plugin")` 后,光标位置的文本就会被一个 字体大小为 18px 字体颜色为 red 的 span 标签包裹了 + +## 继承 + +继承 `MarkPlugin` 抽象类 + +```ts +import { MarkPlugin } from '@aomao/engine' + +export default class extends MarkPlugin { + ... +} +``` + +## 属性 + +### `tagName` + +标签名称,必须 + +此处的标签名称与父类`ElementPlugin`中的标签名称作用是一致的,只不过标签名称是 `MarkPlugin` 插件必要的属性之一 + +### `markdown` + +Markdown 语法,可选 + +类型:`string` + +因为 `MarkPlugin` 插件中已经实现了对 markdown 的语法解析,所以我们只需要传入插件的 markdown 语法即可,例如: + +```ts +//加粗语法 +readonly markdown = "**" +``` + +### `copyOnEnter` + +回车后是否复制 mark 效果,默认为 true,允许 + +类型:`string` + +例如: +`

abc

` cursor 标签代表当前光标位置,按下回车键后会出现新行: + +如果允许复制:`

`,否则 `

` + +### `followStyle` + +是否跟随样式,默认为 true,可选 + +设置为不跟随后,在此标签后输入将不在有此 mark 插件效果,光标重合状态下也无非执行此 mark 插件取消命令。例如: + +`abc` 或者 `abc` cursor 标签代表当前光标位置 + +在此处输入,会在 strong 节点后输入 或者 strong 节点前输入 + +`abc` 如果光标在样式标签中间,还是会继续跟随样式效果 + +`abc123` 如果样式标签后方紧接着还有 strong 节点样式效果,那么还是会继续跟随样式,在 strong abc 后面完成输入 + +### `combineValueByWrap` + +在包裹`schema`判定为相同 mark 插件节点,并且属性名称一致,值不一致的的时候,是合并前者的值到新的节点还是移除前者 mark 节点,默认 false 移除,可选 + +mark 节点样式(style)的值将始终覆盖掉 + +`abc` 在使用 `` 包裹 a=1 的 mark 节点时。如果合并值,就是 `abc` 否则就是 `abc` + +## 方法 + +### `init` + +初始化,可选 + +`MarkPlugin` 插件已经实现了`init`方法,如果需要使用,需要手动再次调用。否则会出现意料外的情况 + +```ts +export default class extends MarkPlugin { + ... + init(){ + super.init() + } +} +``` + +### `execute` + +执行插件命令,可选 + +`MarkPlugin` 插件已经实现了`execute`方法,如果需要使用,可以重写此方法 + +### `queryState` + +查询插件状态命令,可选 + +`MarkPlugin` 插件已经实现了`queryState`方法,如果需要使用,可以重写此方法 + +### `schema` + +设置此 mark 插件的`schema`规则,可选 + +`ElementPlugin` 插件已经实现了`schema`方法,会自动根据 `tagName` `style` `attributes` 设置规则。 + +如果需要使用,可以重写此方法或者使用 super.schema()再次调用此方法 + +### `isTrigger` + +是否触发执行增加当前 mark 标签包裹,否则将移除当前 mark 标签的包裹,可选 + +默认情况下,`MarkPlugin` 插件会调用 `editor.command.queryState` 查询当前插件状态(当前光标范围内选中的节点符合当前 mark 插件设置的节点)与当前设置的`tagName` `style` `attributes`比较,一致的情况下会执行移除当前 mark 插件节点的效果,否则会加上当前 mark 插件节点的效果。 + +如果有实现 `isTrigger` 方法就需要自己判定当前是取消还是加上当前 mark 插件节点的效果 + +```ts +/** + * 是否触发执行增加当前mark标签包裹,否则将移除当前mark标签的包裹 + * @param args 在调用 command.execute 执行插件传入时的参数 + */ +isTrigger?(...args: any): boolean; +``` + +### `triggerMarkdown` + +解析`markdown`语法,可选 + +在 `MarkPlugin` 默认解析后无法满足需求时,我们可以重写此方法 + +```ts +/** + * 解析markdown + * @param event 事件 + * @param text markdown文本 + * @param node 触发节点 + */ +triggerMarkdown(event: KeyboardEvent, text: string, node: NodeInterface): void +``` + +### `pasteMarkdown` + +粘贴时批量解析`markdown`语法 + +在 `MarkPlugin` 默认解析后无法满足需求时,我们可以重写此方法 + +在粘贴时如果有检测到是`markdown`语法,会转换为纯文本后传入,需要把当前符合当前插件的`markdown`语法文本全部替换为 mark 标签 + +```ts +/** + * @param node 含有markdown语法的文本节点 + * */ +pasteMarkdown(node: NodeInterface): void +``` diff --git a/docs/plugin/tutorials.md b/docs/plugin/tutorials.md new file mode 100644 index 00000000..7bc87222 --- /dev/null +++ b/docs/plugin/tutorials.md @@ -0,0 +1,230 @@ +# Basic + +In addition to the plug-in tutorial, you can first have an understanding of the plug-in, read it in `Document` -> `Basic` -> `Plugin` + +All plug-ins must inherit one of the plug-in abstract classes and implement it. We can choose a type of plug-in abstract class to inherit according to its purpose. Let's understand them one by one + +## Inheritance + +Inherit the `Plugin` abstract class + +```ts +import {Plugin} from'@aomao/engine' + +export default class extends Plugin { +... +} +``` + +## Attributes + +### `pluginName` + +Plug-in name, read-only static property + +Type: `string` + +The plug-in name is unique and cannot be repeated with all plug-in names passed in to the engine + +```ts +export default class extends Plugin { + //Define the plug-in name, it is required + static get pluginName() { + return 'plugin name'; + } +} +``` + +### `options` + +Plug-in options + +Type: `T extends PluginOptions = {}` The default is an empty object + +We can pass in an option to the plug-in, for example: shortcut key + +```ts +//Define the optional type +export type Options = { +hotkey?: string | Array; +}; + +export default class extends Plugin { +... + +init() { + //Print the incoming optional hotkey +console.log(this.options.hotkey) +} +} +``` + +When instantiating the engine, pass in the options of the plugin through the `config` attribute + +```ts +//Instantiate the engine +const engine = new Engine(render node, { +config: { +Plug-in name: { +hotkey:'test', +}, +}, +}); +``` + +### `editor` + +Editor example + +Type: `EditorInterface` + +When the plug-in is instantiated, the editor instance will be passed in. We can access it through `this` + +```ts +import {Plugin, isEngine} from'@aomao/engine' + +export default class extends Plugin { +... + +init() { +console.log(isEngine(this.editor)? "Engine": "Reader") +} +} +``` + +We can use `isEngine` to determine whether the currently instantiated editor is an engine or a reader + +## Method + +### `init` + +Executed when the plugin is initialized, we can bind some events or initialize some variables here + +```ts +... +init(){ + if (isEngine(this.editor)) { + this.editor.on('keydown:enter', event => () => { + console.log("Enter key was pressed") + }); + } +} +... +``` + +### `queryState` + +Status query, optional + +This method will be called by `editor.command.queryState`. Mainly used to query whether the current cursor selection area has the node of the current plug-in selected, and then cooperate with the toolbar to display the active or disabled state of the plug-in + +You can also customize any other query, call it through `editor.command.queryState`, and pass back parameters to return any data you want to return + +```ts +/** +* Query plug-in status +* @param args parameters required by the plug-in +*/ +queryState?(...args: any): any; +/** +``` + +### `execute` + +Plug-in execution method, this is an abstract method, it must be implemented + +This method will be called by `editor.command.execute` + +```ts +... +execute(message: string) { + console.log(`Hi, ${message}`) +} +... +``` + +Called by `editor.command.execute` + +```ts +editor.command.execute('plugin name', 'xiaoming'); +//Output Hi, Xiao Ming +``` + +### `hotkey` + +Plug-in hot key binding, optional. + +This returns the key combination characters that need to be matched, such as mod+b. If the match is successful, the plug-in will be executed. You can also bring the parameters required for the plug-in execution. Multiple parameters are returned in the form of an array {key:"mod+b",args:[ ]} + +`mod` means the `ctrl` key under windows, and the `command` (⌘) key under mac + +We use [is-hotkey](https://github.com/ianstormtaylor/is-hotkey) to match whether the hotkey is hit or not. For more usage methods, please go to [is-hotkey](https://github.com/ianstormtaylor/is-hotkey) view + +We can also directly use isHotkey to determine whether it is hit + +```ts +import {isHotkey, Plugin} from'@aomao/engine' + +//Define the optional type +export type Options = { +hotkey?: string | Array; +}; + +export default class extends Plugin { +... +hotkey(event?: KeyboardEvent) { + //Use hotkey to judge + return event && isHotkey(event, this.options.hotkey ||'') + //Or directly return to the shortcut keys for optional configuration +return this.options.hotkey ||''; + //You can also return to multiple sets of shortcut keys + return ["mod+a","mod+b"] +} +} +``` + +Method signature + +```ts +hotkey?(event?: KeyboardEvent,): string | {key: string; args: any} | Array<{ key: string; args: any }> | Array; +``` + +After the hotkey is hit, it will execute the call `editor.command.execute` command to execute the plug-in, and the parameters will also be carried + +### `waiting` + +Wait for the plugin to complete certain actions, optional + +When using asynchronously to get the editor value `engine.getValueAsync`, if the plug-in operation has not been completed, it will wait for the plug-in to complete the action before returning the value. For example, the image is being uploaded + +```ts +async waiting?(): Promise; +``` + +### `Complete example` + +```ts +import { Plugin } from '@aomao/engine'; + +export type Options = { + hotkey?: string | Array; +}; + +export default class extends Plugin { + static get pluginName() { + return 'plugin name'; + } + + init() { + console.log(isEngine(this.editor) ? 'Engine' : 'Reader'); + } + + execute(message: string) { + console.log(`Hi, ${message}`); + } + + hotkey() { + return this.options.hotkey || ''; + } +} +``` diff --git a/docs/plugin/tutorials.zh-CN.md b/docs/plugin/tutorials.zh-CN.md new file mode 100644 index 00000000..b6222bd1 --- /dev/null +++ b/docs/plugin/tutorials.zh-CN.md @@ -0,0 +1,230 @@ +# 基础 + +在学习插件教程外,可以先对插件有一个了解,在 `文档` -> `基础` -> `插件`阅读 + +所有的插件都必须要继承插件抽象类其中一个,并且实现它。我们可以按照用途来选择一种类型的插件抽象类来继承。下面我们一个一个的来了解它们 + +## 继承 + +继承 `Plugin` 抽象类 + +```ts +import { Plugin } from '@aomao/engine' + +export default class extends Plugin { + ... +} +``` + +## 属性 + +### `pluginName` + +插件名称,只读静态属性 + +类型:`string` + +插件名称是唯一的,不可与传入引擎的所有插件名称重复 + +```ts +export default class extends Plugin { + //定义插件名称,它是必须的 + static get pluginName() { + return '插件名称'; + } +} +``` + +### `options` + +插件的可选项 + +类型:`T extends PluginOptions = {}` 默认是一个空对象 + +我们可以给插件传入一个可选项,例如:快捷键 + +```ts +//定义可选项类型 +export type Options = { + hotkey?: string | Array; +}; + +export default class extends Plugin { + ... + + init() { + //打印传入的可选项 hotkey + console.log(this.options.hotkey) + } +} +``` + +在实例化引擎时,通过 `config` 属性传入插件的可选项 + +```ts +//实例化引擎 +const engine = new Engine(渲染节点, { + config: { + 插件名称: { + hotkey: 'test', + }, + }, +}); +``` + +### `editor` + +编辑器实例 + +类型:`EditorInterface` + +在插件实例化的时候,会传入编辑器实例。我们可以通过 `this` 访问它 + +```ts +import { Plugin, isEngine } from '@aomao/engine' + +export default class extends Plugin { + ... + + init() { + console.log(isEngine(this.editor) ? "引擎" : "阅读器") + } +} +``` + +我们可以通过 `isEngine` 判断当前实例化的编辑器是引擎还是阅读器 + +## 方法 + +### `init` + +在插件初始化时执行,我们可以在这里绑定一些事件或者初始化一些变量 + +```ts +... +init(){ + if (isEngine(this.editor)) { + this.editor.on('keydown:enter', event => () => { + console.log("按下了回车键") + }); + } +} +... +``` + +### `queryState` + +状态查询,可选 + +这个方法会被 `editor.command.queryState` 调用。主要用于查询当前光标选区是否有选中当前插件的节点,然后配合 toolbar 显示插件的激活状态或者禁用状态 + +你也可自定义一些其它任何查询,通过 `editor.command.queryState` 调用,并且传回参数,返回任何你想返回的数据 + +```ts +/** +* 查询插件状态 +* @param args 插件需要的参数 +*/ +queryState?(...args: any): any; +/** +``` + +### `execute` + +插件执行方法,这是一个抽象方法,必须要实现它 + +这个方法会被 `editor.command.execute` 调用 + +```ts +... +execute(message: string) { + console.log(`Hi, ${message}`) +} +... +``` + +通过 `editor.command.execute` 调用 + +```ts +editor.command.execute('插件名称', '小明'); +//输出 Hi, 小明 +``` + +### `hotkey` + +插件热键绑定,可选。 + +该返回需要匹配的组合键字符,如 mod+b,匹配成功即执行插件,还可以带上插件执行所需要的参数,多个参数以数组形式返回{key:"mod+b",args:[]} + +`mod` 表示 windows 下的 `ctrl` 键,mac 下的 `command`(⌘) 键 + +我们使用[is-hotkey](https://github.com/ianstormtaylor/is-hotkey)来匹配热键是否命中,更多使用方法请去[is-hotkey](https://github.com/ianstormtaylor/is-hotkey)查看 + +我们也可以直接使用 isHotkey 来判断是否命中 + +```ts +import { isHotkey, Plugin } from '@aomao/engine' + +//定义可选项类型 +export type Options = { + hotkey?: string | Array; +}; + +export default class extends Plugin { + ... + hotkey(event?: KeyboardEvent) { + //使用 hotkey 判断 + return event && isHotkey(event, this.options.hotkey || '') + //或者直接返回可选项配置的快捷键 + return this.options.hotkey || ''; + //还可以返回多组快捷键 + return ["mod+a","mod+b"] + } +} +``` + +方法签名 + +```ts +hotkey?(event?: KeyboardEvent,): string | { key: string; args: any } | Array<{ key: string; args: any }> | Array; +``` + +在热键命中后会执行调用 `editor.command.execute` 命令执行插件,参数也会一起携带 + +### `waiting` + +等待插件完成某些动作,可选 + +在使用异步获取编辑器值的时候 `engine.getValueAsync` ,如果插件操作还未处理完成,会等待插件完成动作后再返回值。例如,图片正处于上传状态中 + +```ts +async waiting?(): Promise; +``` + +### `完整例子` + +```ts +import { Plugin } from '@aomao/engine'; + +export type Options = { + hotkey?: string | Array; +}; + +export default class extends Plugin { + static get pluginName() { + return '插件名称'; + } + + init() { + console.log(isEngine(this.editor) ? '引擎' : '阅读器'); + } + + execute(message: string) { + console.log(`Hi, ${message}`); + } + + hotkey() { + return this.options.hotkey || ''; + } +} +``` diff --git a/docs/view/index.md b/docs/view/index.md new file mode 100644 index 00000000..ce90afe0 --- /dev/null +++ b/docs/view/index.md @@ -0,0 +1,10 @@ +--- +title: Reading renderer +order: 1 +toc: menu +nav: + title: View + order: 1 +--- + + diff --git a/docs/view/index.zh-CN.md b/docs/view/index.zh-CN.md new file mode 100644 index 00000000..c1c6ef08 --- /dev/null +++ b/docs/view/index.zh-CN.md @@ -0,0 +1,10 @@ +--- +title: 阅读渲染器 +order: 1 +toc: menu +nav: + title: 阅读器 + order: 1 +--- + + diff --git a/examples/react/components/comment/button.ts b/examples/react/components/comment/button.ts new file mode 100644 index 00000000..87aebec7 --- /dev/null +++ b/examples/react/components/comment/button.ts @@ -0,0 +1,52 @@ +import { + $, + EditorInterface, + NodeInterface, + RangeInterface, + EventListener, + DATA_ELEMENT, + UI, +} from '@aomao/engine'; + +class Button { + #editor: EditorInterface; + #container: NodeInterface; + + constructor(editor: EditorInterface) { + this.#editor = editor; + this.#container = this.init(); + } + + init() { + const { root } = this.#editor; + const container = $( + `
`, + ); + root.append(container); + return container; + } + + show(range: RangeInterface) { + const { root } = this.#editor; + const rangeRect = range.getClientRect(); + const rootRect = root.get()!.getBoundingClientRect(); + const top = rangeRect.y - rootRect.y; + this.#container.css('top', `${top}px`); + this.#container.css('right', `16px`); + this.#container.show('flex'); + } + + hide() { + this.#container.hide(); + } + + on(eventType: string, listener: EventListener) { + this.#container.on(eventType, listener); + } + + off(eventType: string, listener: EventListener) { + this.#container.off(eventType, listener); + } +} + +export default Button; diff --git a/examples/react/components/comment/edit.tsx b/examples/react/components/comment/edit.tsx new file mode 100644 index 00000000..4d6fa41d --- /dev/null +++ b/examples/react/components/comment/edit.tsx @@ -0,0 +1,59 @@ +import React, { useState, useContext } from 'react'; +import Input from 'antd/es/input'; +import Button from 'antd/es/button'; +import Contentx from '../../context'; +import 'antd/es/input/style'; +import 'antd/es/button/style'; + +type EditProps = { + defaultValue?: string; + loading?: boolean; + onChange?: (value: string) => void; + onCancel: (event: React.MouseEvent) => void; + onOk: (event: React.MouseEvent) => void; +}; + +const CommentEdit: React.FC = ({ + defaultValue, + onChange, + onCancel, + onOk, + loading, +}) => { + const [value, setValue] = useState(defaultValue || ''); + + const { lang } = useContext(Contentx); + + return ( +
+
+ { + const value = e.target.value; + setValue(value); + if (onChange) onChange(value); + }} + /> +
+
+ + +
+
+ ); +}; + +export default CommentEdit; diff --git a/examples/react/components/comment/index.css b/examples/react/components/comment/index.css new file mode 100644 index 00000000..e0d106f3 --- /dev/null +++ b/examples/react/components/comment/index.css @@ -0,0 +1,165 @@ +/** 编辑器中标记样式 ---- 开始 **/ +[data-comment-preview], [data-comment-id] { + position: relative; +} + +span[data-comment-preview], span[data-comment-id] { + display: inline-block; +} + +[data-comment-preview]::before, [data-comment-id]::before{ + content: ''; + position: absolute; + width: 100%; + bottom: 0px; + left: 0; + height: 2px; + border-bottom: 2px solid #f8e1a1 !important; +} + +[data-comment-preview] { + background:rgb(250, 241, 209) !important; +} + +[data-card-key][data-comment-id]::before, [data-card-key][data-comment-preview]::before { + bottom:-2px; +} +/** 编辑器中标记样式 ---- 结束 **/ + +.data-comment-button-container { + display: none; + position: absolute; + border: 1px solid rgba(226, 226, 226, 0.84); + border-radius: 2px; + background: #fff; + width: 20px; + text-align: center; + height: 20px; + align-items: center; + justify-content: center; + cursor: pointer; + color: #888888; +} + +.data-comment-button-container:hover { + color: #347eff; + border-color: #347eff; + box-shadow: 0px 2px 4px 0px rgb(225 225 225 / 50%); +} + +.data-comment-button-container .data-icon { + font-size: 12px; +} + +.doc-comment-layer { + position: absolute; + top: 20px; + width: calc(50% - 454px); + left: calc(50% + 428px); + padding-left: 24px; +} + +.doc-comment-title { + position: relative; + margin-bottom: 10px; + font-size: 14px; + font-weight: 700; + padding: 0 2px 8px; + border-bottom: 1px solid #e8e8e8; +} + +.doc-comment-item { + position: absolute; + background-color: #fff; + border: 1px solid #dee0e3; + border-radius: 4px; + width: calc(100% - 24px); +} + +.doc-comment-item-active::before { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + content: ""; + position: absolute; + width: calc(100% + 2px); + height: 6px; + background: #ffc60a; + margin-top: -1px; + margin-left: -1px; + margin-right: -1px; +} + +.doc-comment-item-haeder { + padding: 12px; + margin-top: 2px; + position: relative; + line-height: 18px; +} + +.doc-comment-item-haeder::before { + content: ""; + position: absolute; + width: 2px; + height: 18px; + border-radius: 15px; + background: #bbbfc4; +} + +.doc-comment-item-title { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + padding-left: 7px; + font-size: 12px; + color: #646a73; +} + +.doc-comment-item-body { + padding: 0px 12px 12px; +} + +.doc-comment-item-body .ant-space-vertical { + display: flex; +} + +.doc-comment-edit-wrapper { + +} + +.doc-comment-edit-input { + +} + +.doc-comment-edit-button { + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: 12px; +} + +.doc-comment-edit-button .ant-btn { + margin-left: 12px; + font-size: 12px; +} + +.doc-comment-item-row { + font-size: 12px; +} + +.doc-comment-item-author { + margin-bottom: 0; + line-height: 18px; + color: #646a73; +} + +.doc-comment-item-time { +} + +.doc-comment-item-content { + margin: 6px 0; + word-wrap: break-word; + white-space: pre-wrap; + hyphens: auto; + word-break: break-word; + color: #1f2329; +} \ No newline at end of file diff --git a/examples/react/components/comment/index.tsx b/examples/react/components/comment/index.tsx new file mode 100644 index 00000000..5027fb7d --- /dev/null +++ b/examples/react/components/comment/index.tsx @@ -0,0 +1,627 @@ +import React, { + useEffect, + useRef, + useState, + useImperativeHandle, + useCallback, + forwardRef, + useContext, +} from 'react'; +import message from 'antd/es/message'; +import { + EditorInterface, + isEngine, + RangeInterface, + random, + NodeInterface, +} from '@aomao/engine'; +import Loading from '../loading'; +import CommentButton from './button'; +import { CommentContent, DataItem, DataSourceItem } from './types'; +import { Member } from '../editor/ot/client'; +import CommentItem from './item'; +import context from '../../context'; +import { useDispatch, useSelector } from '../../hooks'; +import 'antd/es/message/style'; +import './index.css'; + +export type CommentProps = { + editor: EditorInterface; + member: Member; + onUpdate?: () => void; +} & { ref: React.Ref }; + +export type CommentRef = { + reload: () => void; + select: (id?: string) => void; + showButton: (range: RangeInterface) => void; + updateStatus: (ids: Array, status: boolean) => void; +}; + +const getConfig = ( + editor: React.MutableRefObject, + comment: React.MutableRefObject, +) => { + return { + //标记类型集合 + keys: ['comment'], + //标记数据更新后触发 + onChange: ( + addIds: { [key: string]: Array }, + removeIds: { [key: string]: Array }, + ) => { + const commentAddIds = addIds['comment'] || []; + const commentRemoveIds = removeIds['comment'] || []; + + //更新状态 + comment.current?.updateStatus(commentAddIds, true); + comment.current?.updateStatus(commentRemoveIds, false); + }, + //光标改变时触发 + onSelect: ( + range: RangeInterface, + selectInfo?: { key: string; id: string }, + ) => { + const { key, id } = selectInfo || {}; + comment.current?.showButton(range); + comment.current?.select(key === 'comment' ? id : undefined); + if (comment && key === 'comment' && id) { + editor.current?.command.executeMethod( + 'mark-range', + 'action', + key, + 'preview', + id, + ); + } + }, + }; +}; + +const Comment: React.FC = forwardRef( + ({ editor, member, ...props }, ref) => { + const buttonRef = useRef(); + const containerRef = useRef(null); + const { lang } = useContext(context); + const itemNodes = useRef>([]); + const [editItem, setEditItem] = useState< + DataItem & { editId?: number } + >(); + const [editValue, setEditValue] = useState(''); + const [editing, setEditing] = useState(false); + const [list, setList] = useState>([]); + + const dispatch = useDispatch(); + const load = useCallback(() => { + dispatch({ + type: 'comment/fetch', + }); + }, [dispatch]); + + const { dataSource } = useSelector((state) => state.comment); + const loading = useSelector((state) => state.loading['comment/fetch']); + useEffect(() => { + if (loading) return; + const list: Array = []; + dataSource.forEach((item: DataSourceItem) => { + //获取评论编号对应在编辑器中的所有节点 + const elements: Array = + editor.command.executeMethod( + 'mark-range', + 'action', //插件名称 + 'comment', //标记类型 + 'find', //调用的方法 + item.id, + ); + if (elements.length === 0) return; + //获取目标评论在编辑器中的 top + const top = getRectTop(elements[0]); + if (top < 0) return; + list.push({ + ...item, + top, + type: 'view', + }); + }); + //根据top大小排序,越小排在越前面 + updateList(list.sort((a, b) => (a.top < b.top ? -1 : 1))); + }, [loading, dataSource]); + + const remove = (render_id: string, id: number) => { + dispatch({ + type: 'comment/remove', + payload: { + render_id, + id, + }, + })?.then(() => { + const data = [...list]; + const itemIndex = data.findIndex((i) => i.id === render_id); + if (itemIndex > -1) { + const item = data[itemIndex]; + const childIndex = item.children.findIndex( + (c) => c.id === id, + ); + if (childIndex > -1) item.children.splice(childIndex, 1); + if (item.children.length === 0) { + data.splice(itemIndex, 1); + editor.command.executeMethod( + 'mark-range', + 'action', + 'comment', + 'remove', + item.id, + ); + updateEditItem(undefined); + } else { + updateEditItem(item); + } + updateList(data); + //通知外部更新评论列表 + if (props.onUpdate) props.onUpdate(); + } + }); + }; + /** + * 首次渲染后,加载评论列表 + */ + useEffect(() => { + buttonRef.current = new CommentButton(editor); + load(); + //在编辑器每次设置完值后,重新加载评论列表 + editor.on('afterSetValue', load); + return () => { + editor.off('afterSetValue', load); + }; + }, []); + + /** + * 渲染编辑 + * @param title 编辑器中的评论区域的文本 + * @returns + */ + const showEdit = useCallback( + (title: string) => { + const data = [...list]; + //增加 + const editItem: DataItem = { + id: random(18), + title, + top: getRectTop(`[data-comment-preview="true"]`), + type: 'add', + status: true, + children: [], + }; + + const index = data.findIndex( + (item: any, index) => + item.top >= editItem.top && + (!list[index - 1] || + list[index - 1].top <= editItem.top), + ); + if (index === -1) data.push(editItem); + else data.splice(index, 0, editItem); + updateEditItem(editItem); + updateList(data); + }, + [list], + ); + + useEffect(() => { + const onMouseDown = (event: MouseEvent) => { + if (editItem) return; + event.stopPropagation(); + if (isEngine(editor)) { + const text = editor.command.executeMethod( + 'mark-range', + 'action', + 'comment', + 'preview', + ); + if (!!text) { + showEdit(text); + } + } + }; + buttonRef.current?.on('mousedown', onMouseDown); + return () => buttonRef.current?.off('mousedown', onMouseDown); + }, [showEdit, editItem]); + + /** + * 在 加载数据完毕 或者 列表数据 更新后,设置每个评论项位置 + */ + useEffect(() => { + if (loading) return; + updateHeight(); + normalize(); + }, [loading, list]); + + /** + * 暴露函数 + */ + useImperativeHandle(ref, () => ({ + select, + showButton: (range: RangeInterface) => + buttonRef.current?.show(range), + updateStatus, + reload: load, + })); + + const updateHeight = () => { + if (containerRef.current) + containerRef.current.style.minHeight = `${editor.root.height()}px`; + }; + /** + * 获取编辑器中评论所在位置的 top + * @param selectors css 选择器 + * @returns + */ + const getRectTop = (selectors: string | NodeInterface): number => { + //获取选择器节点 + let element = + typeof selectors === 'string' + ? editor.container + .get()! + .querySelector(selectors) + : selectors.get(); + if (!element) return -1; + //选好计算每个标记评论项距离编辑器根节点的top + let top = 0; + while (element && !editor.root.equal(element)) { + const rect = element.getBoundingClientRect(); + const parent: HTMLElement | null = element.parentElement; + if (!parent) break; + const parentRect = parent.getBoundingClientRect(); + top += rect.top - parentRect.top; + element = parent; + } + return top; + }; + /** + * 更新需要渲染的编辑项 + * @param item 评论项 + * @param id 子评论编号 + */ + const updateEditItem = (item?: DataItem, id?: number) => { + setEditValue( + id && item + ? item.children.find((c) => c.id === id)?.content || '' + : '', + ); + setEditItem(item ? { ...item, editId: id } : undefined); + }; + /** + * 更新列表数据 + * @param data + */ + const updateList = (data: Array) => { + //清除每个列表项的 ref + itemNodes.current = []; + setList(data); + }; + + /** + * 选择一个评论设置为可编辑 + * @param id 评论编号 + * @returns + */ + const select = (id?: string) => { + const data = [...list]; + //移除已经在编辑状态的 + let index = data.findIndex((item) => item.type !== 'view'); + const item = data[index]; + if (!item && !id) return; + if (item && item.id !== id) { + //如果增加状态,没有子级,移除整个评论 + if (item.type === 'add') { + if (item.children.length === 0) data.splice(index, 1); + else item.type = 'view'; + } + //编辑状态,设置为浏览状态 + else { + item.type = 'view'; + } + } + //增加目标为编辑状态 + if (id) { + index = data.findIndex((item) => item.id === id); + const item = data[index]; + //设置目标为编辑状态 + if (item) { + item.type = 'add'; + updateEditItem(item); + } + //如果目标吧id不存在 + else { + updateEditItem(undefined); + } + } else { + updateEditItem(undefined); + } + if (isEngine(editor)) + editor.command.executeMethod( + 'mark-range', + 'action', + 'comment', + 'revoke', + ); + //重新设置列表 + updateList(data); + }; + /** + * 更新评论项状态 + * @param ids 编号集合 + * @param status 状态,true 显示、flase 隐藏 + */ + const updateStatus = (ids: Array, status: boolean) => { + updateHeight(); + if (ids.length === 0) return; + dispatch({ + type: 'comment/updateStatus', + payload: { + ids: ids.join(','), + status, + }, + })?.then(() => { + const data = [...list]; + data.forEach((item) => { + if (ids.indexOf(item.id) > -1) { + item.status = status; + if (item.id === editItem?.id && status === false) { + updateEditItem(undefined); + } + } + }); + if (status) { + if (load) load(); + } + //重新设置列表 + else updateList(data); + }); + }; + + /** + * 更新每个评论项的位置,默认以编辑器中的评论区域的top为目标。在评论项过多的情况下。以当前激活的可编辑项为基准,在其上方的向上偏移,在其下方的向下偏移 + * @returns + */ + const normalize = () => { + if (list.length === 0 || itemNodes.current.length === 0) return; + let activeIndex = list.findIndex((item) => item.type !== 'view'); + if (activeIndex < 0) activeIndex = 0; + + const activeItem = list[activeIndex]; + const element: HTMLDivElement = itemNodes.current[activeIndex]; + element.style.top = `${activeItem.top}px`; + element.style.display = activeItem.status ? 'block' : 'none'; + let nextTop = activeItem.top; + for (let i = activeIndex - 1; i >= 0; i--) { + //获取当前评论循环项的dom节点 + const curElement: HTMLDivElement = itemNodes.current[i]; + //获取位置 + const curRect = curElement.getBoundingClientRect(); + //如果其下方的评论项的 top 超过了 当前项在编辑器中评论区域的top,就用其下方的评论项 top - 当前高度 - 预留 16px 空隙 + const nextSourceTop = + list[i + 1].top === nextTop ? list[i + 1].top : nextTop; + const top = + nextSourceTop - 16 < list[i].top + curRect.height + ? nextTop - curRect.height - 16 + : list[i].top; + nextTop = top; + curElement.style.top = `${top}px`; + curElement.style.display = list[i].status ? 'block' : 'none'; + } + let prevTop = activeItem.top; + for (let i = activeIndex + 1; i < list.length; i++) { + //获取当前评论循环项的dom节点 + const curElement: HTMLDivElement = itemNodes.current[i]; + //获取位置 + //const curRect = curElement.getBoundingClientRect() + //如果其上方的评论项的 top + 其上方评论项的高度 大于 当前项在编辑器中评论区域的top,就用其上方的评论项 + 其上方评论项的高度 + 16 预留 16px 空隙 + const prevElement: HTMLDivElement = itemNodes.current[i - 1]; + const prevRect = prevElement.getBoundingClientRect(); + + const prevSourceTop = + list[i - 1].top === prevTop ? list[i - 1].top : prevTop; + + const top = + prevSourceTop + prevRect.height + 16 > list[i].top + ? prevTop + prevRect.height + 16 + : list[i].top; + prevTop = top; + curElement.style.top = `${top}px`; + curElement.style.display = list[i].status ? 'block' : 'none'; + } + }; + /** + * 设置评论项ref用于获取其dom节点 + */ + const itemRef = useCallback( + (node) => { + if (node !== null) { + itemNodes.current.push(node); + } + }, + [list], + ); + + /** + * 渲染编辑区域 + */ + const onItemOk = useCallback( + (event: React.MouseEvent) => { + //提交评论到服务端 + event.preventDefault(); + event.stopPropagation(); + //没有子评论编辑id就认定为增加评论,否则为修改评论内容 + if (!editValue || editing || !editItem) return; + setEditing(true); + let result: + | Promise + | undefined = undefined; + if (!editItem.editId) { + result = dispatch<{ data: CommentContent }>({ + type: 'comment/add', + payload: { + title: editItem.title, + render_id: editItem.id, + content: editValue, + username: member.name, + }, + }); + } else { + result = dispatch({ + type: 'comment/update', + payload: { + id: editItem.editId, + render_id: editItem.id, + content: editValue, + }, + }); + } + result + ?.then((res) => { + const dataList = [...list]; + const index = dataList.findIndex( + (item) => item.id === editItem.id, + ); + if (index > -1) { + const item = dataList[index]; + let comment; + //编辑,就更新评论内容 + if (editItem.editId) { + comment = item.children.find( + (c) => c.id === editItem.editId, + ); + if (comment) comment.content = editValue; + } + //增加,就增加到子级评论列表 + else if (typeof res !== 'boolean') { + comment = { ...res.data }; + item.children.push(comment); + } + //最后在当前评论id内渲染为可继续添加评论 + item.type = 'add'; + updateEditItem(item); + //第一次增加成功,将评论标识应用到编辑器中 + if ( + !editItem.editId && + item.children.length === 1 + ) { + editor.command.executeMethod( + 'mark-range', + 'action', + 'comment', + 'apply', + item.id, + ); + editor.command.executeMethod( + 'mark-range', + 'action', + 'comment', + 'preview', + item.id, + ); + } + //更新列表,重新获取评论项ref + updateList(dataList); + //通知外部更新评论列表 + if ( + (item.children.length > 1 || editItem.editId) && + props.onUpdate + ) + props.onUpdate(); + } + + //标志编辑状态为false + setEditing(false); + }) + .catch((error) => { + message.error(error); + //回调增加失败,将评论预览标识从编辑器中移除 + if (isEngine(editor)) + editor.command.executeMethod( + 'mark-range', + 'action', + 'comment', + 'revoke', + ); + setEditing(false); + }); + }, + [editItem, editValue, editing, list], + ); + + const itemMouseEnter = (id: string) => { + if (isEngine(editor) && editItem?.id !== id) { + editor.command.executeMethod( + 'mark-range', + 'action', + 'comment', + 'preview', + id, + ); + } + }; + + const itemMouseLeave = (id: string) => { + if (isEngine(editor) && editItem?.id !== id) { + editor.command.executeMethod( + 'mark-range', + 'action', + 'comment', + 'revoke', + id, + ); + } + }; + + const itemMouseDown = (event: React.MouseEvent, id: string) => { + if (editItem?.id === id) { + return; + } + select(id); + }; + + const renderList = () => { + return ( +
+
+ {lang === 'zh-CN' ? '评论' : 'Comments'} +
+ {loading && } + {list.map((item) => ( + itemMouseEnter(item.id)} + onMouseLeave={() => itemMouseLeave(item.id)} + onMouseDown={(event) => { + itemMouseDown(event, item.id); + }} + onCancel={(event) => { + event.preventDefault(); + event.stopPropagation(); + select(); + }} + onEdit={(item, id) => { + updateEditItem(item, id); + }} + onOk={(event: React.MouseEvent) => onItemOk(event)} + onRemove={remove} + onChange={(value) => setEditValue(value)} + loading={editing} + /> + ))} +
+ ); + }; + + return renderList(); + }, +); + +export default Comment; + +export { getConfig }; diff --git a/examples/react/components/comment/item.tsx b/examples/react/components/comment/item.tsx new file mode 100644 index 00000000..15dce631 --- /dev/null +++ b/examples/react/components/comment/item.tsx @@ -0,0 +1,129 @@ +import React, { useContext, forwardRef } from 'react'; +import moment from 'moment'; +import Space from 'antd/es/space'; +import { DataItem } from './types'; +import ItemEdit from './edit'; +import Context from '../../context'; +import 'antd/es/space/style'; + +export type CommentItemProps = Omit< + React.AnchorHTMLAttributes, + 'onChange' +> & { + item: DataItem; + edit?: DataItem & { editId?: number }; + loading?: boolean; + onChange?: (value: string) => void; + onCancel: (event: React.MouseEvent) => void; + onOk: (event: React.MouseEvent) => void; + onEdit: (itme: DataItem, info_id: number) => void; + onRemove: (id: string, info_id: number) => void; +} & { ref: React.Ref }; + +const CommentItem: React.FC = forwardRef< + HTMLDivElement, + CommentItemProps +>( + ( + { + item, + edit, + onChange, + onOk, + onCancel, + onEdit, + onRemove, + loading, + ...props + }, + ref, + ) => { + const { lang } = useContext(Context); + return ( +
+
+
{item.title}
+
+
+ {item.children.map( + ({ id, content, username, createdAt }, index) => { + const itemContent = + item.id === edit?.id && id === edit.editId ? ( + + ) : ( + content + ); + return ( + + + {username} + + {moment() + .startOf('seconds') + .from(new Date(createdAt))} + + { + event.preventDefault(); + event.stopPropagation(); + onEdit(item, id); + }} + > + {lang === 'zh-CN' ? '编辑' : 'Edit'} + + { + event.preventDefault(); + event.stopPropagation(); + onRemove(item.id, id); + }} + > + {lang === 'zh-CN' + ? '删除' + : 'Remove'} + + +
+ {itemContent} +
+
+ ); + }, + )} + {item.id === edit?.id && !edit.editId && ( + + )} +
+
+ ); + }, +); + +export default CommentItem; diff --git a/examples/react/components/comment/types.ts b/examples/react/components/comment/types.ts new file mode 100644 index 00000000..055b96ec --- /dev/null +++ b/examples/react/components/comment/types.ts @@ -0,0 +1,18 @@ +export type DataSourceItem = { + id: string; + title: string; + status?: boolean; + children: Array; +}; + +export type CommentContent = { + id: number; + username: string; + content: string; + createdAt: number; +}; + +export type DataItem = DataSourceItem & { + top: number; + type: 'view' | 'edit' | 'add'; +}; diff --git a/examples/react/components/editor/config.tsx b/examples/react/components/editor/config.tsx new file mode 100644 index 00000000..0c736657 --- /dev/null +++ b/examples/react/components/editor/config.tsx @@ -0,0 +1,240 @@ +import { + PluginEntry, + CardEntry, + PluginOptions, + NodeInterface, +} 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'; +import Codeblock, { CodeBlockComponent } from '@aomao/plugin-codeblock'; +import Image, { ImageComponent, ImageUploader } from '@aomao/plugin-image'; +import Table, { TableComponent } from '@aomao/plugin-table'; +import MarkRange from '@aomao/plugin-mark-range'; +import File, { FileComponent, FileUploader } from '@aomao/plugin-file'; +import Video, { VideoComponent, VideoUploader } from '@aomao/plugin-video'; +import Math, { MathComponent } from '@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 Test, { TestComponent } from './plugins/test'; +//import Mind, { MindComponent } from '@aomao/plugin-mind'; +import { + ToolbarPlugin, + ToolbarComponent, + fontFamilyDefaultData, +} from '@aomao/toolbar'; +import { DOMAIN } from '../../config'; +import ReactDOM from 'react-dom'; +import Loading from '../loading'; +import Empty from 'antd/es/empty'; +import 'antd/es/empty/style'; + +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, + MarkRange, + File, + FileUploader, + Video, + VideoUploader, + Math, + ToolbarPlugin, + Fontfamily, + Status, + LineHeight, + Mention, + Test, + //Mind +]; + +export const cards: Array = [ + HrComponent, + CheckboxComponent, + CodeBlockComponent, + ImageComponent, + TableComponent, + FileComponent, + VideoComponent, + MathComponent, + ToolbarComponent, + StatusComponent, + MentionComponent, + TestComponent, + //MindComponent +]; + +export const pluginConfig: { [key: string]: PluginOptions } = { + [Italic.pluginName]: { + // 默认为 _ 下划线,这里修改为单个 * 号 + markdown: '*', + }, + [Image.pluginName]: { + onBeforeRender: (status: string, url: string) => { + if (!url) return url; + return url + `?token=12323`; + }, + }, + [ImageUploader.pluginName]: { + file: { + action: `${DOMAIN}/upload/image`, + headers: { Authorization: 213434 }, + }, + remote: { + action: `${DOMAIN}/upload/image`, + }, + isRemote: (src: string) => src.indexOf(DOMAIN) < 0, + }, + [FileUploader.pluginName]: { + action: `${DOMAIN}/upload/file`, + }, + [VideoUploader.pluginName]: { + action: `${DOMAIN}/upload/video`, + limitSize: 1024 * 1024 * 50, + }, + [Video.pluginName]: { + onBeforeRender: (status: string, url: string) => { + return url + `?token=12323`; + }, + }, + [Math.pluginName]: { + action: `https://g.yanmao.cc/latex`, + parse: (res: any) => { + if (res.success) return { result: true, data: res.svg }; + return { result: false }; + }, + }, + [Mention.pluginName]: { + action: `${DOMAIN}/user/search`, + onLoading: (root: NodeInterface) => { + return ReactDOM.render(, root.get()!); + }, + onEmpty: (root: NodeInterface) => { + return ReactDOM.render(, 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 }, + ) => { + ReactDOM.render( +
+

This is name: {name}

+

配置 mention 插件的 onMouseEnter 方法

+

此处使用 ReactDOM.render 自定义渲染

+

Use ReactDOM.render to customize rendering here

+
, + 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/examples/react/components/editor/index.less b/examples/react/components/editor/index.less new file mode 100644 index 00000000..20e851a8 --- /dev/null +++ b/examples/react/components/editor/index.less @@ -0,0 +1,129 @@ +@import (reference) '../../styles/variables.less'; + +.__dumi-default-layout[data-site-mode='true'][data-route='/'], +.__dumi-default-layout[data-site-mode='true'][data-route='/zh-CN'] { + padding-top: 138px !important; + min-height: auto; + + @media @mobile { + padding-top: 86px !important; + } +} + +.__dumi-default-layout[data-site-mode='true'][data-route='/'], +.__dumi-default-layout[data-site-mode='true'][data-route='/zh-CN'] + .__dumi-default-navbar { + box-shadow: none; +} + +.__dumi-default-layout[data-site-mode='true'] .__dumi-default-navbar nav { + align-items: center; +} + +.__dumi-default-layout[data-site-mode='true'][data-route='/'] + .__dumi-default-navbar + nav + > span + > a.active::after, +.__dumi-default-layout[data-site-mode='true'][data-route='/zh-CN'] + .__dumi-default-navbar + nav + > span + > a.active::after { + display: none; +} + +.__dumi-default-layout[data-site-mode='true'] + .__dumi-default-navbar + nav + .__dumi-default-locale-select { + height: 32px; +} + +.editor-ot-users { + font-size: 12px; + background: #ffffff; + padding: 4px 0 4px 266px; + z-index: 999; + position: fixed; + width: 100%; + top: 64px; + + @media @mobile { + padding: 8px 16px; + z-index: 1; + left: 0; + top: 50px; + } +} + +.editor-ot-users-content { + display: flex; + flex-wrap: wrap; +} + +.editor-ot-users .ant-avatar { + margin: 0 2px; +} + +.editor-toolbar { + position: fixed; + width: 100%; + background: #ffffff; + box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.02); + z-index: 1000; + top: 96px; +} + +.__dumi-default-previewer-demo .editor-toolbar { + top: 0; +} + +.editor-wrapper { + position: relative; + width: 100%; + min-width: 1440px; + + @media @mobile { + min-width: auto; + } +} + +.editor-container { + background: #fafafa; + background-color: #fafafa; + padding: 24px 0 64px; + height: calc(100vh - 138px); + width: 100%; + margin: 0 auto; + overflow: auto; + position: relative; + + @media @mobile { + padding: 0; + height: auto; + overflow: hidden; + } +} + +.editor-content { + position: relative; + width: 812px; + margin: 0 auto; + background: #fff; + border: 1px solid #f0f0f0; + min-height: 800px; + @media @mobile { + width: auto; + min-height: calc(100vh - 96px); + border: 0 none; + } +} + +.editor-content .am-engine { + padding: 40px 60px 60px; + + @media @mobile { + padding: 18px 0 0 0; + } +} diff --git a/examples/react/components/editor/index.tsx b/examples/react/components/editor/index.tsx new file mode 100644 index 00000000..547f280f --- /dev/null +++ b/examples/react/components/editor/index.tsx @@ -0,0 +1,369 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { Modal, ModalFuncProps } from 'antd'; +import { LoadingOutlined } from '@ant-design/icons'; +//引入编辑器引擎 +import { $, EngineInterface, isHotkey, Path, isMobile } from '@aomao/engine'; +import EngineComponent, { EngineProps } from '../engine'; +//协同客户端 +import OTComponent, { OTClient, Member, STATUS, ERROR } from './ot'; +//Demo相关 +import Loading from '../loading'; +import CommentLayer, { CommentRef, getConfig } from '../comment'; +import Toc from '../toc'; +import { cards, pluginConfig, plugins } from './config'; +import Toolbar, { ToolbarItemProps } from './toolbar'; +import './index.less'; + +export type Content = { + value: string; + paths: Array<{ id: Array; path: Array }>; +}; + +export type EditorProps = Omit & { + defaultValue?: Content; + onSave?: (content: Content) => void; + onLoad?: (engine: EngineInterface) => void; + ot?: + | { + url: string; + docId: string; + onReady?: (member: Member) => void; + onMembersChange?: (members: Array) => void; + onStatusChange?: ( + from: keyof typeof STATUS, + to: keyof typeof STATUS, + ) => void; + onError?: (error: ERROR) => void; + onMessage?: (message: { type: string; body: any }) => void; + } + | false; + comment?: boolean; + toc?: boolean; + toolbar?: ToolbarItemProps; +}; + +const EditorComponent: React.FC = ({ + defaultValue, + onLoad, + ...props +}) => { + const engine = useRef(null); + const otClient = useRef(null); + const [value, setValue] = useState(''); + const comment = useRef(null); + const [loading, setLoading] = useState(true); + const [members, setMembers] = useState>([]); + const [member, setMember] = useState(null); + const errorModal = useRef<{ + destroy: () => void; + update: ( + configUpdate: + | ModalFuncProps + | ((prevConfig: ModalFuncProps) => ModalFuncProps), + ) => void; + } | null>(null); + const saveTimeout = useRef(null); + /** + * 保存到服务器 + */ + const save = useCallback(() => { + if (!engine.current || !props.onSave) return; + console.log('save', new Date().getTime()); + const filterValue: Content = props.comment + ? engine.current.command.executeMethod( + 'mark-range', + 'action', + 'comment', + 'filter', + value, + ) + : { value, paths: [] }; + props.onSave(filterValue); + }, [props.onSave, value]); + /** + * 60秒内无更改自动保存 + */ + const autoSave = useCallback(() => { + if (saveTimeout.current) clearTimeout(saveTimeout.current); + saveTimeout.current = setTimeout(() => { + save(); + }, 60000); + }, [save]); + + useEffect(() => { + window.addEventListener('beforeunload', save); + return () => { + window.removeEventListener('beforeunload', save); + }; + }, [save]); + + const engineProps: EngineProps = { + ...props, + plugins: props.plugins || plugins, + cards: props.cards || cards, + config: { + ...props.config, + ...pluginConfig, + 'mark-range': getConfig(engine, comment), + }, + // 编辑器值改变事件 + onChange: useCallback( + (value: string, trigger: 'remote' | 'local' | 'both') => { + if (loading) return; + setValue(value); + //自动保存,非远程更改,触发保存 + if (trigger !== 'remote') autoSave(); + if (props.onChange) props.onChange(value, trigger); + // 获取编辑器的值 + console.log(`value ${trigger} update:`, value); + // 获取当前所有at插件中的名单 + console.log( + 'mention:', + engine.current?.command.executeMethod('mention', 'getList'), + ); + // 获取编辑器的html + console.log('html:', engine.current?.getHtml()); + }, + [loading, autoSave, props.onChange], + ), + }; + + //用户主动保存 + const userSave = useCallback(() => { + if (!engine.current) return; + //获取异步的值,有些组件可能还在处理中,比如正在上传 + console.time('value-async'); + engine.current + .getValueAsync(false, (pluginName, card) => { + console.log(`${pluginName} 正在等待...`, card?.getValue()); + }) + .then((value) => { + console.timeEnd('value-async'); + setValue(value); + save(); + }) + .catch((data) => { + console.log('终止保存:', data.name, data.card?.getValue()); + }); + }, [engine, save]); + + useEffect(() => { + if (!engine.current) return; + //卡片最大化时设置编辑页面样式 + engine.current.on('card:maximize', () => { + $('.editor-toolbar').css('z-index', '9999').css('top', '56px'); + }); + engine.current.on('card:minimize', () => { + $('.editor-toolbar').css('z-index', '').css('top', ''); + }); + + //设置编辑器值,还原评论标记 + if (defaultValue) { + const value = + defaultValue.paths.length > 0 + ? engine.current.command.executeMethod( + 'mark-range', + 'action', + 'comment', + 'wrap', + defaultValue.paths, + defaultValue.value, + ) + : defaultValue.value; + + //连接到协作服务端,demo文档 + if (props.ot) { + //实例化协作编辑客户端 + const ot = new OTClient(engine.current); + const { url, docId, onReady } = props.ot; + // 连接协同服务端,如果服务端没有对应docId的文档,将使用 defaultValue 初始化 + ot.connect(url, docId, value); + ot.on('ready', (member) => { + if (onLoad) onLoad(engine.current!); + if (onReady) onReady(member); + setMember(member); + setLoading(false); + if (errorModal.current) errorModal.current.destroy(); + errorModal.current = null; + }); + ot.on('error', ({ code, message }) => { + const errorMessage = ( +

+ {message} + +

+ ); + if (errorModal.current) { + errorModal.current.update({ + title: code, + content: errorMessage, + }); + } else { + errorModal.current = Modal.error({ + title: code, + keyboard: false, + mask: false, + centered: true, + content: errorMessage, + okButtonProps: { + style: { display: 'none' }, + }, + }); + } + }); + otClient.current = ot; + } else { + // 非协同编辑,设置编辑器值,异步渲染后回调 + engine.current.setValue(value, (count) => { + console.log(count); + if (onLoad) onLoad(engine.current!); + return setLoading(false); + }); + } + setValue(value); + } + + return () => { + otClient.current?.exit(); + engine.current = null; + otClient.current = null; + }; + }, [engine]); + + // 引擎事件绑定 + useEffect(() => { + if (!engine.current || loading) return; + const keydown = (event: KeyboardEvent) => { + if (isHotkey('mod+s', event)) { + event.preventDefault(); + userSave(); + } + }; + // 手动保存 + document.addEventListener('keydown', keydown); + return () => { + document.removeEventListener('keydown', keydown); + }; + }, [engine, userSave, loading]); + // 协同事件绑定 + useEffect(() => { + if (!props.ot || !otClient.current || loading) return; + const { onMembersChange, onStatusChange, onError, onMessage } = + props.ot; + // 用户加入或退出改变 + const membersChange = (members: Array) => { + if (onMembersChange) onMembersChange(members); + setMembers(members); + }; + otClient.current.on('membersChange', membersChange); + // 状态改变,退出时,强制保存 + const statusChange = ( + from: keyof typeof STATUS, + to: keyof typeof STATUS, + ) => { + if (onStatusChange) onStatusChange(from, to); + if (to === STATUS.exit) { + userSave(); + } + }; + otClient.current.on('statusChange', statusChange); + // 错误监听 + const error = (error: ERROR) => { + if (onError) onError(error); + }; + otClient.current.on('error', error); + // 消息监听 + const message = (message: { type: string; body: any }) => { + if (onMessage) onMessage(message); + // 更新评论列表 + if ( + message.type === 'updateCommentList' && + comment.current?.reload + ) { + comment.current.reload(); + } + }; + otClient.current.on('message', message); + return () => { + otClient.current?.off('membersChange', membersChange); + otClient.current?.off('statusChange', statusChange); + otClient.current?.off('error', error); + otClient.current?.off('message', message); + }; + }, [otClient, loading, props.ot, userSave]); + + //广播通知更新评论列表吧 + const onCommentRequestUpdate = () => { + otClient.current?.broadcast('updateCommentList'); + }; + + // 点击编辑区域外的空白位置继续聚焦编辑器 + const wrapperMouseDown = (event: React.MouseEvent) => { + const { target } = event; + if ( + !target || + ['TEXTAREA', 'INPUT'].indexOf((target as Node).nodeName) > -1 + ) + return; + if ( + engine.current && + engine.current.isFocus() && + $(target).closest('.editor-content').length === 0 + ) { + event.preventDefault(); + } + }; + // 编辑器区域单击在没有元素的位置,聚焦到编辑器 + const editorAreaClick = (event: React.MouseEvent) => { + const { target } = event; + if (!target) return; + if (engine.current && $(target).hasClass('editor-content')) { + event.preventDefault(); + if (!engine.current.isFocus()) engine.current.focus(false); + } + }; + + return ( + + <> + {props.ot && } + {engine.current && ( + + )} +
+
+
+ { + + } +
+ {engine.current && + !isMobile && + member && + props.comment && ( + + )} +
+ {engine.current && !isMobile && props.toc && ( + + )} +
+ +
+ ); +}; + +export default EditorComponent; diff --git a/examples/react/components/editor/ot/client.ts b/examples/react/components/editor/ot/client.ts new file mode 100644 index 00000000..6496f854 --- /dev/null +++ b/examples/react/components/editor/ot/client.ts @@ -0,0 +1,406 @@ +import { EventEmitter } from 'events'; +import { EngineInterface } from '@aomao/engine'; +import ReconnectingWebSocket, { ErrorEvent } from 'reconnecting-websocket'; +import { Doc } from 'sharedb'; +import sharedb from 'sharedb/lib/client'; +import { Socket } from 'sharedb/lib/sharedb'; + +export type Member = { + avatar: string; + name: string; + uuid: string; + color?: string; +}; +export const STATUS = { + init: 'init', + loaded: 'loaded', + active: 'active', + exit: 'exit', + error: 'error', +}; + +export const EVENT = { + inactive: 'inactive', + error: 'error', + membersChange: 'membersChange', + statusChange: 'statusChange', + message: 'message', +}; + +export type ERROR = { + code: string; + level: string; + message: string; + error?: ErrorEvent; +}; + +export const ERROR_CODE = { + INIT_FAILED: 'INIT_FAILED', + SAVE_FAILED: 'SAVE_FAILED', + PUBLISH_FAILED: 'PUBLISH_FAILED', + DISCONNECTED: 'DISCONNECTED', + STATUS_CODE: { + TIMEOUT: 4001, + FORCE_DISCONNECTED: 4002, + }, + CONNECTION_ERROR: 'CONNECTION_ERROR', + COLLAB_DOC_ERROR: 'COLLAB_DOC_ERROR', +}; + +export const ERROR_LEVEL = { + FATAL: 'FATAL', + WARNING: 'WARNING', + NOTICE: 'NOTICE', +}; +/** + * 协同客户端 + */ +class OTClient extends EventEmitter { + // 编辑器引擎 + protected engine: EngineInterface; + // ws 连接实例 + protected socket?: WebSocket; + // 当前协同的所有用户 + protected members: Array = []; + // 当前用户 + protected current?: Member; + // 当前状态 + protected status?: string; + // 协作的文档对象 + protected doc?: Doc; + // 当前 ws 是否关闭 + protected isClosed: boolean = true; + // 心跳检测对象 + protected heartbeat?: { + timeout: NodeJS.Timeout; + datetime: Date; + }; + + constructor(engine: EngineInterface) { + super(); + this.engine = engine; + } + /** + * 每隔指定毫秒发送心跳检测 + * @param {number} millisecond 毫秒 默认 30000 + * @return {void} + */ + checkHeartbeat(millisecond: number = 30000): void { + if (!this.socket) return; + if (this.heartbeat?.timeout) clearTimeout(this.heartbeat.timeout); + const timeout = setTimeout(() => { + const now = new Date(); + + if ( + !this.isClosed && + (!this.heartbeat || + now.getTime() - this.heartbeat.datetime.getTime() >= + millisecond) + ) { + this.sendMessage('heartbeat', { time: now.getTime() }); + this.heartbeat = { + timeout, + datetime: now, + }; + } else if (this.heartbeat) { + this.heartbeat.timeout = timeout; + } + this.checkHeartbeat(millisecond); + }, 1000); + } + + /** + * 连接到协作文档 + * @param url 协同服务地址 + * @param docID 文档唯一ID + * @param defautlValue 如果协作服务端没有创建的文档,将作为协同文档的初始值 + * @param collectionName 协作服务名称,与协同服务端相对应 + */ + connect( + url: string, + docID: string, + defautlValue?: string, + collectionName: string = 'yanmao', + ) { + if (this.socket) this.socket.close(); + // 实例化一个可以自动重连的 ws + const socket = new ReconnectingWebSocket( + async () => { + const token = await new Promise((resolve) => { + // 这里可以异步获取一个Token,如果有的话 + resolve(''); + }); + // 组合ws链接 + const uri = new URL(url); + uri.searchParams.set('id', docID); + uri.searchParams.set('token', token); + return uri.toString(); + }, + [], + { + maxReconnectionDelay: 30000, + minReconnectionDelay: 10000, + reconnectionDelayGrowFactor: 10000, + maxRetries: 10, + }, + ); + + // ws 已链接 + socket.addEventListener('open', () => { + this.socket = socket as WebSocket; + // 加载编辑器内部的协同服务 + this.load(socket, docID, collectionName, defautlValue); + // 标记关闭状态为false + this.isClosed = false; + // 监听协同服务端自定义消息 + this.socket.addEventListener('message', (event) => { + const { data, action } = JSON.parse(event.data); + // 当前所有的协作用户 + if ('members' === action) { + this.addMembers(data); + this.engine.ot.setMembers(data); + return; + } + // 有新的协作者加入了 + if ('join' === action) { + this.addMembers([data]); + this.engine.ot.addMember(data); + return; + } + // 有协作者离开了 + if ('leave' === action) { + this.engine.ot.removeMember(data); + this.removeMember(data); + return; + } + // 协作服务端准备好了,可以实例化编辑器内部的协同服务了 + if ('ready' === action) { + // 当前协作者用户 + this.current = data as Member; + this.engine.ot.setCurrentMember(data); + this.emit('ready', this.engine.ot.getCurrentMember()); + this.emit(EVENT.membersChange, this.normalizeMembers()); + this.transmit(STATUS.active); + } + // 广播信息,一个协作用户发送给全部协作者的广播 + if ('broadcast' === action) { + const { uuid, body, type } = data; + // 如果接收者和发送者不是同一人就触发一个message事件,外部可以监听这个事件并作出响应 + if (uuid !== this.current?.uuid) { + this.emit(EVENT.message, { + type, + body, + }); + } + } + }); + // 开始检测心跳 + this.checkHeartbeat(); + }); + // 监听ws关闭事件 + socket.addEventListener('close', () => { + // 如果不是主动退出的关闭,就显示错误信息 + if (this.status !== STATUS.exit) { + this.onError({ + code: ERROR_CODE.DISCONNECTED, + level: ERROR_LEVEL.FATAL, + message: + '网络连接异常,无法继续编辑!正在为您重新连接中...', + }); + } + }); + // 监听ws错误消息 + socket.addEventListener('error', (error) => { + this.onError({ + code: ERROR_CODE.CONNECTION_ERROR, + level: ERROR_LEVEL.FATAL, + message: '协作服务异常,无法继续编辑!正在为您重新连接中...', + error, + }); + }); + } + + /** + * 加载编辑器内部协同服务 + * @param docId 文档唯一ID + * @param collectionName 协作服务名称 + * @param defaultValue 如果服务端没有对应docId的文档,就用这个值初始化 + */ + load( + socket: ReconnectingWebSocket, + docId: string, + collectionName: string, + defaultValue?: string, + ) { + // 实例化一个协同客户端的连接 + const connection = new sharedb.Connection(socket as Socket); + // 获取文档对象 + const doc = connection.get(collectionName, docId); + this.doc = doc; + // 订阅 + doc.subscribe((error) => { + console.log('subscribe'); + if (error) { + console.log('collab doc subscribe error', error); + } else { + try { + // 实例化编辑器内部协同服务 + this.engine.ot.initRemote(doc, defaultValue); + // 聚焦到编辑器 + this.engine.focus(); + } catch (err) { + console.log('am-engine init failed:', err); + } + } + }); + + doc.on('create', () => { + console.log('collab doc create'); + }); + + doc.on('load', () => { + console.log('collab doc loaded'); + this.sendMessage('ready'); + }); + + doc.on('op', (op, type) => { + console.log('op', op, type ? 'local' : 'server'); + }); + + doc.on('del', (t, n) => { + console.log('collab doc deleted', t, n); + }); + + doc.on('error', (error) => { + console.error(error); + }); + } + + /** + * 广播一个消息 + * @param type 消息类型 + * @param body 消息内容 + */ + broadcast(type: string, body: any = {}) { + this.sendMessage('broadcast', { type, body }); + } + + /** + * 给服务端发送一个消息 + * @param action 消息类型 + * @param data 消息数据 + */ + sendMessage(action: string, data?: any) { + this.socket?.send( + JSON.stringify({ + action, + data: { + ...data, + doc_id: this.doc?.id, + uuid: this.current?.uuid, + }, + }), + ); + } + + addMembers(memberList: Array) { + memberList.forEach((member) => { + if (!this.members.find((m) => member.uuid === m.uuid)) { + this.members.push(member); + } + }); + setTimeout(() => { + this.emit(EVENT.membersChange, this.normalizeMembers()); + }, 1000); + } + + removeMember(member: Member) { + this.members = this.members.filter((user) => { + return user.uuid !== member.uuid; + }); + this.emit(EVENT.membersChange, this.normalizeMembers()); + } + + normalizeMembers() { + const members = []; + const colorMap: any = {}; + const users = this.engine.ot.getMembers(); + users.forEach((user) => { + colorMap[user.uuid] = user.color; + }); + const memberMap: any = {}; + for (let i = this.members.length; i > 0; i--) { + const member = this.members[i - 1]; + if (!memberMap[member.uuid]) { + const cloneMember = { ...member }; + cloneMember.color = colorMap[member.uuid]; + memberMap[member.uuid] = member; + members.push(cloneMember); + } + } + return members; + } + + transmit(status: string) { + const prevStatus = this.status; + this.status = status; + this.emit(EVENT.statusChange, { + form: prevStatus, + to: status, + }); + } + + onError(error: ERROR) { + this.emit(EVENT.error, error); + this.status = STATUS.error; + } + + isActive() { + return this.status === STATUS.active; + } + + exit() { + if (this.status !== STATUS.exit) { + this.transmit(STATUS.exit); + this.disconnect(); + } + } + + disconnect() { + if (this.socket) { + try { + this.socket.close( + ERROR_CODE.STATUS_CODE.FORCE_DISCONNECTED, + 'FORCE_DISCONNECTED', + ); + if (this.heartbeat?.timeout) { + clearTimeout(this.heartbeat!.timeout); + } + } catch (e) { + console.log(e); + } + } + } + + bindEvents() { + window.addEventListener('beforeunload', () => this.exit()); + window.addEventListener('visibilitychange', () => { + if ('hidden' === document.visibilityState) { + this.emit(EVENT.inactive); + } + }); + window.addEventListener('pagehide', () => this.exit()); + } + + unbindEvents() { + window.removeEventListener('beforeunload', () => this.exit()); + window.removeEventListener('visibilitychange', () => { + if ('hidden' === document.visibilityState) { + this.emit(EVENT.inactive); + } + }); + window.removeEventListener('pagehide', () => this.exit()); + } +} + +export default OTClient; diff --git a/examples/react/components/editor/ot/index.tsx b/examples/react/components/editor/ot/index.tsx new file mode 100644 index 00000000..0b4d1632 --- /dev/null +++ b/examples/react/components/editor/ot/index.tsx @@ -0,0 +1,47 @@ +import React, { useContext } from 'react'; +import Avatar from 'antd/es/avatar'; +import Space from 'antd/es/space'; +import { isMobile } from '@aomao/engine'; +import OTClient, { STATUS, EVENT } from './client'; +import Context from '../../../context'; +import type { Member, ERROR } from './client'; +import 'antd/es/avatar/style'; +import 'antd/es/space/style'; + +const OTComponent: React.FC<{ members: Array }> = ({ members }) => { + const { lang } = useContext(Context); + + return ( +
+ + {!isMobile && ( + + {lang === 'zh-CN' ? ( + <> + 当前在线{members.length}人 + + ) : ( + <> + {members.length} person online + + )} + + )} + {members.map((member) => { + return ( + + {member['name']} + + ); + })} + +
+ ); +}; +export { OTClient, STATUS, EVENT }; +export type { Member, ERROR }; +export default OTComponent; diff --git a/examples/react/components/editor/plugins/test/component/index.tsx b/examples/react/components/editor/plugins/test/component/index.tsx new file mode 100644 index 00000000..d272fdf5 --- /dev/null +++ b/examples/react/components/editor/plugins/test/component/index.tsx @@ -0,0 +1,62 @@ +import { + $, + Card, + CardToolbarItemOptions, + CardType, + isEngine, + NodeInterface, + ToolbarItemOptions, +} from '@aomao/engine'; +import ReactDOM from 'react-dom'; +import TestComponent from './test'; + +class Test extends Card { + static get cardName() { + return 'test'; + } + + static get cardType() { + return CardType.BLOCK; + } + + #container?: NodeInterface; + + toolbar(): Array { + if (!isEngine(this.editor) || this.editor.readonly) return []; + return [ + { + type: 'dnd', + }, + { + type: 'copy', + }, + { + type: 'delete', + }, + { + type: 'node', + node: $('测试按钮'), + didMount: (node) => { + node.on('click', () => { + alert('test button'); + }); + }, + }, + ]; + } + + render() { + this.#container = $('
Loading
'); + return this.#container; + } + + didRender() { + // 由于项目中 vue 和 react 的混合环境导致 ts 报错 + ReactDOM.render(, this.#container?.get()); + } + + destroy() { + ReactDOM.unmountComponentAtNode(this.#container?.get()!); + } +} +export default Test; diff --git a/examples/react/components/editor/plugins/test/component/test.tsx b/examples/react/components/editor/plugins/test/component/test.tsx new file mode 100644 index 00000000..0f8f2867 --- /dev/null +++ b/examples/react/components/editor/plugins/test/component/test.tsx @@ -0,0 +1,3 @@ +import { FC } from 'react'; +const TestComponent: FC = () =>
This is Test Plugin
; +export default TestComponent; diff --git a/examples/react/components/editor/plugins/test/index.ts b/examples/react/components/editor/plugins/test/index.ts new file mode 100644 index 00000000..76e16b4d --- /dev/null +++ b/examples/react/components/editor/plugins/test/index.ts @@ -0,0 +1,91 @@ +import { + $, + Plugin, + NodeInterface, + CARD_KEY, + isEngine, + SchemaInterface, + PluginOptions, + decodeCardValue, + encodeCardValue, +} from '@aomao/engine'; +import TestComponent from './component'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; +} +export default class extends Plugin { + static get pluginName() { + return 'test'; + } + // 插件初始化 + init() { + // 监听解析成html的事件 + this.editor.on('parse:html', (node) => this.parseHtml(node)); + // 监听粘贴时候设置schema规则的入口 + this.editor.on('paste:schema', (schema) => this.pasteSchema(schema)); + // 监听粘贴时候的节点循环 + this.editor.on('paste:each', (child) => this.pasteHtml(child)); + } + // 执行方法 + execute() { + if (!isEngine(this.editor)) return; + const { card } = this.editor; + card.insert(TestComponent.cardName); + } + // 快捷键 + hotkey() { + return this.options.hotkey || 'mod+shift+f'; + } + // 粘贴的时候添加需要的 schema + pasteSchema(schema: SchemaInterface) { + schema.add({ + type: 'block', + name: 'div', + attributes: { + 'data-type': { + required: true, + value: TestComponent.cardName, + }, + 'data-value': '*', + }, + }); + } + // 解析粘贴过来的html + pasteHtml(node: NodeInterface) { + if (!isEngine(this.editor)) return; + if (node.isElement()) { + const type = node.attributes('data-type'); + if (type === TestComponent.cardName) { + const value = node.attributes('data-value'); + const cardValue = decodeCardValue(value); + this.editor.card.replaceNode( + node, + TestComponent.cardName, + cardValue, + ); + node.remove(); + return false; + } + } + return true; + } + // 解析成html + parseHtml(root: NodeInterface) { + root.find(`[${CARD_KEY}=${TestComponent.cardName}`).each((cardNode) => { + const node = $(cardNode); + const card = this.editor.card.find(node) as TestComponent; + const value = card?.getValue(); + if (value) { + node.empty(); + const div = $( + `
`, + ); + node.replaceWith(div); + } else node.remove(); + }); + } +} +export { TestComponent }; diff --git a/examples/react/components/editor/toolbar.tsx b/examples/react/components/editor/toolbar.tsx new file mode 100644 index 00000000..e5cbe7f3 --- /dev/null +++ b/examples/react/components/editor/toolbar.tsx @@ -0,0 +1,113 @@ +import { FC } from 'react'; +import { isMobile } from '@aomao/engine'; +import Toolbar, { ToolbarProps } from '@aomao/toolbar'; + +export type ToolbarItemProps = ToolbarProps['items']; + +const defaultItems: ToolbarItemProps = isMobile + ? [ + ['undo', 'redo'], + { + icon: 'text', + items: [ + 'bold', + 'italic', + 'strikethrough', + 'underline', + 'fontsize', + 'fontcolor', + 'backcolor', + 'moremark', + ], + }, + [ + { + type: 'button', + name: 'image-uploader', + icon: 'image', + }, + 'link', + 'tasklist', + 'heading', + ], + { + icon: 'more', + items: [ + { + type: 'button', + name: 'video-uploader', + icon: 'video', + }, + { + type: 'button', + name: 'file-uploader', + icon: 'attachment', + }, + { + type: 'button', + name: 'table', + icon: 'table', + }, + { + type: 'button', + name: 'math', + icon: 'math', + }, + { + type: 'button', + name: 'codeblock', + icon: 'codeblock', + }, + { + type: 'button', + name: 'orderedlist', + icon: 'orderedlist', + }, + { + type: 'button', + name: 'unorderedlist', + icon: 'unorderedlist', + }, + { + type: 'button', + name: 'hr', + icon: 'hr', + }, + { + type: 'button', + name: 'quote', + icon: 'quote', + }, + ], + }, + ] + : [ + ['collapse'], + ['undo', 'redo', 'paintformat', 'removeformat'], + ['heading', 'fontfamily', 'fontsize'], + ['bold', 'italic', 'strikethrough', 'underline', 'moremark'], + ['fontcolor', 'backcolor'], + ['alignment'], + [ + 'unorderedlist', + 'orderedlist', + 'tasklist', + 'indent', + 'line-height', + ], + ['link', 'quote', 'hr'], + ]; + +const ToolbarExample: FC< + Omit & { items?: ToolbarItemProps } +> = ({ engine, items, className }) => { + return ( + + ); +}; + +export default ToolbarExample; diff --git a/examples/react/components/engine/index.tsx b/examples/react/components/engine/index.tsx new file mode 100644 index 00000000..cd9afc40 --- /dev/null +++ b/examples/react/components/engine/index.tsx @@ -0,0 +1,83 @@ +import React, { + useEffect, + useRef, + useImperativeHandle, + forwardRef, +} from 'react'; +import message from 'antd/es/message'; +import Modal from 'antd/es/modal'; +import Engine, { EngineInterface, EngineOptions } from '@aomao/engine'; +import 'antd/es/message/style'; +import 'antd/es/modal/style'; + +export type EngineProps = EngineOptions & { + defaultValue?: string; + onChange?: (content: string, trigger: 'remote' | 'local' | 'both') => void; + ref?: React.Ref; +}; +message.config({ + top: 240, + duration: 3, +}); +const EngineComponent: React.FC = forwardRef< + EngineInterface | null, + EngineProps +>(({ defaultValue, onChange, ...options }, ref) => { + const container = useRef(null); + const engineRef = useRef(null); + + const init = () => { + if (!container.current) return null; + + const engine = new Engine(container.current, options); + + engine.messageSuccess = (msg: string) => { + message.success(msg); + }; + engine.messageError = (error: string) => { + message.error(error); + }; + engine.messageConfirm = (msg: string) => { + return new Promise((resolve, reject) => { + Modal.confirm({ + content: msg, + onOk: () => resolve(true), + onCancel: () => reject(), + }); + }); + }; + + engineRef.current = engine; + return engine; + }; + + useEffect(() => { + return () => { + engineRef.current?.destroy(); + }; + }, []); + + const change = (value: string, trigger: 'remote' | 'local' | 'both') => { + if (onChange) onChange(value, trigger); + }; + + useEffect(() => { + // 监听编辑器值改变事件 + engineRef.current?.on('change', change); + return () => { + engineRef.current?.off('change', change); + }; + }, [onChange]); + + useImperativeHandle( + ref, + () => { + return init(); + }, + [], + ); + + return
; +}); + +export default EngineComponent; diff --git a/examples/react/components/loading/index.less b/examples/react/components/loading/index.less new file mode 100644 index 00000000..c86b8e85 --- /dev/null +++ b/examples/react/components/loading/index.less @@ -0,0 +1,12 @@ +.loading { + padding: 20px; + text-align: center; + display: block; +} + +.ant-spin-nested-loading { + position: inherit; +} +.ant-spin-nested-loading .ant-spin-container { + position: inherit; +} diff --git a/examples/react/components/loading/index.tsx b/examples/react/components/loading/index.tsx new file mode 100644 index 00000000..77c5e016 --- /dev/null +++ b/examples/react/components/loading/index.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import Spin from 'antd/es/spin'; +import 'antd/es/spin/style'; +import './index.less'; + +export type LoadingType = { + text?: string; + loading?: boolean; +}; + +const Loading: React.FC = ({ text, loading, children }) => { + return ( + + {children} + + ); +}; + +export default Loading; diff --git a/examples/react/components/toc/index.css b/examples/react/components/toc/index.css new file mode 100644 index 00000000..7a740512 --- /dev/null +++ b/examples/react/components/toc/index.css @@ -0,0 +1,51 @@ +.data-toc-wrapper { + position: absolute; + top: 20px; + width: calc(50% - 454px); + right: calc(50% + 428px); + padding-right: 24px; +} + +.data-toc-title { + position: relative; + margin-bottom: 10px; + font-size: 14px; + font-weight: 700; + padding: 0 2px 8px; + border-bottom: 1px solid #e8e8e8; +} + +.data-toc{ + overflow: auto; + height: calc(100vh - 209px); +} +.data-toc .data-toc-item { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; + font-size: 12px; + line-height: 20px; + color: inherit; + } + .data-toc .data-toc-item-active, + .data-toc .data-toc-item:hover, + .data-toc .data-toc-item:focus { + color: #1890FF; + text-decoration: none; + } + .data-toc .data-toc-item-2 { + padding-left: 16px; + } + .data-toc .data-toc-item-3 { + padding-left: 32px; + } + .data-toc .data-toc-item-4 { + padding-left: 48px; + } + .data-toc .data-toc-item-5 { + padding-left: 64px; + } + .data-toc .data-toc-item-6 { + padding-left: 80px; + } \ No newline at end of file diff --git a/examples/react/components/toc/index.tsx b/examples/react/components/toc/index.tsx new file mode 100644 index 00000000..6c10fabc --- /dev/null +++ b/examples/react/components/toc/index.tsx @@ -0,0 +1,127 @@ +import React, { + useRef, + useEffect, + useState, + useCallback, + useContext, +} from 'react'; +import classnames from 'classnames'; +import { $, EditorInterface, isEngine, Scrollbar } from '@aomao/engine'; +import { Outline, OutlineData } from '@aomao/plugin-heading'; +import context from '../../context'; +import { findReadingSection } from './utils'; +import './index.css'; + +type Props = { + editor: EditorInterface; + title?: string; +}; + +const outline = new Outline(); + +const Toc: React.FC = ({ editor, title }: Props) => { + const rootRef = useRef(null); + const [datas, setDatas] = useState>([]); + const [readingSection, setReadingSection] = useState(-1); + + const { lang } = useContext(context); + + useEffect(() => { + const onChange = () => { + const data = getTocData(); + setDatas(data); + }; + editor.on('change', onChange); + editor.on('afterSetValue', onChange); + return () => { + editor.off('change', onChange); + editor.off('afterSetValue', onChange); + }; + }, [editor]); + + const getTocData = useCallback(() => { + // 从编辑区域提取符合结构要求的标题 Dom 节点 + const nodes: Array = []; + const { card } = editor; + editor.container.find('h1,h2,h3,h4,h5,h6').each((child) => { + const node = $(child); + // Card 里的标题,不纳入大纲 + if (card.closest(node)) { + return; + } + // 非一级深度标题,不纳入大纲 + if (!node.parent()?.isRoot()) { + return; + } + nodes.push(node.get()!); + }); + return outline.normalize(nodes); + }, []); + + const listenerViewChange = useCallback(() => { + const data: Array = datas + .map(({ id }) => document.getElementById(id)) + .filter((element) => element !== null) as Array; + + const index = findReadingSection(data, 220); + setReadingSection(index); + }, [datas]); + + useEffect(() => { + if (isEngine(editor)) { + editor.scrollNode?.on('scroll', listenerViewChange); + editor.scrollNode?.on('resize', listenerViewChange); + } else { + window.addEventListener('scroll', listenerViewChange); + window.addEventListener('resize', listenerViewChange); + } + listenerViewChange(); + return () => { + if (isEngine(editor)) { + editor.scrollNode?.off('scroll', listenerViewChange); + editor.scrollNode?.off('resize', listenerViewChange); + } else { + window.removeEventListener('scroll', listenerViewChange); + window.removeEventListener('resize', listenerViewChange); + } + }; + }, [listenerViewChange]); + + useEffect(() => { + if (!rootRef.current) return; + const scrollbar = new Scrollbar(rootRef.current, false, true, false); + return () => { + scrollbar.destroy(); + }; + }, [datas]); + + if (datas.length === 0) return null; + + return ( +
+
+ {lang === 'zh-CN' ? '大纲' : 'Outline'} +
+
+ {datas.map((data, index) => { + return ( + + {data.text} + + ); + })} +
+
+ ); +}; +export default Toc; diff --git a/examples/react/components/toc/utils.ts b/examples/react/components/toc/utils.ts new file mode 100644 index 00000000..7b2056a6 --- /dev/null +++ b/examples/react/components/toc/utils.ts @@ -0,0 +1,27 @@ +export const findReadingSection = (elements: Array, top: number) => { + top = top || 0; + if (!elements || elements.length === 0) return -1; + let i = 0; + let index = -1; + const len = elements.length; + for (; i < len; i++) { + const element = elements[i]; + if (!element || !element.getBoundingClientRect) continue; + const rect = element.getBoundingClientRect(); + if (rect.height === 0) continue; + if (rect.top <= top + 1) { + if (i === len - 1) { + index = i; + } else { + const nexElement = elements[i + 1]; + if (!nexElement || !nexElement.getBoundingClientRect) continue; + const nextRect = nexElement.getBoundingClientRect(); + if (nextRect.top > top + 1) { + index = i; + break; + } + } + } + } + return index; +}; diff --git a/examples/react/components/view/index.less b/examples/react/components/view/index.less new file mode 100644 index 00000000..a71c561e --- /dev/null +++ b/examples/react/components/view/index.less @@ -0,0 +1,11 @@ +@import (reference) '../../styles/variables.less'; + +.editor-wrapper-view { + width: 812px; + min-width: 812px; + + @media @mobile { + min-width: auto !important; + width: 100% !important; + } +} diff --git a/examples/react/components/view/index.tsx b/examples/react/components/view/index.tsx new file mode 100644 index 00000000..b5e15bf8 --- /dev/null +++ b/examples/react/components/view/index.tsx @@ -0,0 +1,65 @@ +import React, { useRef, useEffect, useState, useContext } from 'react'; +import Message from 'antd/es/message'; +import { View, ViewInterface, isServer } from '@aomao/engine'; +import { plugins, cards } from '../editor/config'; +import Loading from '../loading'; +import Context from '../../context'; +import 'antd/es/message/style'; +import './index.less'; + +export type ViewProps = { + content: string; + html: string; +}; +const viewPlugins = plugins.filter( + (plugin) => + plugin.pluginName.indexOf('uploader') < 0 && + ['mark-range'].indexOf(plugin.pluginName) < 0, +); + +const ViewRender: React.FC = ({ content, html }) => { + const view = useRef(); + const viewRef = useRef(null); + const [viewLoading, setViewLoading] = useState(true); + const { lang } = useContext(Context); + + useEffect(() => { + if (viewRef.current) { + //初始化 + view.current = new View(viewRef.current, { + lang, + plugins: viewPlugins, + cards, + }); + view.current.messageSuccess = (msg: string) => { + Message.success(msg); + }; + view.current.messageError = (error: string) => { + Message.error(error); + }; + setViewLoading(false); + } + }, []); + + useEffect(() => { + if (view.current) { + //渲染内容到viewRef节点下 + view.current.render(content); + } + }, [content, viewLoading]); + + //普通渲染 + const render = () => { + return ( +
+
+
+ ); + }; + return {render()}; +}; + +export default ViewRender; diff --git a/examples/react/config.ts b/examples/react/config.ts new file mode 100644 index 00000000..21f3cdbb --- /dev/null +++ b/examples/react/config.ts @@ -0,0 +1,14 @@ +import { isServer } from '@aomao/engine'; + +export const IS_DEV = process.env.NODE_ENV !== 'production'; +export const DOMAIN = IS_DEV + ? `http://${ + typeof window !== 'undefined' ? 'localhost:7001' : 'localhost:7001' + }` + : 'https://editor.yanmao.cc'; + +export const lang = ( + !isServer ? window.location.href.indexOf('zh-CN') > 0 : false +) + ? 'zh-CN' + : 'en-US'; diff --git a/examples/react/context.ts b/examples/react/context.ts new file mode 100644 index 00000000..dc576689 --- /dev/null +++ b/examples/react/context.ts @@ -0,0 +1,9 @@ +import { createContext } from 'react'; + +type Props = { + lang: string; +}; + +export default createContext({ + lang: 'en-US', +}); diff --git a/examples/react/editor.css b/examples/react/editor.css new file mode 100644 index 00000000..ff277c75 --- /dev/null +++ b/examples/react/editor.css @@ -0,0 +1,9 @@ +.doc-editor-mode { + font-size: 12px; + background: #ffffff; + padding: 0; + z-index: 9999; + position: fixed; + left: 10px; + top: 68px; +} \ No newline at end of file diff --git a/examples/react/editor.tsx b/examples/react/editor.tsx new file mode 100644 index 00000000..9f11848f --- /dev/null +++ b/examples/react/editor.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState, useCallback } from 'react'; +import { isServer, EngineInterface } from '@aomao/engine'; +import Context from './context'; +import useDispatch from './hooks/use-dispatch'; +import useSelector from './hooks/use-selector'; +import Editor, { Content } from './components/editor'; +import Loading from './components/loading'; +import { IS_DEV, lang } from './config'; +import Space from 'antd/es/space'; +import Button from 'antd/es/button'; +import 'antd/es/space/style'; +import 'antd/es/button/style'; +import './editor.css'; + +const localMember = + typeof localStorage === 'undefined' ? null : localStorage.getItem('member'); + +const getMember = () => { + return !!localMember ? JSON.parse(localMember) : null; +}; + +const wsUrl = + IS_DEV && !isServer + ? `ws://${window.location.hostname}:8080` + : 'wss://collab.yanmao.cc'; +const member = getMember(); + +const getReadonlyValue = + typeof localStorage === 'undefined' + ? null + : localStorage.getItem('engine-readonly'); + +const isReadonly = + getReadonlyValue === null ? false : getReadonlyValue === 'true'; + +const setReadonlyValue = (readonly: boolean) => { + localStorage.setItem('engine-readonly', readonly ? 'true' : 'false'); +}; + +export default () => { + const dispatch = useDispatch(); + const [engine, setEngine] = useState(null); + const doc = useSelector((state) => state.doc); + const loading = useSelector((state) => state.loading['doc/get']); + const [readonly, setReadonly] = useState(isReadonly); + + useEffect(() => { + if (!!doc.value) return; + + dispatch({ + type: 'doc/get', + }); + }, [doc]); + + const onSave = (content: Content) => { + dispatch({ + type: 'doc/save', + payload: content, + }); + }; + + const updateReadonly = useCallback( + (readonly: boolean) => { + setReadonlyValue(readonly); + if (engine) engine.readonly = readonly; + setReadonly(readonly); + }, + [engine], + ); + + if (loading !== false) return ; + + return ( + + + + + + { + if (member) + localStorage.setItem( + 'member', + JSON.stringify(member), + ); + }, + }} + onSave={onSave} + /> + + ); +}; diff --git a/examples/react/hooks/index.ts b/examples/react/hooks/index.ts new file mode 100644 index 00000000..97fb0302 --- /dev/null +++ b/examples/react/hooks/index.ts @@ -0,0 +1,4 @@ +import useDispatch from './use-dispatch'; +import useSelector from './use-selector'; + +export { useDispatch, useSelector }; diff --git a/examples/react/hooks/use-dispatch.ts b/examples/react/hooks/use-dispatch.ts new file mode 100644 index 00000000..7509187f --- /dev/null +++ b/examples/react/hooks/use-dispatch.ts @@ -0,0 +1,46 @@ +import models from '../models'; +import { store } from '../store'; +import useSelector from './use-selector'; + +const { dispatch } = store; + +const useDispatch = () => { + const state = useSelector(); + + const setLoading = (type: string, status: boolean) => { + dispatch({ + type: 'loading', + payload: { + [type]: status, + }, + }); + }; + + return (action: { type: string; payload?: P }) => { + const { type, payload } = action; + const [name, call] = type.split('/'); + const model = models[name]; + + if (!model || !model.effets || !model.effets[call]) return; + setLoading(type, true); + return new Promise((resolve, reject) => { + models[name] + .effets![call](payload, { + state, + put: (nextState) => { + dispatch({ type: name, payload: nextState }); + }, + }) + .then((res) => { + resolve(res); + setLoading(type, false); + }) + .catch((res) => { + reject(res); + setLoading(type, false); + }); + }); + }; +}; + +export default useDispatch; diff --git a/examples/react/hooks/use-selector.ts b/examples/react/hooks/use-selector.ts new file mode 100644 index 00000000..0e379ed3 --- /dev/null +++ b/examples/react/hooks/use-selector.ts @@ -0,0 +1,22 @@ +import { useState, useEffect } from 'react'; +import { store } from '../store'; +import { State } from '../types'; + +const useSelector = ( + filter: (state: State) => T = (state) => state as T, +): T => { + const [state, setState] = useState(filter(store.getState())); + useEffect(() => { + const update = () => { + setState(filter(store.getState())); + }; + store.subscribe(update); + return () => { + store.unsubscribe(update); + }; + }, []); + + return state; +}; + +export default useSelector; diff --git a/examples/react/models/comment.ts b/examples/react/models/comment.ts new file mode 100644 index 00000000..a6f93faf --- /dev/null +++ b/examples/react/models/comment.ts @@ -0,0 +1,33 @@ +import { list, add, update, updateStatus, remove } from '../services/comment'; +import { CommentState, Model } from '../types'; + +const comment: Model = { + state: { + dataSource: [], + }, + effets: { + fetch: async (_, { state, put }) => { + const { code, data } = await list(); + if (code === 200) { + put({ + ...state.comment, + dataSource: data, + }); + return data; + } + }, + remove: async (payload) => { + return await remove(payload); + }, + updateStatus: async (payload) => { + return await updateStatus(payload); + }, + add: async (payload) => { + return await add(payload); + }, + update: async (payload) => { + return await update(payload); + }, + }, +}; +export default comment; diff --git a/examples/react/models/doc.ts b/examples/react/models/doc.ts new file mode 100644 index 00000000..6ba27835 --- /dev/null +++ b/examples/react/models/doc.ts @@ -0,0 +1,27 @@ +import { get, update } from '../services/doc'; +import { DocState, Model } from '../types'; + +const doc: Model = { + state: { + value: '', + paths: [], + }, + effets: { + get: async (_, { put }) => { + const { code, data } = await get(); + if (code === 200) { + if (!!!data.value) data.value = '


'; + put(data); + return data; + } + }, + save: async (payload, { put }) => { + const { code, data } = await update(payload); + if (code === 200) { + put(data); + } + return data; + }, + }, +}; +export default doc; diff --git a/examples/react/models/index.ts b/examples/react/models/index.ts new file mode 100644 index 00000000..ef4f9548 --- /dev/null +++ b/examples/react/models/index.ts @@ -0,0 +1,17 @@ +import { Models } from '../types'; +import { store } from '../store'; +import doc from './doc'; +import comment from './comment'; + +const models: Models = { + doc, + comment, + loading: { state: {} }, +}; +Object.keys(models).forEach((type) => { + store.dispatch({ + type, + payload: models[type].state, + }); +}); +export default models; diff --git a/examples/react/services/comment.ts b/examples/react/services/comment.ts new file mode 100644 index 00000000..6dade5d8 --- /dev/null +++ b/examples/react/services/comment.ts @@ -0,0 +1,51 @@ +import { Request } from '@aomao/engine'; +import { DOMAIN } from '../config'; + +const request = new Request(); + +export const list = () => { + return request.ajax({ + url: `${DOMAIN}/comment/list`, + }); +}; + +export const remove = (payload: { render_id: string; id: number }) => { + return request.ajax({ + url: `${DOMAIN}/comment/remove`, + method: 'POST', + data: payload, + }); +}; + +export const updateStatus = (payload: { ids: string; status: boolean }) => { + return request.ajax({ + url: `${DOMAIN}/comment/updateStatus`, + method: 'POST', + data: payload, + }); +}; + +export const add = (payload: { + title: string; + render_id: string; + content: string; + username: string; +}) => { + return request.ajax({ + url: `${DOMAIN}/comment/add`, + method: 'POST', + data: payload, + }); +}; + +export const update = (payload: { + id: string; + render_id: string; + content: string; +}) => { + return request.ajax({ + url: `${DOMAIN}/comment/update`, + method: 'POST', + data: payload, + }); +}; diff --git a/examples/react/services/doc.ts b/examples/react/services/doc.ts new file mode 100644 index 00000000..8241023c --- /dev/null +++ b/examples/react/services/doc.ts @@ -0,0 +1,23 @@ +import { Path, Request } from '@aomao/engine'; +import { DOMAIN } from '../config'; + +const request = new Request(); + +export const get = () => { + return request.ajax({ + url: `${DOMAIN}/doc/get`, + }); +}; + +export const update = (payload: { + value: string; + paths: Array<{ id: Array; path: Array }>; +}) => { + return request.ajax({ + url: `${DOMAIN}/doc/content`, + method: 'POST', + data: { + content: payload, + }, + }); +}; diff --git a/examples/react/store.ts b/examples/react/store.ts new file mode 100644 index 00000000..dc024923 --- /dev/null +++ b/examples/react/store.ts @@ -0,0 +1,30 @@ +import { State } from './types'; + +const createStore = ( + reducer: (state: T, action: { type: string; payload: T[keyof T] }) => T, +) => { + let state: T; //初始化state + const listeners: Array<() => void> = []; //监听变化的回调函数队列 + const subscribe = (listener: () => void) => listeners.push(listener); // 定义并随后暴露添加绑定回调函数的方法 + const unsubscribe = (listener: () => void) => { + const index = listeners.findIndex((s) => s === listener); + if (index > -1) listeners.splice(index, 1); + }; + const getState = () => state; // 定义并暴露获取读取 state 的唯一方法 + const dispatch = (action: { type: string; payload: T[keyof T] }) => { + state = reducer(state, action); //根据action改变状态 + listeners.forEach((listener) => listener()); //把listeners都执行一次 + }; + return { subscribe, unsubscribe, getState, dispatch }; +}; + +// 改变状态的规则函数 +const reducer = ( + state: T, + action: { type: string; payload: T[keyof T] }, +) => { + const { type, payload } = action; + return { ...state, [type]: { ...(state ? state[type] : {}), ...payload } }; +}; + +export const store = createStore(reducer); diff --git a/examples/react/styles/variables.less b/examples/react/styles/variables.less new file mode 100644 index 00000000..cf814d79 --- /dev/null +++ b/examples/react/styles/variables.less @@ -0,0 +1,26 @@ +/* 颜色表 */ +@c-primary: #2f54eb; +@c-heading: #454d64; +@c-text: #454d64; +@c-secondary: #717484; +@c-link: @c-primary; +@c-border: #ebedf1; +@c-light-bg: #f9fafb; + +/* 尺寸表 */ +@s-nav-height: 64px; +@s-mobile-nav-height: 50px; +@s-menu-width: 260px; +@s-site-menu-width: 300px; +@s-menu-mobile-width: 240px; +@s-content-margin: 58px; + +@img-logo: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIQAAACCCAMAAACww5CIAAACf1BMVEUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8YkP8AAAACCxMamv/6+voaGhoXi/YYjv8aoP8cq/8dr/8bo/8cqP8bpv8Ykv8drv8BAwUcrP8Zlf8Xjf/s7OzLy8scp/8anP8ZmP/d3d0BBArg4ODT09O7u7sEGCsKCgoanf8YlP/8/Pz09PTIyMgMTIV1dXUGKEVEREQ0NDQODg4GBgYdsv8dsf8Zl//m5uYVgOXj4+MWgtfW1tYTc87BwcERbLWzs7Ovr6+np6cQX6OgoKCTk5MMSXlwcHBra2tiYmIVFRUetf/39/fp6ekWhOkXi+QVfNvY2NjPz88TdcUSb7u6urq3t7cPYK0NUJGQkJCLi4ttbW0JO2cINFtVVVVRUVEHMFEHLEs6OjoEHDEiIiIcHBwXj/vx8fEWh+4Sb8gRbL+rq6upqakOVZiWlpaJiYmGhoYMSIF9fX15eXkKPnQLRHJMTExHR0c9PT0FHzkqKiomJiYEFyUBBw8bovfu7u4Wht4UedsUeMrFxcW9vb0RZrOkpKSampoPXZqAgIALQmtlZWUJOGJZWVkIMFcFIUExMTEwMDAtLS0DEh8Zl/v4+PgXj/QWhvEWhvAYku8YjuwUfNcUfNAVfc0RaLkSaKsRZ6kPWqENUYlbW1sCEBhkSPCkAAAAOHRSTlMA87y4BeKrltbFnUDo0MCup6D67t7ayZKGemtmWS8rEwLNso1wVEpFGaR+UDUlHwmBYls5i1oN/DMym4YAAAfTSURBVHjaxNndS1NxHMfxX5s6t1Kz1KzsuazMnqjgyxv03ovtQrYxUBEfLkREVBQf0AsFBRUUQvEiSVFQ0YsuiiIiqKC/oH+o31lzjtPZg55zttfVNnbx5ffw+X53pmx5UFl2+XLZ4zpVOPWlJFTntYyiBwF/VbX39Sv9upYU9/QHjbXe6qqayrrnylXXi0kov3GVuFiMuNqbHhIu3FcuuohZZ+jDh7mdXkwqlGtKMGmOSFzrGiYe5ZL4+vdsd/SHFyYxtIQlIdiD4ftCa39osTlxRtzwHO1tUOLm0XYk6T3asMRtdKHdUs6qv+L1l/vKgak2SYjqN+1yYg2G5NgR4Pd5/F7fk9sO3YhSkoYkaW40KCk2Rj9KUoikqmtOn8YpydE6J7xFyq5yUhxIjvZJcUfZ5EOb6oxGQmPdtEQlR4Mxupc6IoOdzWiVypabaF1BiesIS876OiSufRXtvO0DcSi2dAN+ZcclYFZsCaOps3nYUOKprDTiSWzqAioCnpIX9ep03pxkw7jYtMWx0pdn7Jb2i1jixN3cM6OGFCti0zgpyopOsw6xiZHoyHIPLIhNHdD7bWR+c7znFD3+PNp+vxhmRkNi28BoWAzBPbQHKhdlQLe4ogsoVTl4ijYjrmiKATdUdvfjh9Ely8DVHFvWe3HJMBBQ2QWAd+KSeeBxjtuxKC7ZzG07Ht0DusQlfwDfs2wZ4b2EYVBcESHO81BlcIWESXHFV7Qss5aXY1FxRSj7L7QAhv3tsaVBMVn8Ou1MFUtjW3sYKjL0jO6QWJiA7iZxysBbtDplpRT4KZbQWkUbHRMnGFUUKwuNaH1iaRJ+Tf8bDbqcWJH2HuCV+l9DpkuxtdsuGlpYHNAJ1FqNMjnE9QocOXJCPwJ309zPT9la8e5yUJwwC/jTBNWQ5EkIqEyzHROSJzvWSeFDW5M8OUArsdgMq2EmanOyGB4WSyMYAhZp2TwkJouw2mZvmusUSwtraA//m7DXZ8SsBxiQM5tGSxNuv3+ZU/NmIpfN9qDXxp1sO4LDNrE202J6cHE1TVq2f1uNiA39K9/7JJ0JwGe6nvOSZ4OA1/R0bFbyrBWoMUX2nOTZAOA3pcSXjFW7UOJnU17VAYeZv98pTvsB1KsTRVXAtqQVA/rFWSNo11SKiuRYZeknEBRn7WJ4rZKuX8pcROvBj6g4rLUZQ8NJYBo2Jb/ax2KkhKYf6I1I3oWngKqUhfgkBTCL1pics1elICaS/5Y9jk+XBdEBeJKhHZGCCLZAWTIkBqQgNlr+NbGi2wHgS1tTAbQNAxW3i1R58WWgd725ANZ7gXPFNaqagrvwt1t7aW0qiOIAPlErPqJCq6JWrW8r1ar1xf0n4NxnnpCELEKyCNmkJZSQRSCbQltooS4sVApiC10U2kWhFRUEEdGF4vuNH8g7c9NQ2pjepPcB/r5ADjlnzp2ZM+QMXHeYb+1WfO5hi5QfveYe33XJ4+d8a3MNQHbI75KhMt9z9wF4FRNcIi3wO94bAHJiQHCHNgmgh3QD8D1MCK6I+KeNCUgbgFFRcEX8Qwhov014o/juUlEoxeqrgpsA7oWp4AZprnpv1ANgShFcoU4a+36jMgOuVGYmnuJ1Wb0hKWqCC8QCgI4dqyfRbNCFoqDBX7Xz6C0AS660K3UKQCdhuqAbdqFT+B8mAXQTbhtbpM7ng4Yn1oytOwFMu5AP9QGAa4Qz8lFwvFWIH6G7Qjijc8/LDueDyvd4z151EYBvwOF+lRFTAK6TGi+ACWdLk0ozANqvkpojAFJKRnCSlFt3m8pLc9bJTylVn64ty9rJfEl1cpVKbH3uJ2v1QleUqOCI2h9xeeP0aVqLCA4JSLk6s7hu6CbkqOAIGpyB7iRZ5xLvFWlHEkITyjK/41/v9h0AC3lngpCz0PXWf0yDUcmBhFDt0T/flx8CkNL8VLAZjUhvAHSQek5AtyALdqP5e9BdbPCkZsbuFRKVvlRHs/W1AfC902yNgoriWwCeqw1fSL+J2VkWNBF8vckr6mPQ3ZcjtkVBA/3z4Ju6Bs5ANzck2BQFpUMTxlVZQ4ege95vUxRUHoPOe5s01OWBbryf2hEFDX4Fc4Vs4gaYZ3ZEQeXBJPgMcFPnwYzJVmeE6jGsGCNAE/rAlPIBamkMQv9YCLpzxJRjYMr5BLXyg5EvgTlKTOoEkw2LUct6dTz4ojqCNO04mMm4ZE150mhMuQ+jHppwAUxqUM5QK9qkPLIE5jhpygkvmHJYiW45FaL8IwmdZy9pUtc2MK9HtvgloZngJyMVp3tJ846ASb7Q1NYrg1JN+ukDs4e05LwHTO5bUKG0tRBEeXAKzJ3rpEXdB8C9fBIWKW0hhOBIBdy2K6R11zvALY6EFYE21yHF4OdKEkz7ObIlXXvAhV4OquoApaYbpCo9qayA29lLturibhimSgOSFjG1ILRwYnwShn09xArnT8PwdnHML6n+hl+2gD8Wjj+rLMOwq49Y5dZpVKUWS++VcCwdCdT5/Uhck5SH45VpVO3qJFbq2Y5Vvly2VBgQY5KqKWI6HY+n06KiqVJMSQyP/37wB6v29xGrnThyEDWh5dyr+fJscbQw/OjRcGG0OFvO3n+QSqKm7exlYgsvNgolkyFs1HGV2OQgTGsjNjnVBtO8Owj3nwbhgWnttgWxy2PaoWaC+AuAXqWYKHupMgAAAABJRU5ErkJggg=='; +@prefix: __dumi-default; +@mobile: ~'only screen and (max-width: 767px)'; +@desktop: ~'only screen and (min-width: 768px)'; +@icons: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAcwAAAA8CAMAAADc4HoZAAABFFBMVEUAAABGT2VGTmZaYXpCvGtIUGg3tGBGTmU3s2A/tmFKUGxFTmRFTWVQWmxFTWRJUGZFTWRGTmRLWWpFTWRGTmVGTmVLVG1FTmRGTWVHTmVFTWRHUGdFTWRGT2ZFTWVGTmU6t2I7xHA3tF9GTWRIT2dFTmRGTmVFTWQ3s2BFTWRGTmVGTmZKUmVFTWRFTWRGTWRGTmVcXHxFTmVFTmVGTmVFTWRIUGdGTWVNU3FGT2ZGTmVHTmVFTWRFTWVFTmVITWRHUGZFTWVFTmRGTmZGTmVFTWVLU2g4tF83tGBFTWQ3s1/LzdT09faoq7Zwdoieoq58gpLj5OeCh5fq6+2/wsmTmKWQlKJfZnpIUGfU1tu0t8BOVWynlyFSAAAASXRSTlMAkoAHEkDQ/dgYFuf0C8gj+KQQmm1oEuyNWNg53kSXfCYI5tEtzq7ivbOgTBy924R1BfHUibcpYw1JcU7v+7E3Nav6XVPDGraPqQuKawAACh1JREFUeNrsm2lT6kgUhg9QFFUkgWDYZfnAVsi+KQJeQdGqt1xm7jZ3lv//PyYdhe7QWQw1zP0w83xQsY9Um4fTp7vToeBczmaX5MN5rXZO/+NGJzGuLejnkw3dADehLHkQyceAWD5C/0my9XqWPLlCK9WHQScirUMk18g7J9ZosYLFajFyT8siLIpuyQkHKBDw4NgYsnDr0Xybaii60rjYzsmdbrqnw0TvpbvkhjYuzinygDXJXLewR2/O/f73w1cWCUj0LkmiU8SeYsc9LXMZIJNjyXkqmbWQCzV8ICawzLO8jh3q4IyciYfugMnMMGYT4C4UJ2fOEbbSc0EyrVp4T/7u4kiZs6jANjwBxkupWMLG7NIlLZvxM+As3nRLTsD/N5xtekmHIEQuhBAoBuREtmaXWVgB41Smc97JbMZA7pqcKKgopbu7FC1BLUgD22MyeVnPWD0bonLLeCQRhIkzQNnz6gHiK0HmxeF4qkKPSsVygh2x2q50SmlZIGIyiQo8OY+XGVExOLVM2WVRbAkDSma0609aQaxKMgOo6YjQ77Tc8d3laxPRxS7R564yI8WSFkymgUNuJqlbomQLisblpnNAf0nrB1j06rTsA7n0SE5L2skkh+Qcm2CP3vGV2QHWp5Ypu4wDosumRpyzNrBwcFmqk4166dBmrFgJ5aeDKhvSklWLBLokgBhcaF3bFL59lV45EQsR3QLVfV0uAuNFhEy2JaC/fcveMVC8ltKSy3RITtjRl34yDSj0r8rMNkyXQksByJOdCmIdslNAKS7V0BIKdpmGQ1+S9slA2IVa60My89HoRKyZ5XTD8rhBX1DwEN85Gw53drIsT6W0FGTKyYmYtgcI427rI1NB5bQyZZeTuNCSXaEpBX2Cotm9qWqdJOqqajN85y8zTC6E8SGZGalmjja4uaQC0OUy0UzSAckNTKS0FGTKyYmYbfQP42brcFGr/X5+N/XDNVG+36+eXCZ3Kbbkbd644cHBW6bpnTlx0vZO6PL0NI/LE8uksxtUqQ7sUgpoAfp0TgLzqQ4oAFkkeFqadCwFxJMz4SKTwogVpIsaBtrv+qdQzZ8ibSB8cpncJW+Z68iQTBq5EXG6N6UIvTHVr2hPpHTX9ZY5Rf0ImfIEyEMmFWHQmk89gHKhBShCP68UoHVfFtZnqV0yahWYVLTdJyMFwE0ms8l+cnFJfWyIuM2TyuQuecsW4xFJMMcd0S1PzBRQGdkaOKosc4DKYn1amSM2rb4H5lwmaVUVqEXJItoA1LBGokwoHWKUS0AqBZTKxOgocJXJl74uLi+Be+I2TyuTu+SkkCInmrZS3kNXkMnnF9RFT5Qpv1cVJkYwmRzxlavMIRClmTgBYmIeU1bpfC+WqS6RKPOKOTxjaWlZXSpWcp4xq1dBZIaBTxH+v95kySLyCQifSCZ3WYuTnYbDKNvpnVMVPUpulvSGPiFRJlq39M5E95bZNYZXD1icTOaoHophQ1EgLcpkrBOsdLJimbglsstMzpnGxZtSE0vjwlKalGVyuEzZJSXQIxJs2kVVDJOLC6NKVK/0jLWrzEzPYB/G6SxV9pJZq2XlyXSHDqlAjW5XjaSCzfsfom2XiX3hbEN4y3G/r64agy7ZifRrXOa6wmMkmT7YZfbwTuPsUoGi2WUyWOlkxZJIkskGWD7YkpWcb4NtAJlVm8tHYEF2m6KofW/pXLe2INxkTs0QeszB5N5rmJVckg55RzI+gTpEToFySRZ1GAcy94lg8AmOtmtSh2QnNebrTCnmWJlzHRatYeRegbomWSZpU2Cq0UdkdgLKlBMzA2EZNpJkmnmZQ9EwqtSDMijqGU+ZeeSqD/pCkikhZ6ZsU8cNc+kuc3EoU0tgT4hE5q3ELgZCTIBh1nECVAWm0fMs3daA8bV4wUN7f0nhAkdCgkztnx9mZ5iQ+zDLSLxdx5bZFK+Tp8wZDNLqFEAmr5myzRh36TfM8obXX01eAeyaqT4LhYvouMccLzNSRIlZmwGzLnGskVWWWWhBmgBZlXPpOwHieEyA5joGsktZJvumXBN5yzSQW/puGhy2XGBDTjZbWDGXLhMgRZ4ArQF8f375+vnP5y/gFawKYHzlEuOzNPGRSVFgSkT37LcCYDSidpnnCUCQaTmUlyaW1QAyxaVJAVjLLmWZViQSUW+Z9RsWE1DmFuMIOZAddIMtTSrA69PTy/dfXr798QMo7GVmzjXyijleJqVwV7d6t4rL2+NlUeY5GE6bBnNp0yCQTG4zBYVIWGa6y6SMCmDoKZOuFQDVYDI1FWlyJtimQR8/vv76/O319enrl89/wdjLZEnsFeO/nee6NImv8MAW6zpSssylKLMMxrHbebJM2eZohYrkUpL5HhKfqohdesokbZED1oFk0gC5M/Kje+e7nafi9fnl8y8mn1+ef6AtyXSNOV4mZd4q7wAo+8s8fqOdA7httJd3Hwlpo12WeUZUv0PaVWaCuTSVqxgGkznPYTYiP/w32lfAr0+/fAF+++2PV6ApyvSK8ZcpL034LbAWclm2kEU/4i8z8C0wf5mcENQIcTxkJnuTOMV1ZBxkniceqYkmnRmtR4ooQWVSJwbD16b/LbAGTEffnvD705NpC3lZphxzrEwbYVZg2Dd+c9pZZpCb08FltrChj8nsAGpiDD0py9RWUIvAkFWOuwcFuA0ok4bALCuKswQFvTk9gMnL85fvz99h0ttsmp8+tdt9LlOKuXC5OS1fOa42c3jUUrW6sIGetB8bwVCUuUCgYyPBZa6B+w/KpHsVgOq4adBhTQ8RonIOwE3ACRBjGMNquJ/ODcc9YgQ8NtJVYfLn568vMImtVrmcoiitVmLuFON6bMRfpiOPY/QOD3T16juZ9V6AA10+MhkkE0Ys6yuzXFgTY35fzTw6L03iV8MOMbTt8CpJwWVa02C9PSyUt8NPKtBK0hEHuoYAzAH0G0z0c+IEjIGALDMfdeYCuD88ahmrxJnMuBE77qilLHPkKnOZlhLz9CcNnFu06hg7lLBGRx21DMHkr9+ZJ6HFKya4TC9atIOf6woBIX6SK8AhaM8D0D//ELR3ryLXlV4xV0qElhEiz0PQbcNoOx+CvlJgIT6H4xUTHCMGd1LE4aVTKpa+jyf4y/z5jycE7lXwxxO0gtFu5svECRrz/4NDf7dvxjYQwzAMdGEE8RaWq2ySh/cf6OGoyQCRANLkBHenWqnzxyGU6aVP0zRN0zTtmzUru64ZWZ923kC0n6tT9WnnnL+y5R51pj6L9ahlx7k6UR8kVt2Sh1W35GHVLXlYdUseVt2Sh1W3fK8aDmuSOmyfelyGwpqkjtvnnvMyENYcdeA+fSxaDNYUdeg+TovBmqAO3sdpMVjD1eH7OC0Ga7A6QR+nxWANVafo47QYrIHqJH0eWhDWMHWaPosWhTVInahPHzisIepUffrAYQ1QJ+vTgVgD1IP6/AHM0QJdY511NAAAAABJRU5ErkJggg=='; + +.@{prefix}-icon { + background: url(@icons) no-repeat ~'0 0/230px auto'; +} diff --git a/examples/react/types.ts b/examples/react/types.ts new file mode 100644 index 00000000..d23e327a --- /dev/null +++ b/examples/react/types.ts @@ -0,0 +1,31 @@ +import { Path } from '@aomao/engine'; +import { DataSourceItem } from './components/comment/types'; + +export type DocState = { + value: string; + paths: Array<{ id: Array; path: Array }>; +}; + +export type CommentState = { + dataSource: Array; +}; + +export type State = { + loading: { [key: string]: boolean }; + doc: DocState; + comment: CommentState; +}; + +export type Model = { + state: T; + effets?: { + [fun: string]: ( + payload: P, + store: { state: State; put: (nextState: T) => void }, + ) => Promise; + }; +}; + +export type Models = { + [name: string]: Model; +}; diff --git a/examples/react/view.tsx b/examples/react/view.tsx new file mode 100644 index 00000000..53765e7a --- /dev/null +++ b/examples/react/view.tsx @@ -0,0 +1,29 @@ +import { useEffect } from 'react'; +import { isServer } from '@aomao/engine'; +import Context from './context'; +import useDispatch from './hooks/use-dispatch'; +import useSelector from './hooks/use-selector'; +import View from './components/view'; +import Loading from './components/loading'; +import { lang } from './config'; + +export default () => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch({ + type: 'doc/get', + }); + }, []); + + const doc = useSelector((state) => state.doc); + const loadingState = useSelector((state) => state.loading['doc/get']); + + if (loadingState !== false) return ; + + return ( + + + + ); +}; diff --git a/examples/vue/.browserslistrc b/examples/vue/.browserslistrc new file mode 100644 index 00000000..214388fe --- /dev/null +++ b/examples/vue/.browserslistrc @@ -0,0 +1,3 @@ +> 1% +last 2 versions +not dead diff --git a/examples/vue/.gitignore b/examples/vue/.gitignore new file mode 100644 index 00000000..403adbc1 --- /dev/null +++ b/examples/vue/.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/examples/vue/babel.config.js b/examples/vue/babel.config.js new file mode 100644 index 00000000..dcd85203 --- /dev/null +++ b/examples/vue/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['@vue/cli-plugin-babel/preset'], +}; diff --git a/examples/vue/package.json b/examples/vue/package.json new file mode 100644 index 00000000..683e7534 --- /dev/null +++ b/examples/vue/package.json @@ -0,0 +1,67 @@ +{ + "name": "demo-vue", + "version": "0.1.0", + "private": true, + "scripts": { + "serve": "vue-cli-service serve", + "build": "vue-cli-service build", + "lint": "vue-cli-service lint" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@aomao/plugin-alignment": "^2.5.3", + "@aomao/plugin-backcolor": "^2.5.3", + "@aomao/plugin-bold": "^2.5.3", + "@aomao/plugin-code": "^2.5.3", + "@aomao/plugin-codeblock-vue": "^2.5.3", + "@aomao/plugin-file": "^2.5.3", + "@aomao/plugin-math": "^2.5.3", + "@aomao/plugin-fontcolor": "^2.5.3", + "@aomao/plugin-fontsize": "^2.5.3", + "@aomao/plugin-heading": "^2.5.3", + "@aomao/plugin-hr": "^2.5.3", + "@aomao/plugin-image": "^2.5.5", + "@aomao/plugin-indent": "^2.5.3", + "@aomao/plugin-italic": "^2.5.3", + "@aomao/plugin-link-vue": "^2.5.3", + "@aomao/plugin-mark": "^2.5.3", + "@aomao/plugin-mention": "^2.5.3", + "@aomao/plugin-orderedlist": "^2.5.3", + "@aomao/plugin-paintformat": "^2.5.3", + "@aomao/plugin-quote": "^2.5.3", + "@aomao/plugin-redo": "^2.5.3", + "@aomao/plugin-removeformat": "^2.5.3", + "@aomao/plugin-selectall": "^2.5.3", + "@aomao/plugin-strikethrough": "^2.5.3", + "@aomao/plugin-sub": "^2.5.3", + "@aomao/plugin-sup": "^2.5.3", + "@aomao/plugin-table": "^2.5.3", + "@aomao/plugin-tasklist": "^2.5.3", + "@aomao/plugin-underline": "^2.5.3", + "@aomao/plugin-undo": "^2.5.3", + "@aomao/plugin-unorderedlist": "^2.5.3", + "@aomao/plugin-fontfamily": "^2.5.3", + "@aomao/plugin-status": "^2.5.3", + "@aomao/plugin-video": "^2.5.3", + "@aomao/plugin-line-height": "^2.5.3", + "@aomao/toolbar-vue": "^2.5.3", + "ant-design-vue": "^2.2.6", + "core-js": "^3.6.5", + "reconnecting-websocket": "^4.4.0", + "sharedb": "^1.9.2", + "vue": "^3.2.9", + "vue-class-component": "^8.0.0-0", + "vue-router": "^4.0.0-0" + }, + "devDependencies": { + "@types/sharedb": "^1.0.14", + "@vue/cli-plugin-babel": "~4.5.0", + "@vue/cli-plugin-router": "~4.5.0", + "@vue/cli-plugin-typescript": "~4.5.0", + "@vue/cli-service": "~4.5.0", + "@vue/compiler-sfc": "^3.2.9", + "less": "^3.0.4", + "less-loader": "6.0.0", + "typescript": "~4.1.5" + } +} diff --git a/examples/vue/public/favicon.ico b/examples/vue/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..df36fcfb72584e00488330b560ebcf34a41c64c2 GIT binary patch literal 4286 zcmds*O-Phc6o&64GDVCEQHxsW(p4>LW*W<827=Unuo8sGpRux(DN@jWP-e29Wl%wj zY84_aq9}^Am9-cWTD5GGEo#+5Fi2wX_P*bo+xO!)p*7B;iKlbFd(U~_d(U?#hLj56 zPhFkj-|A6~Qk#@g^#D^U0XT1cu=c-vu1+SElX9NR;kzAUV(q0|dl0|%h|dI$%VICy zJnu2^L*Te9JrJMGh%-P79CL0}dq92RGU6gI{v2~|)p}sG5x0U*z<8U;Ij*hB9z?ei z@g6Xq-pDoPl=MANPiR7%172VA%r)kevtV-_5H*QJKFmd;8yA$98zCxBZYXTNZ#QFk2(TX0;Y2dt&WitL#$96|gJY=3xX zpCoi|YNzgO3R`f@IiEeSmKrPSf#h#Qd<$%Ej^RIeeYfsxhPMOG`S`Pz8q``=511zm zAm)MX5AV^5xIWPyEu7u>qYs?pn$I4nL9J!=K=SGlKLXpE<5x+2cDTXq?brj?n6sp= zphe9;_JHf40^9~}9i08r{XM$7HB!`{Ys~TK0kx<}ZQng`UPvH*11|q7&l9?@FQz;8 zx!=3<4seY*%=OlbCbcae?5^V_}*K>Uo6ZWV8mTyE^B=DKy7-sdLYkR5Z?paTgK-zyIkKjIcpyO z{+uIt&YSa_$QnN_@t~L014dyK(fOOo+W*MIxbA6Ndgr=Y!f#Tokqv}n<7-9qfHkc3 z=>a|HWqcX8fzQCT=dqVbogRq!-S>H%yA{1w#2Pn;=e>JiEj7Hl;zdt-2f+j2%DeVD zsW0Ab)ZK@0cIW%W7z}H{&~yGhn~D;aiP4=;m-HCo`BEI+Kd6 z={Xwx{TKxD#iCLfl2vQGDitKtN>z|-AdCN|$jTFDg0m3O`WLD4_s#$S literal 0 HcmV?d00001 diff --git a/examples/vue/public/index.html b/examples/vue/public/index.html new file mode 100644 index 00000000..3e5a1396 --- /dev/null +++ b/examples/vue/public/index.html @@ -0,0 +1,17 @@ + + + + + + + + <%= htmlWebpackPlugin.options.title %> + + + +
+ + + diff --git a/examples/vue/src/App.vue b/examples/vue/src/App.vue new file mode 100644 index 00000000..bf7467bc --- /dev/null +++ b/examples/vue/src/App.vue @@ -0,0 +1,29 @@ + + + diff --git a/examples/vue/src/assets/logo.png b/examples/vue/src/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f3d2503fc2a44b5053b0837ebea6e87a2d339a43 GIT binary patch literal 6849 zcmaKRcUV(fvo}bjDT-7nLI_nlK}sT_69H+`qzVWDA|yaU?}j417wLi^B1KB1SLsC& zL0ag7$U(XW5YR7p&Ux?sP$d4lvMt8C^+TcQu4F zQqv!UF!I+kw)c0jhd6+g6oCr9P?7)?!qX1ui*iL{p}sKCAGuJ{{W)0z1pLF|=>h}& zt(2Lr0Z`2ig8<5i%Zk}cO5Fm=LByqGWaS`oqChZdEFmc`0hSb#gg|Aap^{+WKOYcj zHjINK)KDG%&s?Mt4CL(T=?;~U@bU2x_mLKN!#GJuK_CzbNw5SMEJorG!}_5;?R>@1 zSl)jns3WlU7^J%=(hUtfmuUCU&C3%8B5C^f5>W2Cy8jW3#{Od{lF1}|?c61##3dzA zsPlFG;l_FzBK}8>|H_Ru_H#!_7$UH4UKo3lKOA}g1(R&|e@}GINYVzX?q=_WLZCgh z)L|eJMce`D0EIwgRaNETDsr+?vQknSGAi=7H00r`QnI%oQnFxm`G2umXso9l+8*&Q z7WqF|$p49js$mdzo^BXpH#gURy=UO;=IMrYc5?@+sR4y_?d*~0^YP7d+y0{}0)zBM zIKVM(DBvICK#~7N0a+PY6)7;u=dutmNqK3AlsrUU9U`d;msiucB_|8|2kY=(7XA;G zwDA8AR)VCA#JOkxm#6oHNS^YVuOU;8p$N)2{`;oF|rQ?B~K$%rHDxXs+_G zF5|-uqHZvSzq}L;5Kcy_P+x0${33}Ofb6+TX&=y;;PkEOpz%+_bCw_{<&~ zeLV|!bP%l1qxywfVr9Z9JI+++EO^x>ZuCK);=$VIG1`kxK8F2M8AdC$iOe3cj1fo(ce4l-9 z7*zKy3={MixvUk=enQE;ED~7tv%qh&3lR<0m??@w{ILF|e#QOyPkFYK!&Up7xWNtL zOW%1QMC<3o;G9_S1;NkPB6bqbCOjeztEc6TsBM<(q9((JKiH{01+Ud=uw9B@{;(JJ z-DxI2*{pMq`q1RQc;V8@gYAY44Z!%#W~M9pRxI(R?SJ7sy7em=Z5DbuDlr@*q|25V)($-f}9c#?D%dU^RS<(wz?{P zFFHtCab*!rl(~j@0(Nadvwg8q|4!}L^>d?0al6}Rrv9$0M#^&@zjbfJy_n!%mVHK4 z6pLRIQ^Uq~dnyy$`ay51Us6WaP%&O;@49m&{G3z7xV3dLtt1VTOMYl3UW~Rm{Eq4m zF?Zl_v;?7EFx1_+#WFUXxcK78IV)FO>42@cm@}2I%pVbZqQ}3;p;sDIm&knay03a^ zn$5}Q$G!@fTwD$e(x-~aWP0h+4NRz$KlnO_H2c< z(XX#lPuW_%H#Q+c&(nRyX1-IadKR-%$4FYC0fsCmL9ky3 zKpxyjd^JFR+vg2!=HWf}2Z?@Td`0EG`kU?{8zKrvtsm)|7>pPk9nu@2^z96aU2<#` z2QhvH5w&V;wER?mopu+nqu*n8p~(%QkwSs&*0eJwa zMXR05`OSFpfyRb!Y_+H@O%Y z0=K^y6B8Gcbl?SA)qMP3Z+=C(?8zL@=74R=EVnE?vY!1BQy2@q*RUgRx4yJ$k}MnL zs!?74QciNb-LcG*&o<9=DSL>1n}ZNd)w1z3-0Pd^4ED1{qd=9|!!N?xnXjM!EuylY z5=!H>&hSofh8V?Jofyd!h`xDI1fYAuV(sZwwN~{$a}MX^=+0TH*SFp$vyxmUv7C*W zv^3Gl0+eTFgBi3FVD;$nhcp)ka*4gSskYIqQ&+M}xP9yLAkWzBI^I%zR^l1e?bW_6 zIn{mo{dD=)9@V?s^fa55jh78rP*Ze<3`tRCN4*mpO$@7a^*2B*7N_|A(Ve2VB|)_o z$=#_=aBkhe(ifX}MLT()@5?OV+~7cXC3r!%{QJxriXo9I%*3q4KT4Xxzyd{ z9;_%=W%q!Vw$Z7F3lUnY+1HZ*lO;4;VR2+i4+D(m#01OYq|L_fbnT;KN<^dkkCwtd zF7n+O7KvAw8c`JUh6LmeIrk4`F3o|AagKSMK3))_5Cv~y2Bb2!Ibg9BO7Vkz?pAYX zoI=B}+$R22&IL`NCYUYjrdhwjnMx_v=-Qcx-jmtN>!Zqf|n1^SWrHy zK|MwJ?Z#^>)rfT5YSY{qjZ&`Fjd;^vv&gF-Yj6$9-Dy$<6zeP4s+78gS2|t%Z309b z0^fp~ue_}i`U9j!<|qF92_3oB09NqgAoehQ`)<)dSfKoJl_A6Ec#*Mx9Cpd-p#$Ez z={AM*r-bQs6*z$!*VA4|QE7bf@-4vb?Q+pPKLkY2{yKsw{&udv_2v8{Dbd zm~8VAv!G~s)`O3|Q6vFUV%8%+?ZSVUa(;fhPNg#vab@J*9XE4#D%)$UU-T5`fwjz! z6&gA^`OGu6aUk{l*h9eB?opVdrHK>Q@U>&JQ_2pR%}TyOXGq_6s56_`U(WoOaAb+K zXQr#6H}>a-GYs9^bGP2Y&hSP5gEtW+GVC4=wy0wQk=~%CSXj=GH6q z-T#s!BV`xZVxm{~jr_ezYRpqqIcXC=Oq`b{lu`Rt(IYr4B91hhVC?yg{ol4WUr3v9 zOAk2LG>CIECZ-WIs0$N}F#eoIUEtZudc7DPYIjzGqDLWk_A4#(LgacooD z2K4IWs@N`Bddm-{%oy}!k0^i6Yh)uJ1S*90>|bm3TOZxcV|ywHUb(+CeX-o1|LTZM zwU>dY3R&U)T(}5#Neh?-CWT~@{6Ke@sI)uSuzoah8COy)w)B)aslJmp`WUcjdia-0 zl2Y}&L~XfA`uYQboAJ1;J{XLhYjH){cObH3FDva+^8ioOQy%Z=xyjGLmWMrzfFoH; zEi3AG`_v+%)&lDJE;iJWJDI@-X9K5O)LD~j*PBe(wu+|%ar~C+LK1+-+lK=t# z+Xc+J7qp~5q=B~rD!x78)?1+KUIbYr^5rcl&tB-cTtj+e%{gpZZ4G~6r15+d|J(ky zjg@@UzMW0k9@S#W(1H{u;Nq(7llJbq;;4t$awM;l&(2s+$l!Ay9^Ge|34CVhr7|BG z?dAR83smef^frq9V(OH+a+ki#q&-7TkWfFM=5bsGbU(8mC;>QTCWL5ydz9s6k@?+V zcjiH`VI=59P-(-DWXZ~5DH>B^_H~;4$)KUhnmGo*G!Tq8^LjfUDO)lASN*=#AY_yS zqW9UX(VOCO&p@kHdUUgsBO0KhXxn1sprK5h8}+>IhX(nSXZKwlNsjk^M|RAaqmCZB zHBolOHYBas@&{PT=R+?d8pZu zUHfyucQ`(umXSW7o?HQ3H21M`ZJal+%*)SH1B1j6rxTlG3hx1IGJN^M7{$j(9V;MZ zRKybgVuxKo#XVM+?*yTy{W+XHaU5Jbt-UG33x{u(N-2wmw;zzPH&4DE103HV@ER86 z|FZEmQb|&1s5#`$4!Cm}&`^{(4V}OP$bk`}v6q6rm;P!H)W|2i^e{7lTk2W@jo_9q z*aw|U7#+g59Fv(5qI`#O-qPj#@_P>PC#I(GSp3DLv7x-dmYK=C7lPF8a)bxb=@)B1 zUZ`EqpXV2dR}B&r`uM}N(TS99ZT0UB%IN|0H%DcVO#T%L_chrgn#m6%x4KE*IMfjX zJ%4veCEqbXZ`H`F_+fELMC@wuy_ch%t*+Z+1I}wN#C+dRrf2X{1C8=yZ_%Pt6wL_~ zZ2NN-hXOT4P4n$QFO7yYHS-4wF1Xfr-meG9Pn;uK51?hfel`d38k{W)F*|gJLT2#T z<~>spMu4(mul-8Q3*pf=N4DcI)zzjqAgbE2eOT7~&f1W3VsdD44Ffe;3mJp-V@8UC z)|qnPc12o~$X-+U@L_lWqv-RtvB~%hLF($%Ew5w>^NR82qC_0FB z)=hP1-OEx?lLi#jnLzH}a;Nvr@JDO-zQWd}#k^an$Kwml;MrD&)sC5b`s0ZkVyPkb zt}-jOq^%_9>YZe7Y}PhW{a)c39G`kg(P4@kxjcYfgB4XOOcmezdUI7j-!gs7oAo2o zx(Ph{G+YZ`a%~kzK!HTAA5NXE-7vOFRr5oqY$rH>WI6SFvWmahFav!CfRMM3%8J&c z*p+%|-fNS_@QrFr(at!JY9jCg9F-%5{nb5Bo~z@Y9m&SHYV`49GAJjA5h~h4(G!Se zZmK{Bo7ivCfvl}@A-ptkFGcWXAzj3xfl{evi-OG(TaCn1FAHxRc{}B|x+Ua1D=I6M z!C^ZIvK6aS_c&(=OQDZfm>O`Nxsw{ta&yiYPA~@e#c%N>>#rq)k6Aru-qD4(D^v)y z*>Rs;YUbD1S8^D(ps6Jbj0K3wJw>L4m)0e(6Pee3Y?gy9i0^bZO?$*sv+xKV?WBlh zAp*;v6w!a8;A7sLB*g-^<$Z4L7|5jXxxP1}hQZ<55f9<^KJ>^mKlWSGaLcO0=$jem zWyZkRwe~u{{tU63DlCaS9$Y4CP4f?+wwa(&1ou)b>72ydrFvm`Rj-0`kBJgK@nd(*Eh!(NC{F-@=FnF&Y!q`7){YsLLHf0_B6aHc# z>WIuHTyJwIH{BJ4)2RtEauC7Yq7Cytc|S)4^*t8Va3HR zg=~sN^tp9re@w=GTx$;zOWMjcg-7X3Wk^N$n;&Kf1RgVG2}2L-(0o)54C509C&77i zrjSi{X*WV=%C17((N^6R4Ya*4#6s_L99RtQ>m(%#nQ#wrRC8Y%yxkH;d!MdY+Tw@r zjpSnK`;C-U{ATcgaxoEpP0Gf+tx);buOMlK=01D|J+ROu37qc*rD(w`#O=3*O*w9?biwNoq3WN1`&Wp8TvKj3C z3HR9ssH7a&Vr<6waJrU zdLg!ieYz%U^bmpn%;(V%%ugMk92&?_XX1K@mwnVSE6!&%P%Wdi7_h`CpScvspMx?N zQUR>oadnG17#hNc$pkTp+9lW+MBKHRZ~74XWUryd)4yd zj98$%XmIL4(9OnoeO5Fnyn&fpQ9b0h4e6EHHw*l68j;>(ya`g^S&y2{O8U>1*>4zR zq*WSI_2o$CHQ?x0!wl9bpx|Cm2+kFMR)oMud1%n2=qn5nE&t@Fgr#=Zv2?}wtEz^T z9rrj=?IH*qI5{G@Rn&}^Z{+TW}mQeb9=8b<_a`&Cm#n%n~ zU47MvCBsdXFB1+adOO)03+nczfWa#vwk#r{o{dF)QWya9v2nv43Zp3%Ps}($lA02*_g25t;|T{A5snSY?3A zrRQ~(Ygh_ebltHo1VCbJb*eOAr;4cnlXLvI>*$-#AVsGg6B1r7@;g^L zFlJ_th0vxO7;-opU@WAFe;<}?!2q?RBrFK5U{*ai@NLKZ^};Ul}beukveh?TQn;$%9=R+DX07m82gP$=}Uo_%&ngV`}Hyv8g{u z3SWzTGV|cwQuFIs7ZDOqO_fGf8Q`8MwL}eUp>q?4eqCmOTcwQuXtQckPy|4F1on8l zP*h>d+cH#XQf|+6c|S{7SF(Lg>bR~l(0uY?O{OEVlaxa5@e%T&xju=o1`=OD#qc16 zSvyH*my(dcp6~VqR;o(#@m44Lug@~_qw+HA=mS#Z^4reBy8iV?H~I;{LQWk3aKK8$bLRyt$g?- = [ + 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, +]; + +export const cards: Array = [ + HrComponent, + CheckboxComponent, + CodeBlockComponent, + ImageComponent, + TableComponent, + FileComponent, + VideoComponent, + MathComponent, + ToolbarComponent, + StatusComponent, + MentionComponent, +]; + +export const pluginConfig: { [key: string]: PluginOptions } = { + [Italic.pluginName]: { + // 默认为 _ 下划线,这里修改为单个 * 号 + markdown: '*', + }, + [Image.pluginName]: { + onBeforeRender: (status: string, url: string) => { + if (url.startsWith('data:image/')) return url; + return url + `?token=12323`; + }, + }, + [ImageUploader.pluginName]: { + file: { + action: `${DOMAIN}/upload/image`, + headers: { Authorization: 213434 }, + }, + remote: { + action: `${DOMAIN}/upload/image`, + }, + isRemote: (src: string) => src.indexOf(DOMAIN) < 0, + }, + [FileUploader.pluginName]: { + action: `${DOMAIN}/upload/file`, + }, + [VideoUploader.pluginName]: { + action: `${DOMAIN}/upload/video`, + limitSize: 1024 * 1024 * 50, + }, + [Video.pluginName]: { + onBeforeRender: (status: string, url: string) => { + return url + `?token=12323`; + }, + }, + [Math.pluginName]: { + action: `https://g.yanmao.cc/latex`, + parse: (res: any) => { + if (res.success) return { result: true, data: res.svg }; + return { result: false }; + }, + }, + [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/examples/vue/src/components/demo.vue b/examples/vue/src/components/demo.vue new file mode 100644 index 00000000..340f33d4 --- /dev/null +++ b/examples/vue/src/components/demo.vue @@ -0,0 +1,315 @@ + + + + \ No newline at end of file diff --git a/examples/vue/src/components/loading.vue b/examples/vue/src/components/loading.vue new file mode 100644 index 00000000..a2f70ebb --- /dev/null +++ b/examples/vue/src/components/loading.vue @@ -0,0 +1,35 @@ + + + + \ No newline at end of file diff --git a/examples/vue/src/components/mention.vue b/examples/vue/src/components/mention.vue new file mode 100644 index 00000000..7cfac75d --- /dev/null +++ b/examples/vue/src/components/mention.vue @@ -0,0 +1,24 @@ + + + + \ No newline at end of file diff --git a/examples/vue/src/components/ot-client.ts b/examples/vue/src/components/ot-client.ts new file mode 100644 index 00000000..8df766cb --- /dev/null +++ b/examples/vue/src/components/ot-client.ts @@ -0,0 +1,407 @@ +import { EventEmitter } from 'events'; +import { EngineInterface } from '@aomao/engine'; +import ReconnectingWebSocket, { ErrorEvent } from 'reconnecting-websocket'; +import { Doc } from 'sharedb'; +import sharedb from 'sharedb/lib/client'; +import { Socket } from 'sharedb/lib/sharedb'; + +export type Member = { + id: number; + avatar: string; + name: string; + iid: number; + uuid: string; + color?: string; +}; +export const STATUS = { + init: 'init', + loaded: 'loaded', + active: 'active', + exit: 'exit', + error: 'error', +}; + +export const EVENT = { + inactive: 'inactive', + error: 'error', + membersChange: 'membersChange', + statusChange: 'statusChange', + message: 'message', +}; + +export type ERROR = { + code: string; + level: string; + message: string; + error?: ErrorEvent; +}; + +export const ERROR_CODE = { + INIT_FAILED: 'INIT_FAILED', + SAVE_FAILED: 'SAVE_FAILED', + PUBLISH_FAILED: 'PUBLISH_FAILED', + DISCONNECTED: 'DISCONNECTED', + STATUS_CODE: { + TIMEOUT: 4001, + FORCE_DISCONNECTED: 4002, + }, + CONNECTION_ERROR: 'CONNECTION_ERROR', + COLLAB_DOC_ERROR: 'COLLAB_DOC_ERROR', +}; + +export const ERROR_LEVEL = { + FATAL: 'FATAL', + WARNING: 'WARNING', + NOTICE: 'NOTICE', +}; +/** + * 协同客户端 + */ +class OTClient extends EventEmitter { + // 编辑器引擎 + protected engine: EngineInterface; + // ws 连接实例 + protected socket?: WebSocket; + // 当前协同的所有用户 + protected members: Array = []; + // 当前用户 + protected current?: Member; + // 当前状态 + protected status?: string; + // 协作的文档对象 + protected doc?: Doc; + // 当前 ws 是否关闭 + protected isClosed: boolean = true; + // 心跳检测对象 + protected heartbeat?: { + timeout: NodeJS.Timeout; + datetime: Date; + }; + + constructor(engine: EngineInterface) { + super(); + this.engine = engine; + } + /** + * 每隔指定毫秒发送心跳检测 + * @param {number} millisecond 毫秒 默认 30000 + * @return {void} + */ + checkHeartbeat(millisecond: number = 30000): void { + if (!this.socket) return; + if (this.heartbeat?.timeout) clearTimeout(this.heartbeat.timeout); + const timeout = setTimeout(() => { + const now = new Date(); + + if ( + !this.isClosed && + (!this.heartbeat || + now.getTime() - this.heartbeat.datetime.getTime() >= + millisecond) + ) { + this.sendMessage('heartbeat', { time: now.getTime() }); + this.heartbeat = { + timeout, + datetime: now, + }; + } else if (this.heartbeat) { + this.heartbeat.timeout = timeout; + } + this.checkHeartbeat(millisecond); + }, 1000); + } + + /** + * 连接到协作文档 + * @param url 协同服务地址 + * @param docID 文档唯一ID + * @param defautlValue 如果协作服务端没有创建的文档,将作为协同文档的初始值 + * @param collectionName 协作服务名称,与协同服务端相对应 + */ + connect( + url: string, + docID: string, + defautlValue?: string, + collectionName: string = 'yanmao', + ) { + if (this.socket) this.socket.close(); + // 实例化一个可以自动重连的 ws + const socket = new ReconnectingWebSocket( + async () => { + const token = await new Promise((resolve) => { + // 这里可以异步获取一个Token,如果有的话 + resolve(''); + }); + // 组合ws链接 + const uri = new URL(url); + uri.searchParams.set('id', docID); + uri.searchParams.set('token', token); + return uri.toString(); + }, + [], + { + maxReconnectionDelay: 30000, + minReconnectionDelay: 10000, + reconnectionDelayGrowFactor: 10000, + maxRetries: 10, + }, + ); + + // ws 已链接 + socket.addEventListener('open', () => { + this.socket = socket as WebSocket; + // 加载编辑器内部的协同服务 + this.load(socket, docID, collectionName, defautlValue); + // 标记关闭状态为false + this.isClosed = false; + // 监听协同服务端自定义消息 + this.socket.addEventListener('message', (event) => { + const { data, action } = JSON.parse(event.data); + // 当前所有的协作用户 + if ('members' === action) { + this.addMembers(data); + this.engine.ot.setMembers(data); + return; + } + // 有新的协作者加入了 + if ('join' === action) { + this.addMembers([data]); + this.engine.ot.addMember(data); + return; + } + // 有协作者离开了 + if ('leave' === action) { + this.engine.ot.removeMember(data); + this.removeMember(data); + return; + } + // 协作服务端准备好了,可以实例化编辑器内部的协同服务了 + if ('ready' === action) { + // 当前协作者用户 + this.current = data as Member; + this.engine.ot.setCurrentMember(data); + this.emit('ready', this.engine.ot.getCurrentMember()); + this.emit(EVENT.membersChange, this.normalizeMembers()); + this.transmit(STATUS.active); + } + // 广播信息,一个协作用户发送给全部协作者的广播 + if ('broadcast' === action) { + const { uuid, body, type } = data; + // 如果接收者和发送者不是同一人就触发一个message事件,外部可以监听这个事件并作出响应 + if (uuid !== this.current?.uuid) { + this.emit(EVENT.message, { + type, + body, + }); + } + } + }); + // 开始检测心跳 + this.checkHeartbeat(); + }); + // 监听ws关闭事件 + socket.addEventListener('close', () => { + // 如果不是主动退出的关闭,就显示错误信息 + if (this.status !== STATUS.exit) { + this.onError({ + code: ERROR_CODE.DISCONNECTED, + level: ERROR_LEVEL.FATAL, + message: '网络连接异常,无法继续编辑', + }); + } + }); + // 监听ws错误消息 + socket.addEventListener('error', (error) => { + this.onError({ + code: ERROR_CODE.CONNECTION_ERROR, + level: ERROR_LEVEL.FATAL, + message: '协作服务异常,无法继续编辑', + error, + }); + }); + } + + /** + * 加载编辑器内部协同服务 + * @param docId 文档唯一ID + * @param collectionName 协作服务名称 + * @param defaultValue 如果服务端没有对应docId的文档,就用这个值初始化 + */ + load( + socket: ReconnectingWebSocket, + docId: string, + collectionName: string, + defaultValue?: string, + ) { + // 实例化一个协同客户端的连接 + const connection = new sharedb.Connection(socket as Socket); + // 获取文档对象 + const doc = connection.get(collectionName, docId); + this.doc = doc; + // 订阅 + doc.subscribe((error) => { + console.log('subscribe'); + if (error) { + console.log('collab doc subscribe error', error); + } else { + try { + // 实例化编辑器内部协同服务 + this.engine.ot.initRemote(doc as any, defaultValue); + // 聚焦到编辑器 + this.engine.focus(); + } catch (err) { + console.log('am-engine init failed:', err); + } + } + }); + + doc.on('create', () => { + console.log('collab doc create'); + }); + + doc.on('load', () => { + console.log('collab doc loaded'); + this.sendMessage('ready'); + }); + + doc.on('op', (op, type) => { + console.log('op', op, type ? 'local' : 'server'); + }); + + doc.on('del', (t, n) => { + console.log('collab doc deleted', t, n); + }); + + doc.on('error', (error) => { + console.error(error); + }); + } + + /** + * 广播一个消息 + * @param type 消息类型 + * @param body 消息内容 + */ + broadcast(type: string, body: any = {}) { + this.sendMessage('broadcast', { type, body }); + } + + /** + * 给服务端发送一个消息 + * @param action 消息类型 + * @param data 消息数据 + */ + sendMessage(action: string, data?: any) { + this.socket?.send( + JSON.stringify({ + action, + data: { + ...data, + doc_id: this.doc?.id, + uuid: this.current?.uuid, + }, + }), + ); + } + + addMembers(memberList: Array) { + memberList.forEach((member) => { + if (!this.members.find((m) => member.id === m.id)) { + this.members.push(member); + } + }); + setTimeout(() => { + this.emit(EVENT.membersChange, this.normalizeMembers()); + }, 1000); + } + + removeMember(member: Member) { + this.members = this.members.filter((user) => { + return user.uuid !== member.uuid; + }); + this.emit(EVENT.membersChange, this.normalizeMembers()); + } + + normalizeMembers() { + const members = []; + const colorMap: any = {}; + const users = this.engine.ot.getMembers(); + users.forEach((user) => { + colorMap[user.uuid] = user.color; + }); + const memberMap: any = {}; + for (let i = this.members.length; i > 0; i--) { + const member = this.members[i - 1]; + if (!memberMap[member.id]) { + const cloneMember = { ...member }; + cloneMember.color = colorMap[member.uuid]; + memberMap[member.id] = member; + members.push(cloneMember); + } + } + return members; + } + + transmit(status: string) { + const prevStatus = this.status; + this.status = status; + this.emit(EVENT.statusChange, { + form: prevStatus, + to: status, + }); + } + + onError(error: ERROR) { + this.emit(EVENT.error, error); + this.status = STATUS.error; + } + + isActive() { + return this.status === STATUS.active; + } + + exit() { + if (this.status !== STATUS.exit) { + this.transmit(STATUS.exit); + this.disconnect(); + } + } + + disconnect() { + if (this.socket) { + try { + this.socket.close( + ERROR_CODE.STATUS_CODE.FORCE_DISCONNECTED, + 'FORCE_DISCONNECTED', + ); + if (this.heartbeat?.timeout) { + clearTimeout(this.heartbeat!.timeout); + } + } catch (e) { + console.log(e); + } + } + } + + bindEvents() { + window.addEventListener('beforeunload', () => this.exit()); + window.addEventListener('visibilitychange', () => { + if ('hidden' === document.visibilityState) { + this.emit(EVENT.inactive); + } + }); + window.addEventListener('pagehide', () => this.exit()); + } + + unbindEvents() { + window.removeEventListener('beforeunload', () => this.exit()); + window.removeEventListener('visibilitychange', () => { + if ('hidden' === document.visibilityState) { + this.emit(EVENT.inactive); + } + }); + window.removeEventListener('pagehide', () => this.exit()); + } +} + +export default OTClient; diff --git a/examples/vue/src/main.ts b/examples/vue/src/main.ts new file mode 100644 index 00000000..82ac74e8 --- /dev/null +++ b/examples/vue/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue'; +import App from './App.vue'; +import router from './router'; + +createApp(App).use(router).mount('#app'); diff --git a/examples/vue/src/router/index.ts b/examples/vue/src/router/index.ts new file mode 100644 index 00000000..a417ec23 --- /dev/null +++ b/examples/vue/src/router/index.ts @@ -0,0 +1,26 @@ +import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'; +import Home from '../views/Home.vue'; + +const routes: Array = [ + { + path: '/', + name: 'Home', + component: Home, + }, + { + path: '/about', + name: 'About', + // route level code-splitting + // this generates a separate chunk (about.[hash].js) for this route + // which is lazy-loaded when the route is visited. + component: () => + import(/* webpackChunkName: "about" */ '../views/About.vue'), + }, +]; + +const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes, +}); + +export default router; diff --git a/examples/vue/src/shims-vue.d.ts b/examples/vue/src/shims-vue.d.ts new file mode 100644 index 00000000..ea85c268 --- /dev/null +++ b/examples/vue/src/shims-vue.d.ts @@ -0,0 +1,6 @@ +/* eslint-disable */ +declare module '*.vue' { + import { DefineComponent } from 'vue'; + const component: DefineComponent<{}, {}, any>; + export default component; +} diff --git a/examples/vue/src/views/About.vue b/examples/vue/src/views/About.vue new file mode 100644 index 00000000..3fa28070 --- /dev/null +++ b/examples/vue/src/views/About.vue @@ -0,0 +1,5 @@ + diff --git a/examples/vue/src/views/Home.vue b/examples/vue/src/views/Home.vue new file mode 100644 index 00000000..f3f572a6 --- /dev/null +++ b/examples/vue/src/views/Home.vue @@ -0,0 +1,19 @@ + + + diff --git a/examples/vue/tsconfig.json b/examples/vue/tsconfig.json new file mode 100644 index 00000000..034123e2 --- /dev/null +++ b/examples/vue/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "strict": true, + "jsx": "preserve", + "importHelpers": true, + "moduleResolution": "node", + "experimentalDecorators": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "baseUrl": ".", + "types": ["webpack-env", "node"], + "paths": { + "@/*": ["src/*"] + }, + "lib": ["esnext", "dom", "dom.iterable", "scripthost"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.vue", + "tests/**/*.ts", + "tests/**/*.tsx", + "vue.config.ts" + ], + "exclude": ["node_modules"] +} diff --git a/examples/vue/tslint.json b/examples/vue/tslint.json new file mode 100644 index 00000000..12f8560f --- /dev/null +++ b/examples/vue/tslint.json @@ -0,0 +1,15 @@ +{ + "defaultSeverity": "warning", + "extends": ["tslint:recommended"], + "linterOptions": { + "exclude": ["node_modules/**"] + }, + "rules": { + "indent": [true, "spaces", 4], + "interface-name": false, + "no-consecutive-blank-lines": false, + "object-literal-sort-keys": false, + "ordered-imports": false, + "quotemark": [true, "single"] + } +} diff --git a/examples/vue/vue.config.js b/examples/vue/vue.config.js new file mode 100644 index 00000000..f3ccb969 --- /dev/null +++ b/examples/vue/vue.config.js @@ -0,0 +1,11 @@ +module.exports = { + css: { + loaderOptions: { + less: { + lessOptions: { + javascriptEnabled: true, + }, + }, + }, + }, +}; diff --git a/examples/vue/yarn.lock b/examples/vue/yarn.lock new file mode 100644 index 00000000..c4c84f11 --- /dev/null +++ b/examples/vue/yarn.lock @@ -0,0 +1,9173 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@ant-design/colors@^5.0.0": + version "5.1.1" + resolved "https://registry.npmjs.org/@ant-design/colors/-/colors-5.1.1.tgz#800b2186b1e27e66432e67d03ed96af3e21d8940" + integrity sha512-Txy4KpHrp3q4XZdfgOBqLl+lkQIc3tEvHXOimRN1giX1AEC7mGtyrO9p8iRGJ3FLuVMGa2gNEzQyghVymLttKQ== + dependencies: + "@ctrl/tinycolor" "^3.3.1" + +"@ant-design/icons-svg@^4.0.0": + version "4.1.0" + resolved "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.1.0.tgz#480b025f4b20ef7fe8f47d4a4846e4fee84ea06c" + integrity sha512-Fi03PfuUqRs76aI3UWYpP864lkrfPo0hluwGqh7NJdLhvH4iRDc3jbJqZIvRDLHKbXrvAfPPV3+zjUccfFvWOQ== + +"@ant-design/icons-vue@^6.0.0": + version "6.0.1" + resolved "https://registry.npmjs.org/@ant-design/icons-vue/-/icons-vue-6.0.1.tgz#9d804c3c74d2cfaf97cb18e582d3b9400934f5fd" + integrity sha512-HigIgEVV6bbcrz2A92/qDzi/aKWB5EC6b6E1mxMB6aQA7ksiKY+gi4U94TpqyEIIhR23uaDrjufJ+xCZQ+vx6Q== + dependencies: + "@ant-design/colors" "^5.0.0" + "@ant-design/icons-svg" "^4.0.0" + "@types/lodash" "^4.14.165" + lodash "^4.17.15" + +"@aomao/engine@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/engine/-/engine-2.4.19.tgz#ec001d47ce722487727ce28ac81ff9a0787851c3" + integrity sha512-jzQlNzaeLnyiA53V6SjEAb2OmnSmisQeK4Gw5KHBJDuNdit7jscTnll3D3ENbiYU27igl70CpbBRmPOO+0JXVg== + dependencies: + "@babel/runtime" "^7.13.10" + blueimp-md5 "^2.18.0" + copy-to-clipboard "^3.3.1" + diff-match-patch "^1.0.5" + dom-align "^1.12.2" + eventemitter2 "^6.4.4" + filesize "^6.3.0" + is-hotkey "^0.2.0" + lodash-es "^4.17.21" + ot-json0 "^1.1.0" + tinycolor2 "^1.4.2" + +"@aomao/plugin-alignment@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-alignment/-/plugin-alignment-2.4.19.tgz#41ae0371cfe8c33cb1a6363e1d27f6838f19533b" + integrity sha512-ZGVkCFKZnD+2dLhbkmbukCcNv0dvjZ2OPM22FKweaGFTZkDNJ5SyZqRkvSRACT+SBAOg37Ty8it+s8m/ftjFbQ== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-backcolor@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-backcolor/-/plugin-backcolor-2.4.19.tgz#b1f14f3cb14d3491f368da0c92e8c59f44030f6d" + integrity sha512-bAxbAnVp75kfbJtctDjF7piPqbwAgupqv90p1jB+yrDybaUm9PTYcUX5MJoMLHSzcg+2LONWyJZA1+++iRFjAw== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-bold@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-bold/-/plugin-bold-2.4.19.tgz#cc0cff8ded811df0b407eddd4a172b561a8e7e17" + integrity sha512-CZTnI2u/s7cxBAaFbr0hNBcZlKIAbpqg6KC6FyPRvW2UZZgJs8ehn0etusR/kgGOVQFTf/Xdh7oa34RgAMI9AQ== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-code@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-code/-/plugin-code-2.4.19.tgz#22f131bd134148411c8a7a404368dfb64c516952" + integrity sha512-6L1A0Y9oxGG5ZjJSmCIW5zTHV4LFMbK2P9V6Xx1MJC/dvis/t72p9h31hEhV9IjG90y0sTFDHbgmyy5i76cj1g== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-codeblock-vue@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-codeblock-vue/-/plugin-codeblock-vue-2.4.19.tgz#c30faa75a6b98707ebb221c763b43471c55789b8" + integrity sha512-NP/lHGAcaM/sTodIEfJ3aXspKFfRshA62weYWATAiEYTdQshhCz9MEdALLtjllmaF5BdcQnxgc3YJkHRUtP3Aw== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + ant-design-vue "^2.2.6" + codemirror "^5.60.0" + lodash-es "^4.17.21" + vue "^3.2.9" + +"@aomao/plugin-file@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-file/-/plugin-file-2.4.19.tgz#8684f1f37cb9aad7820067f35eb8f274b11730c8" + integrity sha512-PqKSLKCuK/416Szn190k2AX+6fcbQXpz6a7ENSDMNlEk9TsKqlM9oIzQ/HifCCQSKK1UslhXH5kpX9SQgg/SMA== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-fontcolor@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-fontcolor/-/plugin-fontcolor-2.4.19.tgz#d79b06ff9f429a270b2d999a55395470d9c4baa5" + integrity sha512-2SqL3D6T5ZdBej4pVZ7axCnxJk02D3DRJzuT33/9HGD4nmB2gCoa1+M70fevEozDcNQc9u/13qXRqbZfPqlmuw== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-fontfamily@^1.2.19": + version "1.2.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-fontfamily/-/plugin-fontfamily-1.2.19.tgz#5c312257c88f2de45efbf45abdb51ac3116fefc4" + integrity sha512-ZqVGM58oEKg/46tukSrFVFEQ+3vCqy1/l+NGW7FKUoCHbWU26i6E+Gdjmbspmyjlg9hzYe9rNeunrGtvNcHTGA== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-fontsize@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-fontsize/-/plugin-fontsize-2.4.19.tgz#0d865136ff8642fc21761fb3d3b93dc05adc053f" + integrity sha512-NbLfuNnqLxUpHKuaywrOFzDkP+dDxh9x7krGh/W4NGOlHJZA9sYd41D3a3SuvLPxH29M9zmgZ3Pd9W5L2RZ+/Q== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-heading@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-heading/-/plugin-heading-2.4.19.tgz#7fdbcadc94fb40e389a432fe049d9d7a183bf1b8" + integrity sha512-BNQ6sxkYD7/W7a5748KjWV8F9cR5Ffg72qfRXCAwWTPMXjpHVH2Klt7a7uoKA8hFuTq+y5m0PR0JXYSSRaiTHQ== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-hr@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-hr/-/plugin-hr-2.4.19.tgz#3a0cf317f5deceb612bae1a53db93f425633b483" + integrity sha512-3TobuLJ1Hbz1PvMOkO3S+GMi+w2knKILPgwUG32z35hKrwJ5+i5JkWYnV47AZy2lx5id3pPTXuc1UrtxGacVLg== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-image@^2.4.21": + version "2.4.21" + resolved "https://registry.yarnpkg.com/@aomao/plugin-image/-/plugin-image-2.4.21.tgz#31134b100fd6887361d988716fcb48fa885980ee" + integrity sha512-QL+VXch8HCyQpk+K8JvLsdY2hLhBwDlT5LE4nq6LUJdlvtz8j2qEF2fdSH7QHe292kNDOdtg/ZdOLQODMd1+Yw== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + eventemitter2 "^6.4.4" + photoswipe "^4.1.3" + +"@aomao/plugin-indent@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-indent/-/plugin-indent-2.4.19.tgz#8215f4c3b3cf16344b046c00cb34277ba4d5c7c0" + integrity sha512-nG/SwYxw0vWshymKC26EZHNSevCqLjlnKyIiIeOVhNVryXerDF+iatTclmJIsi0D44tTmrrkdBsDq5ALgtYRmQ== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-italic@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-italic/-/plugin-italic-2.4.19.tgz#13e6a6f25feece036c375c4c906720d1efb5b378" + integrity sha512-XDXFwKt4VNcEBbsT573H4kPkEqbsuLl45okvi1LUV2dMWjrtayiGdfBW+nwD4yxqkZ7hv/YilfxH7qNdYWLyIg== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-line-height@^1.2.19": + version "1.2.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-line-height/-/plugin-line-height-1.2.19.tgz#8a47a4a3f7bdac04cc81d6b66d9d451b19bd52f3" + integrity sha512-tmzWskKQZLdhz6Ot84gxEWsF10nZBp20NqxfbymY3ui3txQjNpzQY6BDIpcKIrfDAwxTfH2JQLaFgpnUPaAWGQ== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-link-vue@^2.4.20": + version "2.4.20" + resolved "https://registry.yarnpkg.com/@aomao/plugin-link-vue/-/plugin-link-vue-2.4.20.tgz#2831111ae8ca430f50dd00eb6ced3c4130cfff2b" + integrity sha512-eI9rUOWhj+EtKcirUqTsNBytAr9qGBkgO7krAxLVOkp3ulM0B7wPb5wi1cpOHAurlvNdrMnH8vthZMlAVmqX3A== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + ant-design-vue "^2.2.6" + vue "^3.2.9" + +"@aomao/plugin-mark@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-mark/-/plugin-mark-2.4.19.tgz#f28377d7967b03186922742eebf92fe2030b2494" + integrity sha512-CNuZtTRn/BLpf17vsmJIJ/STyx00h0/8NMX92UbqtLDEnGWj5FJ8VDN19m73vfafUg5mdo5QDjtlCbGk27jH5g== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-math@^2.4.20": + version "2.4.20" + resolved "https://registry.yarnpkg.com/@aomao/plugin-math/-/plugin-math-2.4.20.tgz#cf1c6cad1ae2f3bdd922eeaa7cce5b9b074c8124" + integrity sha512-veRRpB3bjKSREoLw7ncq5a/yanX78VwWkXhCS9wK0R9is5Wn9zNTEOWfRWwNxU+KffgnDi1XB7jjvqIAFTr6wQ== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-mention@^1.2.22": + version "1.2.22" + resolved "https://registry.yarnpkg.com/@aomao/plugin-mention/-/plugin-mention-1.2.22.tgz#952aeb050fb6452371cd9d0c7017b921256283a2" + integrity sha512-ORjoxpxRLKS0O+EUq2dJs2w0jmSIaPAJJ4t6YCRgXBInRHZbt+AQPL5cceQVl99Ta24Mt852/uRv7houq/fBeA== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + keymaster "^1.6.2" + +"@aomao/plugin-orderedlist@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-orderedlist/-/plugin-orderedlist-2.4.19.tgz#eefb4c2c9884a5e2923a991b660b1cb02fed971a" + integrity sha512-TwTCCs+YR9JvLRlt8HI7RV6LgyFLTc8dIaXhMTArITJjL9zYxYgnf0LF16Hi337TgJVZgfDi+kxHEbw7Vs+t5g== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-paintformat@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-paintformat/-/plugin-paintformat-2.4.19.tgz#c19bc6eadb0a85db055aa2d21d5e368e4e884419" + integrity sha512-mpeGGGGbWLGecwtqK6XPNxIMG2u8An6VLm89AYyd8NXQJKylC7y1WPajWWy4h37RhxKfWrjbPG/VzDvYjlr90Q== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-quote@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-quote/-/plugin-quote-2.4.19.tgz#cbd9657d1d165d396938d7416d2ac7b5108e8a39" + integrity sha512-u+Jn+865rPi1xRLGP8sxoRxWfs1Fq7Ez69uH+wjot+4Uw5PzyD/7or838M7x/oUk/Abia9VchxlMvJReOk/YwQ== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-redo@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-redo/-/plugin-redo-2.4.19.tgz#436f1b02010ca04c0f4c46649e4486ec6ef58d09" + integrity sha512-UZh1azIhBt2CsMAMpQbhzZLxeGkvEmLipQ5QHTzxy/yeokj4iJVYER0qAIQynFD9P+8ykbsbRqaOQ/vyjY49DA== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-removeformat@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-removeformat/-/plugin-removeformat-2.4.19.tgz#39131619340eb31f7849525ccf69acc53b3a3745" + integrity sha512-g4HuDzeYBRSvBw/h07h46oEHSlkFyyy4N/JN1xQr5+8AT02KbQ4YYJ/t+llaRwNpAWzjCGuLjqblQHcjmAtgpg== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-selectall@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-selectall/-/plugin-selectall-2.4.19.tgz#45f28f4c5af34e56e3cca54747cf46c3a5161129" + integrity sha512-FTTnS3xFvua8x8cvQeROn5VzzFL8XEbb7rhnmGbYh+c+KKKZVJ1PRoEWUtUk0TZwwJLZ0BZy0zR4b49BIwQPyQ== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-status@^1.2.19": + version "1.2.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-status/-/plugin-status-1.2.19.tgz#da4aaf41e2bc8891cc8cbab763a83c04c07825e4" + integrity sha512-0lnrSXMfYHJD33vNMot5H05foD9ch4KvBKemqqGmfz1oa5KBSc5T14/Ha9r/Xnk7Y2JgFp3cL/rBi9uZ9sbtqA== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-strikethrough@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-strikethrough/-/plugin-strikethrough-2.4.19.tgz#bbd00bde7b5b50087f88517fe4d0d03f6ef4d002" + integrity sha512-CDrKmMd3+PQ9pIrYtQWMQ1nyi4qJ3hJv6QaFYGN//X6KFJ4f5xANseYNLCgCq0W5JTvRJBz+pdKkd42LisM9rA== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-sub@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-sub/-/plugin-sub-2.4.19.tgz#a9a4a973eab575aa82b7a44591ea719a14ac15b5" + integrity sha512-Iv0lbBmqjZM/KpmWDYrVy1rQxdb4JVNyccw9byo2eCcaR9kI6/HapYf30Uv6EBOvkKZ01gt70XSiCpbE6hj2Cg== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-sup@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-sup/-/plugin-sup-2.4.19.tgz#3d272428d9fd44555f8812555e276bd81453b500" + integrity sha512-0uMnm8rAj9RdT9Hf5tqGTYKUsMX0VpiVCPfCsR3EL3LM9ppl1CvtLOBewIzc2q+qy9gA+9vzANHAENPSyQkUqQ== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-table@^2.4.22": + version "2.4.22" + resolved "https://registry.yarnpkg.com/@aomao/plugin-table/-/plugin-table-2.4.22.tgz#b7f4409021d2dc087b09362b5f05a0b689449a7b" + integrity sha512-7UEGQa/5IZgPqZQh00ixkzmSwvSKMeH+cGdaZ3cLqo5YPibRg+i7/4bHvMopCM6b69nd3Qo14ueEigbiJ4pezg== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + eventemitter2 "^6.4.4" + tinycolor2 "^1.4.2" + +"@aomao/plugin-tasklist@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-tasklist/-/plugin-tasklist-2.4.19.tgz#47341f4fc7bc9c0db9e746d237dc021114718a9e" + integrity sha512-YrE7sCkUBjvkoTk63KJjP0lKk2uAfVYqVLdho/X8Gr1sx2NIMrr2ofs8ZO3TJ9AMmLa6eDVBHtlDBQkeUil9QA== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-underline@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-underline/-/plugin-underline-2.4.19.tgz#dd1e299fdb30a47938acc92342e8252d44e78c7d" + integrity sha512-ejNrLHmjJ8hYTYJ+hXBRFjAiTLlw6mgUTvw0Xm19GKVJyHwf3zTvtrHxUHSl9sup4zhaUgoPcZOjLyfDHAsCIg== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-undo@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-undo/-/plugin-undo-2.4.19.tgz#c4c5046a353d51a5e9b17c8a0e8b7293f5732afb" + integrity sha512-6IEB+Nuw2yIGrF5yxh44ET9iIw9rQvx4kPQPeUOdwmiy0KbLRwG4W92arLNOnUozisY3gWwtpxeDOuYij9b1yQ== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-unorderedlist@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-unorderedlist/-/plugin-unorderedlist-2.4.19.tgz#9a92347a49c882e42a52096193ff9a8cb68458d6" + integrity sha512-E17hmmGtpxoMKaDg/us99vJico6FCvStWBPwMLPWUBN0aVJNMnSt0DX1FLEZwa11lJqmt183IeuEbXyI/cj/0w== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/plugin-video@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/plugin-video/-/plugin-video-2.4.19.tgz#6f7bf10b1e37117eb25ed7482098986ad999c4cc" + integrity sha512-HAO3to3+jnHWVnkG/Zg0oY3GYD92x2Nm9LUgT87tNG6iRhktX1InuLaS27M2qxVqy+sg6XgnFIVpT4VCBgRGeQ== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + +"@aomao/toolbar-vue@^2.4.19": + version "2.4.19" + resolved "https://registry.yarnpkg.com/@aomao/toolbar-vue/-/toolbar-vue-2.4.19.tgz#4a2f06044b1ab4f67c513c5db4769a47ecdf946a" + integrity sha512-KFAPLPnYF0GVDJ37EOLL/z2CTaXZYxDMM5/I6CnGKNeu5O2L8oDvNv4khhNz1dqU6vATOaDtVPz+bQJX1dgOyQ== + dependencies: + "@aomao/engine" "^2.4.19" + "@babel/runtime" "^7.13.10" + ant-design-vue "^2.2.6" + keymaster "^1.6.2" + lodash-es "^4.17.21" + tinycolor2 "^1.4.2" + vue "^3.2.9" + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.8.3": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/code-frame/download/@babel/code-frame-7.12.13.tgz#dcfc826beef65e75c50e21d3837d7d95798dd658" + integrity sha1-3PyCa+72XnXFDiHTg319lXmN1lg= + dependencies: + "@babel/highlight" "^7.12.13" + +"@babel/compat-data@^7.13.11", "@babel/compat-data@^7.13.12", "@babel/compat-data@^7.13.15", "@babel/compat-data@^7.13.8": + version "7.13.15" + resolved "https://registry.npm.taobao.org/@babel/compat-data/download/@babel/compat-data-7.13.15.tgz?cache=0&sync_timestamp=1617897171596&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fcompat-data%2Fdownload%2F%40babel%2Fcompat-data-7.13.15.tgz#7e8eea42d0b64fda2b375b22d06c605222e848f4" + integrity sha1-fo7qQtC2T9orN1si0GxgUiLoSPQ= + +"@babel/core@^7.11.0": + version "7.13.15" + resolved "https://registry.npm.taobao.org/@babel/core/download/@babel/core-7.13.15.tgz#a6d40917df027487b54312202a06812c4f7792d0" + integrity sha1-ptQJF98CdIe1QxIgKgaBLE93ktA= + dependencies: + "@babel/code-frame" "^7.12.13" + "@babel/generator" "^7.13.9" + "@babel/helper-compilation-targets" "^7.13.13" + "@babel/helper-module-transforms" "^7.13.14" + "@babel/helpers" "^7.13.10" + "@babel/parser" "^7.13.15" + "@babel/template" "^7.12.13" + "@babel/traverse" "^7.13.15" + "@babel/types" "^7.13.14" + convert-source-map "^1.7.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.1.2" + semver "^6.3.0" + source-map "^0.5.0" + +"@babel/generator@^7.13.9": + version "7.13.9" + resolved "https://registry.npm.taobao.org/@babel/generator/download/@babel/generator-7.13.9.tgz?cache=0&sync_timestamp=1614635661222&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fgenerator%2Fdownload%2F%40babel%2Fgenerator-7.13.9.tgz#3a7aa96f9efb8e2be42d38d80e2ceb4c64d8de39" + integrity sha1-Onqpb577jivkLTjYDizrTGTY3jk= + dependencies: + "@babel/types" "^7.13.0" + jsesc "^2.5.1" + source-map "^0.5.0" + +"@babel/helper-annotate-as-pure@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/helper-annotate-as-pure/download/@babel/helper-annotate-as-pure-7.12.13.tgz?cache=0&sync_timestamp=1612314636125&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-annotate-as-pure%2Fdownload%2F%40babel%2Fhelper-annotate-as-pure-7.12.13.tgz#0f58e86dfc4bb3b1fcd7db806570e177d439b6ab" + integrity sha1-D1jobfxLs7H819uAZXDhd9Q5tqs= + dependencies: + "@babel/types" "^7.12.13" + +"@babel/helper-builder-binary-assignment-operator-visitor@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/helper-builder-binary-assignment-operator-visitor/download/@babel/helper-builder-binary-assignment-operator-visitor-7.12.13.tgz?cache=0&sync_timestamp=1612314760016&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-builder-binary-assignment-operator-visitor%2Fdownload%2F%40babel%2Fhelper-builder-binary-assignment-operator-visitor-7.12.13.tgz#6bc20361c88b0a74d05137a65cac8d3cbf6f61fc" + integrity sha1-a8IDYciLCnTQUTemXKyNPL9vYfw= + dependencies: + "@babel/helper-explode-assignable-expression" "^7.12.13" + "@babel/types" "^7.12.13" + +"@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.13.13", "@babel/helper-compilation-targets@^7.13.8", "@babel/helper-compilation-targets@^7.9.6": + version "7.13.13" + resolved "https://registry.npm.taobao.org/@babel/helper-compilation-targets/download/@babel/helper-compilation-targets-7.13.13.tgz?cache=0&sync_timestamp=1616794050697&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-compilation-targets%2Fdownload%2F%40babel%2Fhelper-compilation-targets-7.13.13.tgz#2b2972a0926474853f41e4adbc69338f520600e5" + integrity sha1-KylyoJJkdIU/QeStvGkzj1IGAOU= + dependencies: + "@babel/compat-data" "^7.13.12" + "@babel/helper-validator-option" "^7.12.17" + browserslist "^4.14.5" + semver "^6.3.0" + +"@babel/helper-create-class-features-plugin@^7.13.0", "@babel/helper-create-class-features-plugin@^7.13.11": + version "7.13.11" + resolved "https://registry.npm.taobao.org/@babel/helper-create-class-features-plugin/download/@babel/helper-create-class-features-plugin-7.13.11.tgz#30d30a005bca2c953f5653fc25091a492177f4f6" + integrity sha1-MNMKAFvKLJU/VlP8JQkaSSF39PY= + dependencies: + "@babel/helper-function-name" "^7.12.13" + "@babel/helper-member-expression-to-functions" "^7.13.0" + "@babel/helper-optimise-call-expression" "^7.12.13" + "@babel/helper-replace-supers" "^7.13.0" + "@babel/helper-split-export-declaration" "^7.12.13" + +"@babel/helper-create-regexp-features-plugin@^7.12.13": + version "7.12.17" + resolved "https://registry.npm.taobao.org/@babel/helper-create-regexp-features-plugin/download/@babel/helper-create-regexp-features-plugin-7.12.17.tgz?cache=0&sync_timestamp=1613661261586&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-create-regexp-features-plugin%2Fdownload%2F%40babel%2Fhelper-create-regexp-features-plugin-7.12.17.tgz#a2ac87e9e319269ac655b8d4415e94d38d663cb7" + integrity sha1-oqyH6eMZJprGVbjUQV6U041mPLc= + dependencies: + "@babel/helper-annotate-as-pure" "^7.12.13" + regexpu-core "^4.7.1" + +"@babel/helper-define-polyfill-provider@^0.2.0": + version "0.2.0" + resolved "https://registry.npm.taobao.org/@babel/helper-define-polyfill-provider/download/@babel/helper-define-polyfill-provider-0.2.0.tgz?cache=0&sync_timestamp=1617206099868&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-define-polyfill-provider%2Fdownload%2F%40babel%2Fhelper-define-polyfill-provider-0.2.0.tgz#a640051772045fedaaecc6f0c6c69f02bdd34bf1" + integrity sha1-pkAFF3IEX+2q7MbwxsafAr3TS/E= + dependencies: + "@babel/helper-compilation-targets" "^7.13.0" + "@babel/helper-module-imports" "^7.12.13" + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/traverse" "^7.13.0" + debug "^4.1.1" + lodash.debounce "^4.0.8" + resolve "^1.14.2" + semver "^6.1.2" + +"@babel/helper-explode-assignable-expression@^7.12.13": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/helper-explode-assignable-expression/download/@babel/helper-explode-assignable-expression-7.13.0.tgz?cache=0&sync_timestamp=1614034723075&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-explode-assignable-expression%2Fdownload%2F%40babel%2Fhelper-explode-assignable-expression-7.13.0.tgz#17b5c59ff473d9f956f40ef570cf3a76ca12657f" + integrity sha1-F7XFn/Rz2flW9A71cM86dsoSZX8= + dependencies: + "@babel/types" "^7.13.0" + +"@babel/helper-function-name@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/helper-function-name/download/@babel/helper-function-name-7.12.13.tgz#93ad656db3c3c2232559fd7b2c3dbdcbe0eb377a" + integrity sha1-k61lbbPDwiMlWf17LD29y+DrN3o= + dependencies: + "@babel/helper-get-function-arity" "^7.12.13" + "@babel/template" "^7.12.13" + "@babel/types" "^7.12.13" + +"@babel/helper-get-function-arity@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/helper-get-function-arity/download/@babel/helper-get-function-arity-7.12.13.tgz?cache=0&sync_timestamp=1612314636323&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-get-function-arity%2Fdownload%2F%40babel%2Fhelper-get-function-arity-7.12.13.tgz#bc63451d403a3b3082b97e1d8b3fe5bd4091e583" + integrity sha1-vGNFHUA6OzCCuX4diz/lvUCR5YM= + dependencies: + "@babel/types" "^7.12.13" + +"@babel/helper-hoist-variables@^7.13.0": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/helper-hoist-variables/download/@babel/helper-hoist-variables-7.13.0.tgz?cache=0&sync_timestamp=1614034717626&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-hoist-variables%2Fdownload%2F%40babel%2Fhelper-hoist-variables-7.13.0.tgz#5d5882e855b5c5eda91e0cadc26c6e7a2c8593d8" + integrity sha1-XViC6FW1xe2pHgytwmxueiyFk9g= + dependencies: + "@babel/traverse" "^7.13.0" + "@babel/types" "^7.13.0" + +"@babel/helper-member-expression-to-functions@^7.13.0", "@babel/helper-member-expression-to-functions@^7.13.12": + version "7.13.12" + resolved "https://registry.npm.taobao.org/@babel/helper-member-expression-to-functions/download/@babel/helper-member-expression-to-functions-7.13.12.tgz?cache=0&sync_timestamp=1616428120148&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-member-expression-to-functions%2Fdownload%2F%40babel%2Fhelper-member-expression-to-functions-7.13.12.tgz#dfe368f26d426a07299d8d6513821768216e6d72" + integrity sha1-3+No8m1CagcpnY1lE4IXaCFubXI= + dependencies: + "@babel/types" "^7.13.12" + +"@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.12.13", "@babel/helper-module-imports@^7.13.12", "@babel/helper-module-imports@^7.8.3": + version "7.13.12" + resolved "https://registry.npm.taobao.org/@babel/helper-module-imports/download/@babel/helper-module-imports-7.13.12.tgz?cache=0&sync_timestamp=1616428110625&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-module-imports%2Fdownload%2F%40babel%2Fhelper-module-imports-7.13.12.tgz#c6a369a6f3621cb25da014078684da9196b61977" + integrity sha1-xqNppvNiHLJdoBQHhoTakZa2GXc= + dependencies: + "@babel/types" "^7.13.12" + +"@babel/helper-module-transforms@^7.13.0", "@babel/helper-module-transforms@^7.13.14": + version "7.13.14" + resolved "https://registry.npm.taobao.org/@babel/helper-module-transforms/download/@babel/helper-module-transforms-7.13.14.tgz#e600652ba48ccb1641775413cb32cfa4e8b495ef" + integrity sha1-5gBlK6SMyxZBd1QTyzLPpOi0le8= + dependencies: + "@babel/helper-module-imports" "^7.13.12" + "@babel/helper-replace-supers" "^7.13.12" + "@babel/helper-simple-access" "^7.13.12" + "@babel/helper-split-export-declaration" "^7.12.13" + "@babel/helper-validator-identifier" "^7.12.11" + "@babel/template" "^7.12.13" + "@babel/traverse" "^7.13.13" + "@babel/types" "^7.13.14" + +"@babel/helper-optimise-call-expression@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/helper-optimise-call-expression/download/@babel/helper-optimise-call-expression-7.12.13.tgz?cache=0&sync_timestamp=1612314636446&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-optimise-call-expression%2Fdownload%2F%40babel%2Fhelper-optimise-call-expression-7.12.13.tgz#5c02d171b4c8615b1e7163f888c1c81c30a2aaea" + integrity sha1-XALRcbTIYVsecWP4iMHIHDCiquo= + dependencies: + "@babel/types" "^7.12.13" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/helper-plugin-utils/download/@babel/helper-plugin-utils-7.13.0.tgz?cache=0&sync_timestamp=1614034721464&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-plugin-utils%2Fdownload%2F%40babel%2Fhelper-plugin-utils-7.13.0.tgz#806526ce125aed03373bc416a828321e3a6a33af" + integrity sha1-gGUmzhJa7QM3O8QWqCgyHjpqM68= + +"@babel/helper-remap-async-to-generator@^7.13.0": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/helper-remap-async-to-generator/download/@babel/helper-remap-async-to-generator-7.13.0.tgz?cache=0&sync_timestamp=1614034719757&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-remap-async-to-generator%2Fdownload%2F%40babel%2Fhelper-remap-async-to-generator-7.13.0.tgz#376a760d9f7b4b2077a9dd05aa9c3927cadb2209" + integrity sha1-N2p2DZ97SyB3qd0Fqpw5J8rbIgk= + dependencies: + "@babel/helper-annotate-as-pure" "^7.12.13" + "@babel/helper-wrap-function" "^7.13.0" + "@babel/types" "^7.13.0" + +"@babel/helper-replace-supers@^7.12.13", "@babel/helper-replace-supers@^7.13.0", "@babel/helper-replace-supers@^7.13.12": + version "7.13.12" + resolved "https://registry.npm.taobao.org/@babel/helper-replace-supers/download/@babel/helper-replace-supers-7.13.12.tgz?cache=0&sync_timestamp=1616428121774&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-replace-supers%2Fdownload%2F%40babel%2Fhelper-replace-supers-7.13.12.tgz#6442f4c1ad912502481a564a7386de0c77ff3804" + integrity sha1-ZEL0wa2RJQJIGlZKc4beDHf/OAQ= + dependencies: + "@babel/helper-member-expression-to-functions" "^7.13.12" + "@babel/helper-optimise-call-expression" "^7.12.13" + "@babel/traverse" "^7.13.0" + "@babel/types" "^7.13.12" + +"@babel/helper-simple-access@^7.12.13", "@babel/helper-simple-access@^7.13.12": + version "7.13.12" + resolved "https://registry.npm.taobao.org/@babel/helper-simple-access/download/@babel/helper-simple-access-7.13.12.tgz?cache=0&sync_timestamp=1616428121027&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-simple-access%2Fdownload%2F%40babel%2Fhelper-simple-access-7.13.12.tgz#dd6c538afb61819d205a012c31792a39c7a5eaf6" + integrity sha1-3WxTivthgZ0gWgEsMXkqOcel6vY= + dependencies: + "@babel/types" "^7.13.12" + +"@babel/helper-skip-transparent-expression-wrappers@^7.12.1": + version "7.12.1" + resolved "https://registry.npm.taobao.org/@babel/helper-skip-transparent-expression-wrappers/download/@babel/helper-skip-transparent-expression-wrappers-7.12.1.tgz#462dc63a7e435ade8468385c63d2b84cce4b3cbf" + integrity sha1-Ri3GOn5DWt6EaDhcY9K4TM5LPL8= + dependencies: + "@babel/types" "^7.12.1" + +"@babel/helper-split-export-declaration@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/helper-split-export-declaration/download/@babel/helper-split-export-declaration-7.12.13.tgz?cache=0&sync_timestamp=1612314636310&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-split-export-declaration%2Fdownload%2F%40babel%2Fhelper-split-export-declaration-7.12.13.tgz#e9430be00baf3e88b0e13e6f9d4eaf2136372b05" + integrity sha1-6UML4AuvPoiw4T5vnU6vITY3KwU= + dependencies: + "@babel/types" "^7.12.13" + +"@babel/helper-validator-identifier@^7.12.11": + version "7.12.11" + resolved "https://registry.npm.taobao.org/@babel/helper-validator-identifier/download/@babel/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" + integrity sha1-yaHwIZF9y1zPDU5FPjmQIpgfye0= + +"@babel/helper-validator-identifier@^7.14.9": + version "7.14.9" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.9.tgz#6654d171b2024f6d8ee151bf2509699919131d48" + integrity sha512-pQYxPY0UP6IHISRitNe8bsijHex4TWZXi2HwKVsjPiltzlhse2znVcm9Ace510VT1kxIHjGJCZZQBX2gJDbo0g== + +"@babel/helper-validator-option@^7.12.17": + version "7.12.17" + resolved "https://registry.npm.taobao.org/@babel/helper-validator-option/download/@babel/helper-validator-option-7.12.17.tgz?cache=0&sync_timestamp=1613661300791&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-validator-option%2Fdownload%2F%40babel%2Fhelper-validator-option-7.12.17.tgz#d1fbf012e1a79b7eebbfdc6d270baaf8d9eb9831" + integrity sha1-0fvwEuGnm37rv9xtJwuq+NnrmDE= + +"@babel/helper-wrap-function@^7.13.0": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/helper-wrap-function/download/@babel/helper-wrap-function-7.13.0.tgz?cache=0&sync_timestamp=1614034718032&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelper-wrap-function%2Fdownload%2F%40babel%2Fhelper-wrap-function-7.13.0.tgz#bdb5c66fda8526ec235ab894ad53a1235c79fcc4" + integrity sha1-vbXGb9qFJuwjWriUrVOhI1x5/MQ= + dependencies: + "@babel/helper-function-name" "^7.12.13" + "@babel/template" "^7.12.13" + "@babel/traverse" "^7.13.0" + "@babel/types" "^7.13.0" + +"@babel/helpers@^7.13.10": + version "7.13.10" + resolved "https://registry.npm.taobao.org/@babel/helpers/download/@babel/helpers-7.13.10.tgz?cache=0&sync_timestamp=1615243549675&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhelpers%2Fdownload%2F%40babel%2Fhelpers-7.13.10.tgz#fd8e2ba7488533cdeac45cc158e9ebca5e3c7df8" + integrity sha1-/Y4rp0iFM83qxFzBWOnryl48ffg= + dependencies: + "@babel/template" "^7.12.13" + "@babel/traverse" "^7.13.0" + "@babel/types" "^7.13.0" + +"@babel/highlight@^7.12.13": + version "7.13.10" + resolved "https://registry.npm.taobao.org/@babel/highlight/download/@babel/highlight-7.13.10.tgz?cache=0&sync_timestamp=1615243550421&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fhighlight%2Fdownload%2F%40babel%2Fhighlight-7.13.10.tgz#a8b2a66148f5b27d666b15d81774347a731d52d1" + integrity sha1-qLKmYUj1sn1maxXYF3Q0enMdUtE= + dependencies: + "@babel/helper-validator-identifier" "^7.12.11" + chalk "^2.0.0" + js-tokens "^4.0.0" + +"@babel/parser@^7.12.13", "@babel/parser@^7.13.15": + version "7.13.15" + resolved "https://registry.npm.taobao.org/@babel/parser/download/@babel/parser-7.13.15.tgz#8e66775fb523599acb6a289e12929fa5ab0954d8" + integrity sha1-jmZ3X7UjWZrLaiieEpKfpasJVNg= + +"@babel/parser@^7.15.0": + version "7.15.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.15.5.tgz#d33a58ca69facc05b26adfe4abebfed56c1c2dac" + integrity sha512-2hQstc6I7T6tQsWzlboMh3SgMRPaS4H6H7cPQsJkdzTzEGqQrpLDsE2BGASU5sBPoEQyHzeqU6C8uKbFeEk6sg== + +"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.13.12": + version "7.13.12" + resolved "https://registry.npm.taobao.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/download/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.13.12.tgz?cache=0&sync_timestamp=1616428117548&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-bugfix-v8-spread-parameters-in-optional-chaining%2Fdownload%2F%40babel%2Fplugin-bugfix-v8-spread-parameters-in-optional-chaining-7.13.12.tgz#a3484d84d0b549f3fc916b99ee4783f26fabad2a" + integrity sha1-o0hNhNC1SfP8kWuZ7keD8m+rrSo= + dependencies: + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" + "@babel/plugin-proposal-optional-chaining" "^7.13.12" + +"@babel/plugin-proposal-async-generator-functions@^7.13.15": + version "7.13.15" + resolved "https://registry.npm.taobao.org/@babel/plugin-proposal-async-generator-functions/download/@babel/plugin-proposal-async-generator-functions-7.13.15.tgz?cache=0&sync_timestamp=1617897172399&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-proposal-async-generator-functions%2Fdownload%2F%40babel%2Fplugin-proposal-async-generator-functions-7.13.15.tgz#80e549df273a3b3050431b148c892491df1bcc5b" + integrity sha1-gOVJ3yc6OzBQQxsUjIkkkd8bzFs= + dependencies: + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/helper-remap-async-to-generator" "^7.13.0" + "@babel/plugin-syntax-async-generators" "^7.8.4" + +"@babel/plugin-proposal-class-properties@^7.13.0", "@babel/plugin-proposal-class-properties@^7.8.3": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/plugin-proposal-class-properties/download/@babel/plugin-proposal-class-properties-7.13.0.tgz?cache=0&sync_timestamp=1614034719421&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-proposal-class-properties%2Fdownload%2F%40babel%2Fplugin-proposal-class-properties-7.13.0.tgz#146376000b94efd001e57a40a88a525afaab9f37" + integrity sha1-FGN2AAuU79AB5XpAqIpSWvqrnzc= + dependencies: + "@babel/helper-create-class-features-plugin" "^7.13.0" + "@babel/helper-plugin-utils" "^7.13.0" + +"@babel/plugin-proposal-decorators@^7.8.3": + version "7.13.15" + resolved "https://registry.npm.taobao.org/@babel/plugin-proposal-decorators/download/@babel/plugin-proposal-decorators-7.13.15.tgz#e91ccfef2dc24dd5bd5dcc9fc9e2557c684ecfb8" + integrity sha1-6RzP7y3CTdW9XcyfyeJVfGhOz7g= + dependencies: + "@babel/helper-create-class-features-plugin" "^7.13.11" + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/plugin-syntax-decorators" "^7.12.13" + +"@babel/plugin-proposal-dynamic-import@^7.13.8": + version "7.13.8" + resolved "https://registry.npm.taobao.org/@babel/plugin-proposal-dynamic-import/download/@babel/plugin-proposal-dynamic-import-7.13.8.tgz?cache=0&sync_timestamp=1614383314443&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-proposal-dynamic-import%2Fdownload%2F%40babel%2Fplugin-proposal-dynamic-import-7.13.8.tgz#876a1f6966e1dec332e8c9451afda3bebcdf2e1d" + integrity sha1-h2ofaWbh3sMy6MlFGv2jvrzfLh0= + dependencies: + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/plugin-syntax-dynamic-import" "^7.8.3" + +"@babel/plugin-proposal-export-namespace-from@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-proposal-export-namespace-from/download/@babel/plugin-proposal-export-namespace-from-7.12.13.tgz?cache=0&sync_timestamp=1612314774011&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-proposal-export-namespace-from%2Fdownload%2F%40babel%2Fplugin-proposal-export-namespace-from-7.12.13.tgz#393be47a4acd03fa2af6e3cde9b06e33de1b446d" + integrity sha1-OTvkekrNA/oq9uPN6bBuM94bRG0= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + "@babel/plugin-syntax-export-namespace-from" "^7.8.3" + +"@babel/plugin-proposal-json-strings@^7.13.8": + version "7.13.8" + resolved "https://registry.npm.taobao.org/@babel/plugin-proposal-json-strings/download/@babel/plugin-proposal-json-strings-7.13.8.tgz?cache=0&sync_timestamp=1614383315454&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-proposal-json-strings%2Fdownload%2F%40babel%2Fplugin-proposal-json-strings-7.13.8.tgz#bf1fb362547075afda3634ed31571c5901afef7b" + integrity sha1-vx+zYlRwda/aNjTtMVccWQGv73s= + dependencies: + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/plugin-syntax-json-strings" "^7.8.3" + +"@babel/plugin-proposal-logical-assignment-operators@^7.13.8": + version "7.13.8" + resolved "https://registry.npm.taobao.org/@babel/plugin-proposal-logical-assignment-operators/download/@babel/plugin-proposal-logical-assignment-operators-7.13.8.tgz?cache=0&sync_timestamp=1614383316575&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-proposal-logical-assignment-operators%2Fdownload%2F%40babel%2Fplugin-proposal-logical-assignment-operators-7.13.8.tgz#93fa78d63857c40ce3c8c3315220fd00bfbb4e1a" + integrity sha1-k/p41jhXxAzjyMMxUiD9AL+7Tho= + dependencies: + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + +"@babel/plugin-proposal-nullish-coalescing-operator@^7.13.8": + version "7.13.8" + resolved "https://registry.npm.taobao.org/@babel/plugin-proposal-nullish-coalescing-operator/download/@babel/plugin-proposal-nullish-coalescing-operator-7.13.8.tgz?cache=0&sync_timestamp=1614383315747&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-proposal-nullish-coalescing-operator%2Fdownload%2F%40babel%2Fplugin-proposal-nullish-coalescing-operator-7.13.8.tgz#3730a31dafd3c10d8ccd10648ed80a2ac5472ef3" + integrity sha1-NzCjHa/TwQ2MzRBkjtgKKsVHLvM= + dependencies: + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + +"@babel/plugin-proposal-numeric-separator@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-proposal-numeric-separator/download/@babel/plugin-proposal-numeric-separator-7.12.13.tgz#bd9da3188e787b5120b4f9d465a8261ce67ed1db" + integrity sha1-vZ2jGI54e1EgtPnUZagmHOZ+0ds= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + +"@babel/plugin-proposal-object-rest-spread@^7.13.8": + version "7.13.8" + resolved "https://registry.npm.taobao.org/@babel/plugin-proposal-object-rest-spread/download/@babel/plugin-proposal-object-rest-spread-7.13.8.tgz?cache=0&sync_timestamp=1614383321108&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-proposal-object-rest-spread%2Fdownload%2F%40babel%2Fplugin-proposal-object-rest-spread-7.13.8.tgz#5d210a4d727d6ce3b18f9de82cc99a3964eed60a" + integrity sha1-XSEKTXJ9bOOxj53oLMmaOWTu1go= + dependencies: + "@babel/compat-data" "^7.13.8" + "@babel/helper-compilation-targets" "^7.13.8" + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-transform-parameters" "^7.13.0" + +"@babel/plugin-proposal-optional-catch-binding@^7.13.8": + version "7.13.8" + resolved "https://registry.npm.taobao.org/@babel/plugin-proposal-optional-catch-binding/download/@babel/plugin-proposal-optional-catch-binding-7.13.8.tgz?cache=0&sync_timestamp=1614383316033&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-proposal-optional-catch-binding%2Fdownload%2F%40babel%2Fplugin-proposal-optional-catch-binding-7.13.8.tgz#3ad6bd5901506ea996fc31bdcf3ccfa2bed71107" + integrity sha1-Ota9WQFQbqmW/DG9zzzPor7XEQc= + dependencies: + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + +"@babel/plugin-proposal-optional-chaining@^7.13.12": + version "7.13.12" + resolved "https://registry.npm.taobao.org/@babel/plugin-proposal-optional-chaining/download/@babel/plugin-proposal-optional-chaining-7.13.12.tgz?cache=0&sync_timestamp=1616428119276&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-proposal-optional-chaining%2Fdownload%2F%40babel%2Fplugin-proposal-optional-chaining-7.13.12.tgz#ba9feb601d422e0adea6760c2bd6bbb7bfec4866" + integrity sha1-up/rYB1CLgrepnYMK9a7t7/sSGY= + dependencies: + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + +"@babel/plugin-proposal-private-methods@^7.13.0": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/plugin-proposal-private-methods/download/@babel/plugin-proposal-private-methods-7.13.0.tgz?cache=0&sync_timestamp=1614034720852&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-proposal-private-methods%2Fdownload%2F%40babel%2Fplugin-proposal-private-methods-7.13.0.tgz#04bd4c6d40f6e6bbfa2f57e2d8094bad900ef787" + integrity sha1-BL1MbUD25rv6L1fi2AlLrZAO94c= + dependencies: + "@babel/helper-create-class-features-plugin" "^7.13.0" + "@babel/helper-plugin-utils" "^7.13.0" + +"@babel/plugin-proposal-unicode-property-regex@^7.12.13", "@babel/plugin-proposal-unicode-property-regex@^7.4.4": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-proposal-unicode-property-regex/download/@babel/plugin-proposal-unicode-property-regex-7.12.13.tgz?cache=0&sync_timestamp=1612314730740&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-proposal-unicode-property-regex%2Fdownload%2F%40babel%2Fplugin-proposal-unicode-property-regex-7.12.13.tgz#bebde51339be829c17aaaaced18641deb62b39ba" + integrity sha1-vr3lEzm+gpwXqqrO0YZB3rYrObo= + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.12.13" + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-syntax-async-generators@^7.8.4": + version "7.8.4" + resolved "https://registry.npm.taobao.org/@babel/plugin-syntax-async-generators/download/@babel/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" + integrity sha1-qYP7Gusuw/btBCohD2QOkOeG/g0= + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-class-properties@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-syntax-class-properties/download/@babel/plugin-syntax-class-properties-7.12.13.tgz?cache=0&sync_timestamp=1612314770269&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-syntax-class-properties%2Fdownload%2F%40babel%2Fplugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" + integrity sha1-tcmHJ0xKOoK4lxR5aTGmtTVErhA= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-syntax-decorators@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-syntax-decorators/download/@babel/plugin-syntax-decorators-7.12.13.tgz#fac829bf3c7ef4a1bc916257b403e58c6bdaf648" + integrity sha1-+sgpvzx+9KG8kWJXtAPljGva9kg= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-syntax-dynamic-import@^7.8.3": + version "7.8.3" + resolved "https://registry.npm.taobao.org/@babel/plugin-syntax-dynamic-import/download/@babel/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3" + integrity sha1-Yr+Ysto80h1iYVT8lu5bPLaOrLM= + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-export-namespace-from@^7.8.3": + version "7.8.3" + resolved "https://registry.npm.taobao.org/@babel/plugin-syntax-export-namespace-from/download/@babel/plugin-syntax-export-namespace-from-7.8.3.tgz#028964a9ba80dbc094c915c487ad7c4e7a66465a" + integrity sha1-AolkqbqA28CUyRXEh618TnpmRlo= + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-syntax-json-strings@^7.8.3": + version "7.8.3" + resolved "https://registry.npm.taobao.org/@babel/plugin-syntax-json-strings/download/@babel/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" + integrity sha1-AcohtmjNghjJ5kDLbdiMVBKyyWo= + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-jsx@^7.0.0", "@babel/plugin-syntax-jsx@^7.2.0", "@babel/plugin-syntax-jsx@^7.8.3": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-syntax-jsx/download/@babel/plugin-syntax-jsx-7.12.13.tgz#044fb81ebad6698fe62c478875575bcbb9b70f15" + integrity sha1-BE+4HrrWaY/mLEeIdVdby7m3DxU= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-syntax-logical-assignment-operators@^7.10.4": + version "7.10.4" + resolved "https://registry.npm.taobao.org/@babel/plugin-syntax-logical-assignment-operators/download/@babel/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" + integrity sha1-ypHvRjA1MESLkGZSusLp/plB9pk= + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": + version "7.8.3" + resolved "https://registry.npm.taobao.org/@babel/plugin-syntax-nullish-coalescing-operator/download/@babel/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" + integrity sha1-Fn7XA2iIYIH3S1w2xlqIwDtm0ak= + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-numeric-separator@^7.10.4": + version "7.10.4" + resolved "https://registry.npm.taobao.org/@babel/plugin-syntax-numeric-separator/download/@babel/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" + integrity sha1-ubBws+M1cM2f0Hun+pHA3Te5r5c= + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-object-rest-spread@^7.8.3": + version "7.8.3" + resolved "https://registry.npm.taobao.org/@babel/plugin-syntax-object-rest-spread/download/@babel/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" + integrity sha1-YOIl7cvZimQDMqLnLdPmbxr1WHE= + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-catch-binding@^7.8.3": + version "7.8.3" + resolved "https://registry.npm.taobao.org/@babel/plugin-syntax-optional-catch-binding/download/@babel/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" + integrity sha1-YRGiZbz7Ag6579D9/X0mQCue1sE= + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-chaining@^7.8.3": + version "7.8.3" + resolved "https://registry.npm.taobao.org/@babel/plugin-syntax-optional-chaining/download/@babel/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" + integrity sha1-T2nCq5UWfgGAzVM2YT+MV4j31Io= + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-top-level-await@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-syntax-top-level-await/download/@babel/plugin-syntax-top-level-await-7.12.13.tgz?cache=0&sync_timestamp=1612314769908&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-syntax-top-level-await%2Fdownload%2F%40babel%2Fplugin-syntax-top-level-await-7.12.13.tgz#c5f0fa6e249f5b739727f923540cf7a806130178" + integrity sha1-xfD6biSfW3OXJ/kjVAz3qAYTAXg= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-transform-arrow-functions@^7.13.0": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-arrow-functions/download/@babel/plugin-transform-arrow-functions-7.13.0.tgz?cache=0&sync_timestamp=1614034712722&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-arrow-functions%2Fdownload%2F%40babel%2Fplugin-transform-arrow-functions-7.13.0.tgz#10a59bebad52d637a027afa692e8d5ceff5e3dae" + integrity sha1-EKWb661S1jegJ6+mkujVzv9ePa4= + dependencies: + "@babel/helper-plugin-utils" "^7.13.0" + +"@babel/plugin-transform-async-to-generator@^7.13.0": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-async-to-generator/download/@babel/plugin-transform-async-to-generator-7.13.0.tgz?cache=0&sync_timestamp=1614034721772&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-async-to-generator%2Fdownload%2F%40babel%2Fplugin-transform-async-to-generator-7.13.0.tgz#8e112bf6771b82bf1e974e5e26806c5c99aa516f" + integrity sha1-jhEr9ncbgr8el05eJoBsXJmqUW8= + dependencies: + "@babel/helper-module-imports" "^7.12.13" + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/helper-remap-async-to-generator" "^7.13.0" + +"@babel/plugin-transform-block-scoped-functions@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-block-scoped-functions/download/@babel/plugin-transform-block-scoped-functions-7.12.13.tgz?cache=0&sync_timestamp=1612314818063&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-block-scoped-functions%2Fdownload%2F%40babel%2Fplugin-transform-block-scoped-functions-7.12.13.tgz#a9bf1836f2a39b4eb6cf09967739de29ea4bf4c4" + integrity sha1-qb8YNvKjm062zwmWdzneKepL9MQ= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-transform-block-scoping@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-block-scoping/download/@babel/plugin-transform-block-scoping-7.12.13.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-block-scoping%2Fdownload%2F%40babel%2Fplugin-transform-block-scoping-7.12.13.tgz#f36e55076d06f41dfd78557ea039c1b581642e61" + integrity sha1-825VB20G9B39eFV+oDnBtYFkLmE= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-transform-classes@^7.13.0": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-classes/download/@babel/plugin-transform-classes-7.13.0.tgz?cache=0&sync_timestamp=1614034718419&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-classes%2Fdownload%2F%40babel%2Fplugin-transform-classes-7.13.0.tgz#0265155075c42918bf4d3a4053134176ad9b533b" + integrity sha1-AmUVUHXEKRi/TTpAUxNBdq2bUzs= + dependencies: + "@babel/helper-annotate-as-pure" "^7.12.13" + "@babel/helper-function-name" "^7.12.13" + "@babel/helper-optimise-call-expression" "^7.12.13" + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/helper-replace-supers" "^7.13.0" + "@babel/helper-split-export-declaration" "^7.12.13" + globals "^11.1.0" + +"@babel/plugin-transform-computed-properties@^7.13.0": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-computed-properties/download/@babel/plugin-transform-computed-properties-7.13.0.tgz?cache=0&sync_timestamp=1614034713725&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-computed-properties%2Fdownload%2F%40babel%2Fplugin-transform-computed-properties-7.13.0.tgz#845c6e8b9bb55376b1fa0b92ef0bdc8ea06644ed" + integrity sha1-hFxui5u1U3ax+guS7wvcjqBmRO0= + dependencies: + "@babel/helper-plugin-utils" "^7.13.0" + +"@babel/plugin-transform-destructuring@^7.13.0": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-destructuring/download/@babel/plugin-transform-destructuring-7.13.0.tgz?cache=0&sync_timestamp=1614034713216&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-destructuring%2Fdownload%2F%40babel%2Fplugin-transform-destructuring-7.13.0.tgz#c5dce270014d4e1ebb1d806116694c12b7028963" + integrity sha1-xdzicAFNTh67HYBhFmlMErcCiWM= + dependencies: + "@babel/helper-plugin-utils" "^7.13.0" + +"@babel/plugin-transform-dotall-regex@^7.12.13", "@babel/plugin-transform-dotall-regex@^7.4.4": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-dotall-regex/download/@babel/plugin-transform-dotall-regex-7.12.13.tgz?cache=0&sync_timestamp=1612314730663&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-dotall-regex%2Fdownload%2F%40babel%2Fplugin-transform-dotall-regex-7.12.13.tgz#3f1601cc29905bfcb67f53910f197aeafebb25ad" + integrity sha1-PxYBzCmQW/y2f1ORDxl66v67Ja0= + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.12.13" + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-transform-duplicate-keys@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-duplicate-keys/download/@babel/plugin-transform-duplicate-keys-7.12.13.tgz?cache=0&sync_timestamp=1612314817333&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-duplicate-keys%2Fdownload%2F%40babel%2Fplugin-transform-duplicate-keys-7.12.13.tgz#6f06b87a8b803fd928e54b81c258f0a0033904de" + integrity sha1-bwa4eouAP9ko5UuBwljwoAM5BN4= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-transform-exponentiation-operator@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-exponentiation-operator/download/@babel/plugin-transform-exponentiation-operator-7.12.13.tgz?cache=0&sync_timestamp=1612314730682&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-exponentiation-operator%2Fdownload%2F%40babel%2Fplugin-transform-exponentiation-operator-7.12.13.tgz#4d52390b9a273e651e4aba6aee49ef40e80cd0a1" + integrity sha1-TVI5C5onPmUeSrpq7knvQOgM0KE= + dependencies: + "@babel/helper-builder-binary-assignment-operator-visitor" "^7.12.13" + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-transform-for-of@^7.13.0": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-for-of/download/@babel/plugin-transform-for-of-7.13.0.tgz?cache=0&sync_timestamp=1614034713485&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-for-of%2Fdownload%2F%40babel%2Fplugin-transform-for-of-7.13.0.tgz#c799f881a8091ac26b54867a845c3e97d2696062" + integrity sha1-x5n4gagJGsJrVIZ6hFw+l9JpYGI= + dependencies: + "@babel/helper-plugin-utils" "^7.13.0" + +"@babel/plugin-transform-function-name@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-function-name/download/@babel/plugin-transform-function-name-7.12.13.tgz?cache=0&sync_timestamp=1612314730751&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-function-name%2Fdownload%2F%40babel%2Fplugin-transform-function-name-7.12.13.tgz#bb024452f9aaed861d374c8e7a24252ce3a50051" + integrity sha1-uwJEUvmq7YYdN0yOeiQlLOOlAFE= + dependencies: + "@babel/helper-function-name" "^7.12.13" + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-transform-literals@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-literals/download/@babel/plugin-transform-literals-7.12.13.tgz?cache=0&sync_timestamp=1612314767825&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-literals%2Fdownload%2F%40babel%2Fplugin-transform-literals-7.12.13.tgz#2ca45bafe4a820197cf315794a4d26560fe4bdb9" + integrity sha1-LKRbr+SoIBl88xV5Sk0mVg/kvbk= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-transform-member-expression-literals@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-member-expression-literals/download/@babel/plugin-transform-member-expression-literals-7.12.13.tgz#5ffa66cd59b9e191314c9f1f803b938e8c081e40" + integrity sha1-X/pmzVm54ZExTJ8fgDuTjowIHkA= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-transform-modules-amd@^7.13.0": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-modules-amd/download/@babel/plugin-transform-modules-amd-7.13.0.tgz?cache=0&sync_timestamp=1614034721450&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-modules-amd%2Fdownload%2F%40babel%2Fplugin-transform-modules-amd-7.13.0.tgz#19f511d60e3d8753cc5a6d4e775d3a5184866cc3" + integrity sha1-GfUR1g49h1PMWm1Od106UYSGbMM= + dependencies: + "@babel/helper-module-transforms" "^7.13.0" + "@babel/helper-plugin-utils" "^7.13.0" + babel-plugin-dynamic-import-node "^2.3.3" + +"@babel/plugin-transform-modules-commonjs@^7.13.8": + version "7.13.8" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-modules-commonjs/download/@babel/plugin-transform-modules-commonjs-7.13.8.tgz?cache=0&sync_timestamp=1614383318664&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-modules-commonjs%2Fdownload%2F%40babel%2Fplugin-transform-modules-commonjs-7.13.8.tgz#7b01ad7c2dcf2275b06fa1781e00d13d420b3e1b" + integrity sha1-ewGtfC3PInWwb6F4HgDRPUILPhs= + dependencies: + "@babel/helper-module-transforms" "^7.13.0" + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/helper-simple-access" "^7.12.13" + babel-plugin-dynamic-import-node "^2.3.3" + +"@babel/plugin-transform-modules-systemjs@^7.13.8": + version "7.13.8" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-modules-systemjs/download/@babel/plugin-transform-modules-systemjs-7.13.8.tgz?cache=0&sync_timestamp=1614383316294&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-modules-systemjs%2Fdownload%2F%40babel%2Fplugin-transform-modules-systemjs-7.13.8.tgz#6d066ee2bff3c7b3d60bf28dec169ad993831ae3" + integrity sha1-bQZu4r/zx7PWC/KN7Baa2ZODGuM= + dependencies: + "@babel/helper-hoist-variables" "^7.13.0" + "@babel/helper-module-transforms" "^7.13.0" + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/helper-validator-identifier" "^7.12.11" + babel-plugin-dynamic-import-node "^2.3.3" + +"@babel/plugin-transform-modules-umd@^7.13.0": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-modules-umd/download/@babel/plugin-transform-modules-umd-7.13.0.tgz?cache=0&sync_timestamp=1614034723326&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-modules-umd%2Fdownload%2F%40babel%2Fplugin-transform-modules-umd-7.13.0.tgz#8a3d96a97d199705b9fd021580082af81c06e70b" + integrity sha1-ij2WqX0ZlwW5/QIVgAgq+BwG5ws= + dependencies: + "@babel/helper-module-transforms" "^7.13.0" + "@babel/helper-plugin-utils" "^7.13.0" + +"@babel/plugin-transform-named-capturing-groups-regex@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-named-capturing-groups-regex/download/@babel/plugin-transform-named-capturing-groups-regex-7.12.13.tgz?cache=0&sync_timestamp=1612314730683&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-named-capturing-groups-regex%2Fdownload%2F%40babel%2Fplugin-transform-named-capturing-groups-regex-7.12.13.tgz#2213725a5f5bbbe364b50c3ba5998c9599c5c9d9" + integrity sha1-IhNyWl9bu+NktQw7pZmMlZnFydk= + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.12.13" + +"@babel/plugin-transform-new-target@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-new-target/download/@babel/plugin-transform-new-target-7.12.13.tgz?cache=0&sync_timestamp=1612314827274&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-new-target%2Fdownload%2F%40babel%2Fplugin-transform-new-target-7.12.13.tgz#e22d8c3af24b150dd528cbd6e685e799bf1c351c" + integrity sha1-4i2MOvJLFQ3VKMvW5oXnmb8cNRw= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-transform-object-super@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-object-super/download/@babel/plugin-transform-object-super-7.12.13.tgz#b4416a2d63b8f7be314f3d349bd55a9c1b5171f7" + integrity sha1-tEFqLWO4974xTz00m9VanBtRcfc= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + "@babel/helper-replace-supers" "^7.12.13" + +"@babel/plugin-transform-parameters@^7.13.0": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-parameters/download/@babel/plugin-transform-parameters-7.13.0.tgz?cache=0&sync_timestamp=1614034715183&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-parameters%2Fdownload%2F%40babel%2Fplugin-transform-parameters-7.13.0.tgz#8fa7603e3097f9c0b7ca1a4821bc2fb52e9e5007" + integrity sha1-j6dgPjCX+cC3yhpIIbwvtS6eUAc= + dependencies: + "@babel/helper-plugin-utils" "^7.13.0" + +"@babel/plugin-transform-property-literals@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-property-literals/download/@babel/plugin-transform-property-literals-7.12.13.tgz?cache=0&sync_timestamp=1612314768626&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-property-literals%2Fdownload%2F%40babel%2Fplugin-transform-property-literals-7.12.13.tgz#4e6a9e37864d8f1b3bc0e2dce7bf8857db8b1a81" + integrity sha1-TmqeN4ZNjxs7wOLc57+IV9uLGoE= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-transform-regenerator@^7.13.15": + version "7.13.15" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-regenerator/download/@babel/plugin-transform-regenerator-7.13.15.tgz?cache=0&sync_timestamp=1617897172824&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-regenerator%2Fdownload%2F%40babel%2Fplugin-transform-regenerator-7.13.15.tgz#e5eb28945bf8b6563e7f818945f966a8d2997f39" + integrity sha1-5esolFv4tlY+f4GJRflmqNKZfzk= + dependencies: + regenerator-transform "^0.14.2" + +"@babel/plugin-transform-reserved-words@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-reserved-words/download/@babel/plugin-transform-reserved-words-7.12.13.tgz#7d9988d4f06e0fe697ea1d9803188aa18b472695" + integrity sha1-fZmI1PBuD+aX6h2YAxiKoYtHJpU= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-transform-runtime@^7.11.0": + version "7.13.15" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-runtime/download/@babel/plugin-transform-runtime-7.13.15.tgz#2eddf585dd066b84102517e10a577f24f76a9cd7" + integrity sha1-Lt31hd0Ga4QQJRfhCld/JPdqnNc= + dependencies: + "@babel/helper-module-imports" "^7.13.12" + "@babel/helper-plugin-utils" "^7.13.0" + babel-plugin-polyfill-corejs2 "^0.2.0" + babel-plugin-polyfill-corejs3 "^0.2.0" + babel-plugin-polyfill-regenerator "^0.2.0" + semver "^6.3.0" + +"@babel/plugin-transform-shorthand-properties@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-shorthand-properties/download/@babel/plugin-transform-shorthand-properties-7.12.13.tgz#db755732b70c539d504c6390d9ce90fe64aff7ad" + integrity sha1-23VXMrcMU51QTGOQ2c6Q/mSv960= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-transform-spread@^7.13.0": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-spread/download/@babel/plugin-transform-spread-7.13.0.tgz?cache=0&sync_timestamp=1614034714029&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-spread%2Fdownload%2F%40babel%2Fplugin-transform-spread-7.13.0.tgz#84887710e273c1815ace7ae459f6f42a5d31d5fd" + integrity sha1-hIh3EOJzwYFaznrkWfb0Kl0x1f0= + dependencies: + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" + +"@babel/plugin-transform-sticky-regex@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-sticky-regex/download/@babel/plugin-transform-sticky-regex-7.12.13.tgz?cache=0&sync_timestamp=1612314787760&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-sticky-regex%2Fdownload%2F%40babel%2Fplugin-transform-sticky-regex-7.12.13.tgz#760ffd936face73f860ae646fb86ee82f3d06d1f" + integrity sha1-dg/9k2+s5z+GCuZG+4bugvPQbR8= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-transform-template-literals@^7.13.0": + version "7.13.0" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-template-literals/download/@babel/plugin-transform-template-literals-7.13.0.tgz?cache=0&sync_timestamp=1614034715504&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-template-literals%2Fdownload%2F%40babel%2Fplugin-transform-template-literals-7.13.0.tgz#a36049127977ad94438dee7443598d1cefdf409d" + integrity sha1-o2BJEnl3rZRDje50Q1mNHO/fQJ0= + dependencies: + "@babel/helper-plugin-utils" "^7.13.0" + +"@babel/plugin-transform-typeof-symbol@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-typeof-symbol/download/@babel/plugin-transform-typeof-symbol-7.12.13.tgz#785dd67a1f2ea579d9c2be722de8c84cb85f5a7f" + integrity sha1-eF3Weh8upXnZwr5yLejITLhfWn8= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-transform-unicode-escapes@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-unicode-escapes/download/@babel/plugin-transform-unicode-escapes-7.12.13.tgz#840ced3b816d3b5127dd1d12dcedc5dead1a5e74" + integrity sha1-hAztO4FtO1En3R0S3O3F3q0aXnQ= + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-transform-unicode-regex@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/plugin-transform-unicode-regex/download/@babel/plugin-transform-unicode-regex-7.12.13.tgz?cache=0&sync_timestamp=1612314730902&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fplugin-transform-unicode-regex%2Fdownload%2F%40babel%2Fplugin-transform-unicode-regex-7.12.13.tgz#b52521685804e155b1202e83fc188d34bb70f5ac" + integrity sha1-tSUhaFgE4VWxIC6D/BiNNLtw9aw= + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.12.13" + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/preset-env@^7.11.0": + version "7.13.15" + resolved "https://registry.npm.taobao.org/@babel/preset-env/download/@babel/preset-env-7.13.15.tgz#c8a6eb584f96ecba183d3d414a83553a599f478f" + integrity sha1-yKbrWE+W7LoYPT1BSoNVOlmfR48= + dependencies: + "@babel/compat-data" "^7.13.15" + "@babel/helper-compilation-targets" "^7.13.13" + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/helper-validator-option" "^7.12.17" + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.13.12" + "@babel/plugin-proposal-async-generator-functions" "^7.13.15" + "@babel/plugin-proposal-class-properties" "^7.13.0" + "@babel/plugin-proposal-dynamic-import" "^7.13.8" + "@babel/plugin-proposal-export-namespace-from" "^7.12.13" + "@babel/plugin-proposal-json-strings" "^7.13.8" + "@babel/plugin-proposal-logical-assignment-operators" "^7.13.8" + "@babel/plugin-proposal-nullish-coalescing-operator" "^7.13.8" + "@babel/plugin-proposal-numeric-separator" "^7.12.13" + "@babel/plugin-proposal-object-rest-spread" "^7.13.8" + "@babel/plugin-proposal-optional-catch-binding" "^7.13.8" + "@babel/plugin-proposal-optional-chaining" "^7.13.12" + "@babel/plugin-proposal-private-methods" "^7.13.0" + "@babel/plugin-proposal-unicode-property-regex" "^7.12.13" + "@babel/plugin-syntax-async-generators" "^7.8.4" + "@babel/plugin-syntax-class-properties" "^7.12.13" + "@babel/plugin-syntax-dynamic-import" "^7.8.3" + "@babel/plugin-syntax-export-namespace-from" "^7.8.3" + "@babel/plugin-syntax-json-strings" "^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-syntax-top-level-await" "^7.12.13" + "@babel/plugin-transform-arrow-functions" "^7.13.0" + "@babel/plugin-transform-async-to-generator" "^7.13.0" + "@babel/plugin-transform-block-scoped-functions" "^7.12.13" + "@babel/plugin-transform-block-scoping" "^7.12.13" + "@babel/plugin-transform-classes" "^7.13.0" + "@babel/plugin-transform-computed-properties" "^7.13.0" + "@babel/plugin-transform-destructuring" "^7.13.0" + "@babel/plugin-transform-dotall-regex" "^7.12.13" + "@babel/plugin-transform-duplicate-keys" "^7.12.13" + "@babel/plugin-transform-exponentiation-operator" "^7.12.13" + "@babel/plugin-transform-for-of" "^7.13.0" + "@babel/plugin-transform-function-name" "^7.12.13" + "@babel/plugin-transform-literals" "^7.12.13" + "@babel/plugin-transform-member-expression-literals" "^7.12.13" + "@babel/plugin-transform-modules-amd" "^7.13.0" + "@babel/plugin-transform-modules-commonjs" "^7.13.8" + "@babel/plugin-transform-modules-systemjs" "^7.13.8" + "@babel/plugin-transform-modules-umd" "^7.13.0" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.12.13" + "@babel/plugin-transform-new-target" "^7.12.13" + "@babel/plugin-transform-object-super" "^7.12.13" + "@babel/plugin-transform-parameters" "^7.13.0" + "@babel/plugin-transform-property-literals" "^7.12.13" + "@babel/plugin-transform-regenerator" "^7.13.15" + "@babel/plugin-transform-reserved-words" "^7.12.13" + "@babel/plugin-transform-shorthand-properties" "^7.12.13" + "@babel/plugin-transform-spread" "^7.13.0" + "@babel/plugin-transform-sticky-regex" "^7.12.13" + "@babel/plugin-transform-template-literals" "^7.13.0" + "@babel/plugin-transform-typeof-symbol" "^7.12.13" + "@babel/plugin-transform-unicode-escapes" "^7.12.13" + "@babel/plugin-transform-unicode-regex" "^7.12.13" + "@babel/preset-modules" "^0.1.4" + "@babel/types" "^7.13.14" + babel-plugin-polyfill-corejs2 "^0.2.0" + babel-plugin-polyfill-corejs3 "^0.2.0" + babel-plugin-polyfill-regenerator "^0.2.0" + core-js-compat "^3.9.0" + semver "^6.3.0" + +"@babel/preset-modules@^0.1.4": + version "0.1.4" + resolved "https://registry.npm.taobao.org/@babel/preset-modules/download/@babel/preset-modules-0.1.4.tgz#362f2b68c662842970fdb5e254ffc8fc1c2e415e" + integrity sha1-Ni8raMZihClw/bXiVP/I/BwuQV4= + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-proposal-unicode-property-regex" "^7.4.4" + "@babel/plugin-transform-dotall-regex" "^7.4.4" + "@babel/types" "^7.4.4" + esutils "^2.0.2" + +"@babel/runtime@^7.10.5", "@babel/runtime@^7.11.0", "@babel/runtime@^7.8.4": + version "7.13.10" + resolved "https://registry.npm.taobao.org/@babel/runtime/download/@babel/runtime-7.13.10.tgz?cache=0&sync_timestamp=1616061448395&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Fruntime%2Fdownload%2F%40babel%2Fruntime-7.13.10.tgz#47d42a57b6095f4468da440388fdbad8bebf0d7d" + integrity sha1-R9QqV7YJX0Ro2kQDiP262L6/DX0= + dependencies: + regenerator-runtime "^0.13.4" + +"@babel/runtime@^7.13.10": + version "7.14.0" + resolved "https://registry.nlark.com/@babel/runtime/download/@babel/runtime-7.14.0.tgz?cache=0&sync_timestamp=1619727389508&other_urls=https%3A%2F%2Fregistry.nlark.com%2F%40babel%2Fruntime%2Fdownload%2F%40babel%2Fruntime-7.14.0.tgz#46794bc20b612c5f75e62dd071e24dfd95f1cbe6" + integrity sha1-RnlLwgthLF915i3QceJN/ZXxy+Y= + dependencies: + regenerator-runtime "^0.13.4" + +"@babel/template@^7.0.0", "@babel/template@^7.12.13": + version "7.12.13" + resolved "https://registry.npm.taobao.org/@babel/template/download/@babel/template-7.12.13.tgz?cache=0&sync_timestamp=1612314730561&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Ftemplate%2Fdownload%2F%40babel%2Ftemplate-7.12.13.tgz#530265be8a2589dbb37523844c5bcb55947fb327" + integrity sha1-UwJlvooliduzdSOETFvLVZR/syc= + dependencies: + "@babel/code-frame" "^7.12.13" + "@babel/parser" "^7.12.13" + "@babel/types" "^7.12.13" + +"@babel/traverse@^7.0.0", "@babel/traverse@^7.13.0", "@babel/traverse@^7.13.13", "@babel/traverse@^7.13.15": + version "7.13.15" + resolved "https://registry.npm.taobao.org/@babel/traverse/download/@babel/traverse-7.13.15.tgz#c38bf7679334ddd4028e8e1f7b3aa5019f0dada7" + integrity sha1-w4v3Z5M03dQCjo4fezqlAZ8Nrac= + dependencies: + "@babel/code-frame" "^7.12.13" + "@babel/generator" "^7.13.9" + "@babel/helper-function-name" "^7.12.13" + "@babel/helper-split-export-declaration" "^7.12.13" + "@babel/parser" "^7.13.15" + "@babel/types" "^7.13.14" + debug "^4.1.0" + globals "^11.1.0" + +"@babel/types@^7.0.0", "@babel/types@^7.12.1", "@babel/types@^7.12.13", "@babel/types@^7.13.0", "@babel/types@^7.13.12", "@babel/types@^7.13.14", "@babel/types@^7.4.4": + version "7.13.14" + resolved "https://registry.npm.taobao.org/@babel/types/download/@babel/types-7.13.14.tgz?cache=0&sync_timestamp=1617027453341&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40babel%2Ftypes%2Fdownload%2F%40babel%2Ftypes-7.13.14.tgz#c35a4abb15c7cd45a2746d78ab328e362cbace0d" + integrity sha1-w1pKuxXHzUWidG14qzKONiy6zg0= + dependencies: + "@babel/helper-validator-identifier" "^7.12.11" + lodash "^4.17.19" + to-fast-properties "^2.0.0" + +"@babel/types@^7.15.0": + version "7.15.4" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.15.4.tgz#74eeb86dbd6748d2741396557b9860e57fce0a0d" + integrity sha512-0f1HJFuGmmbrKTCZtbm3cU+b/AqdEYk5toj5iQur58xkVMlS0JWaKxTBSmCXd47uiN7vbcozAupm6Mvs80GNhw== + dependencies: + "@babel/helper-validator-identifier" "^7.14.9" + to-fast-properties "^2.0.0" + +"@ctrl/tinycolor@^3.3.1": + version "3.4.0" + resolved "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.4.0.tgz#c3c5ae543c897caa9c2a68630bed355be5f9990f" + integrity sha512-JZButFdZ1+/xAfpguQHoabIXkcqRRKpMrWKBkpEZZyxfY9C1DpADFB8PEqGSTeFr135SaTRfKqGKx5xSCLI7ZQ== + +"@hapi/address@2.x.x": + version "2.1.4" + resolved "https://registry.npm.taobao.org/@hapi/address/download/@hapi/address-2.1.4.tgz?cache=0&sync_timestamp=1603524710662&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40hapi%2Faddress%2Fdownload%2F%40hapi%2Faddress-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" + integrity sha1-XWftQ/P9QaadS5/3tW58DR0KgeU= + +"@hapi/bourne@1.x.x": + version "1.3.2" + resolved "https://registry.npm.taobao.org/@hapi/bourne/download/@hapi/bourne-1.3.2.tgz?cache=0&sync_timestamp=1593915150444&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40hapi%2Fbourne%2Fdownload%2F%40hapi%2Fbourne-1.3.2.tgz#0a7095adea067243ce3283e1b56b8a8f453b242a" + integrity sha1-CnCVreoGckPOMoPhtWuKj0U7JCo= + +"@hapi/hoek@8.x.x", "@hapi/hoek@^8.3.0": + version "8.5.1" + resolved "https://registry.npm.taobao.org/@hapi/hoek/download/@hapi/hoek-8.5.1.tgz?cache=0&sync_timestamp=1609086954944&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40hapi%2Fhoek%2Fdownload%2F%40hapi%2Fhoek-8.5.1.tgz#fde96064ca446dec8c55a8c2f130957b070c6e06" + integrity sha1-/elgZMpEbeyMVajC8TCVewcMbgY= + +"@hapi/joi@^15.0.1": + version "15.1.1" + resolved "https://registry.npm.taobao.org/@hapi/joi/download/@hapi/joi-15.1.1.tgz#c675b8a71296f02833f8d6d243b34c57b8ce19d7" + integrity sha1-xnW4pxKW8Cgz+NbSQ7NMV7jOGdc= + dependencies: + "@hapi/address" "2.x.x" + "@hapi/bourne" "1.x.x" + "@hapi/hoek" "8.x.x" + "@hapi/topo" "3.x.x" + +"@hapi/topo@3.x.x": + version "3.1.6" + resolved "https://registry.npm.taobao.org/@hapi/topo/download/@hapi/topo-3.1.6.tgz#68d935fa3eae7fdd5ab0d7f953f3205d8b2bfc29" + integrity sha1-aNk1+j6uf91asNf5U/MgXYsr/Ck= + dependencies: + "@hapi/hoek" "^8.3.0" + +"@intervolga/optimize-cssnano-plugin@^1.0.5": + version "1.0.6" + resolved "https://registry.npm.taobao.org/@intervolga/optimize-cssnano-plugin/download/@intervolga/optimize-cssnano-plugin-1.0.6.tgz#be7c7846128b88f6a9b1d1261a0ad06eb5c0fdf8" + integrity sha1-vnx4RhKLiPapsdEmGgrQbrXA/fg= + dependencies: + cssnano "^4.0.0" + cssnano-preset-default "^4.0.0" + postcss "^7.0.0" + +"@mrmlnc/readdir-enhanced@^2.2.1": + version "2.2.1" + resolved "https://registry.npm.taobao.org/@mrmlnc/readdir-enhanced/download/@mrmlnc/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde" + integrity sha1-UkryQNGjYFJ7cwR17PoTRKpUDd4= + dependencies: + call-me-maybe "^1.0.1" + glob-to-regexp "^0.3.0" + +"@nodelib/fs.stat@^1.1.2": + version "1.1.3" + resolved "https://registry.npm.taobao.org/@nodelib/fs.stat/download/@nodelib/fs.stat-1.1.3.tgz?cache=0&sync_timestamp=1609074429033&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40nodelib%2Ffs.stat%2Fdownload%2F%40nodelib%2Ffs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b" + integrity sha1-K1o6s/kYzKSKjHVMCBaOPwPrphs= + +"@simonwep/pickr@~1.8.0": + version "1.8.0" + resolved "https://registry.npmjs.org/@simonwep/pickr/-/pickr-1.8.0.tgz#adbff9a4f7f0e59dec9946508c5e481b7abae0f8" + integrity sha512-VaSD7TwktOsro5nQ/FjRx5JAJ09k5CNfGRHacgVRxeVPolUQwelz1SjL8HAOKZwTSmcnIObptpHABQS4zgN7sw== + dependencies: + core-js "^3.8.0" + nanopop "^2.1.0" + +"@soda/friendly-errors-webpack-plugin@^1.7.1": + version "1.8.0" + resolved "https://registry.npm.taobao.org/@soda/friendly-errors-webpack-plugin/download/@soda/friendly-errors-webpack-plugin-1.8.0.tgz?cache=0&sync_timestamp=1607927364103&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40soda%2Ffriendly-errors-webpack-plugin%2Fdownload%2F%40soda%2Ffriendly-errors-webpack-plugin-1.8.0.tgz#84751d82a93019d5c92c0cf0e45ac59087cd2240" + integrity sha1-hHUdgqkwGdXJLAzw5FrFkIfNIkA= + dependencies: + chalk "^2.4.2" + error-stack-parser "^2.0.2" + string-width "^2.0.0" + strip-ansi "^5" + +"@soda/get-current-script@^1.0.0": + version "1.0.2" + resolved "https://registry.npm.taobao.org/@soda/get-current-script/download/@soda/get-current-script-1.0.2.tgz?cache=0&sync_timestamp=1592273265306&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40soda%2Fget-current-script%2Fdownload%2F%40soda%2Fget-current-script-1.0.2.tgz#a53515db25d8038374381b73af20bb4f2e508d87" + integrity sha1-pTUV2yXYA4N0OBtzryC7Ty5QjYc= + +"@types/anymatch@*": + version "1.3.1" + resolved "https://registry.npm.taobao.org/@types/anymatch/download/@types/anymatch-1.3.1.tgz?cache=0&sync_timestamp=1613378017677&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fanymatch%2Fdownload%2F%40types%2Fanymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a" + integrity sha1-M2utwb7sudrMOL6izzKt9ieoQho= + +"@types/body-parser@*": + version "1.19.0" + resolved "https://registry.npm.taobao.org/@types/body-parser/download/@types/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f" + integrity sha1-BoWzxH6zAG/+0RfN1VFkth+AU48= + dependencies: + "@types/connect" "*" + "@types/node" "*" + +"@types/connect-history-api-fallback@*": + version "1.3.4" + resolved "https://registry.npm.taobao.org/@types/connect-history-api-fallback/download/@types/connect-history-api-fallback-1.3.4.tgz#8c0f0e6e5d8252b699f5a662f51bdf82fd9d8bb8" + integrity sha1-jA8Obl2CUraZ9aZi9Rvfgv2di7g= + dependencies: + "@types/express-serve-static-core" "*" + "@types/node" "*" + +"@types/connect@*": + version "3.4.34" + resolved "https://registry.npm.taobao.org/@types/connect/download/@types/connect-3.4.34.tgz#170a40223a6d666006d93ca128af2beb1d9b1901" + integrity sha1-FwpAIjptZmAG2TyhKK8r6x2bGQE= + dependencies: + "@types/node" "*" + +"@types/estree@^0.0.48": + version "0.0.48" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.48.tgz#18dc8091b285df90db2f25aa7d906cfc394b7f74" + integrity sha512-LfZwXoGUDo0C3me81HXgkBg5CTQYb6xzEl+fNmbO4JdRiSKQ8A0GD1OBBvKAIsbCUgoyAty7m99GqqMQe784ew== + +"@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.18": + version "4.17.19" + resolved "https://registry.npm.taobao.org/@types/express-serve-static-core/download/@types/express-serve-static-core-4.17.19.tgz?cache=0&sync_timestamp=1615830719275&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fexpress-serve-static-core%2Fdownload%2F%40types%2Fexpress-serve-static-core-4.17.19.tgz#00acfc1632e729acac4f1530e9e16f6dd1508a1d" + integrity sha1-AKz8FjLnKaysTxUw6eFvbdFQih0= + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + +"@types/express@*": + version "4.17.11" + resolved "https://registry.npm.taobao.org/@types/express/download/@types/express-4.17.11.tgz#debe3caa6f8e5fcda96b47bd54e2f40c4ee59545" + integrity sha1-3r48qm+OX82pa0e9VOL0DE7llUU= + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.18" + "@types/qs" "*" + "@types/serve-static" "*" + +"@types/glob@^7.1.1": + version "7.1.3" + resolved "https://registry.npm.taobao.org/@types/glob/download/@types/glob-7.1.3.tgz#e6ba80f36b7daad2c685acd9266382e68985c183" + integrity sha1-5rqA82t9qtLGhazZJmOC5omFwYM= + dependencies: + "@types/minimatch" "*" + "@types/node" "*" + +"@types/http-proxy@^1.17.5": + version "1.17.5" + resolved "https://registry.npm.taobao.org/@types/http-proxy/download/@types/http-proxy-1.17.5.tgz#c203c5e6e9dc6820d27a40eb1e511c70a220423d" + integrity sha1-wgPF5uncaCDSekDrHlEccKIgQj0= + dependencies: + "@types/node" "*" + +"@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5": + version "7.0.7" + resolved "https://registry.npm.taobao.org/@types/json-schema/download/@types/json-schema-7.0.7.tgz?cache=0&sync_timestamp=1613379029028&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fjson-schema%2Fdownload%2F%40types%2Fjson-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad" + integrity sha1-mKmTUWyFnrDVxMjwmDF6nqaNua0= + +"@types/lodash@^4.14.165": + version "4.14.168" + resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008" + integrity sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q== + +"@types/mime@^1": + version "1.3.2" + resolved "https://registry.npm.taobao.org/@types/mime/download/@types/mime-1.3.2.tgz?cache=0&sync_timestamp=1613379303907&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fmime%2Fdownload%2F%40types%2Fmime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" + integrity sha1-k+Jb+e51/g/YC1lLxP6w6GIRG1o= + +"@types/minimatch@*": + version "3.0.4" + resolved "https://registry.npm.taobao.org/@types/minimatch/download/@types/minimatch-3.0.4.tgz#f0ec25dbf2f0e4b18647313ac031134ca5b24b21" + integrity sha1-8Owl2/Lw5LGGRzE6wDETTKWySyE= + +"@types/minimist@^1.2.0": + version "1.2.1" + resolved "https://registry.npm.taobao.org/@types/minimist/download/@types/minimist-1.2.1.tgz?cache=0&sync_timestamp=1613379305770&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fminimist%2Fdownload%2F%40types%2Fminimist-1.2.1.tgz#283f669ff76d7b8260df8ab7a4262cc83d988256" + integrity sha1-KD9mn/dte4Jg34q3pCYsyD2YglY= + +"@types/node@*": + version "14.14.37" + resolved "https://registry.npm.taobao.org/@types/node/download/@types/node-14.14.37.tgz?cache=0&sync_timestamp=1616803652202&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fnode%2Fdownload%2F%40types%2Fnode-14.14.37.tgz#a3dd8da4eb84a996c36e331df98d82abd76b516e" + integrity sha1-o92NpOuEqZbDbjMd+Y2Cq9drUW4= + +"@types/normalize-package-data@^2.4.0": + version "2.4.0" + resolved "https://registry.npm.taobao.org/@types/normalize-package-data/download/@types/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" + integrity sha1-5IbQ2XOW15vu3QpuM/RTT/a0lz4= + +"@types/parse-json@^4.0.0": + version "4.0.0" + resolved "https://registry.npm.taobao.org/@types/parse-json/download/@types/parse-json-4.0.0.tgz?cache=0&sync_timestamp=1613379435727&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fparse-json%2Fdownload%2F%40types%2Fparse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" + integrity sha1-L4u0QUNNFjs1+4/9zNcTiSf/uMA= + +"@types/q@^1.5.1": + version "1.5.4" + resolved "https://registry.npm.taobao.org/@types/q/download/@types/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24" + integrity sha1-FZJUFOCtLNdlv+9YhC9+JqesyyQ= + +"@types/qs@*": + version "6.9.6" + resolved "https://registry.npm.taobao.org/@types/qs/download/@types/qs-6.9.6.tgz?cache=0&sync_timestamp=1615109224464&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fqs%2Fdownload%2F%40types%2Fqs-6.9.6.tgz#df9c3c8b31a247ec315e6996566be3171df4b3b1" + integrity sha1-35w8izGiR+wxXmmWVmvjFx30s7E= + +"@types/range-parser@*": + version "1.2.3" + resolved "https://registry.npm.taobao.org/@types/range-parser/download/@types/range-parser-1.2.3.tgz?cache=0&sync_timestamp=1613379868458&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Frange-parser%2Fdownload%2F%40types%2Frange-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" + integrity sha1-fuMwunyq+5gJC+zoal7kQRWQTCw= + +"@types/serve-static@*": + version "1.13.9" + resolved "https://registry.npm.taobao.org/@types/serve-static/download/@types/serve-static-1.13.9.tgz?cache=0&sync_timestamp=1613384362265&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fserve-static%2Fdownload%2F%40types%2Fserve-static-1.13.9.tgz#aacf28a85a05ee29a11fb7c3ead935ac56f33e4e" + integrity sha1-qs8oqFoF7imhH7fD6tk1rFbzPk4= + dependencies: + "@types/mime" "^1" + "@types/node" "*" + +"@types/sharedb@^1.0.14": + version "1.0.15" + resolved "https://registry.npmjs.org/@types/sharedb/-/sharedb-1.0.15.tgz#59612db21e9e0dc7db1c5e93fdfff671e778d4b6" + integrity sha512-cYg9cXdakGyeo9lhwefh9lcwGTgHHQ0pJpG81sqPz0Bl0bzqxqMj8Up2TQDxnYelYB+tT7aGSwsGOpkA87IdNg== + +"@types/source-list-map@*": + version "0.1.2" + resolved "https://registry.npm.taobao.org/@types/source-list-map/download/@types/source-list-map-0.1.2.tgz?cache=0&sync_timestamp=1613384391241&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fsource-list-map%2Fdownload%2F%40types%2Fsource-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" + integrity sha1-AHiDYGP/rxdBI0m7o2QIfgrALsk= + +"@types/tapable@^1": + version "1.0.7" + resolved "https://registry.npm.taobao.org/@types/tapable/download/@types/tapable-1.0.7.tgz?cache=0&sync_timestamp=1617127724168&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Ftapable%2Fdownload%2F%40types%2Ftapable-1.0.7.tgz#545158342f949e8fd3bfd813224971ecddc3fac4" + integrity sha1-VFFYNC+Uno/Tv9gTIklx7N3D+sQ= + +"@types/uglify-js@*": + version "3.13.0" + resolved "https://registry.npm.taobao.org/@types/uglify-js/download/@types/uglify-js-3.13.0.tgz?cache=0&sync_timestamp=1615111821481&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fuglify-js%2Fdownload%2F%40types%2Fuglify-js-3.13.0.tgz#1cad8df1fb0b143c5aba08de5712ea9d1ff71124" + integrity sha1-HK2N8fsLFDxaugjeVxLqnR/3ESQ= + dependencies: + source-map "^0.6.1" + +"@types/webpack-dev-server@^3.11.0": + version "3.11.3" + resolved "https://registry.npm.taobao.org/@types/webpack-dev-server/download/@types/webpack-dev-server-3.11.3.tgz#237e26d87651cf95490dcd356f568c8c84016177" + integrity sha1-I34m2HZRz5VJDc01b1aMjIQBYXc= + dependencies: + "@types/connect-history-api-fallback" "*" + "@types/express" "*" + "@types/serve-static" "*" + "@types/webpack" "^4" + http-proxy-middleware "^1.0.0" + +"@types/webpack-env@^1.15.2": + version "1.16.0" + resolved "https://registry.npm.taobao.org/@types/webpack-env/download/@types/webpack-env-1.16.0.tgz#8c0a9435dfa7b3b1be76562f3070efb3f92637b4" + integrity sha1-jAqUNd+ns7G+dlYvMHDvs/kmN7Q= + +"@types/webpack-sources@*": + version "2.1.0" + resolved "https://registry.npm.taobao.org/@types/webpack-sources/download/@types/webpack-sources-2.1.0.tgz#8882b0bd62d1e0ce62f183d0d01b72e6e82e8c10" + integrity sha1-iIKwvWLR4M5i8YPQ0Bty5ugujBA= + dependencies: + "@types/node" "*" + "@types/source-list-map" "*" + source-map "^0.7.3" + +"@types/webpack@^4", "@types/webpack@^4.0.0": + version "4.41.27" + resolved "https://registry.npm.taobao.org/@types/webpack/download/@types/webpack-4.41.27.tgz?cache=0&sync_timestamp=1617056643655&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40types%2Fwebpack%2Fdownload%2F%40types%2Fwebpack-4.41.27.tgz#f47da488c8037e7f1b2dbf2714fbbacb61ec0ffc" + integrity sha1-9H2kiMgDfn8bLb8nFPu6y2HsD/w= + dependencies: + "@types/anymatch" "*" + "@types/node" "*" + "@types/tapable" "^1" + "@types/uglify-js" "*" + "@types/webpack-sources" "*" + source-map "^0.6.0" + +"@vue/babel-helper-vue-jsx-merge-props@^1.2.1": + version "1.2.1" + resolved "https://registry.npm.taobao.org/@vue/babel-helper-vue-jsx-merge-props/download/@vue/babel-helper-vue-jsx-merge-props-1.2.1.tgz?cache=0&sync_timestamp=1602851135129&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fbabel-helper-vue-jsx-merge-props%2Fdownload%2F%40vue%2Fbabel-helper-vue-jsx-merge-props-1.2.1.tgz#31624a7a505fb14da1d58023725a4c5f270e6a81" + integrity sha1-MWJKelBfsU2h1YAjclpMXycOaoE= + +"@vue/babel-helper-vue-transform-on@^1.0.2": + version "1.0.2" + resolved "https://registry.npm.taobao.org/@vue/babel-helper-vue-transform-on/download/@vue/babel-helper-vue-transform-on-1.0.2.tgz?cache=0&sync_timestamp=1610812663852&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fbabel-helper-vue-transform-on%2Fdownload%2F%40vue%2Fbabel-helper-vue-transform-on-1.0.2.tgz#9b9c691cd06fc855221a2475c3cc831d774bc7dc" + integrity sha1-m5xpHNBvyFUiGiR1w8yDHXdLx9w= + +"@vue/babel-plugin-jsx@^1.0.3": + version "1.0.4" + resolved "https://registry.npm.taobao.org/@vue/babel-plugin-jsx/download/@vue/babel-plugin-jsx-1.0.4.tgz#077826ca0eccd77cb6ad698254f5821ded5c5189" + integrity sha1-B3gmyg7M13y2rWmCVPWCHe1cUYk= + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@babel/plugin-syntax-jsx" "^7.0.0" + "@babel/template" "^7.0.0" + "@babel/traverse" "^7.0.0" + "@babel/types" "^7.0.0" + "@vue/babel-helper-vue-transform-on" "^1.0.2" + camelcase "^6.0.0" + html-tags "^3.1.0" + svg-tags "^1.0.0" + +"@vue/babel-plugin-transform-vue-jsx@^1.2.1": + version "1.2.1" + resolved "https://registry.npm.taobao.org/@vue/babel-plugin-transform-vue-jsx/download/@vue/babel-plugin-transform-vue-jsx-1.2.1.tgz?cache=0&sync_timestamp=1602851234609&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fbabel-plugin-transform-vue-jsx%2Fdownload%2F%40vue%2Fbabel-plugin-transform-vue-jsx-1.2.1.tgz#646046c652c2f0242727f34519d917b064041ed7" + integrity sha1-ZGBGxlLC8CQnJ/NFGdkXsGQEHtc= + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@babel/plugin-syntax-jsx" "^7.2.0" + "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1" + html-tags "^2.0.0" + lodash.kebabcase "^4.1.1" + svg-tags "^1.0.0" + +"@vue/babel-preset-app@^4.5.12": + version "4.5.12" + resolved "https://registry.npm.taobao.org/@vue/babel-preset-app/download/@vue/babel-preset-app-4.5.12.tgz?cache=0&sync_timestamp=1616590585748&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fbabel-preset-app%2Fdownload%2F%40vue%2Fbabel-preset-app-4.5.12.tgz#c3a23cf33f6e5ea30536f13c0f9b1fc7e028b1c1" + integrity sha1-w6I88z9uXqMFNvE8D5sfx+AoscE= + dependencies: + "@babel/core" "^7.11.0" + "@babel/helper-compilation-targets" "^7.9.6" + "@babel/helper-module-imports" "^7.8.3" + "@babel/plugin-proposal-class-properties" "^7.8.3" + "@babel/plugin-proposal-decorators" "^7.8.3" + "@babel/plugin-syntax-dynamic-import" "^7.8.3" + "@babel/plugin-syntax-jsx" "^7.8.3" + "@babel/plugin-transform-runtime" "^7.11.0" + "@babel/preset-env" "^7.11.0" + "@babel/runtime" "^7.11.0" + "@vue/babel-plugin-jsx" "^1.0.3" + "@vue/babel-preset-jsx" "^1.2.4" + babel-plugin-dynamic-import-node "^2.3.3" + core-js "^3.6.5" + core-js-compat "^3.6.5" + semver "^6.1.0" + +"@vue/babel-preset-jsx@^1.2.4": + version "1.2.4" + resolved "https://registry.npm.taobao.org/@vue/babel-preset-jsx/download/@vue/babel-preset-jsx-1.2.4.tgz?cache=0&sync_timestamp=1603806812399&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fbabel-preset-jsx%2Fdownload%2F%40vue%2Fbabel-preset-jsx-1.2.4.tgz#92fea79db6f13b01e80d3a0099e2924bdcbe4e87" + integrity sha1-kv6nnbbxOwHoDToAmeKSS9y+Toc= + dependencies: + "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1" + "@vue/babel-plugin-transform-vue-jsx" "^1.2.1" + "@vue/babel-sugar-composition-api-inject-h" "^1.2.1" + "@vue/babel-sugar-composition-api-render-instance" "^1.2.4" + "@vue/babel-sugar-functional-vue" "^1.2.2" + "@vue/babel-sugar-inject-h" "^1.2.2" + "@vue/babel-sugar-v-model" "^1.2.3" + "@vue/babel-sugar-v-on" "^1.2.3" + +"@vue/babel-sugar-composition-api-inject-h@^1.2.1": + version "1.2.1" + resolved "https://registry.npm.taobao.org/@vue/babel-sugar-composition-api-inject-h/download/@vue/babel-sugar-composition-api-inject-h-1.2.1.tgz?cache=0&sync_timestamp=1602851126644&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fbabel-sugar-composition-api-inject-h%2Fdownload%2F%40vue%2Fbabel-sugar-composition-api-inject-h-1.2.1.tgz#05d6e0c432710e37582b2be9a6049b689b6f03eb" + integrity sha1-BdbgxDJxDjdYKyvppgSbaJtvA+s= + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@vue/babel-sugar-composition-api-render-instance@^1.2.4": + version "1.2.4" + resolved "https://registry.npm.taobao.org/@vue/babel-sugar-composition-api-render-instance/download/@vue/babel-sugar-composition-api-render-instance-1.2.4.tgz#e4cbc6997c344fac271785ad7a29325c51d68d19" + integrity sha1-5MvGmXw0T6wnF4WteikyXFHWjRk= + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@vue/babel-sugar-functional-vue@^1.2.2": + version "1.2.2" + resolved "https://registry.npm.taobao.org/@vue/babel-sugar-functional-vue/download/@vue/babel-sugar-functional-vue-1.2.2.tgz?cache=0&sync_timestamp=1602929516892&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fbabel-sugar-functional-vue%2Fdownload%2F%40vue%2Fbabel-sugar-functional-vue-1.2.2.tgz#267a9ac8d787c96edbf03ce3f392c49da9bd2658" + integrity sha1-JnqayNeHyW7b8Dzj85LEnam9Jlg= + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@vue/babel-sugar-inject-h@^1.2.2": + version "1.2.2" + resolved "https://registry.npm.taobao.org/@vue/babel-sugar-inject-h/download/@vue/babel-sugar-inject-h-1.2.2.tgz?cache=0&sync_timestamp=1602929516704&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fbabel-sugar-inject-h%2Fdownload%2F%40vue%2Fbabel-sugar-inject-h-1.2.2.tgz#d738d3c893367ec8491dcbb669b000919293e3aa" + integrity sha1-1zjTyJM2fshJHcu2abAAkZKT46o= + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@vue/babel-sugar-v-model@^1.2.3": + version "1.2.3" + resolved "https://registry.npm.taobao.org/@vue/babel-sugar-v-model/download/@vue/babel-sugar-v-model-1.2.3.tgz?cache=0&sync_timestamp=1603182448903&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fbabel-sugar-v-model%2Fdownload%2F%40vue%2Fbabel-sugar-v-model-1.2.3.tgz#fa1f29ba51ebf0aa1a6c35fa66d539bc459a18f2" + integrity sha1-+h8pulHr8KoabDX6ZtU5vEWaGPI= + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1" + "@vue/babel-plugin-transform-vue-jsx" "^1.2.1" + camelcase "^5.0.0" + html-tags "^2.0.0" + svg-tags "^1.0.0" + +"@vue/babel-sugar-v-on@^1.2.3": + version "1.2.3" + resolved "https://registry.npm.taobao.org/@vue/babel-sugar-v-on/download/@vue/babel-sugar-v-on-1.2.3.tgz?cache=0&sync_timestamp=1603181872947&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fbabel-sugar-v-on%2Fdownload%2F%40vue%2Fbabel-sugar-v-on-1.2.3.tgz#342367178586a69f392f04bfba32021d02913ada" + integrity sha1-NCNnF4WGpp85LwS/ujICHQKROto= + dependencies: + "@babel/plugin-syntax-jsx" "^7.2.0" + "@vue/babel-plugin-transform-vue-jsx" "^1.2.1" + camelcase "^5.0.0" + +"@vue/cli-overlay@^4.5.12": + version "4.5.12" + resolved "https://registry.npm.taobao.org/@vue/cli-overlay/download/@vue/cli-overlay-4.5.12.tgz?cache=0&sync_timestamp=1616590583742&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fcli-overlay%2Fdownload%2F%40vue%2Fcli-overlay-4.5.12.tgz#d5ae353abb187672204197dcd077a4367d4d4a24" + integrity sha1-1a41OrsYdnIgQZfc0HekNn1NSiQ= + +"@vue/cli-plugin-babel@~4.5.0": + version "4.5.12" + resolved "https://registry.npm.taobao.org/@vue/cli-plugin-babel/download/@vue/cli-plugin-babel-4.5.12.tgz?cache=0&sync_timestamp=1616590586425&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fcli-plugin-babel%2Fdownload%2F%40vue%2Fcli-plugin-babel-4.5.12.tgz#c9737d4079485ce9be07c463c81e1e33886c6219" + integrity sha1-yXN9QHlIXOm+B8RjyB4eM4hsYhk= + dependencies: + "@babel/core" "^7.11.0" + "@vue/babel-preset-app" "^4.5.12" + "@vue/cli-shared-utils" "^4.5.12" + babel-loader "^8.1.0" + cache-loader "^4.1.0" + thread-loader "^2.1.3" + webpack "^4.0.0" + +"@vue/cli-plugin-router@^4.5.12", "@vue/cli-plugin-router@~4.5.0": + version "4.5.12" + resolved "https://registry.npm.taobao.org/@vue/cli-plugin-router/download/@vue/cli-plugin-router-4.5.12.tgz?cache=0&sync_timestamp=1616590584435&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fcli-plugin-router%2Fdownload%2F%40vue%2Fcli-plugin-router-4.5.12.tgz#977c4b2b694cc03e9ef816112a5d58923493d0ac" + integrity sha1-l3xLK2lMwD6e+BYRKl1YkjST0Kw= + dependencies: + "@vue/cli-shared-utils" "^4.5.12" + +"@vue/cli-plugin-typescript@~4.5.0": + version "4.5.12" + resolved "https://registry.npm.taobao.org/@vue/cli-plugin-typescript/download/@vue/cli-plugin-typescript-4.5.12.tgz?cache=0&sync_timestamp=1616590664020&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fcli-plugin-typescript%2Fdownload%2F%40vue%2Fcli-plugin-typescript-4.5.12.tgz#4186974541a0305e27e769370c981b86a44c2935" + integrity sha1-QYaXRUGgMF4n52k3DJgbhqRMKTU= + dependencies: + "@types/webpack-env" "^1.15.2" + "@vue/cli-shared-utils" "^4.5.12" + cache-loader "^4.1.0" + fork-ts-checker-webpack-plugin "^3.1.1" + globby "^9.2.0" + thread-loader "^2.1.3" + ts-loader "^6.2.2" + tslint "^5.20.1" + webpack "^4.0.0" + yorkie "^2.0.0" + optionalDependencies: + fork-ts-checker-webpack-plugin-v5 "npm:fork-ts-checker-webpack-plugin@^5.0.11" + +"@vue/cli-plugin-vuex@^4.5.12": + version "4.5.12" + resolved "https://registry.npm.taobao.org/@vue/cli-plugin-vuex/download/@vue/cli-plugin-vuex-4.5.12.tgz?cache=0&sync_timestamp=1616590583525&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fcli-plugin-vuex%2Fdownload%2F%40vue%2Fcli-plugin-vuex-4.5.12.tgz#f7fbe177ee7176f055b546e9e74472f9d9177626" + integrity sha1-9/vhd+5xdvBVtUbp50Ry+dkXdiY= + +"@vue/cli-service@~4.5.0": + version "4.5.12" + resolved "https://registry.npm.taobao.org/@vue/cli-service/download/@vue/cli-service-4.5.12.tgz?cache=0&sync_timestamp=1616590587524&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fcli-service%2Fdownload%2F%40vue%2Fcli-service-4.5.12.tgz#483aef7dc4e2a7b02b7f224f0a2ef7cea910e033" + integrity sha1-SDrvfcTip7ArfyJPCi73zqkQ4DM= + dependencies: + "@intervolga/optimize-cssnano-plugin" "^1.0.5" + "@soda/friendly-errors-webpack-plugin" "^1.7.1" + "@soda/get-current-script" "^1.0.0" + "@types/minimist" "^1.2.0" + "@types/webpack" "^4.0.0" + "@types/webpack-dev-server" "^3.11.0" + "@vue/cli-overlay" "^4.5.12" + "@vue/cli-plugin-router" "^4.5.12" + "@vue/cli-plugin-vuex" "^4.5.12" + "@vue/cli-shared-utils" "^4.5.12" + "@vue/component-compiler-utils" "^3.1.2" + "@vue/preload-webpack-plugin" "^1.1.0" + "@vue/web-component-wrapper" "^1.2.0" + acorn "^7.4.0" + acorn-walk "^7.1.1" + address "^1.1.2" + autoprefixer "^9.8.6" + browserslist "^4.12.0" + cache-loader "^4.1.0" + case-sensitive-paths-webpack-plugin "^2.3.0" + cli-highlight "^2.1.4" + clipboardy "^2.3.0" + cliui "^6.0.0" + copy-webpack-plugin "^5.1.1" + css-loader "^3.5.3" + cssnano "^4.1.10" + debug "^4.1.1" + default-gateway "^5.0.5" + dotenv "^8.2.0" + dotenv-expand "^5.1.0" + file-loader "^4.2.0" + fs-extra "^7.0.1" + globby "^9.2.0" + hash-sum "^2.0.0" + html-webpack-plugin "^3.2.0" + launch-editor-middleware "^2.2.1" + lodash.defaultsdeep "^4.6.1" + lodash.mapvalues "^4.6.0" + lodash.transform "^4.6.0" + mini-css-extract-plugin "^0.9.0" + minimist "^1.2.5" + pnp-webpack-plugin "^1.6.4" + portfinder "^1.0.26" + postcss-loader "^3.0.0" + ssri "^7.1.0" + terser-webpack-plugin "^2.3.6" + thread-loader "^2.1.3" + url-loader "^2.2.0" + vue-loader "^15.9.2" + vue-style-loader "^4.1.2" + webpack "^4.0.0" + webpack-bundle-analyzer "^3.8.0" + webpack-chain "^6.4.0" + webpack-dev-server "^3.11.0" + webpack-merge "^4.2.2" + optionalDependencies: + vue-loader-v16 "npm:vue-loader@^16.1.0" + +"@vue/cli-shared-utils@^4.5.12": + version "4.5.12" + resolved "https://registry.npm.taobao.org/@vue/cli-shared-utils/download/@vue/cli-shared-utils-4.5.12.tgz?cache=0&sync_timestamp=1616590584233&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fcli-shared-utils%2Fdownload%2F%40vue%2Fcli-shared-utils-4.5.12.tgz#0e0693d488336d284ffa658ff33b1ea22927d065" + integrity sha1-DgaT1IgzbShP+mWP8zseoikn0GU= + dependencies: + "@hapi/joi" "^15.0.1" + chalk "^2.4.2" + execa "^1.0.0" + launch-editor "^2.2.1" + lru-cache "^5.1.1" + node-ipc "^9.1.1" + open "^6.3.0" + ora "^3.4.0" + read-pkg "^5.1.1" + request "^2.88.2" + semver "^6.1.0" + strip-ansi "^6.0.0" + +"@vue/compiler-core@3.2.9": + version "3.2.9" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.2.9.tgz#874d04d3e4de98f3a60769db7fa47e041bfca490" + integrity sha512-smi76K+pg1LeltWSLoOI9GqXdH1oK13sd+SrO/XTdyfvf2dOQn5zE0o+C4B4Wj9M8Jd66Z5dEfGEldvcOutixQ== + dependencies: + "@babel/parser" "^7.15.0" + "@babel/types" "^7.15.0" + "@vue/shared" "3.2.9" + estree-walker "^2.0.2" + source-map "^0.6.1" + +"@vue/compiler-dom@3.2.9": + version "3.2.9" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.2.9.tgz#e42b2bc285366224a1738f7ed6648d4260cbbbef" + integrity sha512-7GAMoCyBGMzMsbzxxFFCQMdblg10NRXkgFFhkjLJ4djItL0hyeO8t9wSLmaDaJejo1xjK8lm+4xPAUwvHuC8cA== + dependencies: + "@vue/compiler-core" "3.2.9" + "@vue/shared" "3.2.9" + +"@vue/compiler-sfc@^3.2.9": + version "3.2.9" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.2.9.tgz#82c0cae99625a4e5b9d998cc9ef5e0c26df2a8e9" + integrity sha512-egQCUOvb+3fz7sNx5F85ysPslbbtHiw0l2hOlqSGx5S7vQ8nzPvhxjy/VATYbd4lHZGQltA/3U090ncZu8M6hQ== + dependencies: + "@babel/parser" "^7.15.0" + "@babel/types" "^7.15.0" + "@types/estree" "^0.0.48" + "@vue/compiler-core" "3.2.9" + "@vue/compiler-dom" "3.2.9" + "@vue/compiler-ssr" "3.2.9" + "@vue/ref-transform" "3.2.9" + "@vue/shared" "3.2.9" + consolidate "^0.16.0" + estree-walker "^2.0.2" + hash-sum "^2.0.0" + lru-cache "^5.1.1" + magic-string "^0.25.7" + merge-source-map "^1.1.0" + postcss "^8.1.10" + postcss-modules "^4.0.0" + postcss-selector-parser "^6.0.4" + source-map "^0.6.1" + +"@vue/compiler-ssr@3.2.9": + version "3.2.9" + resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.2.9.tgz#067a9e5ee381c6561d72663c4a1ce42afe33b9bd" + integrity sha512-3QhSnpmMKvM67VQeUttDVy6+BeWlpo1mTqSnEl2x1bIEXNfZ6aIWeV42YmItXfiJ0j+JZI/29sDiEl3QLhAHow== + dependencies: + "@vue/compiler-dom" "3.2.9" + "@vue/shared" "3.2.9" + +"@vue/component-compiler-utils@^3.1.0", "@vue/component-compiler-utils@^3.1.2": + version "3.2.0" + resolved "https://registry.npm.taobao.org/@vue/component-compiler-utils/download/@vue/component-compiler-utils-3.2.0.tgz#8f85182ceed28e9b3c75313de669f83166d11e5d" + integrity sha1-j4UYLO7Sjps8dTE95mn4MWbRHl0= + dependencies: + consolidate "^0.15.1" + hash-sum "^1.0.2" + lru-cache "^4.1.2" + merge-source-map "^1.1.0" + postcss "^7.0.14" + postcss-selector-parser "^6.0.2" + source-map "~0.6.1" + vue-template-es2015-compiler "^1.9.0" + optionalDependencies: + prettier "^1.18.2" + +"@vue/preload-webpack-plugin@^1.1.0": + version "1.1.2" + resolved "https://registry.npm.taobao.org/@vue/preload-webpack-plugin/download/@vue/preload-webpack-plugin-1.1.2.tgz?cache=0&sync_timestamp=1613215046917&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40vue%2Fpreload-webpack-plugin%2Fdownload%2F%40vue%2Fpreload-webpack-plugin-1.1.2.tgz#ceb924b4ecb3b9c43871c7a429a02f8423e621ab" + integrity sha1-zrkktOyzucQ4ccekKaAvhCPmIas= + +"@vue/reactivity@3.2.9": + version "3.2.9" + resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.9.tgz#f4ec61519f4779224d98a23ac07b481d95687cae" + integrity sha512-V0me78KlETt/9u3S9BoViEZNCFr/fDWodLq/KqYbFj+YySnCDD0clmjgBSQvIM63D+z3iUXftJyv08vAjlWrvw== + dependencies: + "@vue/shared" "3.2.9" + +"@vue/ref-transform@3.2.9": + version "3.2.9" + resolved "https://registry.yarnpkg.com/@vue/ref-transform/-/ref-transform-3.2.9.tgz#23af9e2955a6faef7f46bb367494181ad42d1948" + integrity sha512-sKNJZlVWW9s0+Xy8WVaGZTX8jVXvkY85ooDTv21ryAS0gzQ4PzHUvqQFQSgtZSbszU2/Qpi13u2h5oZBBYFm8g== + dependencies: + "@babel/parser" "^7.15.0" + "@vue/compiler-core" "3.2.9" + "@vue/shared" "3.2.9" + estree-walker "^2.0.2" + magic-string "^0.25.7" + +"@vue/runtime-core@3.2.9": + version "3.2.9" + resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.2.9.tgz#32854c9d9853aa2075fcecfc762b5f033a6bae1e" + integrity sha512-CaSjy/kBrSFtSwyW2sY7RTN5YGmcDg8xLzKmFmIrkI9AXv/YjViQjSKUNHTAhnGq0K739vhFO4r3meBNEWqiOw== + dependencies: + "@vue/reactivity" "3.2.9" + "@vue/shared" "3.2.9" + +"@vue/runtime-dom@3.2.9": + version "3.2.9" + resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.2.9.tgz#397572a142db2772fb4b7f0a2bc06b5486e5db81" + integrity sha512-Vi8eOaP7/8NYSWIl8/klPtkiI+IQq/gPAI77U7PVoJ22tTcK/+9IIrMEN2TD+jUkHTRRIymMECEv+hWQT1Mo1g== + dependencies: + "@vue/runtime-core" "3.2.9" + "@vue/shared" "3.2.9" + csstype "^2.6.8" + +"@vue/shared@3.2.9": + version "3.2.9" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.9.tgz#44e44dbd82819997f192fb7dbdb90af5715dbf52" + integrity sha512-+CifxkLVhjKT14g/LMZil8//SdCzkMkS8VfRX0cqNJiFKK4AWvxj0KV1dhbr8czikY0DZUGQew3tRMRRChMGtA== + +"@vue/web-component-wrapper@^1.2.0": + version "1.3.0" + resolved "https://registry.npm.taobao.org/@vue/web-component-wrapper/download/@vue/web-component-wrapper-1.3.0.tgz#b6b40a7625429d2bd7c2281ddba601ed05dc7f1a" + integrity sha1-trQKdiVCnSvXwigd26YB7QXcfxo= + +"@webassemblyjs/ast@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/ast/download/@webassemblyjs/ast-1.9.0.tgz?cache=0&sync_timestamp=1610041386122&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40webassemblyjs%2Fast%2Fdownload%2F%40webassemblyjs%2Fast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964" + integrity sha1-vYUGBLQEJFmlpBzX0zjL7Wle2WQ= + dependencies: + "@webassemblyjs/helper-module-context" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/wast-parser" "1.9.0" + +"@webassemblyjs/floating-point-hex-parser@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/floating-point-hex-parser/download/@webassemblyjs/floating-point-hex-parser-1.9.0.tgz?cache=0&sync_timestamp=1610043633713&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40webassemblyjs%2Ffloating-point-hex-parser%2Fdownload%2F%40webassemblyjs%2Ffloating-point-hex-parser-1.9.0.tgz#3c3d3b271bddfc84deb00f71344438311d52ffb4" + integrity sha1-PD07Jxvd/ITesA9xNEQ4MR1S/7Q= + +"@webassemblyjs/helper-api-error@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/helper-api-error/download/@webassemblyjs/helper-api-error-1.9.0.tgz?cache=0&sync_timestamp=1610041385672&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40webassemblyjs%2Fhelper-api-error%2Fdownload%2F%40webassemblyjs%2Fhelper-api-error-1.9.0.tgz#203f676e333b96c9da2eeab3ccef33c45928b6a2" + integrity sha1-ID9nbjM7lsnaLuqzzO8zxFkotqI= + +"@webassemblyjs/helper-buffer@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/helper-buffer/download/@webassemblyjs/helper-buffer-1.9.0.tgz?cache=0&sync_timestamp=1610041385156&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40webassemblyjs%2Fhelper-buffer%2Fdownload%2F%40webassemblyjs%2Fhelper-buffer-1.9.0.tgz#a1442d269c5feb23fcbc9ef759dac3547f29de00" + integrity sha1-oUQtJpxf6yP8vJ73WdrDVH8p3gA= + +"@webassemblyjs/helper-code-frame@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/helper-code-frame/download/@webassemblyjs/helper-code-frame-1.9.0.tgz?cache=0&sync_timestamp=1610041387135&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40webassemblyjs%2Fhelper-code-frame%2Fdownload%2F%40webassemblyjs%2Fhelper-code-frame-1.9.0.tgz#647f8892cd2043a82ac0c8c5e75c36f1d9159f27" + integrity sha1-ZH+Iks0gQ6gqwMjF51w28dkVnyc= + dependencies: + "@webassemblyjs/wast-printer" "1.9.0" + +"@webassemblyjs/helper-fsm@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/helper-fsm/download/@webassemblyjs/helper-fsm-1.9.0.tgz?cache=0&sync_timestamp=1610041385398&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40webassemblyjs%2Fhelper-fsm%2Fdownload%2F%40webassemblyjs%2Fhelper-fsm-1.9.0.tgz#c05256b71244214671f4b08ec108ad63b70eddb8" + integrity sha1-wFJWtxJEIUZx9LCOwQitY7cO3bg= + +"@webassemblyjs/helper-module-context@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/helper-module-context/download/@webassemblyjs/helper-module-context-1.9.0.tgz#25d8884b76839871a08a6c6f806c3979ef712f07" + integrity sha1-JdiIS3aDmHGgimxvgGw5ee9xLwc= + dependencies: + "@webassemblyjs/ast" "1.9.0" + +"@webassemblyjs/helper-wasm-bytecode@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/helper-wasm-bytecode/download/@webassemblyjs/helper-wasm-bytecode-1.9.0.tgz?cache=0&sync_timestamp=1610041385277&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40webassemblyjs%2Fhelper-wasm-bytecode%2Fdownload%2F%40webassemblyjs%2Fhelper-wasm-bytecode-1.9.0.tgz#4fed8beac9b8c14f8c58b70d124d549dd1fe5790" + integrity sha1-T+2L6sm4wU+MWLcNEk1UndH+V5A= + +"@webassemblyjs/helper-wasm-section@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/helper-wasm-section/download/@webassemblyjs/helper-wasm-section-1.9.0.tgz?cache=0&sync_timestamp=1610041387398&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40webassemblyjs%2Fhelper-wasm-section%2Fdownload%2F%40webassemblyjs%2Fhelper-wasm-section-1.9.0.tgz#5a4138d5a6292ba18b04c5ae49717e4167965346" + integrity sha1-WkE41aYpK6GLBMWuSXF+QWeWU0Y= + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-buffer" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/wasm-gen" "1.9.0" + +"@webassemblyjs/ieee754@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/ieee754/download/@webassemblyjs/ieee754-1.9.0.tgz?cache=0&sync_timestamp=1610041385781&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40webassemblyjs%2Fieee754%2Fdownload%2F%40webassemblyjs%2Fieee754-1.9.0.tgz#15c7a0fbaae83fb26143bbacf6d6df1702ad39e4" + integrity sha1-Fceg+6roP7JhQ7us9tbfFwKtOeQ= + dependencies: + "@xtuc/ieee754" "^1.2.0" + +"@webassemblyjs/leb128@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/leb128/download/@webassemblyjs/leb128-1.9.0.tgz?cache=0&sync_timestamp=1610041385570&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40webassemblyjs%2Fleb128%2Fdownload%2F%40webassemblyjs%2Fleb128-1.9.0.tgz#f19ca0b76a6dc55623a09cffa769e838fa1e1c95" + integrity sha1-8Zygt2ptxVYjoJz/p2noOPoeHJU= + dependencies: + "@xtuc/long" "4.2.2" + +"@webassemblyjs/utf8@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/utf8/download/@webassemblyjs/utf8-1.9.0.tgz?cache=0&sync_timestamp=1610041385889&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40webassemblyjs%2Futf8%2Fdownload%2F%40webassemblyjs%2Futf8-1.9.0.tgz#04d33b636f78e6a6813227e82402f7637b6229ab" + integrity sha1-BNM7Y2945qaBMifoJAL3Y3tiKas= + +"@webassemblyjs/wasm-edit@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/wasm-edit/download/@webassemblyjs/wasm-edit-1.9.0.tgz?cache=0&sync_timestamp=1610041387713&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40webassemblyjs%2Fwasm-edit%2Fdownload%2F%40webassemblyjs%2Fwasm-edit-1.9.0.tgz#3fe6d79d3f0f922183aa86002c42dd256cfee9cf" + integrity sha1-P+bXnT8PkiGDqoYALELdJWz+6c8= + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-buffer" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/helper-wasm-section" "1.9.0" + "@webassemblyjs/wasm-gen" "1.9.0" + "@webassemblyjs/wasm-opt" "1.9.0" + "@webassemblyjs/wasm-parser" "1.9.0" + "@webassemblyjs/wast-printer" "1.9.0" + +"@webassemblyjs/wasm-gen@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/wasm-gen/download/@webassemblyjs/wasm-gen-1.9.0.tgz?cache=0&sync_timestamp=1610041387011&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40webassemblyjs%2Fwasm-gen%2Fdownload%2F%40webassemblyjs%2Fwasm-gen-1.9.0.tgz#50bc70ec68ded8e2763b01a1418bf43491a7a49c" + integrity sha1-ULxw7Gje2OJ2OwGhQYv0NJGnpJw= + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/ieee754" "1.9.0" + "@webassemblyjs/leb128" "1.9.0" + "@webassemblyjs/utf8" "1.9.0" + +"@webassemblyjs/wasm-opt@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/wasm-opt/download/@webassemblyjs/wasm-opt-1.9.0.tgz?cache=0&sync_timestamp=1610041387249&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40webassemblyjs%2Fwasm-opt%2Fdownload%2F%40webassemblyjs%2Fwasm-opt-1.9.0.tgz#2211181e5b31326443cc8112eb9f0b9028721a61" + integrity sha1-IhEYHlsxMmRDzIES658LkChyGmE= + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-buffer" "1.9.0" + "@webassemblyjs/wasm-gen" "1.9.0" + "@webassemblyjs/wasm-parser" "1.9.0" + +"@webassemblyjs/wasm-parser@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/wasm-parser/download/@webassemblyjs/wasm-parser-1.9.0.tgz?cache=0&sync_timestamp=1610041386641&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40webassemblyjs%2Fwasm-parser%2Fdownload%2F%40webassemblyjs%2Fwasm-parser-1.9.0.tgz#9d48e44826df4a6598294aa6c87469d642fff65e" + integrity sha1-nUjkSCbfSmWYKUqmyHRp1kL/9l4= + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-api-error" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/ieee754" "1.9.0" + "@webassemblyjs/leb128" "1.9.0" + "@webassemblyjs/utf8" "1.9.0" + +"@webassemblyjs/wast-parser@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/wast-parser/download/@webassemblyjs/wast-parser-1.9.0.tgz?cache=0&sync_timestamp=1610041213532&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40webassemblyjs%2Fwast-parser%2Fdownload%2F%40webassemblyjs%2Fwast-parser-1.9.0.tgz#3031115d79ac5bd261556cecc3fa90a3ef451914" + integrity sha1-MDERXXmsW9JhVWzsw/qQo+9FGRQ= + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/floating-point-hex-parser" "1.9.0" + "@webassemblyjs/helper-api-error" "1.9.0" + "@webassemblyjs/helper-code-frame" "1.9.0" + "@webassemblyjs/helper-fsm" "1.9.0" + "@xtuc/long" "4.2.2" + +"@webassemblyjs/wast-printer@1.9.0": + version "1.9.0" + resolved "https://registry.npm.taobao.org/@webassemblyjs/wast-printer/download/@webassemblyjs/wast-printer-1.9.0.tgz?cache=0&sync_timestamp=1610041386456&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2F%40webassemblyjs%2Fwast-printer%2Fdownload%2F%40webassemblyjs%2Fwast-printer-1.9.0.tgz#4935d54c85fef637b00ce9f52377451d00d47899" + integrity sha1-STXVTIX+9jewDOn1I3dFHQDUeJk= + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/wast-parser" "1.9.0" + "@xtuc/long" "4.2.2" + +"@xtuc/ieee754@^1.2.0": + version "1.2.0" + resolved "https://registry.npm.taobao.org/@xtuc/ieee754/download/@xtuc/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" + integrity sha1-7vAUoxRa5Hehy8AM0eVSM23Ot5A= + +"@xtuc/long@4.2.2": + version "4.2.2" + resolved "https://registry.npm.taobao.org/@xtuc/long/download/@xtuc/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" + integrity sha1-0pHGpOl5ibXGHZrPOWrk/hM6cY0= + +accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7: + version "1.3.7" + resolved "https://registry.npm.taobao.org/accepts/download/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" + integrity sha1-UxvHJlF6OytB+FACHGzBXqq1B80= + dependencies: + mime-types "~2.1.24" + negotiator "0.6.2" + +acorn-walk@^7.1.1: + version "7.2.0" + resolved "https://registry.npm.taobao.org/acorn-walk/download/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" + integrity sha1-DeiJpgEgOQmw++B7iTjcIdLpZ7w= + +acorn@^6.4.1: + version "6.4.2" + resolved "https://registry.npm.taobao.org/acorn/download/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6" + integrity sha1-NYZv1xBSjpLeEM8GAWSY5H454eY= + +acorn@^7.1.1, acorn@^7.4.0: + version "7.4.1" + resolved "https://registry.npm.taobao.org/acorn/download/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" + integrity sha1-/q7SVZc9LndVW4PbwIhRpsY1IPo= + +address@^1.1.2: + version "1.1.2" + resolved "https://registry.npm.taobao.org/address/download/address-1.1.2.tgz?cache=0&sync_timestamp=1593529661616&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Faddress%2Fdownload%2Faddress-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" + integrity sha1-vxEWycdYxRt6kz0pa3LCIe2UKLY= + +aggregate-error@^3.0.0: + version "3.1.0" + resolved "https://registry.npm.taobao.org/aggregate-error/download/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" + integrity sha1-kmcP9Q9TWb23o+DUDQ7DDFc3aHo= + dependencies: + clean-stack "^2.0.0" + indent-string "^4.0.0" + +ajv-errors@^1.0.0: + version "1.0.1" + resolved "https://registry.npm.taobao.org/ajv-errors/download/ajv-errors-1.0.1.tgz?cache=0&sync_timestamp=1616886640262&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fajv-errors%2Fdownload%2Fajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d" + integrity sha1-81mGrOuRr63sQQL72FAUlQzvpk0= + +ajv-keywords@^3.1.0, ajv-keywords@^3.4.1, ajv-keywords@^3.5.2: + version "3.5.2" + resolved "https://registry.npm.taobao.org/ajv-keywords/download/ajv-keywords-3.5.2.tgz?cache=0&sync_timestamp=1616882441894&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fajv-keywords%2Fdownload%2Fajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" + integrity sha1-MfKdpatuANHC0yms97WSlhTVAU0= + +ajv@^6.1.0, ajv@^6.10.2, ajv@^6.12.2, ajv@^6.12.3, ajv@^6.12.4: + version "6.12.6" + resolved "https://registry.npm.taobao.org/ajv/download/ajv-6.12.6.tgz?cache=0&sync_timestamp=1618159990556&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fajv%2Fdownload%2Fajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha1-uvWmLoArB9l3A0WG+MO69a3ybfQ= + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +alphanum-sort@^1.0.0: + version "1.0.2" + resolved "https://registry.npm.taobao.org/alphanum-sort/download/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" + integrity sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM= + +ansi-colors@^3.0.0: + version "3.2.4" + resolved "https://registry.npm.taobao.org/ansi-colors/download/ansi-colors-3.2.4.tgz?cache=0&sync_timestamp=1593529711167&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fansi-colors%2Fdownload%2Fansi-colors-3.2.4.tgz#e3a3da4bfbae6c86a9c285625de124a234026fbf" + integrity sha1-46PaS/uubIapwoViXeEkojQCb78= + +ansi-html@0.0.7: + version "0.0.7" + resolved "https://registry.npm.taobao.org/ansi-html/download/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" + integrity sha1-gTWEAhliqenm/QOflA0S9WynhZ4= + +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.npm.taobao.org/ansi-regex/download/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= + +ansi-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/ansi-regex/download/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" + integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= + +ansi-regex@^4.1.0: + version "4.1.0" + resolved "https://registry.npm.taobao.org/ansi-regex/download/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" + integrity sha1-i5+PCM8ay4Q3Vqg5yox+MWjFGZc= + +ansi-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.npm.taobao.org/ansi-regex/download/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" + integrity sha1-OIU59VF5vzkznIGvMKZU1p+Hy3U= + +ansi-styles@^2.2.1: + version "2.2.1" + resolved "https://registry.npm.taobao.org/ansi-styles/download/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" + integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4= + +ansi-styles@^3.2.0, ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.npm.taobao.org/ansi-styles/download/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha1-QfuyAkPlCxK+DwS43tvwdSDOhB0= + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.npm.taobao.org/ansi-styles/download/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha1-7dgDYornHATIWuegkG7a00tkiTc= + dependencies: + color-convert "^2.0.1" + +ant-design-vue@^2.2.6: + version "2.2.6" + resolved "https://registry.yarnpkg.com/ant-design-vue/-/ant-design-vue-2.2.6.tgz#2acf45ea8bb2bb8a0e48a83fa9c6a827fef5236f" + integrity sha512-WgZow4FtrsAZON01wv+ObuXWL1Elaq/fhPRdmOEfFx5f8azTDBYL75A8dVl59TNBKW8FdSGBTl9PZYzW5eO6Gw== + dependencies: + "@ant-design/icons-vue" "^6.0.0" + "@babel/runtime" "^7.10.5" + "@simonwep/pickr" "~1.8.0" + array-tree-filter "^2.1.0" + async-validator "^3.3.0" + dom-align "^1.12.1" + dom-scroll-into-view "^2.0.0" + lodash "^4.17.21" + lodash-es "^4.17.15" + moment "^2.27.0" + omit.js "^2.0.0" + resize-observer-polyfill "^1.5.1" + scroll-into-view-if-needed "^2.2.25" + shallow-equal "^1.0.0" + vue-types "^3.0.0" + warning "^4.0.0" + +any-promise@^1.0.0: + version "1.3.0" + resolved "https://registry.npm.taobao.org/any-promise/download/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" + integrity sha1-q8av7tzqUugJzcA3au0845Y10X8= + +anymatch@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/anymatch/download/anymatch-2.0.0.tgz?cache=0&sync_timestamp=1617747806715&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fanymatch%2Fdownload%2Fanymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" + integrity sha1-vLJLTzeTTZqnrBe0ra+J58du8us= + dependencies: + micromatch "^3.1.4" + normalize-path "^2.1.1" + +anymatch@~3.1.1: + version "3.1.2" + resolved "https://registry.npm.taobao.org/anymatch/download/anymatch-3.1.2.tgz?cache=0&sync_timestamp=1617747806715&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fanymatch%2Fdownload%2Fanymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" + integrity sha1-wFV8CWrzLxBhmPT04qODU343hxY= + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +aproba@^1.1.1: + version "1.2.0" + resolved "https://registry.npm.taobao.org/aproba/download/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" + integrity sha1-aALmJk79GMeQobDVF/DyYnvyyUo= + +arch@^2.1.1: + version "2.2.0" + resolved "https://registry.npm.taobao.org/arch/download/arch-2.2.0.tgz?cache=0&sync_timestamp=1603836324975&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Farch%2Fdownload%2Farch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11" + integrity sha1-G8R4GPMFdk8jqzMGsL/AhsWinRE= + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.npm.taobao.org/argparse/download/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha1-vNZ5HqWuCXJeF+WtmIE0zUCz2RE= + dependencies: + sprintf-js "~1.0.2" + +arr-diff@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/arr-diff/download/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" + integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= + +arr-flatten@^1.1.0: + version "1.1.0" + resolved "https://registry.npm.taobao.org/arr-flatten/download/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" + integrity sha1-NgSLv/TntH4TZkQxbJlmnqWukfE= + +arr-union@^3.1.0: + version "3.1.0" + resolved "https://registry.npm.taobao.org/arr-union/download/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" + integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.npm.taobao.org/array-flatten/download/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= + +array-flatten@^2.1.0: + version "2.1.2" + resolved "https://registry.npm.taobao.org/array-flatten/download/array-flatten-2.1.2.tgz#24ef80a28c1a893617e2149b0c6d0d788293b099" + integrity sha1-JO+AoowaiTYX4hSbDG0NeIKTsJk= + +array-tree-filter@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz#873ac00fec83749f255ac8dd083814b4f6329190" + integrity sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw== + +array-union@^1.0.1, array-union@^1.0.2: + version "1.0.2" + resolved "https://registry.npm.taobao.org/array-union/download/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" + integrity sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk= + dependencies: + array-uniq "^1.0.1" + +array-uniq@^1.0.1: + version "1.0.3" + resolved "https://registry.npm.taobao.org/array-uniq/download/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" + integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY= + +array-unique@^0.3.2: + version "0.3.2" + resolved "https://registry.npm.taobao.org/array-unique/download/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" + integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= + +arraydiff@^0.1.1: + version "0.1.3" + resolved "https://registry.npmjs.org/arraydiff/-/arraydiff-0.1.3.tgz#86a5436d7b72f1bdda5fd6d74e8724e42f83ce4d" + integrity sha1-hqVDbXty8b3aX9bXTock5C+Dzk0= + +asn1.js@^5.2.0: + version "5.4.1" + resolved "https://registry.npm.taobao.org/asn1.js/download/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" + integrity sha1-EamAuE67kXgc41sP3C7ilON4Pwc= + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + safer-buffer "^2.1.0" + +asn1@~0.2.3: + version "0.2.4" + resolved "https://registry.npm.taobao.org/asn1/download/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" + integrity sha1-jSR136tVO7M+d7VOWeiAu4ziMTY= + dependencies: + safer-buffer "~2.1.0" + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/assert-plus/download/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= + +assert@^1.1.1: + version "1.5.0" + resolved "https://registry.npm.taobao.org/assert/download/assert-1.5.0.tgz#55c109aaf6e0aefdb3dc4b71240c70bf574b18eb" + integrity sha1-VcEJqvbgrv2z3EtxJAxwv1dLGOs= + dependencies: + object-assign "^4.1.1" + util "0.10.3" + +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/assign-symbols/download/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= + +async-each@^1.0.1: + version "1.0.3" + resolved "https://registry.npm.taobao.org/async-each/download/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" + integrity sha1-tyfb+H12UWAvBvTUrDh/R9kbDL8= + +async-limiter@~1.0.0: + version "1.0.1" + resolved "https://registry.npm.taobao.org/async-limiter/download/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" + integrity sha1-3TeelPDbgxCwgpH51kwyCXZmF/0= + +async-validator@^3.3.0: + version "3.5.1" + resolved "https://registry.npmjs.org/async-validator/-/async-validator-3.5.1.tgz#cd62b9688b2465f48420e27adb47760ab1b5559f" + integrity sha512-DDmKA7sdSAJtTVeNZHrnr2yojfFaoeW8MfQN8CeuXg8DDQHTqKk9Fdv38dSvnesHoO8MUwMI2HphOeSyIF+wmQ== + +async@^2.6.2, async@^2.6.3: + version "2.6.3" + resolved "https://registry.npmjs.org/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" + integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg== + dependencies: + lodash "^4.17.14" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.npm.taobao.org/asynckit/download/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/at-least-node/download/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha1-YCzUtG6EStTv/JKoARo8RuAjjcI= + +atob@^2.1.2: + version "2.1.2" + resolved "https://registry.npm.taobao.org/atob/download/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" + integrity sha1-bZUX654DDSQ2ZmZR6GvZ9vE1M8k= + +autoprefixer@^9.8.6: + version "9.8.6" + resolved "https://registry.npm.taobao.org/autoprefixer/download/autoprefixer-9.8.6.tgz?cache=0&sync_timestamp=1614956824768&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fautoprefixer%2Fdownload%2Fautoprefixer-9.8.6.tgz#3b73594ca1bf9266320c5acf1588d74dea74210f" + integrity sha1-O3NZTKG/kmYyDFrPFYjXTep0IQ8= + dependencies: + browserslist "^4.12.0" + caniuse-lite "^1.0.30001109" + colorette "^1.2.1" + normalize-range "^0.1.2" + num2fraction "^1.2.2" + postcss "^7.0.32" + postcss-value-parser "^4.1.0" + +aws-sign2@~0.7.0: + version "0.7.0" + resolved "https://registry.npm.taobao.org/aws-sign2/download/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" + integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= + +aws4@^1.8.0: + version "1.11.0" + resolved "https://registry.npm.taobao.org/aws4/download/aws4-1.11.0.tgz?cache=0&sync_timestamp=1604101210422&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Faws4%2Fdownload%2Faws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" + integrity sha1-1h9G2DslGSUOJ4Ta9bCUeai0HFk= + +babel-code-frame@^6.22.0: + version "6.26.0" + resolved "https://registry.npm.taobao.org/babel-code-frame/download/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" + integrity sha1-Y/1D99weO7fONZR9uP42mj9Yx0s= + dependencies: + chalk "^1.1.3" + esutils "^2.0.2" + js-tokens "^3.0.2" + +babel-loader@^8.1.0: + version "8.2.2" + resolved "https://registry.npm.taobao.org/babel-loader/download/babel-loader-8.2.2.tgz?cache=0&sync_timestamp=1606424705083&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fbabel-loader%2Fdownload%2Fbabel-loader-8.2.2.tgz#9363ce84c10c9a40e6c753748e1441b60c8a0b81" + integrity sha1-k2POhMEMmkDmx1N0jhRBtgyKC4E= + dependencies: + find-cache-dir "^3.3.1" + loader-utils "^1.4.0" + make-dir "^3.1.0" + schema-utils "^2.6.5" + +babel-plugin-dynamic-import-node@^2.3.3: + version "2.3.3" + resolved "https://registry.npm.taobao.org/babel-plugin-dynamic-import-node/download/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3" + integrity sha1-hP2hnJduxcbe/vV/lCez3vZuF6M= + dependencies: + object.assign "^4.1.0" + +babel-plugin-polyfill-corejs2@^0.2.0: + version "0.2.0" + resolved "https://registry.npm.taobao.org/babel-plugin-polyfill-corejs2/download/babel-plugin-polyfill-corejs2-0.2.0.tgz?cache=0&sync_timestamp=1617206642106&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fbabel-plugin-polyfill-corejs2%2Fdownload%2Fbabel-plugin-polyfill-corejs2-0.2.0.tgz#686775bf9a5aa757e10520903675e3889caeedc4" + integrity sha1-aGd1v5pap1fhBSCQNnXjiJyu7cQ= + dependencies: + "@babel/compat-data" "^7.13.11" + "@babel/helper-define-polyfill-provider" "^0.2.0" + semver "^6.1.1" + +babel-plugin-polyfill-corejs3@^0.2.0: + version "0.2.0" + resolved "https://registry.npm.taobao.org/babel-plugin-polyfill-corejs3/download/babel-plugin-polyfill-corejs3-0.2.0.tgz#f4b4bb7b19329827df36ff56f6e6d367026cb7a2" + integrity sha1-9LS7exkymCffNv9W9ubTZwJst6I= + dependencies: + "@babel/helper-define-polyfill-provider" "^0.2.0" + core-js-compat "^3.9.1" + +babel-plugin-polyfill-regenerator@^0.2.0: + version "0.2.0" + resolved "https://registry.npm.taobao.org/babel-plugin-polyfill-regenerator/download/babel-plugin-polyfill-regenerator-0.2.0.tgz?cache=0&sync_timestamp=1617206100318&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fbabel-plugin-polyfill-regenerator%2Fdownload%2Fbabel-plugin-polyfill-regenerator-0.2.0.tgz#853f5f5716f4691d98c84f8069c7636ea8da7ab8" + integrity sha1-hT9fVxb0aR2YyE+Aacdjbqjaerg= + dependencies: + "@babel/helper-define-polyfill-provider" "^0.2.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.npm.taobao.org/balanced-match/download/balanced-match-1.0.2.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fbalanced-match%2Fdownload%2Fbalanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha1-6D46fj8wCzTLnYf2FfoMvzV2kO4= + +base64-js@^1.0.2: + version "1.5.1" + resolved "https://registry.npm.taobao.org/base64-js/download/base64-js-1.5.1.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fbase64-js%2Fdownload%2Fbase64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha1-GxtEAWClv3rUC2UPCVljSBkDkwo= + +base@^0.11.1: + version "0.11.2" + resolved "https://registry.npm.taobao.org/base/download/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" + integrity sha1-e95c7RRbbVUakNuH+DxVi060io8= + dependencies: + cache-base "^1.0.1" + class-utils "^0.3.5" + component-emitter "^1.2.1" + define-property "^1.0.0" + isobject "^3.0.1" + mixin-deep "^1.2.0" + pascalcase "^0.1.1" + +batch@0.6.1: + version "0.6.1" + resolved "https://registry.npm.taobao.org/batch/download/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" + integrity sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY= + +bcrypt-pbkdf@^1.0.0: + version "1.0.2" + resolved "https://registry.npm.taobao.org/bcrypt-pbkdf/download/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= + dependencies: + tweetnacl "^0.14.3" + +bfj@^6.1.1: + version "6.1.2" + resolved "https://registry.npm.taobao.org/bfj/download/bfj-6.1.2.tgz#325c861a822bcb358a41c78a33b8e6e2086dde7f" + integrity sha1-MlyGGoIryzWKQceKM7jm4ght3n8= + dependencies: + bluebird "^3.5.5" + check-types "^8.0.3" + hoopy "^0.1.4" + tryer "^1.0.1" + +big.js@^3.1.3: + version "3.2.0" + resolved "https://registry.npm.taobao.org/big.js/download/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e" + integrity sha1-pfwpi4G54Nyi5FiCR4S2XFK6WI4= + +big.js@^5.2.2: + version "5.2.2" + resolved "https://registry.npm.taobao.org/big.js/download/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" + integrity sha1-ZfCvOC9Xi83HQr2cKB6cstd2gyg= + +binary-extensions@^1.0.0: + version "1.13.1" + resolved "https://registry.npm.taobao.org/binary-extensions/download/binary-extensions-1.13.1.tgz?cache=0&sync_timestamp=1610299285874&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fbinary-extensions%2Fdownload%2Fbinary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" + integrity sha1-WYr+VHVbKGilMw0q/51Ou1Mgm2U= + +binary-extensions@^2.0.0: + version "2.2.0" + resolved "https://registry.npm.taobao.org/binary-extensions/download/binary-extensions-2.2.0.tgz?cache=0&sync_timestamp=1610299285874&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fbinary-extensions%2Fdownload%2Fbinary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" + integrity sha1-dfUC7q+f/eQvyYgpZFvk6na9ni0= + +bindings@^1.5.0: + version "1.5.0" + resolved "https://registry.npm.taobao.org/bindings/download/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha1-EDU8npRTNLwFEabZCzj7x8nFBN8= + dependencies: + file-uri-to-path "1.0.0" + +bluebird@^3.1.1, bluebird@^3.5.5, bluebird@^3.7.2: + version "3.7.2" + resolved "https://registry.npm.taobao.org/bluebird/download/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" + integrity sha1-nyKcFb4nJFT/qXOs4NvueaGww28= + +blueimp-md5@^2.18.0: + version "2.18.0" + resolved "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.18.0.tgz#1152be1335f0c6b3911ed9e36db54f3e6ac52935" + integrity sha512-vE52okJvzsVWhcgUHOv+69OG3Mdg151xyn41aVQN/5W5S+S43qZhxECtYLAEHMSFWX6Mv5IZrzj3T5+JqXfj5Q== + +bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9: + version "4.12.0" + resolved "https://registry.npm.taobao.org/bn.js/download/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" + integrity sha1-d1s/J477uXGO7HNh9IP7Nvu/6og= + +bn.js@^5.0.0, bn.js@^5.1.1: + version "5.2.0" + resolved "https://registry.npm.taobao.org/bn.js/download/bn.js-5.2.0.tgz#358860674396c6997771a9d051fcc1b57d4ae002" + integrity sha1-NYhgZ0OWxpl3canQUfzBtX1K4AI= + +body-parser@1.19.0: + version "1.19.0" + resolved "https://registry.npm.taobao.org/body-parser/download/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" + integrity sha1-lrJwnlfJxOCab9Zqj9l5hE9p8Io= + dependencies: + bytes "3.1.0" + content-type "~1.0.4" + debug "2.6.9" + depd "~1.1.2" + http-errors "1.7.2" + iconv-lite "0.4.24" + on-finished "~2.3.0" + qs "6.7.0" + raw-body "2.4.0" + type-is "~1.6.17" + +bonjour@^3.5.0: + version "3.5.0" + resolved "https://registry.npm.taobao.org/bonjour/download/bonjour-3.5.0.tgz#8e890a183d8ee9a2393b3844c691a42bcf7bc9f5" + integrity sha1-jokKGD2O6aI5OzhExpGkK897yfU= + dependencies: + array-flatten "^2.1.0" + deep-equal "^1.0.1" + dns-equal "^1.0.0" + dns-txt "^2.0.2" + multicast-dns "^6.0.1" + multicast-dns-service-types "^1.1.0" + +boolbase@^1.0.0, boolbase@~1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/boolbase/download/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.npm.taobao.org/brace-expansion/download/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha1-PH/L9SnYcibz0vUrlm/1Jx60Qd0= + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^2.3.1, braces@^2.3.2: + version "2.3.2" + resolved "https://registry.npm.taobao.org/braces/download/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" + integrity sha1-WXn9PxTNUxVl5fot8av/8d+u5yk= + dependencies: + arr-flatten "^1.1.0" + array-unique "^0.3.2" + extend-shallow "^2.0.1" + fill-range "^4.0.0" + isobject "^3.0.1" + repeat-element "^1.1.2" + snapdragon "^0.8.1" + snapdragon-node "^2.0.1" + split-string "^3.0.2" + to-regex "^3.0.1" + +braces@^3.0.1, braces@~3.0.2: + version "3.0.2" + resolved "https://registry.npm.taobao.org/braces/download/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha1-NFThpGLujVmeI23zNs2epPiv4Qc= + dependencies: + fill-range "^7.0.1" + +brorand@^1.0.1, brorand@^1.1.0: + version "1.1.0" + resolved "https://registry.npm.taobao.org/brorand/download/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" + integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= + +browserify-aes@^1.0.0, browserify-aes@^1.0.4: + version "1.2.0" + resolved "https://registry.npm.taobao.org/browserify-aes/download/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" + integrity sha1-Mmc0ZC9APavDADIJhTu3CtQo70g= + dependencies: + buffer-xor "^1.0.3" + cipher-base "^1.0.0" + create-hash "^1.1.0" + evp_bytestokey "^1.0.3" + inherits "^2.0.1" + safe-buffer "^5.0.1" + +browserify-cipher@^1.0.0: + version "1.0.1" + resolved "https://registry.npm.taobao.org/browserify-cipher/download/browserify-cipher-1.0.1.tgz#8d6474c1b870bfdabcd3bcfcc1934a10e94f15f0" + integrity sha1-jWR0wbhwv9q807z8wZNKEOlPFfA= + dependencies: + browserify-aes "^1.0.4" + browserify-des "^1.0.0" + evp_bytestokey "^1.0.0" + +browserify-des@^1.0.0: + version "1.0.2" + resolved "https://registry.npm.taobao.org/browserify-des/download/browserify-des-1.0.2.tgz#3af4f1f59839403572f1c66204375f7a7f703e9c" + integrity sha1-OvTx9Zg5QDVy8cZiBDdfen9wPpw= + dependencies: + cipher-base "^1.0.1" + des.js "^1.0.0" + inherits "^2.0.1" + safe-buffer "^5.1.2" + +browserify-rsa@^4.0.0, browserify-rsa@^4.0.1: + version "4.1.0" + resolved "https://registry.npm.taobao.org/browserify-rsa/download/browserify-rsa-4.1.0.tgz#b2fd06b5b75ae297f7ce2dc651f918f5be158c8d" + integrity sha1-sv0Gtbda4pf3zi3GUfkY9b4VjI0= + dependencies: + bn.js "^5.0.0" + randombytes "^2.0.1" + +browserify-sign@^4.0.0: + version "4.2.1" + resolved "https://registry.npm.taobao.org/browserify-sign/download/browserify-sign-4.2.1.tgz?cache=0&sync_timestamp=1596557809886&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fbrowserify-sign%2Fdownload%2Fbrowserify-sign-4.2.1.tgz#eaf4add46dd54be3bb3b36c0cf15abbeba7956c3" + integrity sha1-6vSt1G3VS+O7OzbAzxWrvrp5VsM= + dependencies: + bn.js "^5.1.1" + browserify-rsa "^4.0.1" + create-hash "^1.2.0" + create-hmac "^1.1.7" + elliptic "^6.5.3" + inherits "^2.0.4" + parse-asn1 "^5.1.5" + readable-stream "^3.6.0" + safe-buffer "^5.2.0" + +browserify-zlib@^0.2.0: + version "0.2.0" + resolved "https://registry.npm.taobao.org/browserify-zlib/download/browserify-zlib-0.2.0.tgz#2869459d9aa3be245fe8fe2ca1f46e2e7f54d73f" + integrity sha1-KGlFnZqjviRf6P4sofRuLn9U1z8= + dependencies: + pako "~1.0.5" + +browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.3: + version "4.16.3" + resolved "https://registry.npm.taobao.org/browserslist/download/browserslist-4.16.3.tgz?cache=0&sync_timestamp=1612124016433&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fbrowserslist%2Fdownload%2Fbrowserslist-4.16.3.tgz#340aa46940d7db878748567c5dea24a48ddf3717" + integrity sha1-NAqkaUDX24eHSFZ8XeokpI3fNxc= + dependencies: + caniuse-lite "^1.0.30001181" + colorette "^1.2.1" + electron-to-chromium "^1.3.649" + escalade "^3.1.1" + node-releases "^1.1.70" + +buffer-from@^1.0.0: + version "1.1.1" + resolved "https://registry.npm.taobao.org/buffer-from/download/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" + integrity sha1-MnE7wCj3XAL9txDXx7zsHyxgcO8= + +buffer-indexof@^1.0.0: + version "1.1.1" + resolved "https://registry.npm.taobao.org/buffer-indexof/download/buffer-indexof-1.1.1.tgz#52fabcc6a606d1a00302802648ef68f639da268c" + integrity sha1-Uvq8xqYG0aADAoAmSO9o9jnaJow= + +buffer-json@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/buffer-json/download/buffer-json-2.0.0.tgz#f73e13b1e42f196fe2fd67d001c7d7107edd7c23" + integrity sha1-9z4TseQvGW/i/WfQAcfXEH7dfCM= + +buffer-xor@^1.0.3: + version "1.0.3" + resolved "https://registry.npm.taobao.org/buffer-xor/download/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" + integrity sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk= + +buffer@^4.3.0: + version "4.9.2" + resolved "https://registry.npm.taobao.org/buffer/download/buffer-4.9.2.tgz?cache=0&sync_timestamp=1606098066706&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fbuffer%2Fdownload%2Fbuffer-4.9.2.tgz#230ead344002988644841ab0244af8c44bbe3ef8" + integrity sha1-Iw6tNEACmIZEhBqwJEr4xEu+Pvg= + dependencies: + base64-js "^1.0.2" + ieee754 "^1.1.4" + isarray "^1.0.0" + +builtin-modules@^1.1.1: + version "1.1.1" + resolved "https://registry.npm.taobao.org/builtin-modules/download/builtin-modules-1.1.1.tgz?cache=0&sync_timestamp=1608616928723&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fbuiltin-modules%2Fdownload%2Fbuiltin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" + integrity sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8= + +builtin-status-codes@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/builtin-status-codes/download/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" + integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug= + +bytes@3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/bytes/download/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" + integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg= + +bytes@3.1.0: + version "3.1.0" + resolved "https://registry.npm.taobao.org/bytes/download/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" + integrity sha1-9s95M6Ng4FiPqf3oVlHNx/gF0fY= + +cacache@^12.0.2, cacache@^12.0.3: + version "12.0.4" + resolved "https://registry.npm.taobao.org/cacache/download/cacache-12.0.4.tgz?cache=0&sync_timestamp=1616431125500&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcacache%2Fdownload%2Fcacache-12.0.4.tgz#668bcbd105aeb5f1d92fe25570ec9525c8faa40c" + integrity sha1-ZovL0QWutfHZL+JVcOyVJcj6pAw= + dependencies: + bluebird "^3.5.5" + chownr "^1.1.1" + figgy-pudding "^3.5.1" + glob "^7.1.4" + graceful-fs "^4.1.15" + infer-owner "^1.0.3" + lru-cache "^5.1.1" + mississippi "^3.0.0" + mkdirp "^0.5.1" + move-concurrently "^1.0.1" + promise-inflight "^1.0.1" + rimraf "^2.6.3" + ssri "^6.0.1" + unique-filename "^1.1.1" + y18n "^4.0.0" + +cacache@^13.0.1: + version "13.0.1" + resolved "https://registry.npm.taobao.org/cacache/download/cacache-13.0.1.tgz?cache=0&sync_timestamp=1616431125500&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcacache%2Fdownload%2Fcacache-13.0.1.tgz#a8000c21697089082f85287a1aec6e382024a71c" + integrity sha1-qAAMIWlwiQgvhSh6GuxuOCAkpxw= + dependencies: + chownr "^1.1.2" + figgy-pudding "^3.5.1" + fs-minipass "^2.0.0" + glob "^7.1.4" + graceful-fs "^4.2.2" + infer-owner "^1.0.4" + lru-cache "^5.1.1" + minipass "^3.0.0" + minipass-collect "^1.0.2" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.2" + mkdirp "^0.5.1" + move-concurrently "^1.0.1" + p-map "^3.0.0" + promise-inflight "^1.0.1" + rimraf "^2.7.1" + ssri "^7.0.0" + unique-filename "^1.1.1" + +cache-base@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/cache-base/download/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" + integrity sha1-Cn9GQWgxyLZi7jb+TnxZ129marI= + dependencies: + collection-visit "^1.0.0" + component-emitter "^1.2.1" + get-value "^2.0.6" + has-value "^1.0.0" + isobject "^3.0.1" + set-value "^2.0.0" + to-object-path "^0.3.0" + union-value "^1.0.0" + unset-value "^1.0.0" + +cache-loader@^4.1.0: + version "4.1.0" + resolved "https://registry.npm.taobao.org/cache-loader/download/cache-loader-4.1.0.tgz?cache=0&sync_timestamp=1601432152909&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcache-loader%2Fdownload%2Fcache-loader-4.1.0.tgz#9948cae353aec0a1fcb1eafda2300816ec85387e" + integrity sha1-mUjK41OuwKH8ser9ojAIFuyFOH4= + dependencies: + buffer-json "^2.0.0" + find-cache-dir "^3.0.0" + loader-utils "^1.2.3" + mkdirp "^0.5.1" + neo-async "^2.6.1" + schema-utils "^2.0.0" + +call-bind@^1.0.0, call-bind@^1.0.2: + version "1.0.2" + resolved "https://registry.npm.taobao.org/call-bind/download/call-bind-1.0.2.tgz?cache=0&sync_timestamp=1610403232833&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcall-bind%2Fdownload%2Fcall-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" + integrity sha1-sdTonmiBGcPJqQOtMKuy9qkZvjw= + dependencies: + function-bind "^1.1.1" + get-intrinsic "^1.0.2" + +call-me-maybe@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/call-me-maybe/download/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" + integrity sha1-JtII6onje1y95gJQoV8DHBak1ms= + +caller-callsite@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/caller-callsite/download/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" + integrity sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ= + dependencies: + callsites "^2.0.0" + +caller-path@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/caller-path/download/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4" + integrity sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ= + dependencies: + caller-callsite "^2.0.0" + +callsites@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/callsites/download/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50" + integrity sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA= + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.npm.taobao.org/callsites/download/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha1-s2MKvYlDQy9Us/BRkjjjPNffL3M= + +camel-case@3.0.x: + version "3.0.0" + resolved "https://registry.npm.taobao.org/camel-case/download/camel-case-3.0.0.tgz?cache=0&sync_timestamp=1606869170809&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcamel-case%2Fdownload%2Fcamel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73" + integrity sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M= + dependencies: + no-case "^2.2.0" + upper-case "^1.1.1" + +camelcase@^5.0.0, camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.npm.taobao.org/camelcase/download/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha1-48mzFWnhBoEd8kL3FXJaH0xJQyA= + +camelcase@^6.0.0: + version "6.2.0" + resolved "https://registry.npm.taobao.org/camelcase/download/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809" + integrity sha1-kkr4gcnVJaydh/QNlk5c6pgqGAk= + +caniuse-api@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/caniuse-api/download/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" + integrity sha1-Xk2Q4idJYdRikZl99Znj7QCO5MA= + dependencies: + browserslist "^4.0.0" + caniuse-lite "^1.0.0" + lodash.memoize "^4.1.2" + lodash.uniq "^4.5.0" + +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001181: + version "1.0.30001208" + resolved "https://registry.npm.taobao.org/caniuse-lite/download/caniuse-lite-1.0.30001208.tgz?cache=0&sync_timestamp=1617866642507&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcaniuse-lite%2Fdownload%2Fcaniuse-lite-1.0.30001208.tgz#a999014a35cebd4f98c405930a057a0d75352eb9" + integrity sha1-qZkBSjXOvU+YxAWTCgV6DXU1Lrk= + +case-sensitive-paths-webpack-plugin@^2.3.0: + version "2.4.0" + resolved "https://registry.npm.taobao.org/case-sensitive-paths-webpack-plugin/download/case-sensitive-paths-webpack-plugin-2.4.0.tgz?cache=0&sync_timestamp=1614019098201&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcase-sensitive-paths-webpack-plugin%2Fdownload%2Fcase-sensitive-paths-webpack-plugin-2.4.0.tgz#db64066c6422eed2e08cc14b986ca43796dbc6d4" + integrity sha1-22QGbGQi7tLgjMFLmGykN5bbxtQ= + +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.npm.taobao.org/caseless/download/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= + +chalk@^1.1.3: + version "1.1.3" + resolved "https://registry.npm.taobao.org/chalk/download/chalk-1.1.3.tgz?cache=0&sync_timestamp=1593529719605&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fchalk%2Fdownload%2Fchalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" + integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg= + dependencies: + ansi-styles "^2.2.1" + escape-string-regexp "^1.0.2" + has-ansi "^2.0.0" + strip-ansi "^3.0.0" + supports-color "^2.0.0" + +chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.npm.taobao.org/chalk/download/chalk-2.4.2.tgz?cache=0&sync_timestamp=1593529719605&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fchalk%2Fdownload%2Fchalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha1-zUJUFnelQzPPVBpJEIwUMrRMlCQ= + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^4.0.0, chalk@^4.1.0: + version "4.1.0" + resolved "https://registry.npm.taobao.org/chalk/download/chalk-4.1.0.tgz?cache=0&sync_timestamp=1593529719605&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fchalk%2Fdownload%2Fchalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" + integrity sha1-ThSHCmGNni7dl92DRf2dncMVZGo= + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +check-types@^8.0.3: + version "8.0.3" + resolved "https://registry.npm.taobao.org/check-types/download/check-types-8.0.3.tgz#3356cca19c889544f2d7a95ed49ce508a0ecf552" + integrity sha1-M1bMoZyIlUTy16le1JzlCKDs9VI= + +chokidar@^2.1.8: + version "2.1.8" + resolved "https://registry.npm.taobao.org/chokidar/download/chokidar-2.1.8.tgz?cache=0&sync_timestamp=1610719499558&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fchokidar%2Fdownload%2Fchokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" + integrity sha1-gEs6e2qZNYw8XGHnHYco8EHP+Rc= + dependencies: + anymatch "^2.0.0" + async-each "^1.0.1" + braces "^2.3.2" + glob-parent "^3.1.0" + inherits "^2.0.3" + is-binary-path "^1.0.0" + is-glob "^4.0.0" + normalize-path "^3.0.0" + path-is-absolute "^1.0.0" + readdirp "^2.2.1" + upath "^1.1.1" + optionalDependencies: + fsevents "^1.2.7" + +chokidar@^3.3.0, chokidar@^3.4.1: + version "3.5.1" + resolved "https://registry.npm.taobao.org/chokidar/download/chokidar-3.5.1.tgz?cache=0&sync_timestamp=1610719499558&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fchokidar%2Fdownload%2Fchokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a" + integrity sha1-7pznu+vSt59J8wR5nVRo4x4U5oo= + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.5.0" + optionalDependencies: + fsevents "~2.3.1" + +chownr@^1.1.1, chownr@^1.1.2: + version "1.1.4" + resolved "https://registry.npm.taobao.org/chownr/download/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha1-b8nXtC0ypYNZYzdmbn0ICE2izGs= + +chrome-trace-event@^1.0.2: + version "1.0.3" + resolved "https://registry.npm.taobao.org/chrome-trace-event/download/chrome-trace-event-1.0.3.tgz?cache=0&sync_timestamp=1617905826919&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fchrome-trace-event%2Fdownload%2Fchrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac" + integrity sha1-EBXs7UdB4V0GZkqVfbv1DQQeJqw= + +ci-info@^1.5.0: + version "1.6.0" + resolved "https://registry.npm.taobao.org/ci-info/download/ci-info-1.6.0.tgz?cache=0&sync_timestamp=1613628860338&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fci-info%2Fdownload%2Fci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497" + integrity sha1-LKINu5zrMtRSSmgzAzE/AwSx5Jc= + +cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: + version "1.0.4" + resolved "https://registry.npm.taobao.org/cipher-base/download/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" + integrity sha1-h2Dk7MJy9MNjUy+SbYdKriwTl94= + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +class-utils@^0.3.5: + version "0.3.6" + resolved "https://registry.npm.taobao.org/class-utils/download/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" + integrity sha1-+TNprouafOAv1B+q0MqDAzGQxGM= + dependencies: + arr-union "^3.1.0" + define-property "^0.2.5" + isobject "^3.0.0" + static-extend "^0.1.1" + +clean-css@4.2.x: + version "4.2.3" + resolved "https://registry.npm.taobao.org/clean-css/download/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78" + integrity sha1-UHtd59l7SO5T2ErbAWD/YhY4D3g= + dependencies: + source-map "~0.6.0" + +clean-stack@^2.0.0: + version "2.2.0" + resolved "https://registry.npm.taobao.org/clean-stack/download/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" + integrity sha1-7oRy27Ep5yezHooQpCfe6d/kAIs= + +cli-cursor@^2.1.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/cli-cursor/download/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" + integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU= + dependencies: + restore-cursor "^2.0.0" + +cli-highlight@^2.1.4: + version "2.1.11" + resolved "https://registry.npm.taobao.org/cli-highlight/download/cli-highlight-2.1.11.tgz?cache=0&sync_timestamp=1616955426054&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcli-highlight%2Fdownload%2Fcli-highlight-2.1.11.tgz#49736fa452f0aaf4fae580e30acb26828d2dc1bf" + integrity sha1-SXNvpFLwqvT65YDjCssmgo0twb8= + dependencies: + chalk "^4.0.0" + highlight.js "^10.7.1" + mz "^2.4.0" + parse5 "^5.1.1" + parse5-htmlparser2-tree-adapter "^6.0.0" + yargs "^16.0.0" + +cli-spinners@^2.0.0: + version "2.6.0" + resolved "https://registry.npm.taobao.org/cli-spinners/download/cli-spinners-2.6.0.tgz?cache=0&sync_timestamp=1616091622495&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcli-spinners%2Fdownload%2Fcli-spinners-2.6.0.tgz#36c7dc98fb6a9a76bd6238ec3f77e2425627e939" + integrity sha1-NsfcmPtqmna9YjjsP3fiQlYn6Tk= + +clipboardy@^2.3.0: + version "2.3.0" + resolved "https://registry.npm.taobao.org/clipboardy/download/clipboardy-2.3.0.tgz#3c2903650c68e46a91b388985bc2774287dba290" + integrity sha1-PCkDZQxo5GqRs4iYW8J3QofbopA= + dependencies: + arch "^2.1.1" + execa "^1.0.0" + is-wsl "^2.1.1" + +cliui@^5.0.0: + version "5.0.0" + resolved "https://registry.npm.taobao.org/cliui/download/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" + integrity sha1-3u/P2y6AB4SqNPRvoI4GhRx7u8U= + dependencies: + string-width "^3.1.0" + strip-ansi "^5.2.0" + wrap-ansi "^5.1.0" + +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.npm.taobao.org/cliui/download/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" + integrity sha1-UR1wLAxOQcoVbX0OlgIfI+EyJbE= + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.npm.taobao.org/cliui/download/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha1-oCZe5lVHb8gHrqnfPfjfd4OAi08= + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +clone@^1.0.2: + version "1.0.4" + resolved "https://registry.npm.taobao.org/clone/download/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" + integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= + +clone@^2.1.2: + version "2.1.2" + resolved "https://registry.npm.taobao.org/clone/download/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= + +coa@^2.0.2: + version "2.0.2" + resolved "https://registry.npm.taobao.org/coa/download/coa-2.0.2.tgz#43f6c21151b4ef2bf57187db0d73de229e3e7ec3" + integrity sha1-Q/bCEVG07yv1cYfbDXPeIp4+fsM= + dependencies: + "@types/q" "^1.5.1" + chalk "^2.4.1" + q "^1.1.2" + +codemirror@^5.60.0: + version "5.61.1" + resolved "https://registry.nlark.com/codemirror/download/codemirror-5.61.1.tgz#ccfc8a43b8fcfb8b12e8e75b5ffde48d541406e0" + integrity sha1-zPyKQ7j8+4sS6OdbX/3kjVQUBuA= + +collection-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/collection-visit/download/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" + integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA= + dependencies: + map-visit "^1.0.0" + object-visit "^1.0.0" + +color-convert@^1.9.0, color-convert@^1.9.1: + version "1.9.3" + resolved "https://registry.npm.taobao.org/color-convert/download/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha1-u3GFBpDh8TZWfeYp0tVHHe2kweg= + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.npm.taobao.org/color-convert/download/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha1-ctOmjVmMm9s68q0ehPIdiWq9TeM= + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.npm.taobao.org/color-name/download/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +color-name@^1.0.0, color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.npm.taobao.org/color-name/download/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha1-wqCah6y95pVD3m9j+jmVyCbFNqI= + +color-string@^1.5.4: + version "1.5.5" + resolved "https://registry.npm.taobao.org/color-string/download/color-string-1.5.5.tgz#65474a8f0e7439625f3d27a6a19d89fc45223014" + integrity sha1-ZUdKjw50OWJfPSemoZ2J/EUiMBQ= + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + +color@^3.0.0: + version "3.1.3" + resolved "https://registry.npm.taobao.org/color/download/color-3.1.3.tgz#ca67fb4e7b97d611dcde39eceed422067d91596e" + integrity sha1-ymf7TnuX1hHc3jns7tQiBn2RWW4= + dependencies: + color-convert "^1.9.1" + color-string "^1.5.4" + +colorette@^1.2.1, colorette@^1.2.2: + version "1.2.2" + resolved "https://registry.npm.taobao.org/colorette/download/colorette-1.2.2.tgz?cache=0&sync_timestamp=1614259647923&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcolorette%2Fdownload%2Fcolorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" + integrity sha1-y8x51emcrqLb8Q6zom/Ys+as+pQ= + +combined-stream@^1.0.6, combined-stream@~1.0.6: + version "1.0.8" + resolved "https://registry.npm.taobao.org/combined-stream/download/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha1-w9RaizT9cwYxoRCoolIGgrMdWn8= + dependencies: + delayed-stream "~1.0.0" + +commander@2.17.x: + version "2.17.1" + resolved "https://registry.npm.taobao.org/commander/download/commander-2.17.1.tgz?cache=0&sync_timestamp=1616364569946&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcommander%2Fdownload%2Fcommander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf" + integrity sha1-vXerfebelCBc6sxy8XFtKfIKd78= + +commander@^2.12.1, commander@^2.18.0, commander@^2.20.0: + version "2.20.3" + resolved "https://registry.npm.taobao.org/commander/download/commander-2.20.3.tgz?cache=0&sync_timestamp=1616364569946&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcommander%2Fdownload%2Fcommander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha1-/UhehMA+tIgcIHIrpIA16FMa6zM= + +commander@~2.19.0: + version "2.19.0" + resolved "https://registry.npm.taobao.org/commander/download/commander-2.19.0.tgz?cache=0&sync_timestamp=1616364569946&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcommander%2Fdownload%2Fcommander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a" + integrity sha1-9hmKqE5bg8RgVLlN3tv+1e6f8So= + +commondir@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/commondir/download/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" + integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= + +component-emitter@^1.2.1: + version "1.3.0" + resolved "https://registry.npm.taobao.org/component-emitter/download/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" + integrity sha1-FuQHD7qK4ptnnyIVhT7hgasuq8A= + +compressible@~2.0.16: + version "2.0.18" + resolved "https://registry.npm.taobao.org/compressible/download/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" + integrity sha1-r1PMprBw1MPAdQ+9dyhqbXzEb7o= + dependencies: + mime-db ">= 1.43.0 < 2" + +compression@^1.7.4: + version "1.7.4" + resolved "https://registry.npm.taobao.org/compression/download/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f" + integrity sha1-lVI+/xcMpXwpoMpB5v4TH0Hlu48= + dependencies: + accepts "~1.3.5" + bytes "3.0.0" + compressible "~2.0.16" + debug "2.6.9" + on-headers "~1.0.2" + safe-buffer "5.1.2" + vary "~1.1.2" + +compute-scroll-into-view@^1.0.17: + version "1.0.17" + resolved "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz#6a88f18acd9d42e9cf4baa6bec7e0522607ab7ab" + integrity sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.npm.taobao.org/concat-map/download/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + +concat-stream@^1.5.0: + version "1.6.2" + resolved "https://registry.npm.taobao.org/concat-stream/download/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha1-kEvfGUzTEi/Gdcd/xKw9T/D9GjQ= + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +connect-history-api-fallback@^1.6.0: + version "1.6.0" + resolved "https://registry.npm.taobao.org/connect-history-api-fallback/download/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc" + integrity sha1-izIIk1kwjRERFdgcrT/Oq4iPl7w= + +console-browserify@^1.1.0: + version "1.2.0" + resolved "https://registry.npm.taobao.org/console-browserify/download/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" + integrity sha1-ZwY871fOts9Jk6KrOlWECujEkzY= + +consolidate@^0.15.1: + version "0.15.1" + resolved "https://registry.npm.taobao.org/consolidate/download/consolidate-0.15.1.tgz?cache=0&sync_timestamp=1599604996729&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fconsolidate%2Fdownload%2Fconsolidate-0.15.1.tgz#21ab043235c71a07d45d9aad98593b0dba56bab7" + integrity sha1-IasEMjXHGgfUXZqtmFk7DbpWurc= + dependencies: + bluebird "^3.1.1" + +consolidate@^0.16.0: + version "0.16.0" + resolved "https://registry.npm.taobao.org/consolidate/download/consolidate-0.16.0.tgz?cache=0&sync_timestamp=1599604996729&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fconsolidate%2Fdownload%2Fconsolidate-0.16.0.tgz#a11864768930f2f19431660a65906668f5fbdc16" + integrity sha1-oRhkdokw8vGUMWYKZZBmaPX73BY= + dependencies: + bluebird "^3.7.2" + +constants-browserify@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/constants-browserify/download/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" + integrity sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U= + +content-disposition@0.5.3: + version "0.5.3" + resolved "https://registry.npm.taobao.org/content-disposition/download/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd" + integrity sha1-4TDK9+cnkIfFYWwgB9BIVpiYT70= + dependencies: + safe-buffer "5.1.2" + +content-type@~1.0.4: + version "1.0.4" + resolved "https://registry.npm.taobao.org/content-type/download/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" + integrity sha1-4TjMdeBAxyexlm/l5fjJruJW/js= + +convert-source-map@^1.7.0: + version "1.7.0" + resolved "https://registry.npm.taobao.org/convert-source-map/download/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" + integrity sha1-F6LLiC1/d9NJBYXizmxSRCSjpEI= + dependencies: + safe-buffer "~5.1.1" + +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.npm.taobao.org/cookie-signature/download/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" + integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= + +cookie@0.4.0: + version "0.4.0" + resolved "https://registry.npm.taobao.org/cookie/download/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" + integrity sha1-vrQ35wIrO21JAZ0IhmUwPr6cFLo= + +copy-anything@^2.0.1: + version "2.0.3" + resolved "https://registry.npm.taobao.org/copy-anything/download/copy-anything-2.0.3.tgz#842407ba02466b0df844819bbe3baebbe5d45d87" + integrity sha1-hCQHugJGaw34RIGbvjuuu+XUXYc= + dependencies: + is-what "^3.12.0" + +copy-concurrently@^1.0.0: + version "1.0.5" + resolved "https://registry.npm.taobao.org/copy-concurrently/download/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0" + integrity sha1-kilzmMrjSTf8r9bsgTnBgFHwteA= + dependencies: + aproba "^1.1.1" + fs-write-stream-atomic "^1.0.8" + iferr "^0.1.5" + mkdirp "^0.5.1" + rimraf "^2.5.4" + run-queue "^1.0.0" + +copy-descriptor@^0.1.0: + version "0.1.1" + resolved "https://registry.npm.taobao.org/copy-descriptor/download/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" + integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= + +copy-to-clipboard@^3.3.1: + version "3.3.1" + resolved "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.1.tgz#115aa1a9998ffab6196f93076ad6da3b913662ae" + integrity sha512-i13qo6kIHTTpCm8/Wup+0b1mVWETvu2kIMzKoK8FpkLkFxlt0znUAHcMzox+T8sPlqtZXq3CulEjQHsYiGFJUw== + dependencies: + toggle-selection "^1.0.6" + +copy-webpack-plugin@^5.1.1: + version "5.1.2" + resolved "https://registry.npm.taobao.org/copy-webpack-plugin/download/copy-webpack-plugin-5.1.2.tgz?cache=0&sync_timestamp=1617709623958&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcopy-webpack-plugin%2Fdownload%2Fcopy-webpack-plugin-5.1.2.tgz#8a889e1dcafa6c91c6cd4be1ad158f1d3823bae2" + integrity sha1-ioieHcr6bJHGzUvhrRWPHTgjuuI= + dependencies: + cacache "^12.0.3" + find-cache-dir "^2.1.0" + glob-parent "^3.1.0" + globby "^7.1.1" + is-glob "^4.0.1" + loader-utils "^1.2.3" + minimatch "^3.0.4" + normalize-path "^3.0.0" + p-limit "^2.2.1" + schema-utils "^1.0.0" + serialize-javascript "^4.0.0" + webpack-log "^2.0.0" + +core-js-compat@^3.6.5, core-js-compat@^3.9.0, core-js-compat@^3.9.1: + version "3.10.1" + resolved "https://registry.npm.taobao.org/core-js-compat/download/core-js-compat-3.10.1.tgz?cache=0&sync_timestamp=1617822648954&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcore-js-compat%2Fdownload%2Fcore-js-compat-3.10.1.tgz#62183a3a77ceeffcc420d907a3e6fc67d9b27f1c" + integrity sha1-Yhg6OnfO7/zEINkHo+b8Z9myfxw= + dependencies: + browserslist "^4.16.3" + semver "7.0.0" + +core-js@^3.6.5, core-js@^3.8.0: + version "3.10.1" + resolved "https://registry.npm.taobao.org/core-js/download/core-js-3.10.1.tgz#e683963978b6806dcc6c0a4a8bd4ab0bdaf3f21a" + integrity sha1-5oOWOXi2gG3MbApKi9SrC9rz8ho= + +core-util-is@1.0.2, core-util-is@~1.0.0: + version "1.0.2" + resolved "https://registry.npm.taobao.org/core-util-is/download/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + +cosmiconfig@^5.0.0: + version "5.2.1" + resolved "https://registry.npm.taobao.org/cosmiconfig/download/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a" + integrity sha1-BA9yaAnFked6F8CjYmykW08Wixo= + dependencies: + import-fresh "^2.0.0" + is-directory "^0.3.1" + js-yaml "^3.13.1" + parse-json "^4.0.0" + +cosmiconfig@^6.0.0: + version "6.0.0" + resolved "https://registry.npm.taobao.org/cosmiconfig/download/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" + integrity sha1-2k/uhTxS9rHmk19BwaL8UL1KmYI= + dependencies: + "@types/parse-json" "^4.0.0" + import-fresh "^3.1.0" + parse-json "^5.0.0" + path-type "^4.0.0" + yaml "^1.7.2" + +create-ecdh@^4.0.0: + version "4.0.4" + resolved "https://registry.npm.taobao.org/create-ecdh/download/create-ecdh-4.0.4.tgz?cache=0&sync_timestamp=1596557810113&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcreate-ecdh%2Fdownload%2Fcreate-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e" + integrity sha1-1uf0v/pmc2CFoHYv06YyaE2rzE4= + dependencies: + bn.js "^4.1.0" + elliptic "^6.5.3" + +create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: + version "1.2.0" + resolved "https://registry.npm.taobao.org/create-hash/download/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" + integrity sha1-iJB4rxGmN1a8+1m9IhmWvjqe8ZY= + dependencies: + cipher-base "^1.0.1" + inherits "^2.0.1" + md5.js "^1.3.4" + ripemd160 "^2.0.1" + sha.js "^2.4.0" + +create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7: + version "1.1.7" + resolved "https://registry.npm.taobao.org/create-hmac/download/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" + integrity sha1-aRcMeLOrlXFHsriwRXLkfq0iQ/8= + dependencies: + cipher-base "^1.0.3" + create-hash "^1.1.0" + inherits "^2.0.1" + ripemd160 "^2.0.0" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + +cross-spawn@^5.0.1: + version "5.1.0" + resolved "https://registry.npm.taobao.org/cross-spawn/download/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" + integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk= + dependencies: + lru-cache "^4.0.1" + shebang-command "^1.2.0" + which "^1.2.9" + +cross-spawn@^6.0.0: + version "6.0.5" + resolved "https://registry.npm.taobao.org/cross-spawn/download/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + integrity sha1-Sl7Hxk364iw6FBJNus3uhG2Ay8Q= + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + +cross-spawn@^7.0.0: + version "7.0.3" + resolved "https://registry.npm.taobao.org/cross-spawn/download/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha1-9zqFudXUHQRVUcF34ogtSshXKKY= + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +crypto-browserify@^3.11.0: + version "3.12.0" + resolved "https://registry.npm.taobao.org/crypto-browserify/download/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec" + integrity sha1-OWz58xN/A+S45TLFj2mCVOAPgOw= + dependencies: + browserify-cipher "^1.0.0" + browserify-sign "^4.0.0" + create-ecdh "^4.0.0" + create-hash "^1.1.0" + create-hmac "^1.1.0" + diffie-hellman "^5.0.0" + inherits "^2.0.1" + pbkdf2 "^3.0.3" + public-encrypt "^4.0.0" + randombytes "^2.0.0" + randomfill "^1.0.3" + +css-color-names@0.0.4, css-color-names@^0.0.4: + version "0.0.4" + resolved "https://registry.npm.taobao.org/css-color-names/download/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" + integrity sha1-gIrcLnnPhHOAabZGyyDsJ762KeA= + +css-declaration-sorter@^4.0.1: + version "4.0.1" + resolved "https://registry.npm.taobao.org/css-declaration-sorter/download/css-declaration-sorter-4.0.1.tgz#c198940f63a76d7e36c1e71018b001721054cb22" + integrity sha1-wZiUD2OnbX42wecQGLABchBUyyI= + dependencies: + postcss "^7.0.1" + timsort "^0.3.0" + +css-loader@^3.5.3: + version "3.6.0" + resolved "https://registry.npm.taobao.org/css-loader/download/css-loader-3.6.0.tgz?cache=0&sync_timestamp=1617981411622&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcss-loader%2Fdownload%2Fcss-loader-3.6.0.tgz#2e4b2c7e6e2d27f8c8f28f61bffcd2e6c91ef645" + integrity sha1-Lkssfm4tJ/jI8o9hv/zS5ske9kU= + dependencies: + camelcase "^5.3.1" + cssesc "^3.0.0" + icss-utils "^4.1.1" + loader-utils "^1.2.3" + normalize-path "^3.0.0" + postcss "^7.0.32" + postcss-modules-extract-imports "^2.0.0" + postcss-modules-local-by-default "^3.0.2" + postcss-modules-scope "^2.2.0" + postcss-modules-values "^3.0.0" + postcss-value-parser "^4.1.0" + schema-utils "^2.7.0" + semver "^6.3.0" + +css-select-base-adapter@^0.1.1: + version "0.1.1" + resolved "https://registry.npm.taobao.org/css-select-base-adapter/download/css-select-base-adapter-0.1.1.tgz#3b2ff4972cc362ab88561507a95408a1432135d7" + integrity sha1-Oy/0lyzDYquIVhUHqVQIoUMhNdc= + +css-select@^2.0.0, css-select@^2.0.2: + version "2.1.0" + resolved "https://registry.npm.taobao.org/css-select/download/css-select-2.1.0.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcss-select%2Fdownload%2Fcss-select-2.1.0.tgz#6a34653356635934a81baca68d0255432105dbef" + integrity sha1-ajRlM1ZjWTSoG6ymjQJVQyEF2+8= + dependencies: + boolbase "^1.0.0" + css-what "^3.2.1" + domutils "^1.7.0" + nth-check "^1.0.2" + +css-tree@1.0.0-alpha.37: + version "1.0.0-alpha.37" + resolved "https://registry.npm.taobao.org/css-tree/download/css-tree-1.0.0-alpha.37.tgz?cache=0&sync_timestamp=1617191603409&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcss-tree%2Fdownload%2Fcss-tree-1.0.0-alpha.37.tgz#98bebd62c4c1d9f960ec340cf9f7522e30709a22" + integrity sha1-mL69YsTB2flg7DQM+fdSLjBwmiI= + dependencies: + mdn-data "2.0.4" + source-map "^0.6.1" + +css-tree@^1.1.2: + version "1.1.3" + resolved "https://registry.npm.taobao.org/css-tree/download/css-tree-1.1.3.tgz?cache=0&sync_timestamp=1617191603409&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcss-tree%2Fdownload%2Fcss-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" + integrity sha1-60hw+2/XcHMn7JXC/yqwm16NuR0= + dependencies: + mdn-data "2.0.14" + source-map "^0.6.1" + +css-what@^3.2.1: + version "3.4.2" + resolved "https://registry.npm.taobao.org/css-what/download/css-what-3.4.2.tgz#ea7026fcb01777edbde52124e21f327e7ae950e4" + integrity sha1-6nAm/LAXd+295SEk4h8yfnrpUOQ= + +cssesc@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/cssesc/download/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" + integrity sha1-N3QZGZA7hoVl4cCep0dEXNGJg+4= + +cssnano-preset-default@^4.0.0, cssnano-preset-default@^4.0.8: + version "4.0.8" + resolved "https://registry.npm.taobao.org/cssnano-preset-default/download/cssnano-preset-default-4.0.8.tgz?cache=0&sync_timestamp=1618056437874&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcssnano-preset-default%2Fdownload%2Fcssnano-preset-default-4.0.8.tgz#920622b1fc1e95a34e8838203f1397a504f2d3ff" + integrity sha1-kgYisfwelaNOiDggPxOXpQTy0/8= + dependencies: + css-declaration-sorter "^4.0.1" + cssnano-util-raw-cache "^4.0.1" + postcss "^7.0.0" + postcss-calc "^7.0.1" + postcss-colormin "^4.0.3" + postcss-convert-values "^4.0.1" + postcss-discard-comments "^4.0.2" + postcss-discard-duplicates "^4.0.2" + postcss-discard-empty "^4.0.1" + postcss-discard-overridden "^4.0.1" + postcss-merge-longhand "^4.0.11" + postcss-merge-rules "^4.0.3" + postcss-minify-font-values "^4.0.2" + postcss-minify-gradients "^4.0.2" + postcss-minify-params "^4.0.2" + postcss-minify-selectors "^4.0.2" + postcss-normalize-charset "^4.0.1" + postcss-normalize-display-values "^4.0.2" + postcss-normalize-positions "^4.0.2" + postcss-normalize-repeat-style "^4.0.2" + postcss-normalize-string "^4.0.2" + postcss-normalize-timing-functions "^4.0.2" + postcss-normalize-unicode "^4.0.1" + postcss-normalize-url "^4.0.1" + postcss-normalize-whitespace "^4.0.2" + postcss-ordered-values "^4.1.2" + postcss-reduce-initial "^4.0.3" + postcss-reduce-transforms "^4.0.2" + postcss-svgo "^4.0.3" + postcss-unique-selectors "^4.0.1" + +cssnano-util-get-arguments@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/cssnano-util-get-arguments/download/cssnano-util-get-arguments-4.0.0.tgz#ed3a08299f21d75741b20f3b81f194ed49cc150f" + integrity sha1-7ToIKZ8h11dBsg87gfGU7UnMFQ8= + +cssnano-util-get-match@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/cssnano-util-get-match/download/cssnano-util-get-match-4.0.0.tgz#c0e4ca07f5386bb17ec5e52250b4f5961365156d" + integrity sha1-wOTKB/U4a7F+xeUiULT1lhNlFW0= + +cssnano-util-raw-cache@^4.0.1: + version "4.0.1" + resolved "https://registry.npm.taobao.org/cssnano-util-raw-cache/download/cssnano-util-raw-cache-4.0.1.tgz#b26d5fd5f72a11dfe7a7846fb4c67260f96bf282" + integrity sha1-sm1f1fcqEd/np4RvtMZyYPlr8oI= + dependencies: + postcss "^7.0.0" + +cssnano-util-same-parent@^4.0.0: + version "4.0.1" + resolved "https://registry.npm.taobao.org/cssnano-util-same-parent/download/cssnano-util-same-parent-4.0.1.tgz#574082fb2859d2db433855835d9a8456ea18bbf3" + integrity sha1-V0CC+yhZ0ttDOFWDXZqEVuoYu/M= + +cssnano@^4.0.0, cssnano@^4.1.10: + version "4.1.11" + resolved "https://registry.npm.taobao.org/cssnano/download/cssnano-4.1.11.tgz?cache=0&sync_timestamp=1618056440634&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcssnano%2Fdownload%2Fcssnano-4.1.11.tgz#c7b5f5b81da269cb1fd982cb960c1200910c9a99" + integrity sha1-x7X1uB2iacsf2YLLlgwSAJEMmpk= + dependencies: + cosmiconfig "^5.0.0" + cssnano-preset-default "^4.0.8" + is-resolvable "^1.0.0" + postcss "^7.0.0" + +csso@^4.0.2: + version "4.2.0" + resolved "https://registry.npm.taobao.org/csso/download/csso-4.2.0.tgz?cache=0&sync_timestamp=1606408849393&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcsso%2Fdownload%2Fcsso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529" + integrity sha1-6jpWE0bo3J9UbW/r7dUBh884lSk= + dependencies: + css-tree "^1.1.2" + +csstype@^2.6.8: + version "2.6.16" + resolved "https://registry.npm.taobao.org/csstype/download/csstype-2.6.16.tgz#544d69f547013b85a40d15bff75db38f34fe9c39" + integrity sha1-VE1p9UcBO4WkDRW/912zjzT+nDk= + +cyclist@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/cyclist/download/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" + integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= + +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.npm.taobao.org/dashdash/download/dashdash-1.14.1.tgz?cache=0&sync_timestamp=1601073714105&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdashdash%2Fdownload%2Fdashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= + dependencies: + assert-plus "^1.0.0" + +debug@2.6.9, debug@^2.2.0, debug@^2.3.3: + version "2.6.9" + resolved "https://registry.npm.taobao.org/debug/download/debug-2.6.9.tgz?cache=0&sync_timestamp=1607566571506&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdebug%2Fdownload%2Fdebug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha1-XRKFFd8TT/Mn6QpMk/Tgd6U2NB8= + dependencies: + ms "2.0.0" + +debug@^3.1.1, debug@^3.2.6: + version "3.2.7" + resolved "https://registry.npm.taobao.org/debug/download/debug-3.2.7.tgz?cache=0&sync_timestamp=1607566571506&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdebug%2Fdownload%2Fdebug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha1-clgLfpFF+zm2Z2+cXl+xALk0F5o= + dependencies: + ms "^2.1.1" + +debug@^4.1.0, debug@^4.1.1: + version "4.3.1" + resolved "https://registry.npm.taobao.org/debug/download/debug-4.3.1.tgz?cache=0&sync_timestamp=1607566571506&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdebug%2Fdownload%2Fdebug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha1-8NIpxQXgxtjEmsVT0bE9wYP2su4= + dependencies: + ms "2.1.2" + +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.npm.taobao.org/decamelize/download/decamelize-1.2.0.tgz?cache=0&sync_timestamp=1610348666353&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdecamelize%2Fdownload%2Fdecamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + +decode-uri-component@^0.2.0: + version "0.2.0" + resolved "https://registry.npm.taobao.org/decode-uri-component/download/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" + integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= + +deep-equal@^1.0.1: + version "1.1.1" + resolved "https://registry.npm.taobao.org/deep-equal/download/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" + integrity sha1-tcmMlCzv+vfLBR4k4UNKJaLmB2o= + dependencies: + is-arguments "^1.0.4" + is-date-object "^1.0.1" + is-regex "^1.0.4" + object-is "^1.0.1" + object-keys "^1.1.1" + regexp.prototype.flags "^1.2.0" + +deepmerge@^1.5.2: + version "1.5.2" + resolved "https://registry.npm.taobao.org/deepmerge/download/deepmerge-1.5.2.tgz?cache=0&sync_timestamp=1593463429320&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdeepmerge%2Fdownload%2Fdeepmerge-1.5.2.tgz#10499d868844cdad4fee0842df8c7f6f0c95a753" + integrity sha1-EEmdhohEza1P7ghC34x/bwyVp1M= + +deepmerge@^4.2.2: + version "4.2.2" + resolved "https://registry.npm.taobao.org/deepmerge/download/deepmerge-4.2.2.tgz?cache=0&sync_timestamp=1593463429320&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdeepmerge%2Fdownload%2Fdeepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" + integrity sha1-RNLqNnm49NT/ujPwPYZfwee/SVU= + +default-gateway@^4.2.0: + version "4.2.0" + resolved "https://registry.npm.taobao.org/default-gateway/download/default-gateway-4.2.0.tgz?cache=0&sync_timestamp=1610365876050&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdefault-gateway%2Fdownload%2Fdefault-gateway-4.2.0.tgz#167104c7500c2115f6dd69b0a536bb8ed720552b" + integrity sha1-FnEEx1AMIRX23WmwpTa7jtcgVSs= + dependencies: + execa "^1.0.0" + ip-regex "^2.1.0" + +default-gateway@^5.0.5: + version "5.0.5" + resolved "https://registry.npm.taobao.org/default-gateway/download/default-gateway-5.0.5.tgz?cache=0&sync_timestamp=1610365876050&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdefault-gateway%2Fdownload%2Fdefault-gateway-5.0.5.tgz#4fd6bd5d2855d39b34cc5a59505486e9aafc9b10" + integrity sha1-T9a9XShV05s0zFpZUFSG6ar8mxA= + dependencies: + execa "^3.3.0" + +defaults@^1.0.3: + version "1.0.3" + resolved "https://registry.npm.taobao.org/defaults/download/defaults-1.0.3.tgz#c656051e9817d9ff08ed881477f3fe4019f3ef7d" + integrity sha1-xlYFHpgX2f8I7YgUd/P+QBnz730= + dependencies: + clone "^1.0.2" + +define-properties@^1.1.2, define-properties@^1.1.3: + version "1.1.3" + resolved "https://registry.npm.taobao.org/define-properties/download/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" + integrity sha1-z4jabL7ib+bbcJT2HYcMvYTO6fE= + dependencies: + object-keys "^1.0.12" + +define-property@^0.2.5: + version "0.2.5" + resolved "https://registry.npm.taobao.org/define-property/download/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" + integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY= + dependencies: + is-descriptor "^0.1.0" + +define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/define-property/download/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" + integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY= + dependencies: + is-descriptor "^1.0.0" + +define-property@^2.0.2: + version "2.0.2" + resolved "https://registry.npm.taobao.org/define-property/download/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" + integrity sha1-1Flono1lS6d+AqgX+HENcCyxbp0= + dependencies: + is-descriptor "^1.0.2" + isobject "^3.0.1" + +del@^4.1.1: + version "4.1.1" + resolved "https://registry.npm.taobao.org/del/download/del-4.1.1.tgz#9e8f117222ea44a31ff3a156c049b99052a9f0b4" + integrity sha1-no8RciLqRKMf86FWwEm5kFKp8LQ= + dependencies: + "@types/glob" "^7.1.1" + globby "^6.1.0" + is-path-cwd "^2.0.0" + is-path-in-cwd "^2.0.0" + p-map "^2.0.0" + pify "^4.0.1" + rimraf "^2.6.3" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/delayed-stream/download/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + +depd@~1.1.2: + version "1.1.2" + resolved "https://registry.npm.taobao.org/depd/download/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= + +des.js@^1.0.0: + version "1.0.1" + resolved "https://registry.npm.taobao.org/des.js/download/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843" + integrity sha1-U4IULhvcU/hdhtU+X0qn3rkeCEM= + dependencies: + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + +destroy@~1.0.4: + version "1.0.4" + resolved "https://registry.npm.taobao.org/destroy/download/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" + integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= + +detect-node@^2.0.4: + version "2.0.5" + resolved "https://registry.npm.taobao.org/detect-node/download/detect-node-2.0.5.tgz?cache=0&sync_timestamp=1615920847007&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdetect-node%2Fdownload%2Fdetect-node-2.0.5.tgz#9d270aa7eaa5af0b72c4c9d9b814e7f4ce738b79" + integrity sha1-nScKp+qlrwtyxMnZuBTn9M5zi3k= + +diff-match-patch@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37" + integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== + +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.npm.taobao.org/diff/download/diff-4.0.2.tgz?cache=0&sync_timestamp=1604803633979&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdiff%2Fdownload%2Fdiff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha1-YPOuy4nV+uUgwRqhnvwruYKq3n0= + +diffie-hellman@^5.0.0: + version "5.0.3" + resolved "https://registry.npm.taobao.org/diffie-hellman/download/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" + integrity sha1-QOjumPVaIUlgcUaSHGPhrl89KHU= + dependencies: + bn.js "^4.1.0" + miller-rabin "^4.0.0" + randombytes "^2.0.0" + +dir-glob@^2.0.0, dir-glob@^2.2.2: + version "2.2.2" + resolved "https://registry.npm.taobao.org/dir-glob/download/dir-glob-2.2.2.tgz#fa09f0694153c8918b18ba0deafae94769fc50c4" + integrity sha1-+gnwaUFTyJGLGLoN6vrpR2n8UMQ= + dependencies: + path-type "^3.0.0" + +dns-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/dns-equal/download/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d" + integrity sha1-s55/HabrCnW6nBcySzR1PEfgZU0= + +dns-packet@^1.3.1: + version "1.3.1" + resolved "https://registry.npm.taobao.org/dns-packet/download/dns-packet-1.3.1.tgz#12aa426981075be500b910eedcd0b47dd7deda5a" + integrity sha1-EqpCaYEHW+UAuRDu3NC0fdfe2lo= + dependencies: + ip "^1.1.0" + safe-buffer "^5.0.1" + +dns-txt@^2.0.2: + version "2.0.2" + resolved "https://registry.npm.taobao.org/dns-txt/download/dns-txt-2.0.2.tgz#b91d806f5d27188e4ab3e7d107d881a1cc4642b6" + integrity sha1-uR2Ab10nGI5Ks+fRB9iBocxGQrY= + dependencies: + buffer-indexof "^1.0.0" + +dom-align@^1.12.1, dom-align@^1.12.2: + version "1.12.2" + resolved "https://registry.yarnpkg.com/dom-align/-/dom-align-1.12.2.tgz#0f8164ebd0c9c21b0c790310493cd855892acd4b" + integrity sha512-pHuazgqrsTFrGU2WLDdXxCFabkdQDx72ddkraZNih1KsMcN5qsRSTR9O4VJRlwTPCPb5COYg3LOfiMHHcPInHg== + +dom-converter@^0.2: + version "0.2.0" + resolved "https://registry.npm.taobao.org/dom-converter/download/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768" + integrity sha1-ZyGp2u4uKTaClVtq/kFncWJ7t2g= + dependencies: + utila "~0.4" + +dom-scroll-into-view@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz#0decc8522801fd8d3f1c6ba355a74d382c5f989b" + integrity sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w== + +dom-serializer@0: + version "0.2.2" + resolved "https://registry.npm.taobao.org/dom-serializer/download/dom-serializer-0.2.2.tgz?cache=0&sync_timestamp=1617912077476&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdom-serializer%2Fdownload%2Fdom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" + integrity sha1-GvuB9TNxcXXUeGVd68XjMtn5u1E= + dependencies: + domelementtype "^2.0.1" + entities "^2.0.0" + +domain-browser@^1.1.1: + version "1.2.0" + resolved "https://registry.npm.taobao.org/domain-browser/download/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" + integrity sha1-PTH1AZGmdJ3RN1p/Ui6CPULlTto= + +domelementtype@1, domelementtype@^1.3.1: + version "1.3.1" + resolved "https://registry.npm.taobao.org/domelementtype/download/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" + integrity sha1-0EjESzew0Qp/Kj1f7j9DM9eQSB8= + +domelementtype@^2.0.1: + version "2.2.0" + resolved "https://registry.npm.taobao.org/domelementtype/download/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57" + integrity sha1-mgtsJ4LtahxzI9QiZxg9+b2LHVc= + +domhandler@^2.3.0: + version "2.4.2" + resolved "https://registry.npm.taobao.org/domhandler/download/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803" + integrity sha1-iAUJfpM9ZehVRvcm1g9euItE+AM= + dependencies: + domelementtype "1" + +domutils@^1.5.1, domutils@^1.7.0: + version "1.7.0" + resolved "https://registry.npm.taobao.org/domutils/download/domutils-1.7.0.tgz?cache=0&sync_timestamp=1617913465626&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdomutils%2Fdownload%2Fdomutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" + integrity sha1-Vuo0HoNOBuZ0ivehyyXaZ+qfjCo= + dependencies: + dom-serializer "0" + domelementtype "1" + +dot-prop@^5.2.0: + version "5.3.0" + resolved "https://registry.npm.taobao.org/dot-prop/download/dot-prop-5.3.0.tgz?cache=0&sync_timestamp=1605778171073&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fdot-prop%2Fdownload%2Fdot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" + integrity sha1-kMzOcIzZzYLMTcjD3dmr3VWyDog= + dependencies: + is-obj "^2.0.0" + +dotenv-expand@^5.1.0: + version "5.1.0" + resolved "https://registry.npm.taobao.org/dotenv-expand/download/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0" + integrity sha1-P7rwIL/XlIhAcuomsel5HUWmKfA= + +dotenv@^8.2.0: + version "8.2.0" + resolved "https://registry.npm.taobao.org/dotenv/download/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" + integrity sha1-l+YZJZradQ7qPk6j4mvO6lQksWo= + +duplexer@^0.1.1: + version "0.1.2" + resolved "https://registry.npm.taobao.org/duplexer/download/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" + integrity sha1-Or5DrvODX4rgd9E23c4PJ2sEAOY= + +duplexify@^3.4.2, duplexify@^3.6.0: + version "3.7.1" + resolved "https://registry.npm.taobao.org/duplexify/download/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309" + integrity sha1-Kk31MX9sz9kfhtb9JdjYoQO4gwk= + dependencies: + end-of-stream "^1.0.0" + inherits "^2.0.1" + readable-stream "^2.0.0" + stream-shift "^1.0.0" + +easy-stack@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/easy-stack/download/easy-stack-1.0.1.tgz#8afe4264626988cabb11f3c704ccd0c835411066" + integrity sha1-iv5CZGJpiMq7EfPHBMzQyDVBEGY= + +ecc-jsbn@~0.1.1: + version "0.1.2" + resolved "https://registry.npm.taobao.org/ecc-jsbn/download/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" + integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= + dependencies: + jsbn "~0.1.0" + safer-buffer "^2.1.0" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.npm.taobao.org/ee-first/download/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= + +ejs@^2.6.1: + version "2.7.4" + resolved "https://registry.npm.taobao.org/ejs/download/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba" + integrity sha1-SGYSh1c9zFPjZsehrlLDoSDuybo= + +electron-to-chromium@^1.3.649: + version "1.3.712" + resolved "https://registry.npm.taobao.org/electron-to-chromium/download/electron-to-chromium-1.3.712.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Felectron-to-chromium%2Fdownload%2Felectron-to-chromium-1.3.712.tgz#ae467ffe5f95961c6d41ceefe858fc36eb53b38f" + integrity sha1-rkZ//l+VlhxtQc7v6Fj8NutTs48= + +elliptic@^6.5.3: + version "6.5.4" + resolved "https://registry.npm.taobao.org/elliptic/download/elliptic-6.5.4.tgz?cache=0&sync_timestamp=1612290637117&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Felliptic%2Fdownload%2Felliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" + integrity sha1-2jfOvTHnmhNn6UG1ku0fvr1Yq7s= + dependencies: + bn.js "^4.11.9" + brorand "^1.1.0" + hash.js "^1.0.0" + hmac-drbg "^1.0.1" + inherits "^2.0.4" + minimalistic-assert "^1.0.1" + minimalistic-crypto-utils "^1.0.1" + +emoji-regex@^7.0.1: + version "7.0.3" + resolved "https://registry.npm.taobao.org/emoji-regex/download/emoji-regex-7.0.3.tgz?cache=0&sync_timestamp=1614682725186&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Femoji-regex%2Fdownload%2Femoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha1-kzoEBShgyF6DwSJHnEdIqOTHIVY= + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.npm.taobao.org/emoji-regex/download/emoji-regex-8.0.0.tgz?cache=0&sync_timestamp=1614682725186&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Femoji-regex%2Fdownload%2Femoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc= + +emojis-list@^2.0.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/emojis-list/download/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" + integrity sha1-TapNnbAPmBmIDHn6RXrlsJof04k= + +emojis-list@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/emojis-list/download/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" + integrity sha1-VXBmIEatKeLpFucariYKvf9Pang= + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.npm.taobao.org/encodeurl/download/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= + +end-of-stream@^1.0.0, end-of-stream@^1.1.0: + version "1.4.4" + resolved "https://registry.npm.taobao.org/end-of-stream/download/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha1-WuZKX0UFe682JuwU2gyl5LJDHrA= + dependencies: + once "^1.4.0" + +enhanced-resolve@^4.0.0, enhanced-resolve@^4.5.0: + version "4.5.0" + resolved "https://registry.npm.taobao.org/enhanced-resolve/download/enhanced-resolve-4.5.0.tgz#2f3cfd84dbe3b487f18f2db2ef1e064a571ca5ec" + integrity sha1-Lzz9hNvjtIfxjy2y7x4GSlccpew= + dependencies: + graceful-fs "^4.1.2" + memory-fs "^0.5.0" + tapable "^1.0.0" + +entities@^1.1.1: + version "1.1.2" + resolved "https://registry.npm.taobao.org/entities/download/entities-1.1.2.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fentities%2Fdownload%2Fentities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" + integrity sha1-vfpzUplmTfr9NFKe1PhSKidf6lY= + +entities@^2.0.0: + version "2.2.0" + resolved "https://registry.npm.taobao.org/entities/download/entities-2.2.0.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fentities%2Fdownload%2Fentities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" + integrity sha1-CY3JDruD2N/6CJ1VJWs1HTTE2lU= + +errno@^0.1.1, errno@^0.1.3, errno@~0.1.7: + version "0.1.8" + resolved "https://registry.npm.taobao.org/errno/download/errno-0.1.8.tgz#8bb3e9c7d463be4976ff888f76b4809ebc2e811f" + integrity sha1-i7Ppx9Rjvkl2/4iPdrSAnrwugR8= + dependencies: + prr "~1.0.1" + +error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.npm.taobao.org/error-ex/download/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" + integrity sha1-tKxAZIEH/c3PriQvQovqihTU8b8= + dependencies: + is-arrayish "^0.2.1" + +error-stack-parser@^2.0.2: + version "2.0.6" + resolved "https://registry.npm.taobao.org/error-stack-parser/download/error-stack-parser-2.0.6.tgz#5a99a707bd7a4c58a797902d48d82803ede6aad8" + integrity sha1-WpmnB716TFinl5AtSNgoA+3mqtg= + dependencies: + stackframe "^1.1.1" + +es-abstract@^1.17.2, es-abstract@^1.18.0-next.2: + version "1.18.0" + resolved "https://registry.npm.taobao.org/es-abstract/download/es-abstract-1.18.0.tgz?cache=0&sync_timestamp=1614814465007&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fes-abstract%2Fdownload%2Fes-abstract-1.18.0.tgz#ab80b359eecb7ede4c298000390bc5ac3ec7b5a4" + integrity sha1-q4CzWe7Lft5MKYAAOQvFrD7HtaQ= + dependencies: + call-bind "^1.0.2" + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + get-intrinsic "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.2" + is-callable "^1.2.3" + is-negative-zero "^2.0.1" + is-regex "^1.1.2" + is-string "^1.0.5" + object-inspect "^1.9.0" + object-keys "^1.1.1" + object.assign "^4.1.2" + string.prototype.trimend "^1.0.4" + string.prototype.trimstart "^1.0.4" + unbox-primitive "^1.0.0" + +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.npm.taobao.org/es-to-primitive/download/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha1-5VzUyc3BiLzvsDs2bHNjI/xciYo= + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.npm.taobao.org/escalade/download/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha1-2M/ccACWXFoBdLSoLqpcBVJ0LkA= + +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.npm.taobao.org/escape-html/download/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= + +escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.npm.taobao.org/escape-string-regexp/download/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + +eslint-scope@^4.0.3: + version "4.0.3" + resolved "https://registry.npm.taobao.org/eslint-scope/download/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" + integrity sha1-ygODMxD2iJoyZHgaqC5j65z+eEg= + dependencies: + esrecurse "^4.1.0" + estraverse "^4.1.1" + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.npm.taobao.org/esprima/download/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha1-E7BM2z5sXRnfkatph6hpVhmwqnE= + +esrecurse@^4.1.0: + version "4.3.0" + resolved "https://registry.npm.taobao.org/esrecurse/download/esrecurse-4.3.0.tgz?cache=0&sync_timestamp=1598899004767&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fesrecurse%2Fdownload%2Fesrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha1-eteWTWeauyi+5yzsY3WLHF0smSE= + dependencies: + estraverse "^5.2.0" + +estraverse@^4.1.1: + version "4.3.0" + resolved "https://registry.npm.taobao.org/estraverse/download/estraverse-4.3.0.tgz?cache=0&sync_timestamp=1596642946176&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Festraverse%2Fdownload%2Festraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" + integrity sha1-OYrT88WiSUi+dyXoPRGn3ijNvR0= + +estraverse@^5.2.0: + version "5.2.0" + resolved "https://registry.npm.taobao.org/estraverse/download/estraverse-5.2.0.tgz?cache=0&sync_timestamp=1596642946176&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Festraverse%2Fdownload%2Festraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880" + integrity sha1-MH30JUfmzHMk088DwVXVzbjFOIA= + +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.npm.taobao.org/estree-walker/download/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha1-UvAQF4wqTBF6d1fP6UKtt9LaTKw= + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.npm.taobao.org/esutils/download/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha1-dNLrTeC42hKTcRkQ1Qd1ubcQ72Q= + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.npm.taobao.org/etag/download/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= + +event-pubsub@4.3.0: + version "4.3.0" + resolved "https://registry.npm.taobao.org/event-pubsub/download/event-pubsub-4.3.0.tgz?cache=0&sync_timestamp=1606361549058&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fevent-pubsub%2Fdownload%2Fevent-pubsub-4.3.0.tgz#f68d816bc29f1ec02c539dc58c8dd40ce72cb36e" + integrity sha1-9o2Ba8KfHsAsU53FjI3UDOcss24= + +eventemitter2@^6.4.4: + version "6.4.4" + resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.4.tgz#aa96e8275c4dbeb017a5d0e03780c65612a1202b" + integrity sha512-HLU3NDY6wARrLCEwyGKRBvuWYyvW6mHYv72SJJAH3iJN3a6eVUvkjFkcxah1bcTgGVBBrFdIopBJPhCQFMLyXw== + +eventemitter3@^4.0.0: + version "4.0.7" + resolved "https://registry.npm.taobao.org/eventemitter3/download/eventemitter3-4.0.7.tgz?cache=0&sync_timestamp=1598517790184&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Feventemitter3%2Fdownload%2Feventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha1-Lem2j2Uo1WRO9cWVJqG0oHMGFp8= + +events@^3.0.0: + version "3.3.0" + resolved "https://registry.npm.taobao.org/events/download/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha1-Mala0Kkk4tLEGagTrrLE6HjqdAA= + +eventsource@^1.0.7: + version "1.1.0" + resolved "https://registry.npm.taobao.org/eventsource/download/eventsource-1.1.0.tgz?cache=0&sync_timestamp=1616041700200&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Feventsource%2Fdownload%2Feventsource-1.1.0.tgz#00e8ca7c92109e94b0ddf32dac677d841028cfaf" + integrity sha1-AOjKfJIQnpSw3fMtrGd9hBAoz68= + dependencies: + original "^1.0.0" + +evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: + version "1.0.3" + resolved "https://registry.npm.taobao.org/evp_bytestokey/download/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" + integrity sha1-f8vbGY3HGVlDLv4ThCaE4FJaywI= + dependencies: + md5.js "^1.3.4" + safe-buffer "^5.1.1" + +execa@^0.8.0: + version "0.8.0" + resolved "https://registry.npm.taobao.org/execa/download/execa-0.8.0.tgz#d8d76bbc1b55217ed190fd6dd49d3c774ecfc8da" + integrity sha1-2NdrvBtVIX7RkP1t1J08d07PyNo= + dependencies: + cross-spawn "^5.0.1" + get-stream "^3.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + +execa@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/execa/download/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" + integrity sha1-xiNqW7TfbW8V6I5/AXeYIWdJ3dg= + dependencies: + cross-spawn "^6.0.0" + get-stream "^4.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + +execa@^3.3.0: + version "3.4.0" + resolved "https://registry.npm.taobao.org/execa/download/execa-3.4.0.tgz#c08ed4550ef65d858fac269ffc8572446f37eb89" + integrity sha1-wI7UVQ72XYWPrCaf/IVyRG8364k= + dependencies: + cross-spawn "^7.0.0" + get-stream "^5.0.0" + human-signals "^1.1.1" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.0" + onetime "^5.1.0" + p-finally "^2.0.0" + signal-exit "^3.0.2" + strip-final-newline "^2.0.0" + +expand-brackets@^2.1.4: + version "2.1.4" + resolved "https://registry.npm.taobao.org/expand-brackets/download/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" + integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI= + dependencies: + debug "^2.3.3" + define-property "^0.2.5" + extend-shallow "^2.0.1" + posix-character-classes "^0.1.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +express@^4.16.3, express@^4.17.1: + version "4.17.1" + resolved "https://registry.npm.taobao.org/express/download/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" + integrity sha1-RJH8OGBc9R+GKdOcK10Cb5ikwTQ= + dependencies: + accepts "~1.3.7" + array-flatten "1.1.1" + body-parser "1.19.0" + content-disposition "0.5.3" + content-type "~1.0.4" + cookie "0.4.0" + cookie-signature "1.0.6" + debug "2.6.9" + depd "~1.1.2" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "~1.1.2" + fresh "0.5.2" + merge-descriptors "1.0.1" + methods "~1.1.2" + on-finished "~2.3.0" + parseurl "~1.3.3" + path-to-regexp "0.1.7" + proxy-addr "~2.0.5" + qs "6.7.0" + range-parser "~1.2.1" + safe-buffer "5.1.2" + send "0.17.1" + serve-static "1.14.1" + setprototypeof "1.1.1" + statuses "~1.5.0" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.npm.taobao.org/extend-shallow/download/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8= + dependencies: + is-extendable "^0.1.0" + +extend-shallow@^3.0.0, extend-shallow@^3.0.2: + version "3.0.2" + resolved "https://registry.npm.taobao.org/extend-shallow/download/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" + integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg= + dependencies: + assign-symbols "^1.0.0" + is-extendable "^1.0.1" + +extend@~3.0.2: + version "3.0.2" + resolved "https://registry.npm.taobao.org/extend/download/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha1-+LETa0Bx+9jrFAr/hYsQGewpFfo= + +extglob@^2.0.4: + version "2.0.4" + resolved "https://registry.npm.taobao.org/extglob/download/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" + integrity sha1-rQD+TcYSqSMuhxhxHcXLWrAoVUM= + dependencies: + array-unique "^0.3.2" + define-property "^1.0.0" + expand-brackets "^2.1.4" + extend-shallow "^2.0.1" + fragment-cache "^0.2.1" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +extsprintf@1.3.0: + version "1.3.0" + resolved "https://registry.npm.taobao.org/extsprintf/download/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" + integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= + +extsprintf@^1.2.0: + version "1.4.0" + resolved "https://registry.npm.taobao.org/extsprintf/download/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" + integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= + +fast-deep-equal@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" + integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk= + +fast-deep-equal@^3.1.1: + version "3.1.3" + resolved "https://registry.npm.taobao.org/fast-deep-equal/download/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha1-On1WtVnWy8PrUSMlJE5hmmXGxSU= + +fast-glob@^2.2.6: + version "2.2.7" + resolved "https://registry.npm.taobao.org/fast-glob/download/fast-glob-2.2.7.tgz?cache=0&sync_timestamp=1610876590762&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffast-glob%2Fdownload%2Ffast-glob-2.2.7.tgz#6953857c3afa475fff92ee6015d52da70a4cd39d" + integrity sha1-aVOFfDr6R1//ku5gFdUtpwpM050= + dependencies: + "@mrmlnc/readdir-enhanced" "^2.2.1" + "@nodelib/fs.stat" "^1.1.2" + glob-parent "^3.1.0" + is-glob "^4.0.0" + merge2 "^1.2.3" + micromatch "^3.1.10" + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/fast-json-stable-stringify/download/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha1-h0v2nG9ATCtdmcSBNBOZ/VWJJjM= + +faye-websocket@^0.11.3: + version "0.11.3" + resolved "https://registry.npm.taobao.org/faye-websocket/download/faye-websocket-0.11.3.tgz#5c0e9a8968e8912c286639fde977a8b209f2508e" + integrity sha1-XA6aiWjokSwoZjn96XeosgnyUI4= + dependencies: + websocket-driver ">=0.5.1" + +figgy-pudding@^3.5.1: + version "3.5.2" + resolved "https://registry.npm.taobao.org/figgy-pudding/download/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e" + integrity sha1-tO7oFIq7Adzx0aw0Nn1Z4S+mHW4= + +file-loader@^4.2.0: + version "4.3.0" + resolved "https://registry.npm.taobao.org/file-loader/download/file-loader-4.3.0.tgz#780f040f729b3d18019f20605f723e844b8a58af" + integrity sha1-eA8ED3KbPRgBnyBgX3I+hEuKWK8= + dependencies: + loader-utils "^1.2.3" + schema-utils "^2.5.0" + +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/file-uri-to-path/download/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha1-VTp7hEb/b2hDWcRF8eN6BdrMM90= + +filesize@^3.6.1: + version "3.6.1" + resolved "https://registry.npm.taobao.org/filesize/download/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317" + integrity sha1-CQuz7gG2+AGoqL6Z0xcQs0Irsxc= + +filesize@^6.3.0: + version "6.3.0" + resolved "https://registry.nlark.com/filesize/download/filesize-6.3.0.tgz#dff53cfb3f104c9e422f346d53be8dbcc971bf11" + integrity sha1-3/U8+z8QTJ5CLzRtU76NvMlxvxE= + +fill-range@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/fill-range/download/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" + integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc= + dependencies: + extend-shallow "^2.0.1" + is-number "^3.0.0" + repeat-string "^1.6.1" + to-regex-range "^2.1.0" + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.npm.taobao.org/fill-range/download/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha1-GRmmp8df44ssfHflGYU12prN2kA= + dependencies: + to-regex-range "^5.0.1" + +finalhandler@~1.1.2: + version "1.1.2" + resolved "https://registry.npm.taobao.org/finalhandler/download/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" + integrity sha1-t+fQAP/RGTjQ/bBTUG9uur6fWH0= + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "~2.3.0" + parseurl "~1.3.3" + statuses "~1.5.0" + unpipe "~1.0.0" + +find-cache-dir@^2.1.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/find-cache-dir/download/find-cache-dir-2.1.0.tgz?cache=0&sync_timestamp=1593529714508&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffind-cache-dir%2Fdownload%2Ffind-cache-dir-2.1.0.tgz#8d0f94cd13fe43c6c7c261a0d86115ca918c05f7" + integrity sha1-jQ+UzRP+Q8bHwmGg2GEVypGMBfc= + dependencies: + commondir "^1.0.1" + make-dir "^2.0.0" + pkg-dir "^3.0.0" + +find-cache-dir@^3.0.0, find-cache-dir@^3.3.1: + version "3.3.1" + resolved "https://registry.npm.taobao.org/find-cache-dir/download/find-cache-dir-3.3.1.tgz?cache=0&sync_timestamp=1593529714508&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffind-cache-dir%2Fdownload%2Ffind-cache-dir-3.3.1.tgz#89b33fad4a4670daa94f855f7fbe31d6d84fe880" + integrity sha1-ibM/rUpGcNqpT4Vff74x1thP6IA= + dependencies: + commondir "^1.0.1" + make-dir "^3.0.2" + pkg-dir "^4.1.0" + +find-up@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/find-up/download/find-up-3.0.0.tgz?cache=0&sync_timestamp=1597169882796&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffind-up%2Fdownload%2Ffind-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" + integrity sha1-SRafHXmTQwZG2mHsxa41XCHJe3M= + dependencies: + locate-path "^3.0.0" + +find-up@^4.0.0: + version "4.1.0" + resolved "https://registry.npm.taobao.org/find-up/download/find-up-4.1.0.tgz?cache=0&sync_timestamp=1597169882796&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffind-up%2Fdownload%2Ffind-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha1-l6/n1s3AvFkoWEt8jXsW6KmqXRk= + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +flush-write-stream@^1.0.0: + version "1.1.1" + resolved "https://registry.npm.taobao.org/flush-write-stream/download/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8" + integrity sha1-jdfYc6G6vCB9lOrQwuDkQnbr8ug= + dependencies: + inherits "^2.0.3" + readable-stream "^2.3.6" + +follow-redirects@^1.0.0: + version "1.13.3" + resolved "https://registry.npm.taobao.org/follow-redirects/download/follow-redirects-1.13.3.tgz?cache=0&sync_timestamp=1614436920073&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffollow-redirects%2Fdownload%2Ffollow-redirects-1.13.3.tgz#e5598ad50174c1bc4e872301e82ac2cd97f90267" + integrity sha1-5VmK1QF0wbxOhyMB6CrCzZf5Amc= + +for-in@^1.0.2: + version "1.0.2" + resolved "https://registry.npm.taobao.org/for-in/download/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" + integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.npm.taobao.org/forever-agent/download/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= + +"fork-ts-checker-webpack-plugin-v5@npm:fork-ts-checker-webpack-plugin@^5.0.11": + version "5.2.1" + resolved "https://registry.npm.taobao.org/fork-ts-checker-webpack-plugin/download/fork-ts-checker-webpack-plugin-5.2.1.tgz?cache=0&sync_timestamp=1617967627084&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffork-ts-checker-webpack-plugin%2Fdownload%2Ffork-ts-checker-webpack-plugin-5.2.1.tgz#79326d869797906fa8b24e2abcf9421fc805450d" + integrity sha1-eTJthpeXkG+osk4qvPlCH8gFRQ0= + dependencies: + "@babel/code-frame" "^7.8.3" + "@types/json-schema" "^7.0.5" + chalk "^4.1.0" + cosmiconfig "^6.0.0" + deepmerge "^4.2.2" + fs-extra "^9.0.0" + memfs "^3.1.2" + minimatch "^3.0.4" + schema-utils "2.7.0" + semver "^7.3.2" + tapable "^1.0.0" + +fork-ts-checker-webpack-plugin@^3.1.1: + version "3.1.1" + resolved "https://registry.npm.taobao.org/fork-ts-checker-webpack-plugin/download/fork-ts-checker-webpack-plugin-3.1.1.tgz?cache=0&sync_timestamp=1617967627084&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffork-ts-checker-webpack-plugin%2Fdownload%2Ffork-ts-checker-webpack-plugin-3.1.1.tgz#a1642c0d3e65f50c2cc1742e9c0a80f441f86b19" + integrity sha1-oWQsDT5l9QwswXQunAqA9EH4axk= + dependencies: + babel-code-frame "^6.22.0" + chalk "^2.4.1" + chokidar "^3.3.0" + micromatch "^3.1.10" + minimatch "^3.0.4" + semver "^5.6.0" + tapable "^1.0.0" + worker-rpc "^0.1.0" + +form-data@~2.3.2: + version "2.3.3" + resolved "https://registry.npm.taobao.org/form-data/download/form-data-2.3.3.tgz?cache=0&sync_timestamp=1613410812604&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fform-data%2Fdownload%2Fform-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" + integrity sha1-3M5SwF9kTymManq5Nr1yTO/786Y= + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + +forwarded@~0.1.2: + version "0.1.2" + resolved "https://registry.npm.taobao.org/forwarded/download/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" + integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ= + +fragment-cache@^0.2.1: + version "0.2.1" + resolved "https://registry.npm.taobao.org/fragment-cache/download/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" + integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk= + dependencies: + map-cache "^0.2.2" + +fresh@0.5.2: + version "0.5.2" + resolved "https://registry.npm.taobao.org/fresh/download/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= + +from2@^2.1.0: + version "2.3.0" + resolved "https://registry.npm.taobao.org/from2/download/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" + integrity sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8= + dependencies: + inherits "^2.0.1" + readable-stream "^2.0.0" + +fs-extra@^7.0.1: + version "7.0.1" + resolved "https://registry.npm.taobao.org/fs-extra/download/fs-extra-7.0.1.tgz?cache=0&sync_timestamp=1611075517449&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffs-extra%2Fdownload%2Ffs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" + integrity sha1-TxicRKoSO4lfcigE9V6iPq3DSOk= + dependencies: + graceful-fs "^4.1.2" + jsonfile "^4.0.0" + universalify "^0.1.0" + +fs-extra@^9.0.0: + version "9.1.0" + resolved "https://registry.npm.taobao.org/fs-extra/download/fs-extra-9.1.0.tgz?cache=0&sync_timestamp=1611075517449&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffs-extra%2Fdownload%2Ffs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" + integrity sha1-WVRGDHZKjaIJS6NVS/g55rmnyG0= + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs-minipass@^2.0.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/fs-minipass/download/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" + integrity sha1-f1A2/b8SxjwWkZDL5BmchSJx+fs= + dependencies: + minipass "^3.0.0" + +fs-monkey@1.0.3: + version "1.0.3" + resolved "https://registry.npm.taobao.org/fs-monkey/download/fs-monkey-1.0.3.tgz?cache=0&sync_timestamp=1617593328016&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffs-monkey%2Fdownload%2Ffs-monkey-1.0.3.tgz#ae3ac92d53bb328efe0e9a1d9541f6ad8d48e2d3" + integrity sha1-rjrJLVO7Mo7+DpodlUH2rY1I4tM= + +fs-write-stream-atomic@^1.0.8: + version "1.0.10" + resolved "https://registry.npm.taobao.org/fs-write-stream-atomic/download/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9" + integrity sha1-tH31NJPvkR33VzHnCp3tAYnbQMk= + dependencies: + graceful-fs "^4.1.2" + iferr "^0.1.5" + imurmurhash "^0.1.4" + readable-stream "1 || 2" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/fs.realpath/download/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + +fsevents@^1.2.7: + version "1.2.13" + resolved "https://registry.npm.taobao.org/fsevents/download/fsevents-1.2.13.tgz?cache=0&sync_timestamp=1612536512306&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffsevents%2Fdownload%2Ffsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38" + integrity sha1-8yXLBFVZJCi88Rs4M3DvcOO/zDg= + dependencies: + bindings "^1.5.0" + nan "^2.12.1" + +fsevents@~2.3.1: + version "2.3.2" + resolved "https://registry.npm.taobao.org/fsevents/download/fsevents-2.3.2.tgz?cache=0&sync_timestamp=1612536512306&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ffsevents%2Fdownload%2Ffsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha1-ilJveLj99GI7cJ4Ll1xSwkwC/Ro= + +function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.npm.taobao.org/function-bind/download/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + integrity sha1-pWiZ0+o8m6uHS7l3O3xe3pL0iV0= + +generic-names@^2.0.1: + version "2.0.1" + resolved "https://registry.npm.taobao.org/generic-names/download/generic-names-2.0.1.tgz?cache=0&sync_timestamp=1603542356660&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fgeneric-names%2Fdownload%2Fgeneric-names-2.0.1.tgz#f8a378ead2ccaa7a34f0317b05554832ae41b872" + integrity sha1-+KN46tLMqno08DF7BVVIMq5BuHI= + dependencies: + loader-utils "^1.1.0" + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.npm.taobao.org/gensync/download/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha1-MqbudsPX9S1GsrGuXZP+qFgKJeA= + +get-caller-file@^2.0.1, get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.npm.taobao.org/get-caller-file/download/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha1-T5RBKoLbMvNuOwuXQfipf+sDH34= + +get-intrinsic@^1.0.2, get-intrinsic@^1.1.1: + version "1.1.1" + resolved "https://registry.npm.taobao.org/get-intrinsic/download/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6" + integrity sha1-FfWfN2+FXERpY5SPDSTNNje0q8Y= + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + +get-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/get-stream/download/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" + integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ= + +get-stream@^4.0.0: + version "4.1.0" + resolved "https://registry.npm.taobao.org/get-stream/download/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" + integrity sha1-wbJVV189wh1Zv8ec09K0axw6VLU= + dependencies: + pump "^3.0.0" + +get-stream@^5.0.0: + version "5.2.0" + resolved "https://registry.npm.taobao.org/get-stream/download/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha1-SWaheV7lrOZecGxLe+txJX1uItM= + dependencies: + pump "^3.0.0" + +get-value@^2.0.3, get-value@^2.0.6: + version "2.0.6" + resolved "https://registry.npm.taobao.org/get-value/download/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" + integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.npm.taobao.org/getpass/download/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= + dependencies: + assert-plus "^1.0.0" + +glob-parent@^3.1.0: + version "3.1.0" + resolved "https://registry.npm.taobao.org/glob-parent/download/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" + integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4= + dependencies: + is-glob "^3.1.0" + path-dirname "^1.0.0" + +glob-parent@~5.1.0: + version "5.1.2" + resolved "https://registry.npm.taobao.org/glob-parent/download/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha1-hpgyxYA0/mikCTwX3BXoNA2EAcQ= + dependencies: + is-glob "^4.0.1" + +glob-to-regexp@^0.3.0: + version "0.3.0" + resolved "https://registry.npm.taobao.org/glob-to-regexp/download/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab" + integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs= + +glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: + version "7.1.6" + resolved "https://registry.npm.taobao.org/glob/download/glob-7.1.6.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fglob%2Fdownload%2Fglob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha1-FB8zuBp8JJLhJVlDB0gMRmeSeKY= + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.npm.taobao.org/globals/download/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha1-q4eVM4hooLq9hSV1gBjCp+uVxC4= + +globby@^6.1.0: + version "6.1.0" + resolved "https://registry.npm.taobao.org/globby/download/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c" + integrity sha1-9abXDoOV4hyFj7BInWTfAkJNUGw= + dependencies: + array-union "^1.0.1" + glob "^7.0.3" + object-assign "^4.0.1" + pify "^2.0.0" + pinkie-promise "^2.0.0" + +globby@^7.1.1: + version "7.1.1" + resolved "https://registry.npm.taobao.org/globby/download/globby-7.1.1.tgz#fb2ccff9401f8600945dfada97440cca972b8680" + integrity sha1-+yzP+UAfhgCUXfral0QMypcrhoA= + dependencies: + array-union "^1.0.1" + dir-glob "^2.0.0" + glob "^7.1.2" + ignore "^3.3.5" + pify "^3.0.0" + slash "^1.0.0" + +globby@^9.2.0: + version "9.2.0" + resolved "https://registry.npm.taobao.org/globby/download/globby-9.2.0.tgz#fd029a706c703d29bdd170f4b6db3a3f7a7cb63d" + integrity sha1-/QKacGxwPSm90XD0tts6P3p8tj0= + dependencies: + "@types/glob" "^7.1.1" + array-union "^1.0.2" + dir-glob "^2.2.2" + fast-glob "^2.2.6" + glob "^7.1.3" + ignore "^4.0.3" + pify "^4.0.1" + slash "^2.0.0" + +graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2: + version "4.2.6" + resolved "https://registry.npm.taobao.org/graceful-fs/download/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee" + integrity sha1-/wQLKwhTsjw9MQJ1I3BvGIXXa+4= + +gzip-size@^5.0.0: + version "5.1.1" + resolved "https://registry.npm.taobao.org/gzip-size/download/gzip-size-5.1.1.tgz?cache=0&sync_timestamp=1605523125680&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fgzip-size%2Fdownload%2Fgzip-size-5.1.1.tgz#cb9bee692f87c0612b232840a873904e4c135274" + integrity sha1-y5vuaS+HwGErIyhAqHOQTkwTUnQ= + dependencies: + duplexer "^0.1.1" + pify "^4.0.1" + +handle-thing@^2.0.0: + version "2.0.1" + resolved "https://registry.npm.taobao.org/handle-thing/download/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" + integrity sha1-hX95zjWVgMNA1DCBzGSJcNC7I04= + +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/har-schema/download/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= + +har-validator@~5.1.3: + version "5.1.5" + resolved "https://registry.npm.taobao.org/har-validator/download/har-validator-5.1.5.tgz?cache=0&sync_timestamp=1596082605533&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fhar-validator%2Fdownload%2Fhar-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd" + integrity sha1-HwgDufjLIMD6E4It8ezds2veHv0= + dependencies: + ajv "^6.12.3" + har-schema "^2.0.0" + +has-ansi@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/has-ansi/download/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" + integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE= + dependencies: + ansi-regex "^2.0.0" + +has-bigints@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/has-bigints/download/has-bigints-1.0.1.tgz?cache=0&sync_timestamp=1615461293395&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fhas-bigints%2Fdownload%2Fhas-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" + integrity sha1-ZP5qywIGc+O3jbA1pa9pqp0HsRM= + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/has-flag/download/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/has-flag/download/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha1-lEdx/ZyByBJlxNaUGGDaBrtZR5s= + +has-symbols@^1.0.1, has-symbols@^1.0.2: + version "1.0.2" + resolved "https://registry.npm.taobao.org/has-symbols/download/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423" + integrity sha1-Fl0wcMADCXUqEjakeTMeOsVvFCM= + +has-value@^0.3.1: + version "0.3.1" + resolved "https://registry.npm.taobao.org/has-value/download/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" + integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8= + dependencies: + get-value "^2.0.3" + has-values "^0.1.4" + isobject "^2.0.0" + +has-value@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/has-value/download/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" + integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc= + dependencies: + get-value "^2.0.6" + has-values "^1.0.0" + isobject "^3.0.0" + +has-values@^0.1.4: + version "0.1.4" + resolved "https://registry.npm.taobao.org/has-values/download/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" + integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E= + +has-values@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/has-values/download/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" + integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8= + dependencies: + is-number "^3.0.0" + kind-of "^4.0.0" + +has@^1.0.0, has@^1.0.3: + version "1.0.3" + resolved "https://registry.npm.taobao.org/has/download/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + integrity sha1-ci18v8H2qoJB8W3YFOAR4fQeh5Y= + dependencies: + function-bind "^1.1.1" + +hash-base@^3.0.0: + version "3.1.0" + resolved "https://registry.npm.taobao.org/hash-base/download/hash-base-3.1.0.tgz#55c381d9e06e1d2997a883b4a3fddfe7f0d3af33" + integrity sha1-VcOB2eBuHSmXqIO0o/3f5/DTrzM= + dependencies: + inherits "^2.0.4" + readable-stream "^3.6.0" + safe-buffer "^5.2.0" + +hash-sum@^1.0.2: + version "1.0.2" + resolved "https://registry.npm.taobao.org/hash-sum/download/hash-sum-1.0.2.tgz#33b40777754c6432573c120cc3808bbd10d47f04" + integrity sha1-M7QHd3VMZDJXPBIMw4CLvRDUfwQ= + +hash-sum@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/hash-sum/download/hash-sum-2.0.0.tgz#81d01bb5de8ea4a214ad5d6ead1b523460b0b45a" + integrity sha1-gdAbtd6OpKIUrV1urRtSNGCwtFo= + +hash.js@^1.0.0, hash.js@^1.0.3: + version "1.1.7" + resolved "https://registry.npm.taobao.org/hash.js/download/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" + integrity sha1-C6vKU46NTuSg+JiNaIZlN6ADz0I= + dependencies: + inherits "^2.0.3" + minimalistic-assert "^1.0.1" + +hat@0.0.3: + version "0.0.3" + resolved "https://registry.npmjs.org/hat/-/hat-0.0.3.tgz#bb014a9e64b3788aed8005917413d4ff3d502d8a" + integrity sha1-uwFKnmSzeIrtgAWRdBPU/z1QLYo= + +he@1.2.x: + version "1.2.0" + resolved "https://registry.npm.taobao.org/he/download/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha1-hK5l+n6vsWX922FWauFLrwVmTw8= + +hex-color-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.npm.taobao.org/hex-color-regex/download/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" + integrity sha1-TAb8y0YC/iYCs8k9+C1+fb8aio4= + +highlight.js@^10.7.1: + version "10.7.2" + resolved "https://registry.npm.taobao.org/highlight.js/download/highlight.js-10.7.2.tgz#89319b861edc66c48854ed1e6da21ea89f847360" + integrity sha1-iTGbhh7cZsSIVO0ebaIeqJ+Ec2A= + +hmac-drbg@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/hmac-drbg/download/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" + integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE= + dependencies: + hash.js "^1.0.3" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.1" + +hoopy@^0.1.4: + version "0.1.4" + resolved "https://registry.npm.taobao.org/hoopy/download/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d" + integrity sha1-YJIH1mEQADOpqUAq096mdzgcGx0= + +hosted-git-info@^2.1.4: + version "2.8.9" + resolved "https://registry.npm.taobao.org/hosted-git-info/download/hosted-git-info-2.8.9.tgz?cache=0&sync_timestamp=1617826790271&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fhosted-git-info%2Fdownload%2Fhosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha1-3/wL+aIcAiCQkPKqaUKeFBTa8/k= + +hpack.js@^2.1.6: + version "2.1.6" + resolved "https://registry.npm.taobao.org/hpack.js/download/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" + integrity sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI= + dependencies: + inherits "^2.0.1" + obuf "^1.0.0" + readable-stream "^2.0.1" + wbuf "^1.1.0" + +hsl-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/hsl-regex/download/hsl-regex-1.0.0.tgz#d49330c789ed819e276a4c0d272dffa30b18fe6e" + integrity sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4= + +hsla-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/hsla-regex/download/hsla-regex-1.0.0.tgz#c1ce7a3168c8c6614033a4b5f7877f3b225f9c38" + integrity sha1-wc56MWjIxmFAM6S194d/OyJfnDg= + +html-entities@^1.3.1: + version "1.4.0" + resolved "https://registry.npm.taobao.org/html-entities/download/html-entities-1.4.0.tgz?cache=0&sync_timestamp=1617031494718&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fhtml-entities%2Fdownload%2Fhtml-entities-1.4.0.tgz#cfbd1b01d2afaf9adca1b10ae7dffab98c71d2dc" + integrity sha1-z70bAdKvr5rcobEK59/6uYxx0tw= + +html-minifier@^3.2.3: + version "3.5.21" + resolved "https://registry.npm.taobao.org/html-minifier/download/html-minifier-3.5.21.tgz#d0040e054730e354db008463593194015212d20c" + integrity sha1-0AQOBUcw41TbAIRjWTGUAVIS0gw= + dependencies: + camel-case "3.0.x" + clean-css "4.2.x" + commander "2.17.x" + he "1.2.x" + param-case "2.1.x" + relateurl "0.2.x" + uglify-js "3.4.x" + +html-tags@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/html-tags/download/html-tags-2.0.0.tgz#10b30a386085f43cede353cc8fa7cb0deeea668b" + integrity sha1-ELMKOGCF9Dzt41PMj6fLDe7qZos= + +html-tags@^3.1.0: + version "3.1.0" + resolved "https://registry.npm.taobao.org/html-tags/download/html-tags-3.1.0.tgz#7b5e6f7e665e9fb41f30007ed9e0d41e97fb2140" + integrity sha1-e15vfmZen7QfMAB+2eDUHpf7IUA= + +html-webpack-plugin@^3.2.0: + version "3.2.0" + resolved "https://registry.npm.taobao.org/html-webpack-plugin/download/html-webpack-plugin-3.2.0.tgz#b01abbd723acaaa7b37b6af4492ebda03d9dd37b" + integrity sha1-sBq71yOsqqeze2r0SS69oD2d03s= + dependencies: + html-minifier "^3.2.3" + loader-utils "^0.2.16" + lodash "^4.17.3" + pretty-error "^2.0.2" + tapable "^1.0.0" + toposort "^1.0.0" + util.promisify "1.0.0" + +htmlparser2@^3.10.1: + version "3.10.1" + resolved "https://registry.npm.taobao.org/htmlparser2/download/htmlparser2-3.10.1.tgz?cache=0&sync_timestamp=1617915295732&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fhtmlparser2%2Fdownload%2Fhtmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" + integrity sha1-vWedw/WYl7ajS7EHSchVu1OpOS8= + dependencies: + domelementtype "^1.3.1" + domhandler "^2.3.0" + domutils "^1.5.1" + entities "^1.1.1" + inherits "^2.0.1" + readable-stream "^3.1.1" + +http-deceiver@^1.2.7: + version "1.2.7" + resolved "https://registry.npm.taobao.org/http-deceiver/download/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" + integrity sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc= + +http-errors@1.7.2: + version "1.7.2" + resolved "https://registry.npm.taobao.org/http-errors/download/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f" + integrity sha1-T1ApzxMjnzEDblsuVSkrz7zIXI8= + dependencies: + depd "~1.1.2" + inherits "2.0.3" + setprototypeof "1.1.1" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.0" + +http-errors@~1.6.2: + version "1.6.3" + resolved "https://registry.npm.taobao.org/http-errors/download/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" + integrity sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0= + dependencies: + depd "~1.1.2" + inherits "2.0.3" + setprototypeof "1.1.0" + statuses ">= 1.4.0 < 2" + +http-errors@~1.7.2: + version "1.7.3" + resolved "https://registry.npm.taobao.org/http-errors/download/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06" + integrity sha1-bGGeT5xgMIw4UZSYwU+7EKrOuwY= + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.1.1" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.0" + +http-parser-js@>=0.5.1: + version "0.5.3" + resolved "https://registry.npm.taobao.org/http-parser-js/download/http-parser-js-0.5.3.tgz?cache=0&sync_timestamp=1609542336109&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fhttp-parser-js%2Fdownload%2Fhttp-parser-js-0.5.3.tgz#01d2709c79d41698bb01d4decc5e9da4e4a033d9" + integrity sha1-AdJwnHnUFpi7AdTezF6dpOSgM9k= + +http-proxy-middleware@0.19.1: + version "0.19.1" + resolved "https://registry.npm.taobao.org/http-proxy-middleware/download/http-proxy-middleware-0.19.1.tgz#183c7dc4aa1479150306498c210cdaf96080a43a" + integrity sha1-GDx9xKoUeRUDBkmMIQza+WCApDo= + dependencies: + http-proxy "^1.17.0" + is-glob "^4.0.0" + lodash "^4.17.11" + micromatch "^3.1.10" + +http-proxy-middleware@^1.0.0: + version "1.1.1" + resolved "https://registry.npm.taobao.org/http-proxy-middleware/download/http-proxy-middleware-1.1.1.tgz#48900a68cd9d388c735d1dd97302c919b7e94a13" + integrity sha1-SJAKaM2dOIxzXR3ZcwLJGbfpShM= + dependencies: + "@types/http-proxy" "^1.17.5" + http-proxy "^1.18.1" + is-glob "^4.0.1" + is-plain-obj "^3.0.0" + micromatch "^4.0.2" + +http-proxy@^1.17.0, http-proxy@^1.18.1: + version "1.18.1" + resolved "https://registry.npm.taobao.org/http-proxy/download/http-proxy-1.18.1.tgz?cache=0&sync_timestamp=1593529719673&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fhttp-proxy%2Fdownload%2Fhttp-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" + integrity sha1-QBVB8FNIhLv5UmAzTnL4juOXZUk= + dependencies: + eventemitter3 "^4.0.0" + follow-redirects "^1.0.0" + requires-port "^1.0.0" + +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.npm.taobao.org/http-signature/download/http-signature-1.2.0.tgz?cache=0&sync_timestamp=1600868441269&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fhttp-signature%2Fdownload%2Fhttp-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +https-browserify@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/https-browserify/download/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" + integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= + +human-signals@^1.1.1: + version "1.1.1" + resolved "https://registry.npm.taobao.org/human-signals/download/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" + integrity sha1-xbHNFPUK6uCatsWf5jujOV/k36M= + +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.npm.taobao.org/iconv-lite/download/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha1-ICK0sl+93CHS9SSXSkdKr+czkIs= + dependencies: + safer-buffer ">= 2.1.2 < 3" + +icss-replace-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.npm.taobao.org/icss-replace-symbols/download/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" + integrity sha1-Bupvg2ead0njhs/h/oEq5dsiPe0= + +icss-utils@^4.0.0, icss-utils@^4.1.1: + version "4.1.1" + resolved "https://registry.npm.taobao.org/icss-utils/download/icss-utils-4.1.1.tgz?cache=0&sync_timestamp=1605801375650&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ficss-utils%2Fdownload%2Ficss-utils-4.1.1.tgz#21170b53789ee27447c2f47dd683081403f9a467" + integrity sha1-IRcLU3ie4nRHwvR91oMIFAP5pGc= + dependencies: + postcss "^7.0.14" + +icss-utils@^5.0.0: + version "5.1.0" + resolved "https://registry.npm.taobao.org/icss-utils/download/icss-utils-5.1.0.tgz?cache=0&sync_timestamp=1605801375650&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ficss-utils%2Fdownload%2Ficss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" + integrity sha1-xr5oWKvQE9do6YNmrkfiXViHsa4= + +ieee754@^1.1.4: + version "1.2.1" + resolved "https://registry.npm.taobao.org/ieee754/download/ieee754-1.2.1.tgz?cache=0&sync_timestamp=1603838235461&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fieee754%2Fdownload%2Fieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha1-jrehCmP/8l0VpXsAFYbRd9Gw01I= + +iferr@^0.1.5: + version "0.1.5" + resolved "https://registry.npm.taobao.org/iferr/download/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" + integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= + +ignore@^3.3.5: + version "3.3.10" + resolved "https://registry.npm.taobao.org/ignore/download/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043" + integrity sha1-Cpf7h2mG6AgcYxFg+PnziRV/AEM= + +ignore@^4.0.3: + version "4.0.6" + resolved "https://registry.npm.taobao.org/ignore/download/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" + integrity sha1-dQ49tYYgh7RzfrrIIH/9HvJ7Jfw= + +image-size@~0.5.0: + version "0.5.5" + resolved "https://registry.npm.taobao.org/image-size/download/image-size-0.5.5.tgz?cache=0&sync_timestamp=1615832918965&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fimage-size%2Fdownload%2Fimage-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c" + integrity sha1-Cd/Uq50g4p6xw+gLiZA3jfnjy5w= + +import-cwd@^2.0.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/import-cwd/download/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" + integrity sha1-qmzzbnInYShcs3HsZRn1PiQ1sKk= + dependencies: + import-from "^2.1.0" + +import-fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/import-fresh/download/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" + integrity sha1-2BNVwVYS04bGH53dOSLUMEgipUY= + dependencies: + caller-path "^2.0.0" + resolve-from "^3.0.0" + +import-fresh@^3.1.0: + version "3.3.0" + resolved "https://registry.npm.taobao.org/import-fresh/download/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" + integrity sha1-NxYsJfy566oublPVtNiM4X2eDCs= + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +import-from@^2.1.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/import-from/download/import-from-2.1.0.tgz#335db7f2a7affd53aaa471d4b8021dee36b7f3b1" + integrity sha1-M1238qev/VOqpHHUuAId7ja387E= + dependencies: + resolve-from "^3.0.0" + +import-local@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/import-local/download/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d" + integrity sha1-VQcL44pZk88Y72236WH1vuXFoJ0= + dependencies: + pkg-dir "^3.0.0" + resolve-cwd "^2.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.npm.taobao.org/imurmurhash/download/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= + +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/indent-string/download/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha1-Yk+PRJfWGbLZdoUx1Y9BIoVNclE= + +indexes-of@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/indexes-of/download/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607" + integrity sha1-8w9xbI4r00bHtn0985FVZqfAVgc= + +infer-owner@^1.0.3, infer-owner@^1.0.4: + version "1.0.4" + resolved "https://registry.npm.taobao.org/infer-owner/download/infer-owner-1.0.4.tgz#c4cefcaa8e51051c2a40ba2ce8a3d27295af9467" + integrity sha1-xM78qo5RBRwqQLos6KPScpWvlGc= + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.npm.taobao.org/inflight/download/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.npm.taobao.org/inherits/download/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha1-D6LGT5MpF8NDOg3tVTY6rjdBa3w= + +inherits@2.0.1: + version "2.0.1" + resolved "https://registry.npm.taobao.org/inherits/download/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" + integrity sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE= + +inherits@2.0.3: + version "2.0.3" + resolved "https://registry.npm.taobao.org/inherits/download/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= + +internal-ip@^4.3.0: + version "4.3.0" + resolved "https://registry.npm.taobao.org/internal-ip/download/internal-ip-4.3.0.tgz?cache=0&sync_timestamp=1605885556992&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Finternal-ip%2Fdownload%2Finternal-ip-4.3.0.tgz#845452baad9d2ca3b69c635a137acb9a0dad0907" + integrity sha1-hFRSuq2dLKO2nGNaE3rLmg2tCQc= + dependencies: + default-gateway "^4.2.0" + ipaddr.js "^1.9.0" + +ip-regex@^2.1.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/ip-regex/download/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" + integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= + +ip@^1.1.0, ip@^1.1.5: + version "1.1.5" + resolved "https://registry.npm.taobao.org/ip/download/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" + integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= + +ipaddr.js@1.9.1, ipaddr.js@^1.9.0: + version "1.9.1" + resolved "https://registry.npm.taobao.org/ipaddr.js/download/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha1-v/OFQ+64mEglB5/zoqjmy9RngbM= + +is-absolute-url@^2.0.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/is-absolute-url/download/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" + integrity sha1-UFMN+4T8yap9vnhS6Do3uTufKqY= + +is-absolute-url@^3.0.3: + version "3.0.3" + resolved "https://registry.npm.taobao.org/is-absolute-url/download/is-absolute-url-3.0.3.tgz#96c6a22b6a23929b11ea0afb1836c36ad4a5d698" + integrity sha1-lsaiK2ojkpsR6gr7GDbDatSl1pg= + +is-accessor-descriptor@^0.1.6: + version "0.1.6" + resolved "https://registry.npm.taobao.org/is-accessor-descriptor/download/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" + integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY= + dependencies: + kind-of "^3.0.2" + +is-accessor-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/is-accessor-descriptor/download/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" + integrity sha1-FpwvbT3x+ZJhgHI2XJsOofaHhlY= + dependencies: + kind-of "^6.0.0" + +is-arguments@^1.0.4: + version "1.1.0" + resolved "https://registry.npm.taobao.org/is-arguments/download/is-arguments-1.1.0.tgz#62353031dfbee07ceb34656a6bde59efecae8dd9" + integrity sha1-YjUwMd++4HzrNGVqa95Z7+yujdk= + dependencies: + call-bind "^1.0.0" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.npm.taobao.org/is-arrayish/download/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= + +is-arrayish@^0.3.1: + version "0.3.2" + resolved "https://registry.npm.taobao.org/is-arrayish/download/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" + integrity sha1-RXSirlb3qyBolvtDHq7tBm/fjwM= + +is-bigint@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/is-bigint/download/is-bigint-1.0.1.tgz#6923051dfcbc764278540b9ce0e6b3213aa5ebc2" + integrity sha1-aSMFHfy8dkJ4VAuc4OazITql68I= + +is-binary-path@^1.0.0: + version "1.0.1" + resolved "https://registry.npm.taobao.org/is-binary-path/download/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" + integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg= + dependencies: + binary-extensions "^1.0.0" + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/is-binary-path/download/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha1-6h9/O4DwZCNug0cPhsCcJU+0Wwk= + dependencies: + binary-extensions "^2.0.0" + +is-boolean-object@^1.1.0: + version "1.1.0" + resolved "https://registry.npm.taobao.org/is-boolean-object/download/is-boolean-object-1.1.0.tgz#e2aaad3a3a8fca34c28f6eee135b156ed2587ff0" + integrity sha1-4qqtOjqPyjTCj27uE1sVbtJYf/A= + dependencies: + call-bind "^1.0.0" + +is-buffer@^1.1.5: + version "1.1.6" + resolved "https://registry.npm.taobao.org/is-buffer/download/is-buffer-1.1.6.tgz?cache=0&sync_timestamp=1604432378894&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fis-buffer%2Fdownload%2Fis-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha1-76ouqdqg16suoTqXsritUf776L4= + +is-callable@^1.1.4, is-callable@^1.2.3: + version "1.2.3" + resolved "https://registry.npm.taobao.org/is-callable/download/is-callable-1.2.3.tgz?cache=0&sync_timestamp=1612133035765&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fis-callable%2Fdownload%2Fis-callable-1.2.3.tgz#8b1e0500b73a1d76c70487636f368e519de8db8e" + integrity sha1-ix4FALc6HXbHBIdjbzaOUZ3o244= + +is-ci@^1.0.10: + version "1.2.1" + resolved "https://registry.npm.taobao.org/is-ci/download/is-ci-1.2.1.tgz?cache=0&sync_timestamp=1613631987391&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fis-ci%2Fdownload%2Fis-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c" + integrity sha1-43ecjuF/zPQoSI9uKBGH8uYyhBw= + dependencies: + ci-info "^1.5.0" + +is-color-stop@^1.0.0: + version "1.1.0" + resolved "https://registry.npm.taobao.org/is-color-stop/download/is-color-stop-1.1.0.tgz#cfff471aee4dd5c9e158598fbe12967b5cdad345" + integrity sha1-z/9HGu5N1cnhWFmPvhKWe1za00U= + dependencies: + css-color-names "^0.0.4" + hex-color-regex "^1.1.0" + hsl-regex "^1.0.0" + hsla-regex "^1.0.0" + rgb-regex "^1.0.1" + rgba-regex "^1.0.0" + +is-core-module@^2.2.0: + version "2.2.0" + resolved "https://registry.npm.taobao.org/is-core-module/download/is-core-module-2.2.0.tgz?cache=0&sync_timestamp=1606411588663&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fis-core-module%2Fdownload%2Fis-core-module-2.2.0.tgz#97037ef3d52224d85163f5597b2b63d9afed981a" + integrity sha1-lwN+89UiJNhRY/VZeytj2a/tmBo= + dependencies: + has "^1.0.3" + +is-data-descriptor@^0.1.4: + version "0.1.4" + resolved "https://registry.npm.taobao.org/is-data-descriptor/download/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" + integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y= + dependencies: + kind-of "^3.0.2" + +is-data-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/is-data-descriptor/download/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" + integrity sha1-2Eh2Mh0Oet0DmQQGq7u9NrqSaMc= + dependencies: + kind-of "^6.0.0" + +is-date-object@^1.0.1: + version "1.0.2" + resolved "https://registry.npm.taobao.org/is-date-object/download/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e" + integrity sha1-vac28s2P0G0yhE53Q7+nSUw7/X4= + +is-descriptor@^0.1.0: + version "0.1.6" + resolved "https://registry.npm.taobao.org/is-descriptor/download/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" + integrity sha1-Nm2CQN3kh8pRgjsaufB6EKeCUco= + dependencies: + is-accessor-descriptor "^0.1.6" + is-data-descriptor "^0.1.4" + kind-of "^5.0.0" + +is-descriptor@^1.0.0, is-descriptor@^1.0.2: + version "1.0.2" + resolved "https://registry.npm.taobao.org/is-descriptor/download/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" + integrity sha1-OxWXRqZmBLBPjIFSS6NlxfFNhuw= + dependencies: + is-accessor-descriptor "^1.0.0" + is-data-descriptor "^1.0.0" + kind-of "^6.0.2" + +is-directory@^0.3.1: + version "0.3.1" + resolved "https://registry.npm.taobao.org/is-directory/download/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" + integrity sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE= + +is-docker@^2.0.0: + version "2.2.1" + resolved "https://registry.npm.taobao.org/is-docker/download/is-docker-2.2.1.tgz?cache=0&sync_timestamp=1617958843085&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fis-docker%2Fdownload%2Fis-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" + integrity sha1-M+6r4jz+hvFL3kQIoCwM+4U6zao= + +is-extendable@^0.1.0, is-extendable@^0.1.1: + version "0.1.1" + resolved "https://registry.npm.taobao.org/is-extendable/download/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= + +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/is-extendable/download/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + integrity sha1-p0cPnkJnM9gb2B4RVSZOOjUHyrQ= + dependencies: + is-plain-object "^2.0.4" + +is-extglob@^2.1.0, is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.npm.taobao.org/is-extglob/download/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/is-fullwidth-code-point/download/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/is-fullwidth-code-point/download/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha1-8Rb4Bk/pCz94RKOJl8C3UFEmnx0= + +is-glob@^3.1.0: + version "3.1.0" + resolved "https://registry.npm.taobao.org/is-glob/download/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" + integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo= + dependencies: + is-extglob "^2.1.0" + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.1" + resolved "https://registry.npm.taobao.org/is-glob/download/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" + integrity sha1-dWfb6fL14kZ7x3q4PEopSCQHpdw= + dependencies: + is-extglob "^2.1.1" + +is-hotkey@^0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz#1835a68171a91e5c9460869d96336947c8340cef" + integrity sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw== + +is-negative-zero@^2.0.1: + version "2.0.1" + resolved "https://registry.npm.taobao.org/is-negative-zero/download/is-negative-zero-2.0.1.tgz?cache=0&sync_timestamp=1607123159909&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fis-negative-zero%2Fdownload%2Fis-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24" + integrity sha1-PedGwY3aIxkkGlNnWQjY92bxHCQ= + +is-number-object@^1.0.4: + version "1.0.4" + resolved "https://registry.npm.taobao.org/is-number-object/download/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197" + integrity sha1-NqyV50HPGLKD/B3fXoPaeY4+wZc= + +is-number@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/is-number/download/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" + integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU= + dependencies: + kind-of "^3.0.2" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.npm.taobao.org/is-number/download/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha1-dTU0W4lnNNX4DE0GxQlVUnoU8Ss= + +is-obj@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/is-obj/download/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" + integrity sha1-Rz+wXZc3BeP9liBUUBjKjiLvSYI= + +is-path-cwd@^2.0.0: + version "2.2.0" + resolved "https://registry.npm.taobao.org/is-path-cwd/download/is-path-cwd-2.2.0.tgz#67d43b82664a7b5191fd9119127eb300048a9fdb" + integrity sha1-Z9Q7gmZKe1GR/ZEZEn6zAASKn9s= + +is-path-in-cwd@^2.0.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/is-path-in-cwd/download/is-path-in-cwd-2.1.0.tgz#bfe2dca26c69f397265a4009963602935a053acb" + integrity sha1-v+Lcomxp85cmWkAJljYCk1oFOss= + dependencies: + is-path-inside "^2.1.0" + +is-path-inside@^2.1.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/is-path-inside/download/is-path-inside-2.1.0.tgz#7c9810587d659a40d27bcdb4d5616eab059494b2" + integrity sha1-fJgQWH1lmkDSe8201WFuqwWUlLI= + dependencies: + path-is-inside "^1.0.2" + +is-plain-obj@^1.0.0: + version "1.1.0" + resolved "https://registry.npm.taobao.org/is-plain-obj/download/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" + integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= + +is-plain-obj@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/is-plain-obj/download/is-plain-obj-3.0.0.tgz#af6f2ea14ac5a646183a5bbdb5baabbc156ad9d7" + integrity sha1-r28uoUrFpkYYOlu9tbqrvBVq2dc= + +is-plain-object@3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz#662d92d24c0aa4302407b0d45d21f2251c85f85b" + integrity sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g== + +is-plain-object@^2.0.3, is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.npm.taobao.org/is-plain-object/download/is-plain-object-2.0.4.tgz?cache=0&sync_timestamp=1599667316315&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fis-plain-object%2Fdownload%2Fis-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + integrity sha1-LBY7P6+xtgbZ0Xko8FwqHDjgdnc= + dependencies: + isobject "^3.0.1" + +is-regex@^1.0.4, is-regex@^1.1.2: + version "1.1.2" + resolved "https://registry.npm.taobao.org/is-regex/download/is-regex-1.1.2.tgz#81c8ebde4db142f2cf1c53fc86d6a45788266251" + integrity sha1-gcjr3k2xQvLPHFP8htakV4gmYlE= + dependencies: + call-bind "^1.0.2" + has-symbols "^1.0.1" + +is-resolvable@^1.0.0: + version "1.1.0" + resolved "https://registry.npm.taobao.org/is-resolvable/download/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" + integrity sha1-+xj4fOH+uSUWnJpAfBkxijIG7Yg= + +is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.npm.taobao.org/is-stream/download/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= + +is-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/is-stream/download/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3" + integrity sha1-venDJoDW+uBBKdasnZIc54FfeOM= + +is-string@^1.0.5: + version "1.0.5" + resolved "https://registry.npm.taobao.org/is-string/download/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6" + integrity sha1-QEk+0ZjvP/R3uMf5L2ROyCpc06Y= + +is-symbol@^1.0.2, is-symbol@^1.0.3: + version "1.0.3" + resolved "https://registry.npm.taobao.org/is-symbol/download/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937" + integrity sha1-OOEBS55jKb4N6dJKQU/XRB7GGTc= + dependencies: + has-symbols "^1.0.1" + +is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/is-typedarray/download/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= + +is-what@^3.12.0: + version "3.14.1" + resolved "https://registry.npm.taobao.org/is-what/download/is-what-3.14.1.tgz?cache=0&sync_timestamp=1615169709354&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fis-what%2Fdownload%2Fis-what-3.14.1.tgz#e1222f46ddda85dead0fd1c9df131760e77755c1" + integrity sha1-4SIvRt3ahd6tD9HJ3xMXYOd3VcE= + +is-windows@^1.0.2: + version "1.0.2" + resolved "https://registry.npm.taobao.org/is-windows/download/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" + integrity sha1-0YUOuXkezRjmGCzhKjDzlmNLsZ0= + +is-wsl@^1.1.0: + version "1.1.0" + resolved "https://registry.npm.taobao.org/is-wsl/download/is-wsl-1.1.0.tgz?cache=0&sync_timestamp=1593529690119&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fis-wsl%2Fdownload%2Fis-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" + integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= + +is-wsl@^2.1.1: + version "2.2.0" + resolved "https://registry.npm.taobao.org/is-wsl/download/is-wsl-2.2.0.tgz?cache=0&sync_timestamp=1593529690119&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fis-wsl%2Fdownload%2Fis-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" + integrity sha1-dKTHbnfKn9P5MvKQwX6jJs0VcnE= + dependencies: + is-docker "^2.0.0" + +isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/isarray/download/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/isexe/download/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + +isobject@^2.0.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/isobject/download/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" + integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk= + dependencies: + isarray "1.0.0" + +isobject@^3.0.0, isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.npm.taobao.org/isobject/download/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= + +isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.npm.taobao.org/isstream/download/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= + +javascript-stringify@^2.0.1: + version "2.0.1" + resolved "https://registry.npm.taobao.org/javascript-stringify/download/javascript-stringify-2.0.1.tgz#6ef358035310e35d667c675ed63d3eb7c1aa19e5" + integrity sha1-bvNYA1MQ411mfGde1j0+t8GqGeU= + +jest-worker@^25.4.0: + version "25.5.0" + resolved "https://registry.npm.taobao.org/jest-worker/download/jest-worker-25.5.0.tgz?cache=0&sync_timestamp=1617371342671&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fjest-worker%2Fdownload%2Fjest-worker-25.5.0.tgz#2611d071b79cea0f43ee57a3d118593ac1547db1" + integrity sha1-JhHQcbec6g9D7lej0RhZOsFUfbE= + dependencies: + merge-stream "^2.0.0" + supports-color "^7.0.0" + +js-message@1.0.7: + version "1.0.7" + resolved "https://registry.npm.taobao.org/js-message/download/js-message-1.0.7.tgz#fbddd053c7a47021871bb8b2c95397cc17c20e47" + integrity sha1-+93QU8ekcCGHG7iyyVOXzBfCDkc= + +js-queue@2.0.2: + version "2.0.2" + resolved "https://registry.npm.taobao.org/js-queue/download/js-queue-2.0.2.tgz#0be590338f903b36c73d33c31883a821412cd482" + integrity sha1-C+WQM4+QOzbHPTPDGIOoIUEs1II= + dependencies: + easy-stack "^1.0.1" + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/js-tokens/download/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha1-GSA/tZmR35jjoocFDUZHzerzJJk= + +js-tokens@^3.0.2: + version "3.0.2" + resolved "https://registry.npm.taobao.org/js-tokens/download/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" + integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= + +js-yaml@^3.13.1: + version "3.14.1" + resolved "https://registry.npm.taobao.org/js-yaml/download/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + integrity sha1-2ugS/bOCX6MGYJqHFzg8UMNqBTc= + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.npm.taobao.org/jsbn/download/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= + +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.npm.taobao.org/jsesc/download/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + integrity sha1-gFZNLkg9rPbo7yCWUKZ98/DCg6Q= + +jsesc@~0.5.0: + version "0.5.0" + resolved "https://registry.npm.taobao.org/jsesc/download/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" + integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0= + +json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2: + version "1.0.2" + resolved "https://registry.npm.taobao.org/json-parse-better-errors/download/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" + integrity sha1-u4Z8+zRQ5pEHwTHRxRS6s9yLyqk= + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.npm.taobao.org/json-parse-even-better-errors/download/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha1-fEeAWpQxmSjgV3dAXcEuH3pO4C0= + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.npm.taobao.org/json-schema-traverse/download/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha1-afaofZUTq4u4/mO9sJecRI5oRmA= + +json-schema@0.2.3: + version "0.2.3" + resolved "https://registry.npm.taobao.org/json-schema/download/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" + integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= + +json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.npm.taobao.org/json-stringify-safe/download/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= + +json3@^3.3.3: + version "3.3.3" + resolved "https://registry.npm.taobao.org/json3/download/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81" + integrity sha1-f8EON1/FrkLEcFpcwKpvYr4wW4E= + +json5@^0.5.0: + version "0.5.1" + resolved "https://registry.npm.taobao.org/json5/download/json5-0.5.1.tgz?cache=0&sync_timestamp=1612146875530&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fjson5%2Fdownload%2Fjson5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" + integrity sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE= + +json5@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/json5/download/json5-1.0.1.tgz?cache=0&sync_timestamp=1612146875530&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fjson5%2Fdownload%2Fjson5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" + integrity sha1-d5+wAYYE+oVOrL9iUhgNg1Q+Pb4= + dependencies: + minimist "^1.2.0" + +json5@^2.1.2: + version "2.2.0" + resolved "https://registry.npm.taobao.org/json5/download/json5-2.2.0.tgz?cache=0&sync_timestamp=1612146875530&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fjson5%2Fdownload%2Fjson5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3" + integrity sha1-Lf7+cgxrpSXZ69kJlQ8FFTFsiaM= + dependencies: + minimist "^1.2.5" + +jsonfile@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/jsonfile/download/jsonfile-4.0.0.tgz?cache=0&sync_timestamp=1604161844511&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fjsonfile%2Fdownload%2Fjsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" + integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= + optionalDependencies: + graceful-fs "^4.1.6" + +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.npm.taobao.org/jsonfile/download/jsonfile-6.1.0.tgz?cache=0&sync_timestamp=1604161844511&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fjsonfile%2Fdownload%2Fjsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha1-vFWyY0eTxnnsZAMJTrE2mKbsCq4= + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +jsprim@^1.2.2: + version "1.4.1" + resolved "https://registry.npm.taobao.org/jsprim/download/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" + integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI= + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.2.3" + verror "1.10.0" + +keymaster@^1.6.2: + version "1.6.2" + resolved "https://registry.npm.taobao.org/keymaster/download/keymaster-1.6.2.tgz#e1ae54d0ea9488f9f60b66b668f02e9a1946c6eb" + integrity sha1-4a5U0OqUiPn2C2a2aPAumhlGxus= + +killable@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/killable/download/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892" + integrity sha1-TIzkQRh6Bhx0dPuHygjipjgZSJI= + +kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: + version "3.2.2" + resolved "https://registry.npm.taobao.org/kind-of/download/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= + dependencies: + is-buffer "^1.1.5" + +kind-of@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/kind-of/download/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" + integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc= + dependencies: + is-buffer "^1.1.5" + +kind-of@^5.0.0: + version "5.1.0" + resolved "https://registry.npm.taobao.org/kind-of/download/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" + integrity sha1-cpyR4thXt6QZofmqZWhcTDP1hF0= + +kind-of@^6.0.0, kind-of@^6.0.2: + version "6.0.3" + resolved "https://registry.npm.taobao.org/kind-of/download/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha1-B8BQNKbDSfoG4k+jWqdttFgM5N0= + +launch-editor-middleware@^2.2.1: + version "2.2.1" + resolved "https://registry.npm.taobao.org/launch-editor-middleware/download/launch-editor-middleware-2.2.1.tgz#e14b07e6c7154b0a4b86a0fd345784e45804c157" + integrity sha1-4UsH5scVSwpLhqD9NFeE5FgEwVc= + dependencies: + launch-editor "^2.2.1" + +launch-editor@^2.2.1: + version "2.2.1" + resolved "https://registry.npm.taobao.org/launch-editor/download/launch-editor-2.2.1.tgz#871b5a3ee39d6680fcc26d37930b6eeda89db0ca" + integrity sha1-hxtaPuOdZoD8wm03kwtu7aidsMo= + dependencies: + chalk "^2.3.0" + shell-quote "^1.6.1" + +less-loader@6.0.0: + version "6.0.0" + resolved "https://registry.nlark.com/less-loader/download/less-loader-6.0.0.tgz#f7833177c2fc3de5014de072d5c26eca0954c1cb" + integrity sha1-94Mxd8L8PeUBTeBy1cJuyglUwcs= + dependencies: + clone "^2.1.2" + less "^3.11.1" + loader-utils "^2.0.0" + schema-utils "^2.6.6" + +less@^3.0.4, less@^3.11.1: + version "3.13.1" + resolved "https://registry.npm.taobao.org/less/download/less-3.13.1.tgz#0ebc91d2a0e9c0c6735b83d496b0ab0583077909" + integrity sha1-DryR0qDpwMZzW4PUlrCrBYMHeQk= + dependencies: + copy-anything "^2.0.1" + tslib "^1.10.0" + optionalDependencies: + errno "^0.1.1" + graceful-fs "^4.1.2" + image-size "~0.5.0" + make-dir "^2.1.0" + mime "^1.4.1" + native-request "^1.0.5" + source-map "~0.6.0" + +lines-and-columns@^1.1.6: + version "1.1.6" + resolved "https://registry.npm.taobao.org/lines-and-columns/download/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" + integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= + +loader-runner@^2.3.1, loader-runner@^2.4.0: + version "2.4.0" + resolved "https://registry.npm.taobao.org/loader-runner/download/loader-runner-2.4.0.tgz?cache=0&sync_timestamp=1610027852811&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Floader-runner%2Fdownload%2Floader-runner-2.4.0.tgz#ed47066bfe534d7e84c4c7b9998c2a75607d9357" + integrity sha1-7UcGa/5TTX6ExMe5mYwqdWB9k1c= + +loader-utils@^0.2.16: + version "0.2.17" + resolved "https://registry.npm.taobao.org/loader-utils/download/loader-utils-0.2.17.tgz#f86e6374d43205a6e6c60e9196f17c0299bfb348" + integrity sha1-+G5jdNQyBabmxg6RlvF8Apm/s0g= + dependencies: + big.js "^3.1.3" + emojis-list "^2.0.0" + json5 "^0.5.0" + object-assign "^4.0.1" + +loader-utils@^1.0.2, loader-utils@^1.1.0, loader-utils@^1.2.3, loader-utils@^1.4.0: + version "1.4.0" + resolved "https://registry.npm.taobao.org/loader-utils/download/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613" + integrity sha1-xXm140yzSxp07cbB+za/o3HVphM= + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^1.0.1" + +loader-utils@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/loader-utils/download/loader-utils-2.0.0.tgz#e4cace5b816d425a166b5f097e10cd12b36064b0" + integrity sha1-5MrOW4FtQloWa18JfhDNErNgZLA= + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^2.1.2" + +locate-path@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/locate-path/download/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" + integrity sha1-2+w7OrdZdYBxtY/ln8QYca8hQA4= + dependencies: + p-locate "^3.0.0" + path-exists "^3.0.0" + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.npm.taobao.org/locate-path/download/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha1-Gvujlq/WdqbUJQTQpno6frn2KqA= + dependencies: + p-locate "^4.1.0" + +lodash-es@^4.17.15, lodash-es@^4.17.21: + version "4.17.21" + resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.npm.taobao.org/lodash.camelcase/download/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY= + +lodash.debounce@^4.0.8: + version "4.0.8" + resolved "https://registry.npm.taobao.org/lodash.debounce/download/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" + integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= + +lodash.defaultsdeep@^4.6.1: + version "4.6.1" + resolved "https://registry.npm.taobao.org/lodash.defaultsdeep/download/lodash.defaultsdeep-4.6.1.tgz#512e9bd721d272d94e3d3a63653fa17516741ca6" + integrity sha1-US6b1yHSctlOPTpjZT+hdRZ0HKY= + +lodash.kebabcase@^4.1.1: + version "4.1.1" + resolved "https://registry.npm.taobao.org/lodash.kebabcase/download/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" + integrity sha1-hImxyw0p/4gZXM7KRI/21swpXDY= + +lodash.mapvalues@^4.6.0: + version "4.6.0" + resolved "https://registry.npm.taobao.org/lodash.mapvalues/download/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c" + integrity sha1-G6+lAF3p3W9PJmaMMMo3IwzJaJw= + +lodash.memoize@^4.1.2: + version "4.1.2" + resolved "https://registry.npm.taobao.org/lodash.memoize/download/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" + integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= + +lodash.transform@^4.6.0: + version "4.6.0" + resolved "https://registry.npm.taobao.org/lodash.transform/download/lodash.transform-4.6.0.tgz#12306422f63324aed8483d3f38332b5f670547a0" + integrity sha1-EjBkIvYzJK7YSD0/ODMrX2cFR6A= + +lodash.uniq@^4.5.0: + version "4.5.0" + resolved "https://registry.npm.taobao.org/lodash.uniq/download/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" + integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= + +lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.3: + version "4.17.21" + resolved "https://registry.npm.taobao.org/lodash/download/lodash-4.17.21.tgz?cache=0&sync_timestamp=1613835817439&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha1-Z5WRxWTDv/quhFTPCz3zcMPWkRw= + +log-symbols@^2.2.0: + version "2.2.0" + resolved "https://registry.npm.taobao.org/log-symbols/download/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a" + integrity sha1-V0Dhxdbw39pK2TI7UzIQfva0xAo= + dependencies: + chalk "^2.0.1" + +loglevel@^1.6.8: + version "1.7.1" + resolved "https://registry.npm.taobao.org/loglevel/download/loglevel-1.7.1.tgz?cache=0&sync_timestamp=1606314029553&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Floglevel%2Fdownload%2Floglevel-1.7.1.tgz#005fde2f5e6e47068f935ff28573e125ef72f197" + integrity sha1-AF/eL15uRwaPk1/yhXPhJe9y8Zc= + +loose-envify@^1.0.0: + version "1.4.0" + resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +lower-case@^1.1.1: + version "1.1.4" + resolved "https://registry.npm.taobao.org/lower-case/download/lower-case-1.1.4.tgz?cache=0&sync_timestamp=1606867317282&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flower-case%2Fdownload%2Flower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac" + integrity sha1-miyr0bno4K6ZOkv31YdcOcQujqw= + +lru-cache@^4.0.1, lru-cache@^4.1.2: + version "4.1.5" + resolved "https://registry.npm.taobao.org/lru-cache/download/lru-cache-4.1.5.tgz?cache=0&sync_timestamp=1594427567713&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flru-cache%2Fdownload%2Flru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" + integrity sha1-i75Q6oW+1ZvJ4z3KuCNe6bz0Q80= + dependencies: + pseudomap "^1.0.2" + yallist "^2.1.2" + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.npm.taobao.org/lru-cache/download/lru-cache-5.1.1.tgz?cache=0&sync_timestamp=1594427567713&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flru-cache%2Fdownload%2Flru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha1-HaJ+ZxAnGUdpXa9oSOhH8B2EuSA= + dependencies: + yallist "^3.0.2" + +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.npm.taobao.org/lru-cache/download/lru-cache-6.0.0.tgz?cache=0&sync_timestamp=1594427567713&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flru-cache%2Fdownload%2Flru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha1-bW/mVw69lqr5D8rR2vo7JWbbOpQ= + dependencies: + yallist "^4.0.0" + +magic-string@^0.25.7: + version "0.25.7" + resolved "https://registry.npm.taobao.org/magic-string/download/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051" + integrity sha1-P0l9b9NMZpxnmNy4IfLvMfVEUFE= + dependencies: + sourcemap-codec "^1.4.4" + +make-dir@^2.0.0, make-dir@^2.1.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/make-dir/download/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" + integrity sha1-XwMQ4YuL6JjMBwCSlaMK5B6R5vU= + dependencies: + pify "^4.0.1" + semver "^5.6.0" + +make-dir@^3.0.2, make-dir@^3.1.0: + version "3.1.0" + resolved "https://registry.npm.taobao.org/make-dir/download/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" + integrity sha1-QV6WcEazp/HRhSd9hKpYIDcmoT8= + dependencies: + semver "^6.0.0" + +map-cache@^0.2.2: + version "0.2.2" + resolved "https://registry.npm.taobao.org/map-cache/download/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" + integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= + +map-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/map-visit/download/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" + integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48= + dependencies: + object-visit "^1.0.0" + +md5.js@^1.3.4: + version "1.3.5" + resolved "https://registry.npm.taobao.org/md5.js/download/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" + integrity sha1-tdB7jjIW4+J81yjXL3DR5qNCAF8= + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + safe-buffer "^5.1.2" + +mdn-data@2.0.14: + version "2.0.14" + resolved "https://registry.npm.taobao.org/mdn-data/download/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" + integrity sha1-cRP8QoGRfWPOKbQ0RvcB5owlulA= + +mdn-data@2.0.4: + version "2.0.4" + resolved "https://registry.npm.taobao.org/mdn-data/download/mdn-data-2.0.4.tgz#699b3c38ac6f1d728091a64650b65d388502fd5b" + integrity sha1-aZs8OKxvHXKAkaZGULZdOIUC/Vs= + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.npm.taobao.org/media-typer/download/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= + +memfs@^3.1.2: + version "3.2.2" + resolved "https://registry.npm.taobao.org/memfs/download/memfs-3.2.2.tgz?cache=0&sync_timestamp=1617599764263&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmemfs%2Fdownload%2Fmemfs-3.2.2.tgz#5de461389d596e3f23d48bb7c2afb6161f4df40e" + integrity sha1-XeRhOJ1Zbj8j1Iu3wq+2Fh9N9A4= + dependencies: + fs-monkey "1.0.3" + +memory-fs@^0.4.1: + version "0.4.1" + resolved "https://registry.npm.taobao.org/memory-fs/download/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" + integrity sha1-OpoguEYlI+RHz7x+i7gO1me/xVI= + dependencies: + errno "^0.1.3" + readable-stream "^2.0.1" + +memory-fs@^0.5.0: + version "0.5.0" + resolved "https://registry.npm.taobao.org/memory-fs/download/memory-fs-0.5.0.tgz#324c01288b88652966d161db77838720845a8e3c" + integrity sha1-MkwBKIuIZSlm0WHbd4OHIIRajjw= + dependencies: + errno "^0.1.3" + readable-stream "^2.0.1" + +merge-descriptors@1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/merge-descriptors/download/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" + integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= + +merge-source-map@^1.1.0: + version "1.1.0" + resolved "https://registry.npm.taobao.org/merge-source-map/download/merge-source-map-1.1.0.tgz#2fdde7e6020939f70906a68f2d7ae685e4c8c646" + integrity sha1-L93n5gIJOfcJBqaPLXrmheTIxkY= + dependencies: + source-map "^0.6.1" + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/merge-stream/download/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha1-UoI2KaFN0AyXcPtq1H3GMQ8sH2A= + +merge2@^1.2.3: + version "1.4.1" + resolved "https://registry.npm.taobao.org/merge2/download/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha1-Q2iJL4hekHRVpv19xVwMnUBJkK4= + +methods@~1.1.2: + version "1.1.2" + resolved "https://registry.npm.taobao.org/methods/download/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= + +microevent.ts@~0.1.1: + version "0.1.1" + resolved "https://registry.npm.taobao.org/microevent.ts/download/microevent.ts-0.1.1.tgz#70b09b83f43df5172d0205a63025bce0f7357fa0" + integrity sha1-cLCbg/Q99RctAgWmMCW84Pc1f6A= + +micromatch@^3.1.10, micromatch@^3.1.4: + version "3.1.10" + resolved "https://registry.npm.taobao.org/micromatch/download/micromatch-3.1.10.tgz?cache=0&sync_timestamp=1618054740956&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmicromatch%2Fdownload%2Fmicromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" + integrity sha1-cIWbyVyYQJUvNZoGij/En57PrCM= + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + braces "^2.3.1" + define-property "^2.0.2" + extend-shallow "^3.0.2" + extglob "^2.0.4" + fragment-cache "^0.2.1" + kind-of "^6.0.2" + nanomatch "^1.2.9" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.2" + +micromatch@^4.0.0, micromatch@^4.0.2: + version "4.0.4" + resolved "https://registry.npm.taobao.org/micromatch/download/micromatch-4.0.4.tgz?cache=0&sync_timestamp=1618054740956&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmicromatch%2Fdownload%2Fmicromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9" + integrity sha1-iW1Rnf6dsl/OlM63pQCRm/iB6/k= + dependencies: + braces "^3.0.1" + picomatch "^2.2.3" + +miller-rabin@^4.0.0: + version "4.0.1" + resolved "https://registry.npm.taobao.org/miller-rabin/download/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" + integrity sha1-8IA1HIZbDcViqEYpZtqlNUPHik0= + dependencies: + bn.js "^4.0.0" + brorand "^1.0.1" + +mime-db@1.47.0, "mime-db@>= 1.43.0 < 2": + version "1.47.0" + resolved "https://registry.npm.taobao.org/mime-db/download/mime-db-1.47.0.tgz?cache=0&sync_timestamp=1617306118828&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmime-db%2Fdownload%2Fmime-db-1.47.0.tgz#8cb313e59965d3c05cfbf898915a267af46a335c" + integrity sha1-jLMT5Zll08Bc+/iYkVomevRqM1w= + +mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: + version "2.1.30" + resolved "https://registry.npm.taobao.org/mime-types/download/mime-types-2.1.30.tgz?cache=0&sync_timestamp=1617340124913&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmime-types%2Fdownload%2Fmime-types-2.1.30.tgz#6e7be8b4c479825f85ed6326695db73f9305d62d" + integrity sha1-bnvotMR5gl+F7WMmaV23P5MF1i0= + dependencies: + mime-db "1.47.0" + +mime@1.6.0, mime@^1.4.1: + version "1.6.0" + resolved "https://registry.npm.taobao.org/mime/download/mime-1.6.0.tgz?cache=0&sync_timestamp=1613584838235&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmime%2Fdownload%2Fmime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha1-Ms2eXGRVO9WNGaVor0Uqz/BJgbE= + +mime@^2.4.4: + version "2.5.2" + resolved "https://registry.npm.taobao.org/mime/download/mime-2.5.2.tgz?cache=0&sync_timestamp=1613584838235&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmime%2Fdownload%2Fmime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe" + integrity sha1-bj3GzCuVEGQ4MOXxnVy3U9pe6r4= + +mimic-fn@^1.0.0: + version "1.2.0" + resolved "https://registry.npm.taobao.org/mimic-fn/download/mimic-fn-1.2.0.tgz?cache=0&sync_timestamp=1617823674050&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmimic-fn%2Fdownload%2Fmimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" + integrity sha1-ggyGo5M0ZA6ZUWkovQP8qIBX0CI= + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/mimic-fn/download/mimic-fn-2.1.0.tgz?cache=0&sync_timestamp=1617823674050&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmimic-fn%2Fdownload%2Fmimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha1-ftLCzMyvhNP/y3pptXcR/CCDQBs= + +mini-css-extract-plugin@^0.9.0: + version "0.9.0" + resolved "https://registry.npm.taobao.org/mini-css-extract-plugin/download/mini-css-extract-plugin-0.9.0.tgz?cache=0&sync_timestamp=1617797924932&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmini-css-extract-plugin%2Fdownload%2Fmini-css-extract-plugin-0.9.0.tgz#47f2cf07aa165ab35733b1fc97d4c46c0564339e" + integrity sha1-R/LPB6oWWrNXM7H8l9TEbAVkM54= + dependencies: + loader-utils "^1.1.0" + normalize-url "1.9.1" + schema-utils "^1.0.0" + webpack-sources "^1.1.0" + +minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/minimalistic-assert/download/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha1-LhlN4ERibUoQ5/f7wAznPoPk1cc= + +minimalistic-crypto-utils@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/minimalistic-crypto-utils/download/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" + integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= + +minimatch@^3.0.4: + version "3.0.4" + resolved "https://registry.npm.taobao.org/minimatch/download/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha1-UWbihkV/AzBgZL5Ul+jbsMPTIIM= + dependencies: + brace-expansion "^1.1.7" + +minimist@^1.2.0, minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.npm.taobao.org/minimist/download/minimist-1.2.5.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fminimist%2Fdownload%2Fminimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha1-Z9ZgFLZqaoqqDAg8X9WN9OTpdgI= + +minipass-collect@^1.0.2: + version "1.0.2" + resolved "https://registry.npm.taobao.org/minipass-collect/download/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617" + integrity sha1-IrgTv3Rdxu26JXa5QAIq1u3Ixhc= + dependencies: + minipass "^3.0.0" + +minipass-flush@^1.0.5: + version "1.0.5" + resolved "https://registry.npm.taobao.org/minipass-flush/download/minipass-flush-1.0.5.tgz#82e7135d7e89a50ffe64610a787953c4c4cbb373" + integrity sha1-gucTXX6JpQ/+ZGEKeHlTxMTLs3M= + dependencies: + minipass "^3.0.0" + +minipass-pipeline@^1.2.2: + version "1.2.4" + resolved "https://registry.npm.taobao.org/minipass-pipeline/download/minipass-pipeline-1.2.4.tgz#68472f79711c084657c067c5c6ad93cddea8214c" + integrity sha1-aEcveXEcCEZXwGfFxq2Tzd6oIUw= + dependencies: + minipass "^3.0.0" + +minipass@^3.0.0, minipass@^3.1.1: + version "3.1.3" + resolved "https://registry.npm.taobao.org/minipass/download/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd" + integrity sha1-fUL/HzljVILhX5zbUxhN7r1YFf0= + dependencies: + yallist "^4.0.0" + +mississippi@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/mississippi/download/mississippi-3.0.0.tgz#ea0a3291f97e0b5e8776b363d5f0a12d94c67022" + integrity sha1-6goykfl+C16HdrNj1fChLZTGcCI= + dependencies: + concat-stream "^1.5.0" + duplexify "^3.4.2" + end-of-stream "^1.1.0" + flush-write-stream "^1.0.0" + from2 "^2.1.0" + parallel-transform "^1.1.0" + pump "^3.0.0" + pumpify "^1.3.3" + stream-each "^1.1.0" + through2 "^2.0.0" + +mixin-deep@^1.2.0: + version "1.3.2" + resolved "https://registry.npm.taobao.org/mixin-deep/download/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" + integrity sha1-ESC0PcNZp4Xc5ltVuC4lfM9HlWY= + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + +mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.1: + version "0.5.5" + resolved "https://registry.npm.taobao.org/mkdirp/download/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" + integrity sha1-2Rzv1i0UNsoPQWIOJRKI1CAJne8= + dependencies: + minimist "^1.2.5" + +moment@^2.27.0: + version "2.29.1" + resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" + integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== + +move-concurrently@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/move-concurrently/download/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" + integrity sha1-viwAX9oy4LKa8fBdfEszIUxwH5I= + dependencies: + aproba "^1.1.1" + copy-concurrently "^1.0.0" + fs-write-stream-atomic "^1.0.8" + mkdirp "^0.5.1" + rimraf "^2.5.4" + run-queue "^1.0.3" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/ms/download/ms-2.0.0.tgz?cache=0&sync_timestamp=1607433872491&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fms%2Fdownload%2Fms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + +ms@2.1.1: + version "2.1.1" + resolved "https://registry.npm.taobao.org/ms/download/ms-2.1.1.tgz?cache=0&sync_timestamp=1607433872491&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fms%2Fdownload%2Fms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" + integrity sha1-MKWGTrPrsKZvLr5tcnrwagnYbgo= + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.npm.taobao.org/ms/download/ms-2.1.2.tgz?cache=0&sync_timestamp=1607433872491&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fms%2Fdownload%2Fms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha1-0J0fNXtEP0kzgqjrPM0YOHKuYAk= + +ms@^2.1.1: + version "2.1.3" + resolved "https://registry.npm.taobao.org/ms/download/ms-2.1.3.tgz?cache=0&sync_timestamp=1607433872491&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fms%2Fdownload%2Fms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha1-V0yBOM4dK1hh8LRFedut1gxmFbI= + +multicast-dns-service-types@^1.1.0: + version "1.1.0" + resolved "https://registry.npm.taobao.org/multicast-dns-service-types/download/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901" + integrity sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE= + +multicast-dns@^6.0.1: + version "6.2.3" + resolved "https://registry.npm.taobao.org/multicast-dns/download/multicast-dns-6.2.3.tgz#a0ec7bd9055c4282f790c3c82f4e28db3b31b229" + integrity sha1-oOx72QVcQoL3kMPIL04o2zsxsik= + dependencies: + dns-packet "^1.3.1" + thunky "^1.0.2" + +mz@^2.4.0: + version "2.7.0" + resolved "https://registry.npm.taobao.org/mz/download/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" + integrity sha1-lQCAV6Vsr63CvGPd5/n/aVWUjjI= + dependencies: + any-promise "^1.0.0" + object-assign "^4.0.1" + thenify-all "^1.0.0" + +nan@^2.12.1: + version "2.14.2" + resolved "https://registry.npm.taobao.org/nan/download/nan-2.14.2.tgz?cache=0&sync_timestamp=1602591709094&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fnan%2Fdownload%2Fnan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19" + integrity sha1-9TdkAGlRaPTMaUrJOT0MlYXu6hk= + +nanoid@^3.1.22: + version "3.1.22" + resolved "https://registry.npm.taobao.org/nanoid/download/nanoid-3.1.22.tgz#b35f8fb7d151990a8aebd5aa5015c03cf726f844" + integrity sha1-s1+Pt9FRmQqK69WqUBXAPPcm+EQ= + +nanomatch@^1.2.9: + version "1.2.13" + resolved "https://registry.npm.taobao.org/nanomatch/download/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" + integrity sha1-uHqKpPwN6P5r6IiVs4mD/yZb0Rk= + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + define-property "^2.0.2" + extend-shallow "^3.0.2" + fragment-cache "^0.2.1" + is-windows "^1.0.2" + kind-of "^6.0.2" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +nanopop@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/nanopop/-/nanopop-2.1.0.tgz#23476513cee2405888afd2e8a4b54066b70b9e60" + integrity sha512-jGTwpFRexSH+fxappnGQtN9dspgE2ipa1aOjtR24igG0pv6JCxImIAmrLRHX+zUF5+1wtsFVbKyfP51kIGAVNw== + +native-request@^1.0.5: + version "1.0.8" + resolved "https://registry.npm.taobao.org/native-request/download/native-request-1.0.8.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fnative-request%2Fdownload%2Fnative-request-1.0.8.tgz#8f66bf606e0f7ea27c0e5995eb2f5d03e33ae6fb" + integrity sha1-j2a/YG4PfqJ8DlmV6y9dA+M65vs= + +negotiator@0.6.2: + version "0.6.2" + resolved "https://registry.npm.taobao.org/negotiator/download/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" + integrity sha1-/qz3zPUlp3rpY0Q2pkiD/+yjRvs= + +neo-async@^2.5.0, neo-async@^2.6.0, neo-async@^2.6.1: + version "2.6.2" + resolved "https://registry.npm.taobao.org/neo-async/download/neo-async-2.6.2.tgz?cache=0&sync_timestamp=1594317853334&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fneo-async%2Fdownload%2Fneo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha1-tKr7k+OustgXTKU88WOrfXMIMF8= + +nice-try@^1.0.4: + version "1.0.5" + resolved "https://registry.npm.taobao.org/nice-try/download/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" + integrity sha1-ozeKdpbOfSI+iPybdkvX7xCJ42Y= + +no-case@^2.2.0: + version "2.3.2" + resolved "https://registry.npm.taobao.org/no-case/download/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac" + integrity sha1-YLgTOWvjmz8SiKTB7V0efSi0ZKw= + dependencies: + lower-case "^1.1.1" + +node-forge@^0.10.0: + version "0.10.0" + resolved "https://registry.npm.taobao.org/node-forge/download/node-forge-0.10.0.tgz?cache=0&sync_timestamp=1599010746318&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fnode-forge%2Fdownload%2Fnode-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" + integrity sha1-Mt6ir7Ppkm8C7lzoeUkCaRpna/M= + +node-ipc@^9.1.1: + version "9.1.4" + resolved "https://registry.npm.taobao.org/node-ipc/download/node-ipc-9.1.4.tgz?cache=0&sync_timestamp=1614360246127&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fnode-ipc%2Fdownload%2Fnode-ipc-9.1.4.tgz#2acf962681afdac2602876d98fe6434d54d9bd3c" + integrity sha1-Ks+WJoGv2sJgKHbZj+ZDTVTZvTw= + dependencies: + event-pubsub "4.3.0" + js-message "1.0.7" + js-queue "2.0.2" + +node-libs-browser@^2.2.1: + version "2.2.1" + resolved "https://registry.npm.taobao.org/node-libs-browser/download/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425" + integrity sha1-tk9RPRgzhiX5A0bSew0jXmMfZCU= + dependencies: + assert "^1.1.1" + browserify-zlib "^0.2.0" + buffer "^4.3.0" + console-browserify "^1.1.0" + constants-browserify "^1.0.0" + crypto-browserify "^3.11.0" + domain-browser "^1.1.1" + events "^3.0.0" + https-browserify "^1.0.0" + os-browserify "^0.3.0" + path-browserify "0.0.1" + process "^0.11.10" + punycode "^1.2.4" + querystring-es3 "^0.2.0" + readable-stream "^2.3.3" + stream-browserify "^2.0.1" + stream-http "^2.7.2" + string_decoder "^1.0.0" + timers-browserify "^2.0.4" + tty-browserify "0.0.0" + url "^0.11.0" + util "^0.11.0" + vm-browserify "^1.0.1" + +node-releases@^1.1.70: + version "1.1.71" + resolved "https://registry.npm.taobao.org/node-releases/download/node-releases-1.1.71.tgz?cache=0&sync_timestamp=1614113881684&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fnode-releases%2Fdownload%2Fnode-releases-1.1.71.tgz#cb1334b179896b1c89ecfdd4b725fb7bbdfc7dbb" + integrity sha1-yxM0sXmJaxyJ7P3UtyX7e738fbs= + +normalize-package-data@^2.5.0: + version "2.5.0" + resolved "https://registry.npm.taobao.org/normalize-package-data/download/normalize-package-data-2.5.0.tgz?cache=0&sync_timestamp=1616086930281&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fnormalize-package-data%2Fdownload%2Fnormalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" + integrity sha1-5m2xg4sgDB38IzIl0SyzZSDiNKg= + dependencies: + hosted-git-info "^2.1.4" + resolve "^1.10.0" + semver "2 || 3 || 4 || 5" + validate-npm-package-license "^3.0.1" + +normalize-path@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/normalize-path/download/normalize-path-1.0.0.tgz?cache=0&sync_timestamp=1593529695654&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fnormalize-path%2Fdownload%2Fnormalize-path-1.0.0.tgz#32d0e472f91ff345701c15a8311018d3b0a90379" + integrity sha1-MtDkcvkf80VwHBWoMRAY07CpA3k= + +normalize-path@^2.1.1: + version "2.1.1" + resolved "https://registry.npm.taobao.org/normalize-path/download/normalize-path-2.1.1.tgz?cache=0&sync_timestamp=1593529695654&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fnormalize-path%2Fdownload%2Fnormalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" + integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= + dependencies: + remove-trailing-separator "^1.0.1" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/normalize-path/download/normalize-path-3.0.0.tgz?cache=0&sync_timestamp=1593529695654&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fnormalize-path%2Fdownload%2Fnormalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha1-Dc1p/yOhybEf0JeDFmRKA4ghamU= + +normalize-range@^0.1.2: + version "0.1.2" + resolved "https://registry.npm.taobao.org/normalize-range/download/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" + integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= + +normalize-url@1.9.1: + version "1.9.1" + resolved "https://registry.npm.taobao.org/normalize-url/download/normalize-url-1.9.1.tgz?cache=0&sync_timestamp=1617786295473&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fnormalize-url%2Fdownload%2Fnormalize-url-1.9.1.tgz#2cc0d66b31ea23036458436e3620d85954c66c3c" + integrity sha1-LMDWazHqIwNkWENuNiDYWVTGbDw= + dependencies: + object-assign "^4.0.1" + prepend-http "^1.0.0" + query-string "^4.1.0" + sort-keys "^1.0.0" + +normalize-url@^3.0.0: + version "3.3.0" + resolved "https://registry.npm.taobao.org/normalize-url/download/normalize-url-3.3.0.tgz?cache=0&sync_timestamp=1617786295473&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fnormalize-url%2Fdownload%2Fnormalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" + integrity sha1-suHE3E98bVd0PfczpPWXjRhlBVk= + +npm-run-path@^2.0.0: + version "2.0.2" + resolved "https://registry.npm.taobao.org/npm-run-path/download/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" + integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= + dependencies: + path-key "^2.0.0" + +npm-run-path@^4.0.0: + version "4.0.1" + resolved "https://registry.npm.taobao.org/npm-run-path/download/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha1-t+zR5e1T2o43pV4cImnguX7XSOo= + dependencies: + path-key "^3.0.0" + +nth-check@^1.0.2: + version "1.0.2" + resolved "https://registry.npm.taobao.org/nth-check/download/nth-check-1.0.2.tgz?cache=0&sync_timestamp=1606860664533&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fnth-check%2Fdownload%2Fnth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" + integrity sha1-sr0pXDfj3VijvwcAN2Zjuk2c8Fw= + dependencies: + boolbase "~1.0.0" + +num2fraction@^1.2.2: + version "1.2.2" + resolved "https://registry.npm.taobao.org/num2fraction/download/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede" + integrity sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4= + +oauth-sign@~0.9.0: + version "0.9.0" + resolved "https://registry.npm.taobao.org/oauth-sign/download/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" + integrity sha1-R6ewFrqmi1+g7PPe4IqFxnmsZFU= + +object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.npm.taobao.org/object-assign/download/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + +object-copy@^0.1.0: + version "0.1.0" + resolved "https://registry.npm.taobao.org/object-copy/download/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" + integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw= + dependencies: + copy-descriptor "^0.1.0" + define-property "^0.2.5" + kind-of "^3.0.3" + +object-inspect@^1.9.0: + version "1.9.0" + resolved "https://registry.npm.taobao.org/object-inspect/download/object-inspect-1.9.0.tgz?cache=0&sync_timestamp=1606804307501&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fobject-inspect%2Fdownload%2Fobject-inspect-1.9.0.tgz#c90521d74e1127b67266ded3394ad6116986533a" + integrity sha1-yQUh104RJ7ZyZt7TOUrWEWmGUzo= + +object-is@^1.0.1: + version "1.1.5" + resolved "https://registry.npm.taobao.org/object-is/download/object-is-1.1.5.tgz?cache=0&sync_timestamp=1613858223300&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fobject-is%2Fdownload%2Fobject-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac" + integrity sha1-ud7qpfx/GEag+uzc7sE45XePU6w= + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + +object-keys@^1.0.12, object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.npm.taobao.org/object-keys/download/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha1-HEfyct8nfzsdrwYWd9nILiMixg4= + +object-visit@^1.0.0: + version "1.0.1" + resolved "https://registry.npm.taobao.org/object-visit/download/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" + integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs= + dependencies: + isobject "^3.0.0" + +object.assign@^4.1.0, object.assign@^4.1.2: + version "4.1.2" + resolved "https://registry.npm.taobao.org/object.assign/download/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" + integrity sha1-DtVKNC7Os3s4/3brgxoOeIy2OUA= + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + has-symbols "^1.0.1" + object-keys "^1.1.1" + +object.getownpropertydescriptors@^2.0.3, object.getownpropertydescriptors@^2.1.0: + version "2.1.2" + resolved "https://registry.npm.taobao.org/object.getownpropertydescriptors/download/object.getownpropertydescriptors-2.1.2.tgz?cache=0&sync_timestamp=1613860098805&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fobject.getownpropertydescriptors%2Fdownload%2Fobject.getownpropertydescriptors-2.1.2.tgz#1bd63aeacf0d5d2d2f31b5e393b03a7c601a23f7" + integrity sha1-G9Y66s8NXS0vMbXjk7A6fGAaI/c= + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.18.0-next.2" + +object.pick@^1.3.0: + version "1.3.0" + resolved "https://registry.npm.taobao.org/object.pick/download/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" + integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c= + dependencies: + isobject "^3.0.1" + +object.values@^1.1.0: + version "1.1.3" + resolved "https://registry.npm.taobao.org/object.values/download/object.values-1.1.3.tgz?cache=0&sync_timestamp=1614057760863&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fobject.values%2Fdownload%2Fobject.values-1.1.3.tgz#eaa8b1e17589f02f698db093f7c62ee1699742ee" + integrity sha1-6qix4XWJ8C9pjbCT98Yu4WmXQu4= + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.18.0-next.2" + has "^1.0.3" + +obuf@^1.0.0, obuf@^1.1.2: + version "1.1.2" + resolved "https://registry.npm.taobao.org/obuf/download/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" + integrity sha1-Cb6jND1BhZ69RGKS0RydTbYZCE4= + +omit.js@^2.0.0: + version "2.0.2" + resolved "https://registry.npmjs.org/omit.js/-/omit.js-2.0.2.tgz#dd9b8436fab947a5f3ff214cb2538631e313ec2f" + integrity sha512-hJmu9D+bNB40YpL9jYebQl4lsTW6yEHRTroJzNLqQJYHm7c+NQnJGfZmIWh8S3q3KoaxV1aLhV6B3+0N0/kyJg== + +on-finished@~2.3.0: + version "2.3.0" + resolved "https://registry.npm.taobao.org/on-finished/download/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" + integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= + dependencies: + ee-first "1.1.1" + +on-headers@~1.0.2: + version "1.0.2" + resolved "https://registry.npm.taobao.org/on-headers/download/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" + integrity sha1-dysK5qqlJcOZ5Imt+tkMQD6zwo8= + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.npm.taobao.org/once/download/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + +onetime@^2.0.0: + version "2.0.1" + resolved "https://registry.npm.taobao.org/onetime/download/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" + integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ= + dependencies: + mimic-fn "^1.0.0" + +onetime@^5.1.0: + version "5.1.2" + resolved "https://registry.npm.taobao.org/onetime/download/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha1-0Oluu1awdHbfHdnEgG5SN5hcpF4= + dependencies: + mimic-fn "^2.1.0" + +open@^6.3.0: + version "6.4.0" + resolved "https://registry.npm.taobao.org/open/download/open-6.4.0.tgz#5c13e96d0dc894686164f18965ecfe889ecfc8a9" + integrity sha1-XBPpbQ3IlGhhZPGJZez+iJ7PyKk= + dependencies: + is-wsl "^1.1.0" + +opener@^1.5.1: + version "1.5.2" + resolved "https://registry.npm.taobao.org/opener/download/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" + integrity sha1-XTfh81B3udysQwE3InGv3rKhNZg= + +opn@^5.5.0: + version "5.5.0" + resolved "https://registry.npm.taobao.org/opn/download/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc" + integrity sha1-/HFk+rVtI1kExRw7J9pnWMo7m/w= + dependencies: + is-wsl "^1.1.0" + +ora@^3.4.0: + version "3.4.0" + resolved "https://registry.npm.taobao.org/ora/download/ora-3.4.0.tgz#bf0752491059a3ef3ed4c85097531de9fdbcd318" + integrity sha1-vwdSSRBZo+8+1MhQl1Md6f280xg= + dependencies: + chalk "^2.4.2" + cli-cursor "^2.1.0" + cli-spinners "^2.0.0" + log-symbols "^2.2.0" + strip-ansi "^5.2.0" + wcwidth "^1.0.1" + +original@^1.0.0: + version "1.0.2" + resolved "https://registry.npm.taobao.org/original/download/original-1.0.2.tgz#e442a61cffe1c5fd20a65f3261c26663b303f25f" + integrity sha1-5EKmHP/hxf0gpl8yYcJmY7MD8l8= + dependencies: + url-parse "^1.4.3" + +os-browserify@^0.3.0: + version "0.3.0" + resolved "https://registry.npm.taobao.org/os-browserify/download/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" + integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= + +ot-json0@^1.0.1, ot-json0@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/ot-json0/-/ot-json0-1.1.0.tgz#f5edeff162673b62f0f136bb64724c40ac5e590d" + integrity sha512-wf5fci7GGpMYRDnbbdIFQymvhsbFACMHtxjivQo5KgvAHlxekyfJ9aPsRr6YfFQthQkk4bmsl5yESrZwC/oMYQ== + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/p-finally/download/p-finally-1.0.0.tgz?cache=0&sync_timestamp=1617947676340&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fp-finally%2Fdownload%2Fp-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= + +p-finally@^2.0.0: + version "2.0.1" + resolved "https://registry.npm.taobao.org/p-finally/download/p-finally-2.0.1.tgz?cache=0&sync_timestamp=1617947676340&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fp-finally%2Fdownload%2Fp-finally-2.0.1.tgz#bd6fcaa9c559a096b680806f4d657b3f0f240561" + integrity sha1-vW/KqcVZoJa2gIBvTWV7Pw8kBWE= + +p-limit@^2.0.0, p-limit@^2.2.0, p-limit@^2.2.1, p-limit@^2.3.0: + version "2.3.0" + resolved "https://registry.npm.taobao.org/p-limit/download/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha1-PdM8ZHohT9//2DWTPrCG2g3CHbE= + dependencies: + p-try "^2.0.0" + +p-locate@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/p-locate/download/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" + integrity sha1-Mi1poFwCZLJZl9n0DNiokasAZKQ= + dependencies: + p-limit "^2.0.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.npm.taobao.org/p-locate/download/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha1-o0KLtwiLOmApL2aRkni3wpetTwc= + dependencies: + p-limit "^2.2.0" + +p-map@^2.0.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/p-map/download/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" + integrity sha1-MQko/u+cnsxltosXaTAYpmXOoXU= + +p-map@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/p-map/download/p-map-3.0.0.tgz#d704d9af8a2ba684e2600d9a215983d4141a979d" + integrity sha1-1wTZr4orpoTiYA2aIVmD1BQal50= + dependencies: + aggregate-error "^3.0.0" + +p-retry@^3.0.1: + version "3.0.1" + resolved "https://registry.npm.taobao.org/p-retry/download/p-retry-3.0.1.tgz?cache=0&sync_timestamp=1617002041200&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fp-retry%2Fdownload%2Fp-retry-3.0.1.tgz#316b4c8893e2c8dc1cfa891f406c4b422bebf328" + integrity sha1-MWtMiJPiyNwc+okfQGxLQivr8yg= + dependencies: + retry "^0.12.0" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.npm.taobao.org/p-try/download/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha1-yyhoVA4xPWHeWPr741zpAE1VQOY= + +pako@~1.0.5: + version "1.0.11" + resolved "https://registry.npm.taobao.org/pako/download/pako-1.0.11.tgz?cache=0&sync_timestamp=1610208860443&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpako%2Fdownload%2Fpako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha1-bJWZ00DVTf05RjgCUqNXBaa5kr8= + +parallel-transform@^1.1.0: + version "1.2.0" + resolved "https://registry.npm.taobao.org/parallel-transform/download/parallel-transform-1.2.0.tgz#9049ca37d6cb2182c3b1d2c720be94d14a5814fc" + integrity sha1-kEnKN9bLIYLDsdLHIL6U0UpYFPw= + dependencies: + cyclist "^1.0.1" + inherits "^2.0.3" + readable-stream "^2.1.5" + +param-case@2.1.x: + version "2.1.1" + resolved "https://registry.npm.taobao.org/param-case/download/param-case-2.1.1.tgz?cache=0&sync_timestamp=1606869196249&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fparam-case%2Fdownload%2Fparam-case-2.1.1.tgz#df94fd8cf6531ecf75e6bef9a0858fbc72be2247" + integrity sha1-35T9jPZTHs915r75oIWPvHK+Ikc= + dependencies: + no-case "^2.2.0" + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.npm.taobao.org/parent-module/download/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha1-aR0nCeeMefrjoVZiJFLQB2LKqqI= + dependencies: + callsites "^3.0.0" + +parse-asn1@^5.0.0, parse-asn1@^5.1.5: + version "5.1.6" + resolved "https://registry.npm.taobao.org/parse-asn1/download/parse-asn1-5.1.6.tgz?cache=0&sync_timestamp=1597165880981&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fparse-asn1%2Fdownload%2Fparse-asn1-5.1.6.tgz#385080a3ec13cb62a62d39409cb3e88844cdaed4" + integrity sha1-OFCAo+wTy2KmLTlAnLPoiETNrtQ= + dependencies: + asn1.js "^5.2.0" + browserify-aes "^1.0.0" + evp_bytestokey "^1.0.0" + pbkdf2 "^3.0.3" + safe-buffer "^5.1.1" + +parse-json@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/parse-json/download/parse-json-4.0.0.tgz?cache=0&sync_timestamp=1610966642419&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fparse-json%2Fdownload%2Fparse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" + integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA= + dependencies: + error-ex "^1.3.1" + json-parse-better-errors "^1.0.1" + +parse-json@^5.0.0: + version "5.2.0" + resolved "https://registry.npm.taobao.org/parse-json/download/parse-json-5.2.0.tgz?cache=0&sync_timestamp=1610966642419&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fparse-json%2Fdownload%2Fparse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha1-x2/Gbe5UIxyWKyK8yKcs8vmXU80= + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +parse5-htmlparser2-tree-adapter@^6.0.0: + version "6.0.1" + resolved "https://registry.npm.taobao.org/parse5-htmlparser2-tree-adapter/download/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6" + integrity sha1-LN+a2CMyEUA3DU2/XT6Sx8jdxuY= + dependencies: + parse5 "^6.0.1" + +parse5@^5.1.1: + version "5.1.1" + resolved "https://registry.npm.taobao.org/parse5/download/parse5-5.1.1.tgz?cache=0&sync_timestamp=1595849185980&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fparse5%2Fdownload%2Fparse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" + integrity sha1-9o5OW6GFKsLK3AD0VV//bCq7YXg= + +parse5@^6.0.1: + version "6.0.1" + resolved "https://registry.npm.taobao.org/parse5/download/parse5-6.0.1.tgz?cache=0&sync_timestamp=1595849185980&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fparse5%2Fdownload%2Fparse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" + integrity sha1-4aHAhcVps9wIMhGE8Zo5zCf3wws= + +parseurl@~1.3.2, parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.npm.taobao.org/parseurl/download/parseurl-1.3.3.tgz?cache=0&sync_timestamp=1593529696791&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fparseurl%2Fdownload%2Fparseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha1-naGee+6NEt/wUT7Vt2lXeTvC6NQ= + +pascalcase@^0.1.1: + version "0.1.1" + resolved "https://registry.npm.taobao.org/pascalcase/download/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" + integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= + +path-browserify@0.0.1: + version "0.0.1" + resolved "https://registry.npm.taobao.org/path-browserify/download/path-browserify-0.0.1.tgz#e6c4ddd7ed3aa27c68a20cc4e50e1a4ee83bbc4a" + integrity sha1-5sTd1+06onxoogzE5Q4aTug7vEo= + +path-dirname@^1.0.0: + version "1.0.2" + resolved "https://registry.npm.taobao.org/path-dirname/download/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" + integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA= + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/path-exists/download/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/path-exists/download/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha1-UTvb4tO5XXdi6METfvoZXGxhtbM= + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.npm.taobao.org/path-is-absolute/download/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + +path-is-inside@^1.0.2: + version "1.0.2" + resolved "https://registry.npm.taobao.org/path-is-inside/download/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" + integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= + +path-key@^2.0.0, path-key@^2.0.1: + version "2.0.1" + resolved "https://registry.npm.taobao.org/path-key/download/path-key-2.0.1.tgz?cache=0&sync_timestamp=1617971675964&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpath-key%2Fdownload%2Fpath-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= + +path-key@^3.0.0, path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.npm.taobao.org/path-key/download/path-key-3.1.1.tgz?cache=0&sync_timestamp=1617971675964&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpath-key%2Fdownload%2Fpath-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha1-WB9q3mWMu6ZaDTOA3ndTKVBU83U= + +path-parse@^1.0.6: + version "1.0.6" + resolved "https://registry.npm.taobao.org/path-parse/download/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" + integrity sha1-1i27VnlAXXLEc37FhgDp3c8G0kw= + +path-to-regexp@0.1.7: + version "0.1.7" + resolved "https://registry.npm.taobao.org/path-to-regexp/download/path-to-regexp-0.1.7.tgz?cache=0&sync_timestamp=1601400247487&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpath-to-regexp%2Fdownload%2Fpath-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" + integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= + +path-type@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/path-type/download/path-type-3.0.0.tgz?cache=0&sync_timestamp=1611752058913&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpath-type%2Fdownload%2Fpath-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" + integrity sha1-zvMdyOCho7sNEFwM2Xzzv0f0428= + dependencies: + pify "^3.0.0" + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/path-type/download/path-type-4.0.0.tgz?cache=0&sync_timestamp=1611752058913&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpath-type%2Fdownload%2Fpath-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha1-hO0BwKe6OAr+CdkKjBgNzZ0DBDs= + +pbkdf2@^3.0.3: + version "3.1.2" + resolved "https://registry.npm.taobao.org/pbkdf2/download/pbkdf2-3.1.2.tgz#dd822aa0887580e52f1a039dc3eda108efae3075" + integrity sha1-3YIqoIh1gOUvGgOdw+2hCO+uMHU= + dependencies: + create-hash "^1.1.2" + create-hmac "^1.1.4" + ripemd160 "^2.0.1" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/performance-now/download/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= + +photoswipe@^4.1.3: + version "4.1.3" + resolved "https://registry.npm.taobao.org/photoswipe/download/photoswipe-4.1.3.tgz#59f49494eeb9ddab5888d03392926a19bc197550" + integrity sha1-WfSUlO653atYiNAzkpJqGbwZdVA= + +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3: + version "2.2.3" + resolved "https://registry.npm.taobao.org/picomatch/download/picomatch-2.2.3.tgz?cache=0&sync_timestamp=1618049925917&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpicomatch%2Fdownload%2Fpicomatch-2.2.3.tgz#465547f359ccc206d3c48e46a1bcb89bf7ee619d" + integrity sha1-RlVH81nMwgbTxI5Goby4m/fuYZ0= + +pify@^2.0.0: + version "2.3.0" + resolved "https://registry.npm.taobao.org/pify/download/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= + +pify@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/pify/download/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" + integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY= + +pify@^4.0.1: + version "4.0.1" + resolved "https://registry.npm.taobao.org/pify/download/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" + integrity sha1-SyzSXFDVmHNcUCkiJP2MbfQeMjE= + +pinkie-promise@^2.0.0: + version "2.0.1" + resolved "https://registry.npm.taobao.org/pinkie-promise/download/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" + integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o= + dependencies: + pinkie "^2.0.0" + +pinkie@^2.0.0: + version "2.0.4" + resolved "https://registry.npm.taobao.org/pinkie/download/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" + integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA= + +pkg-dir@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/pkg-dir/download/pkg-dir-3.0.0.tgz?cache=0&sync_timestamp=1602858985920&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpkg-dir%2Fdownload%2Fpkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3" + integrity sha1-J0kCDyOe2ZCIGx9xIQ1R62UjvqM= + dependencies: + find-up "^3.0.0" + +pkg-dir@^4.1.0: + version "4.2.0" + resolved "https://registry.npm.taobao.org/pkg-dir/download/pkg-dir-4.2.0.tgz?cache=0&sync_timestamp=1602858985920&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpkg-dir%2Fdownload%2Fpkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha1-8JkTPfft5CLoHR2ESCcO6z5CYfM= + dependencies: + find-up "^4.0.0" + +pnp-webpack-plugin@^1.6.4: + version "1.6.4" + resolved "https://registry.npm.taobao.org/pnp-webpack-plugin/download/pnp-webpack-plugin-1.6.4.tgz#c9711ac4dc48a685dabafc86f8b6dd9f8df84149" + integrity sha1-yXEaxNxIpoXauvyG+Lbdn434QUk= + dependencies: + ts-pnp "^1.1.6" + +portfinder@^1.0.26: + version "1.0.28" + resolved "https://registry.npm.taobao.org/portfinder/download/portfinder-1.0.28.tgz?cache=0&sync_timestamp=1596018197667&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fportfinder%2Fdownload%2Fportfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778" + integrity sha1-Z8RiKFK9U3TdHdkA93n1NGL6x3g= + dependencies: + async "^2.6.2" + debug "^3.1.1" + mkdirp "^0.5.5" + +posix-character-classes@^0.1.0: + version "0.1.1" + resolved "https://registry.npm.taobao.org/posix-character-classes/download/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" + integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= + +postcss-calc@^7.0.1: + version "7.0.5" + resolved "https://registry.npm.taobao.org/postcss-calc/download/postcss-calc-7.0.5.tgz?cache=0&sync_timestamp=1609689190192&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-calc%2Fdownload%2Fpostcss-calc-7.0.5.tgz#f8a6e99f12e619c2ebc23cf6c486fdc15860933e" + integrity sha1-+KbpnxLmGcLrwjz2xIb9wVhgkz4= + dependencies: + postcss "^7.0.27" + postcss-selector-parser "^6.0.2" + postcss-value-parser "^4.0.2" + +postcss-colormin@^4.0.3: + version "4.0.3" + resolved "https://registry.npm.taobao.org/postcss-colormin/download/postcss-colormin-4.0.3.tgz?cache=0&sync_timestamp=1618056303067&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-colormin%2Fdownload%2Fpostcss-colormin-4.0.3.tgz#ae060bce93ed794ac71264f08132d550956bd381" + integrity sha1-rgYLzpPteUrHEmTwgTLVUJVr04E= + dependencies: + browserslist "^4.0.0" + color "^3.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-convert-values@^4.0.1: + version "4.0.1" + resolved "https://registry.npm.taobao.org/postcss-convert-values/download/postcss-convert-values-4.0.1.tgz?cache=0&sync_timestamp=1618056302291&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-convert-values%2Fdownload%2Fpostcss-convert-values-4.0.1.tgz#ca3813ed4da0f812f9d43703584e449ebe189a7f" + integrity sha1-yjgT7U2g+BL51DcDWE5Enr4Ymn8= + dependencies: + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-discard-comments@^4.0.2: + version "4.0.2" + resolved "https://registry.npm.taobao.org/postcss-discard-comments/download/postcss-discard-comments-4.0.2.tgz?cache=0&sync_timestamp=1618056302844&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-discard-comments%2Fdownload%2Fpostcss-discard-comments-4.0.2.tgz#1fbabd2c246bff6aaad7997b2b0918f4d7af4033" + integrity sha1-H7q9LCRr/2qq15l7KwkY9NevQDM= + dependencies: + postcss "^7.0.0" + +postcss-discard-duplicates@^4.0.2: + version "4.0.2" + resolved "https://registry.npm.taobao.org/postcss-discard-duplicates/download/postcss-discard-duplicates-4.0.2.tgz?cache=0&sync_timestamp=1618056303153&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-discard-duplicates%2Fdownload%2Fpostcss-discard-duplicates-4.0.2.tgz#3fe133cd3c82282e550fc9b239176a9207b784eb" + integrity sha1-P+EzzTyCKC5VD8myORdqkge3hOs= + dependencies: + postcss "^7.0.0" + +postcss-discard-empty@^4.0.1: + version "4.0.1" + resolved "https://registry.npm.taobao.org/postcss-discard-empty/download/postcss-discard-empty-4.0.1.tgz?cache=0&sync_timestamp=1618056303643&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-discard-empty%2Fdownload%2Fpostcss-discard-empty-4.0.1.tgz#c8c951e9f73ed9428019458444a02ad90bb9f765" + integrity sha1-yMlR6fc+2UKAGUWERKAq2Qu592U= + dependencies: + postcss "^7.0.0" + +postcss-discard-overridden@^4.0.1: + version "4.0.1" + resolved "https://registry.npm.taobao.org/postcss-discard-overridden/download/postcss-discard-overridden-4.0.1.tgz?cache=0&sync_timestamp=1618056543118&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-discard-overridden%2Fdownload%2Fpostcss-discard-overridden-4.0.1.tgz#652aef8a96726f029f5e3e00146ee7a4e755ff57" + integrity sha1-ZSrvipZybwKfXj4AFG7npOdV/1c= + dependencies: + postcss "^7.0.0" + +postcss-load-config@^2.0.0: + version "2.1.2" + resolved "https://registry.npm.taobao.org/postcss-load-config/download/postcss-load-config-2.1.2.tgz#c5ea504f2c4aef33c7359a34de3573772ad7502a" + integrity sha1-xepQTyxK7zPHNZo03jVzdyrXUCo= + dependencies: + cosmiconfig "^5.0.0" + import-cwd "^2.0.0" + +postcss-loader@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/postcss-loader/download/postcss-loader-3.0.0.tgz#6b97943e47c72d845fa9e03f273773d4e8dd6c2d" + integrity sha1-a5eUPkfHLYRfqeA/Jzdz1OjdbC0= + dependencies: + loader-utils "^1.1.0" + postcss "^7.0.0" + postcss-load-config "^2.0.0" + schema-utils "^1.0.0" + +postcss-merge-longhand@^4.0.11: + version "4.0.11" + resolved "https://registry.npm.taobao.org/postcss-merge-longhand/download/postcss-merge-longhand-4.0.11.tgz?cache=0&sync_timestamp=1618056437484&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-merge-longhand%2Fdownload%2Fpostcss-merge-longhand-4.0.11.tgz#62f49a13e4a0ee04e7b98f42bb16062ca2549e24" + integrity sha1-YvSaE+Sg7gTnuY9CuxYGLKJUniQ= + dependencies: + css-color-names "0.0.4" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + stylehacks "^4.0.0" + +postcss-merge-rules@^4.0.3: + version "4.0.3" + resolved "https://registry.npm.taobao.org/postcss-merge-rules/download/postcss-merge-rules-4.0.3.tgz?cache=0&sync_timestamp=1618056433865&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-merge-rules%2Fdownload%2Fpostcss-merge-rules-4.0.3.tgz#362bea4ff5a1f98e4075a713c6cb25aefef9a650" + integrity sha1-NivqT/Wh+Y5AdacTxsslrv75plA= + dependencies: + browserslist "^4.0.0" + caniuse-api "^3.0.0" + cssnano-util-same-parent "^4.0.0" + postcss "^7.0.0" + postcss-selector-parser "^3.0.0" + vendors "^1.0.0" + +postcss-minify-font-values@^4.0.2: + version "4.0.2" + resolved "https://registry.npm.taobao.org/postcss-minify-font-values/download/postcss-minify-font-values-4.0.2.tgz?cache=0&sync_timestamp=1618056546492&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-minify-font-values%2Fdownload%2Fpostcss-minify-font-values-4.0.2.tgz#cd4c344cce474343fac5d82206ab2cbcb8afd5a6" + integrity sha1-zUw0TM5HQ0P6xdgiBqssvLiv1aY= + dependencies: + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-minify-gradients@^4.0.2: + version "4.0.2" + resolved "https://registry.npm.taobao.org/postcss-minify-gradients/download/postcss-minify-gradients-4.0.2.tgz?cache=0&sync_timestamp=1618056547050&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-minify-gradients%2Fdownload%2Fpostcss-minify-gradients-4.0.2.tgz#93b29c2ff5099c535eecda56c4aa6e665a663471" + integrity sha1-k7KcL/UJnFNe7NpWxKpuZlpmNHE= + dependencies: + cssnano-util-get-arguments "^4.0.0" + is-color-stop "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-minify-params@^4.0.2: + version "4.0.2" + resolved "https://registry.npm.taobao.org/postcss-minify-params/download/postcss-minify-params-4.0.2.tgz?cache=0&sync_timestamp=1618056435166&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-minify-params%2Fdownload%2Fpostcss-minify-params-4.0.2.tgz#6b9cef030c11e35261f95f618c90036d680db874" + integrity sha1-a5zvAwwR41Jh+V9hjJADbWgNuHQ= + dependencies: + alphanum-sort "^1.0.0" + browserslist "^4.0.0" + cssnano-util-get-arguments "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + uniqs "^2.0.0" + +postcss-minify-selectors@^4.0.2: + version "4.0.2" + resolved "https://registry.npm.taobao.org/postcss-minify-selectors/download/postcss-minify-selectors-4.0.2.tgz?cache=0&sync_timestamp=1618056542755&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-minify-selectors%2Fdownload%2Fpostcss-minify-selectors-4.0.2.tgz#e2e5eb40bfee500d0cd9243500f5f8ea4262fbd8" + integrity sha1-4uXrQL/uUA0M2SQ1APX46kJi+9g= + dependencies: + alphanum-sort "^1.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-selector-parser "^3.0.0" + +postcss-modules-extract-imports@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/postcss-modules-extract-imports/download/postcss-modules-extract-imports-2.0.0.tgz#818719a1ae1da325f9832446b01136eeb493cd7e" + integrity sha1-gYcZoa4doyX5gyRGsBE27rSTzX4= + dependencies: + postcss "^7.0.5" + +postcss-modules-extract-imports@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/postcss-modules-extract-imports/download/postcss-modules-extract-imports-3.0.0.tgz#cda1f047c0ae80c97dbe28c3e76a43b88025741d" + integrity sha1-zaHwR8CugMl9vijD52pDuIAldB0= + +postcss-modules-local-by-default@^3.0.2: + version "3.0.3" + resolved "https://registry.npm.taobao.org/postcss-modules-local-by-default/download/postcss-modules-local-by-default-3.0.3.tgz#bb14e0cc78279d504dbdcbfd7e0ca28993ffbbb0" + integrity sha1-uxTgzHgnnVBNvcv9fgyiiZP/u7A= + dependencies: + icss-utils "^4.1.1" + postcss "^7.0.32" + postcss-selector-parser "^6.0.2" + postcss-value-parser "^4.1.0" + +postcss-modules-local-by-default@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/postcss-modules-local-by-default/download/postcss-modules-local-by-default-4.0.0.tgz#ebbb54fae1598eecfdf691a02b3ff3b390a5a51c" + integrity sha1-67tU+uFZjuz99pGgKz/zs5ClpRw= + dependencies: + icss-utils "^5.0.0" + postcss-selector-parser "^6.0.2" + postcss-value-parser "^4.1.0" + +postcss-modules-scope@^2.2.0: + version "2.2.0" + resolved "https://registry.npm.taobao.org/postcss-modules-scope/download/postcss-modules-scope-2.2.0.tgz#385cae013cc7743f5a7d7602d1073a89eaae62ee" + integrity sha1-OFyuATzHdD9afXYC0Qc6iequYu4= + dependencies: + postcss "^7.0.6" + postcss-selector-parser "^6.0.0" + +postcss-modules-scope@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/postcss-modules-scope/download/postcss-modules-scope-3.0.0.tgz#9ef3151456d3bbfa120ca44898dfca6f2fa01f06" + integrity sha1-nvMVFFbTu/oSDKRImN/Kby+gHwY= + dependencies: + postcss-selector-parser "^6.0.4" + +postcss-modules-values@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/postcss-modules-values/download/postcss-modules-values-3.0.0.tgz#5b5000d6ebae29b4255301b4a3a54574423e7f10" + integrity sha1-W1AA1uuuKbQlUwG0o6VFdEI+fxA= + dependencies: + icss-utils "^4.0.0" + postcss "^7.0.6" + +postcss-modules-values@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/postcss-modules-values/download/postcss-modules-values-4.0.0.tgz#d7c5e7e68c3bb3c9b27cbf48ca0bb3ffb4602c9c" + integrity sha1-18Xn5ow7s8myfL9Iyguz/7RgLJw= + dependencies: + icss-utils "^5.0.0" + +postcss-modules@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/postcss-modules/download/postcss-modules-4.0.0.tgz?cache=0&sync_timestamp=1606641387568&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-modules%2Fdownload%2Fpostcss-modules-4.0.0.tgz#2bc7f276ab88f3f1b0fadf6cbd7772d43b5f3b9b" + integrity sha1-K8fydquI8/Gw+t9svXdy1DtfO5s= + dependencies: + generic-names "^2.0.1" + icss-replace-symbols "^1.1.0" + lodash.camelcase "^4.3.0" + postcss-modules-extract-imports "^3.0.0" + postcss-modules-local-by-default "^4.0.0" + postcss-modules-scope "^3.0.0" + postcss-modules-values "^4.0.0" + string-hash "^1.1.1" + +postcss-normalize-charset@^4.0.1: + version "4.0.1" + resolved "https://registry.npm.taobao.org/postcss-normalize-charset/download/postcss-normalize-charset-4.0.1.tgz?cache=0&sync_timestamp=1618056430813&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-normalize-charset%2Fdownload%2Fpostcss-normalize-charset-4.0.1.tgz#8b35add3aee83a136b0471e0d59be58a50285dd4" + integrity sha1-izWt067oOhNrBHHg1ZvlilAoXdQ= + dependencies: + postcss "^7.0.0" + +postcss-normalize-display-values@^4.0.2: + version "4.0.2" + resolved "https://registry.npm.taobao.org/postcss-normalize-display-values/download/postcss-normalize-display-values-4.0.2.tgz#0dbe04a4ce9063d4667ed2be476bb830c825935a" + integrity sha1-Db4EpM6QY9RmftK+R2u4MMglk1o= + dependencies: + cssnano-util-get-match "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-positions@^4.0.2: + version "4.0.2" + resolved "https://registry.npm.taobao.org/postcss-normalize-positions/download/postcss-normalize-positions-4.0.2.tgz?cache=0&sync_timestamp=1618056388080&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-normalize-positions%2Fdownload%2Fpostcss-normalize-positions-4.0.2.tgz#05f757f84f260437378368a91f8932d4b102917f" + integrity sha1-BfdX+E8mBDc3g2ipH4ky1LECkX8= + dependencies: + cssnano-util-get-arguments "^4.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-repeat-style@^4.0.2: + version "4.0.2" + resolved "https://registry.npm.taobao.org/postcss-normalize-repeat-style/download/postcss-normalize-repeat-style-4.0.2.tgz?cache=0&sync_timestamp=1618056392185&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-normalize-repeat-style%2Fdownload%2Fpostcss-normalize-repeat-style-4.0.2.tgz#c4ebbc289f3991a028d44751cbdd11918b17910c" + integrity sha1-xOu8KJ85kaAo1EdRy90RkYsXkQw= + dependencies: + cssnano-util-get-arguments "^4.0.0" + cssnano-util-get-match "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-string@^4.0.2: + version "4.0.2" + resolved "https://registry.npm.taobao.org/postcss-normalize-string/download/postcss-normalize-string-4.0.2.tgz?cache=0&sync_timestamp=1618056389791&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-normalize-string%2Fdownload%2Fpostcss-normalize-string-4.0.2.tgz#cd44c40ab07a0c7a36dc5e99aace1eca4ec2690c" + integrity sha1-zUTECrB6DHo23F6Zqs4eyk7CaQw= + dependencies: + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-timing-functions@^4.0.2: + version "4.0.2" + resolved "https://registry.npm.taobao.org/postcss-normalize-timing-functions/download/postcss-normalize-timing-functions-4.0.2.tgz?cache=0&sync_timestamp=1618056392487&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-normalize-timing-functions%2Fdownload%2Fpostcss-normalize-timing-functions-4.0.2.tgz#8e009ca2a3949cdaf8ad23e6b6ab99cb5e7d28d9" + integrity sha1-jgCcoqOUnNr4rSPmtquZy159KNk= + dependencies: + cssnano-util-get-match "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-unicode@^4.0.1: + version "4.0.1" + resolved "https://registry.npm.taobao.org/postcss-normalize-unicode/download/postcss-normalize-unicode-4.0.1.tgz?cache=0&sync_timestamp=1618056510687&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-normalize-unicode%2Fdownload%2Fpostcss-normalize-unicode-4.0.1.tgz#841bd48fdcf3019ad4baa7493a3d363b52ae1cfb" + integrity sha1-hBvUj9zzAZrUuqdJOj02O1KuHPs= + dependencies: + browserslist "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-url@^4.0.1: + version "4.0.1" + resolved "https://registry.npm.taobao.org/postcss-normalize-url/download/postcss-normalize-url-4.0.1.tgz#10e437f86bc7c7e58f7b9652ed878daaa95faae1" + integrity sha1-EOQ3+GvHx+WPe5ZS7YeNqqlfquE= + dependencies: + is-absolute-url "^2.0.0" + normalize-url "^3.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-whitespace@^4.0.2: + version "4.0.2" + resolved "https://registry.npm.taobao.org/postcss-normalize-whitespace/download/postcss-normalize-whitespace-4.0.2.tgz?cache=0&sync_timestamp=1618056511329&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-normalize-whitespace%2Fdownload%2Fpostcss-normalize-whitespace-4.0.2.tgz#bf1d4070fe4fcea87d1348e825d8cc0c5faa7d82" + integrity sha1-vx1AcP5Pzqh9E0joJdjMDF+qfYI= + dependencies: + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-ordered-values@^4.1.2: + version "4.1.2" + resolved "https://registry.npm.taobao.org/postcss-ordered-values/download/postcss-ordered-values-4.1.2.tgz?cache=0&sync_timestamp=1618056434887&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-ordered-values%2Fdownload%2Fpostcss-ordered-values-4.1.2.tgz#0cf75c820ec7d5c4d280189559e0b571ebac0eee" + integrity sha1-DPdcgg7H1cTSgBiVWeC1ceusDu4= + dependencies: + cssnano-util-get-arguments "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-reduce-initial@^4.0.3: + version "4.0.3" + resolved "https://registry.npm.taobao.org/postcss-reduce-initial/download/postcss-reduce-initial-4.0.3.tgz#7fd42ebea5e9c814609639e2c2e84ae270ba48df" + integrity sha1-f9QuvqXpyBRgljniwuhK4nC6SN8= + dependencies: + browserslist "^4.0.0" + caniuse-api "^3.0.0" + has "^1.0.0" + postcss "^7.0.0" + +postcss-reduce-transforms@^4.0.2: + version "4.0.2" + resolved "https://registry.npm.taobao.org/postcss-reduce-transforms/download/postcss-reduce-transforms-4.0.2.tgz?cache=0&sync_timestamp=1618056547685&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-reduce-transforms%2Fdownload%2Fpostcss-reduce-transforms-4.0.2.tgz#17efa405eacc6e07be3414a5ca2d1074681d4e29" + integrity sha1-F++kBerMbge+NBSlyi0QdGgdTik= + dependencies: + cssnano-util-get-match "^4.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-selector-parser@^3.0.0: + version "3.1.2" + resolved "https://registry.npm.taobao.org/postcss-selector-parser/download/postcss-selector-parser-3.1.2.tgz?cache=0&sync_timestamp=1601045450967&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-selector-parser%2Fdownload%2Fpostcss-selector-parser-3.1.2.tgz#b310f5c4c0fdaf76f94902bbaa30db6aa84f5270" + integrity sha1-sxD1xMD9r3b5SQK7qjDbaqhPUnA= + dependencies: + dot-prop "^5.2.0" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4: + version "6.0.4" + resolved "https://registry.npm.taobao.org/postcss-selector-parser/download/postcss-selector-parser-6.0.4.tgz?cache=0&sync_timestamp=1601045450967&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-selector-parser%2Fdownload%2Fpostcss-selector-parser-6.0.4.tgz#56075a1380a04604c38b063ea7767a129af5c2b3" + integrity sha1-VgdaE4CgRgTDiwY+p3Z6Epr1wrM= + dependencies: + cssesc "^3.0.0" + indexes-of "^1.0.1" + uniq "^1.0.1" + util-deprecate "^1.0.2" + +postcss-svgo@^4.0.3: + version "4.0.3" + resolved "https://registry.npm.taobao.org/postcss-svgo/download/postcss-svgo-4.0.3.tgz?cache=0&sync_timestamp=1618056433323&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-svgo%2Fdownload%2Fpostcss-svgo-4.0.3.tgz#343a2cdbac9505d416243d496f724f38894c941e" + integrity sha1-NDos26yVBdQWJD1Jb3JPOIlMlB4= + dependencies: + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + svgo "^1.0.0" + +postcss-unique-selectors@^4.0.1: + version "4.0.1" + resolved "https://registry.npm.taobao.org/postcss-unique-selectors/download/postcss-unique-selectors-4.0.1.tgz?cache=0&sync_timestamp=1618056544309&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpostcss-unique-selectors%2Fdownload%2Fpostcss-unique-selectors-4.0.1.tgz#9446911f3289bfd64c6d680f073c03b1f9ee4bac" + integrity sha1-lEaRHzKJv9ZMbWgPBzwDsfnuS6w= + dependencies: + alphanum-sort "^1.0.0" + postcss "^7.0.0" + uniqs "^2.0.0" + +postcss-value-parser@^3.0.0: + version "3.3.1" + resolved "https://registry.npm.taobao.org/postcss-value-parser/download/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" + integrity sha1-n/giVH4okyE88cMO+lGsX9G6goE= + +postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0: + version "4.1.0" + resolved "https://registry.npm.taobao.org/postcss-value-parser/download/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb" + integrity sha1-RD9qIM7WSBor2k+oUypuVdeJoss= + +postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.27, postcss@^7.0.32, postcss@^7.0.5, postcss@^7.0.6: + version "7.0.35" + resolved "https://registry.npm.taobao.org/postcss/download/postcss-7.0.35.tgz#d2be00b998f7f211d8a276974079f2e92b970e24" + integrity sha1-0r4AuZj38hHYonaXQHny6SuXDiQ= + dependencies: + chalk "^2.4.2" + source-map "^0.6.1" + supports-color "^6.1.0" + +postcss@^8.1.10: + version "8.2.10" + resolved "https://registry.npm.taobao.org/postcss/download/postcss-8.2.10.tgz#ca7a042aa8aff494b334d0ff3e9e77079f6f702b" + integrity sha1-ynoEKqiv9JSzNND/Pp53B59vcCs= + dependencies: + colorette "^1.2.2" + nanoid "^3.1.22" + source-map "^0.6.1" + +prepend-http@^1.0.0: + version "1.0.4" + resolved "https://registry.npm.taobao.org/prepend-http/download/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" + integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= + +prettier@^1.18.2: + version "1.19.1" + resolved "https://registry.npm.taobao.org/prettier/download/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" + integrity sha1-99f1/4qc2HKnvkyhQglZVqYHl8s= + +pretty-error@^2.0.2: + version "2.1.2" + resolved "https://registry.npm.taobao.org/pretty-error/download/pretty-error-2.1.2.tgz?cache=0&sync_timestamp=1609589372178&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fpretty-error%2Fdownload%2Fpretty-error-2.1.2.tgz#be89f82d81b1c86ec8fdfbc385045882727f93b6" + integrity sha1-von4LYGxyG7I/fvDhQRYgnJ/k7Y= + dependencies: + lodash "^4.17.20" + renderkid "^2.0.4" + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.npm.taobao.org/process-nextick-args/download/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha1-eCDZsWEgzFXKmud5JoCufbptf+I= + +process@^0.11.10: + version "0.11.10" + resolved "https://registry.npm.taobao.org/process/download/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= + +promise-inflight@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/promise-inflight/download/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" + integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= + +proxy-addr@~2.0.5: + version "2.0.6" + resolved "https://registry.npm.taobao.org/proxy-addr/download/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf" + integrity sha1-/cIzZQVEfT8vLGOO0nLK9hS7sr8= + dependencies: + forwarded "~0.1.2" + ipaddr.js "1.9.1" + +prr@~1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/prr/download/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" + integrity sha1-0/wRS6BplaRexok/SEzrHXj19HY= + +pseudomap@^1.0.2: + version "1.0.2" + resolved "https://registry.npm.taobao.org/pseudomap/download/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= + +psl@^1.1.28: + version "1.8.0" + resolved "https://registry.npm.taobao.org/psl/download/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" + integrity sha1-kyb4vPsBOtzABf3/BWrM4CDlHCQ= + +public-encrypt@^4.0.0: + version "4.0.3" + resolved "https://registry.npm.taobao.org/public-encrypt/download/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0" + integrity sha1-T8ydd6B+SLp1J+fL4N4z0HATMeA= + dependencies: + bn.js "^4.1.0" + browserify-rsa "^4.0.0" + create-hash "^1.1.0" + parse-asn1 "^5.0.0" + randombytes "^2.0.1" + safe-buffer "^5.1.2" + +pump@^2.0.0: + version "2.0.1" + resolved "https://registry.npm.taobao.org/pump/download/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909" + integrity sha1-Ejma3W5M91Jtlzy8i1zi4pCLOQk= + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/pump/download/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha1-tKIRaBW94vTh6mAjVOjHVWUQemQ= + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pumpify@^1.3.3: + version "1.5.1" + resolved "https://registry.npm.taobao.org/pumpify/download/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce" + integrity sha1-NlE74karJ1cLGjdKXOJ4v9dDcM4= + dependencies: + duplexify "^3.6.0" + inherits "^2.0.3" + pump "^2.0.0" + +punycode@1.3.2: + version "1.3.2" + resolved "https://registry.npm.taobao.org/punycode/download/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" + integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= + +punycode@^1.2.4: + version "1.4.1" + resolved "https://registry.npm.taobao.org/punycode/download/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= + +punycode@^2.1.0, punycode@^2.1.1: + version "2.1.1" + resolved "https://registry.npm.taobao.org/punycode/download/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" + integrity sha1-tYsBCsQMIsVldhbI0sLALHv0eew= + +q@^1.1.2: + version "1.5.1" + resolved "https://registry.npm.taobao.org/q/download/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" + integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= + +qs@6.7.0: + version "6.7.0" + resolved "https://registry.npm.taobao.org/qs/download/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" + integrity sha1-QdwaAV49WB8WIXdr4xr7KHapsbw= + +qs@~6.5.2: + version "6.5.2" + resolved "https://registry.npm.taobao.org/qs/download/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + integrity sha1-yzroBuh0BERYTvFUzo7pjUA/PjY= + +query-string@^4.1.0: + version "4.3.4" + resolved "https://registry.npm.taobao.org/query-string/download/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb" + integrity sha1-u7aTucqRXCMlFbIosaArYJBD2+s= + dependencies: + object-assign "^4.1.0" + strict-uri-encode "^1.0.0" + +querystring-es3@^0.2.0: + version "0.2.1" + resolved "https://registry.npm.taobao.org/querystring-es3/download/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" + integrity sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM= + +querystring@0.2.0: + version "0.2.0" + resolved "https://registry.npm.taobao.org/querystring/download/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" + integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= + +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.npm.taobao.org/querystringify/download/querystringify-2.2.0.tgz?cache=0&sync_timestamp=1597687136608&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fquerystringify%2Fdownload%2Fquerystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha1-M0WUG0FTy50ILY7uTNogFqmu9/Y= + +randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.npm.taobao.org/randombytes/download/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha1-32+ENy8CcNxlzfYpE0mrekc9Tyo= + dependencies: + safe-buffer "^5.1.0" + +randomfill@^1.0.3: + version "1.0.4" + resolved "https://registry.npm.taobao.org/randomfill/download/randomfill-1.0.4.tgz#c92196fc86ab42be983f1bf31778224931d61458" + integrity sha1-ySGW/IarQr6YPxvzF3giSTHWFFg= + dependencies: + randombytes "^2.0.5" + safe-buffer "^5.1.0" + +range-parser@^1.2.1, range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.npm.taobao.org/range-parser/download/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha1-PPNwI9GZ4cJNGlW4SADC8+ZGgDE= + +raw-body@2.4.0: + version "2.4.0" + resolved "https://registry.npm.taobao.org/raw-body/download/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332" + integrity sha1-oc5vucm8NWylLoklarWQWeE9AzI= + dependencies: + bytes "3.1.0" + http-errors "1.7.2" + iconv-lite "0.4.24" + unpipe "1.0.0" + +read-pkg@^5.1.1: + version "5.2.0" + resolved "https://registry.npm.taobao.org/read-pkg/download/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc" + integrity sha1-e/KVQ4yloz5WzTDgU7NO5yUMk8w= + dependencies: + "@types/normalize-package-data" "^2.4.0" + normalize-package-data "^2.5.0" + parse-json "^5.0.0" + type-fest "^0.6.0" + +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: + version "2.3.7" + resolved "https://registry.npm.taobao.org/readable-stream/download/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha1-Hsoc9xGu+BTAT2IlKjamL2yyO1c= + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.6.0: + version "3.6.0" + resolved "https://registry.npm.taobao.org/readable-stream/download/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha1-M3u9o63AcGvT4CRCaihtS0sskZg= + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readdirp@^2.2.1: + version "2.2.1" + resolved "https://registry.npm.taobao.org/readdirp/download/readdirp-2.2.1.tgz?cache=0&sync_timestamp=1615717425931&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Freaddirp%2Fdownload%2Freaddirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" + integrity sha1-DodiKjMlqjPokihcr4tOhGUppSU= + dependencies: + graceful-fs "^4.1.11" + micromatch "^3.1.10" + readable-stream "^2.0.2" + +readdirp@~3.5.0: + version "3.5.0" + resolved "https://registry.npm.taobao.org/readdirp/download/readdirp-3.5.0.tgz?cache=0&sync_timestamp=1615717425931&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Freaddirp%2Fdownload%2Freaddirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e" + integrity sha1-m6dMAZsV02UnjS6Ru4xI17TULJ4= + dependencies: + picomatch "^2.2.1" + +reconnecting-websocket@^4.4.0: + version "4.4.0" + resolved "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783" + integrity sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng== + +regenerate-unicode-properties@^8.2.0: + version "8.2.0" + resolved "https://registry.npm.taobao.org/regenerate-unicode-properties/download/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec" + integrity sha1-5d5xEdZV57pgwFfb6f83yH5lzew= + dependencies: + regenerate "^1.4.0" + +regenerate@^1.4.0: + version "1.4.2" + resolved "https://registry.npm.taobao.org/regenerate/download/regenerate-1.4.2.tgz?cache=0&sync_timestamp=1604218421881&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fregenerate%2Fdownload%2Fregenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" + integrity sha1-uTRtiCfo9aMve6KWN9OYtpAUhIo= + +regenerator-runtime@^0.13.4: + version "0.13.7" + resolved "https://registry.npm.taobao.org/regenerator-runtime/download/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" + integrity sha1-ysLazIoepnX+qrrriugziYrkb1U= + +regenerator-transform@^0.14.2: + version "0.14.5" + resolved "https://registry.npm.taobao.org/regenerator-transform/download/regenerator-transform-0.14.5.tgz#c98da154683671c9c4dcb16ece736517e1b7feb4" + integrity sha1-yY2hVGg2ccnE3LFuznNlF+G3/rQ= + dependencies: + "@babel/runtime" "^7.8.4" + +regex-not@^1.0.0, regex-not@^1.0.2: + version "1.0.2" + resolved "https://registry.npm.taobao.org/regex-not/download/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" + integrity sha1-H07OJ+ALC2XgJHpoEOaoXYOldSw= + dependencies: + extend-shallow "^3.0.2" + safe-regex "^1.1.0" + +regexp.prototype.flags@^1.2.0: + version "1.3.1" + resolved "https://registry.npm.taobao.org/regexp.prototype.flags/download/regexp.prototype.flags-1.3.1.tgz?cache=0&sync_timestamp=1610725764337&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fregexp.prototype.flags%2Fdownload%2Fregexp.prototype.flags-1.3.1.tgz#7ef352ae8d159e758c0eadca6f8fcb4eef07be26" + integrity sha1-fvNSro0VnnWMDq3Kb4/LTu8HviY= + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + +regexpu-core@^4.7.1: + version "4.7.1" + resolved "https://registry.npm.taobao.org/regexpu-core/download/regexpu-core-4.7.1.tgz?cache=0&sync_timestamp=1600413554352&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fregexpu-core%2Fdownload%2Fregexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6" + integrity sha1-LepamgcjMpj78NuR+pq8TG4PitY= + dependencies: + regenerate "^1.4.0" + regenerate-unicode-properties "^8.2.0" + regjsgen "^0.5.1" + regjsparser "^0.6.4" + unicode-match-property-ecmascript "^1.0.4" + unicode-match-property-value-ecmascript "^1.2.0" + +regjsgen@^0.5.1: + version "0.5.2" + resolved "https://registry.npm.taobao.org/regjsgen/download/regjsgen-0.5.2.tgz#92ff295fb1deecbf6ecdab2543d207e91aa33733" + integrity sha1-kv8pX7He7L9uzaslQ9IH6RqjNzM= + +regjsparser@^0.6.4: + version "0.6.9" + resolved "https://registry.npm.taobao.org/regjsparser/download/regjsparser-0.6.9.tgz#b489eef7c9a2ce43727627011429cf833a7183e6" + integrity sha1-tInu98mizkNydicBFCnPgzpxg+Y= + dependencies: + jsesc "~0.5.0" + +relateurl@0.2.x: + version "0.2.7" + resolved "https://registry.npm.taobao.org/relateurl/download/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" + integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk= + +remove-trailing-separator@^1.0.1: + version "1.1.0" + resolved "https://registry.npm.taobao.org/remove-trailing-separator/download/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" + integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= + +renderkid@^2.0.4: + version "2.0.5" + resolved "https://registry.npm.taobao.org/renderkid/download/renderkid-2.0.5.tgz?cache=0&sync_timestamp=1609588553625&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Frenderkid%2Fdownload%2Frenderkid-2.0.5.tgz#483b1ac59c6601ab30a7a596a5965cabccfdd0a5" + integrity sha1-SDsaxZxmAaswp6WWpZZcq8z90KU= + dependencies: + css-select "^2.0.2" + dom-converter "^0.2" + htmlparser2 "^3.10.1" + lodash "^4.17.20" + strip-ansi "^3.0.0" + +repeat-element@^1.1.2: + version "1.1.4" + resolved "https://registry.npm.taobao.org/repeat-element/download/repeat-element-1.1.4.tgz?cache=0&sync_timestamp=1617837642601&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Frepeat-element%2Fdownload%2Frepeat-element-1.1.4.tgz#be681520847ab58c7568ac75fbfad28ed42d39e9" + integrity sha1-vmgVIIR6tYx1aKx1+/rSjtQtOek= + +repeat-string@^1.6.1: + version "1.6.1" + resolved "https://registry.npm.taobao.org/repeat-string/download/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= + +request@^2.88.2: + version "2.88.2" + resolved "https://registry.npm.taobao.org/request/download/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" + integrity sha1-1zyRhzHLWofaBH4gcjQUb2ZNErM= + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.3" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.5.0" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.npm.taobao.org/require-directory/download/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= + +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/require-main-filename/download/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha1-0LMp7MfMD2Fkn2IhW+aa9UqomJs= + +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/requires-port/download/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= + +resize-observer-polyfill@^1.5.1: + version "1.5.1" + resolved "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + +resolve-cwd@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/resolve-cwd/download/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" + integrity sha1-AKn3OHVW4nA46uIyyqNypqWbZlo= + dependencies: + resolve-from "^3.0.0" + +resolve-from@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/resolve-from/download/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" + integrity sha1-six699nWiBvItuZTM17rywoYh0g= + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/resolve-from/download/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha1-SrzYUq0y3Xuqv+m0DgCjbbXzkuY= + +resolve-url@^0.2.1: + version "0.2.1" + resolved "https://registry.npm.taobao.org/resolve-url/download/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" + integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= + +resolve@^1.10.0, resolve@^1.14.2, resolve@^1.3.2: + version "1.20.0" + resolved "https://registry.npm.taobao.org/resolve/download/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" + integrity sha1-YpoBP7P3B1XW8LeTXMHCxTeLGXU= + dependencies: + is-core-module "^2.2.0" + path-parse "^1.0.6" + +restore-cursor@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/restore-cursor/download/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" + integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368= + dependencies: + onetime "^2.0.0" + signal-exit "^3.0.2" + +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.npm.taobao.org/ret/download/ret-0.1.15.tgz?cache=0&sync_timestamp=1613002746282&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fret%2Fdownload%2Fret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + integrity sha1-uKSCXVvbH8P29Twrwz+BOIaBx7w= + +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.npm.taobao.org/retry/download/retry-0.12.0.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fretry%2Fdownload%2Fretry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= + +rgb-regex@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/rgb-regex/download/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1" + integrity sha1-wODWiC3w4jviVKR16O3UGRX+rrE= + +rgba-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/rgba-regex/download/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3" + integrity sha1-QzdOLiyglosO8VI0YLfXMP8i7rM= + +rimraf@^2.5.4, rimraf@^2.6.3, rimraf@^2.7.1: + version "2.7.1" + resolved "https://registry.npm.taobao.org/rimraf/download/rimraf-2.7.1.tgz?cache=0&sync_timestamp=1593529714524&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Frimraf%2Fdownload%2Frimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" + integrity sha1-NXl/E6f9rcVmFCwp1PB8ytSD4+w= + dependencies: + glob "^7.1.3" + +ripemd160@^2.0.0, ripemd160@^2.0.1: + version "2.0.2" + resolved "https://registry.npm.taobao.org/ripemd160/download/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" + integrity sha1-ocGm9iR1FXe6XQeRTLyShQWFiQw= + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + +run-queue@^1.0.0, run-queue@^1.0.3: + version "1.0.3" + resolved "https://registry.npm.taobao.org/run-queue/download/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47" + integrity sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec= + dependencies: + aproba "^1.1.1" + +safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.npm.taobao.org/safe-buffer/download/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha1-mR7GnSluAxN0fVm9/St0XDX4go0= + +safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.npm.taobao.org/safe-buffer/download/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha1-Hq+fqb2x/dTsdfWPnNtOa3gn7sY= + +safe-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.npm.taobao.org/safe-regex/download/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" + integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4= + dependencies: + ret "~0.1.10" + +"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.npm.taobao.org/safer-buffer/download/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha1-RPoWGwGHuVSd2Eu5GAL5vYOFzWo= + +sax@~1.2.4: + version "1.2.4" + resolved "https://registry.npm.taobao.org/sax/download/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" + integrity sha1-KBYjTiN4vdxOU1T6tcqold9xANk= + +schema-utils@2.7.0: + version "2.7.0" + resolved "https://registry.npm.taobao.org/schema-utils/download/schema-utils-2.7.0.tgz?cache=0&sync_timestamp=1601922425223&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fschema-utils%2Fdownload%2Fschema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7" + integrity sha1-FxUfdtjq5n+793lgwzxnatn078c= + dependencies: + "@types/json-schema" "^7.0.4" + ajv "^6.12.2" + ajv-keywords "^3.4.1" + +schema-utils@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/schema-utils/download/schema-utils-1.0.0.tgz?cache=0&sync_timestamp=1601922425223&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fschema-utils%2Fdownload%2Fschema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770" + integrity sha1-C3mpMgTXtgDUsoUNH2bCo0lRx3A= + dependencies: + ajv "^6.1.0" + ajv-errors "^1.0.0" + ajv-keywords "^3.1.0" + +schema-utils@^2.0.0, schema-utils@^2.5.0, schema-utils@^2.6.5, schema-utils@^2.6.6, schema-utils@^2.7.0: + version "2.7.1" + resolved "https://registry.npm.taobao.org/schema-utils/download/schema-utils-2.7.1.tgz?cache=0&sync_timestamp=1601922425223&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fschema-utils%2Fdownload%2Fschema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7" + integrity sha1-HKTzLRskxZDCA7jnpQvw6kzTlNc= + dependencies: + "@types/json-schema" "^7.0.5" + ajv "^6.12.4" + ajv-keywords "^3.5.2" + +scroll-into-view-if-needed@^2.2.25: + version "2.2.28" + resolved "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.28.tgz#5a15b2f58a52642c88c8eca584644e01703d645a" + integrity sha512-8LuxJSuFVc92+0AdNv4QOxRL4Abeo1DgLnGNkn1XlaujPH/3cCFz3QI60r2VNu4obJJROzgnIUw5TKQkZvZI1w== + dependencies: + compute-scroll-into-view "^1.0.17" + +select-hose@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/select-hose/download/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" + integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= + +selfsigned@^1.10.8: + version "1.10.8" + resolved "https://registry.npm.taobao.org/selfsigned/download/selfsigned-1.10.8.tgz?cache=0&sync_timestamp=1600187989135&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fselfsigned%2Fdownload%2Fselfsigned-1.10.8.tgz#0d17208b7d12c33f8eac85c41835f27fc3d81a30" + integrity sha1-DRcgi30Swz+OrIXEGDXyf8PYGjA= + dependencies: + node-forge "^0.10.0" + +"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.5.0, semver@^5.6.0: + version "5.7.1" + resolved "https://registry.npm.taobao.org/semver/download/semver-5.7.1.tgz?cache=0&sync_timestamp=1616463641178&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsemver%2Fdownload%2Fsemver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha1-qVT5Ma66UI0we78Gnv8MAclhFvc= + +semver@7.0.0: + version "7.0.0" + resolved "https://registry.npm.taobao.org/semver/download/semver-7.0.0.tgz?cache=0&sync_timestamp=1616463641178&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsemver%2Fdownload%2Fsemver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" + integrity sha1-XzyjV2HkfgWyBsba/yz4FPAxa44= + +semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: + version "6.3.0" + resolved "https://registry.npm.taobao.org/semver/download/semver-6.3.0.tgz?cache=0&sync_timestamp=1616463641178&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsemver%2Fdownload%2Fsemver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha1-7gpkyK9ejO6mdoexM3YeG+y9HT0= + +semver@^7.3.2: + version "7.3.5" + resolved "https://registry.npm.taobao.org/semver/download/semver-7.3.5.tgz?cache=0&sync_timestamp=1616463641178&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsemver%2Fdownload%2Fsemver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" + integrity sha1-C2Ich5NI2JmOSw5L6Us/EuYBjvc= + dependencies: + lru-cache "^6.0.0" + +send@0.17.1: + version "0.17.1" + resolved "https://registry.npm.taobao.org/send/download/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" + integrity sha1-wdiwWfeQD3Rm3Uk4vcROEd2zdsg= + dependencies: + debug "2.6.9" + depd "~1.1.2" + destroy "~1.0.4" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "~1.7.2" + mime "1.6.0" + ms "2.1.1" + on-finished "~2.3.0" + range-parser "~1.2.1" + statuses "~1.5.0" + +serialize-javascript@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/serialize-javascript/download/serialize-javascript-4.0.0.tgz?cache=0&sync_timestamp=1599741180858&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fserialize-javascript%2Fdownload%2Fserialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" + integrity sha1-tSXhI4SJpez8Qq+sw/6Z5mb0sao= + dependencies: + randombytes "^2.1.0" + +serve-index@^1.9.1: + version "1.9.1" + resolved "https://registry.npm.taobao.org/serve-index/download/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" + integrity sha1-03aNabHn2C5c4FD/9bRTvqEqkjk= + dependencies: + accepts "~1.3.4" + batch "0.6.1" + debug "2.6.9" + escape-html "~1.0.3" + http-errors "~1.6.2" + mime-types "~2.1.17" + parseurl "~1.3.2" + +serve-static@1.14.1: + version "1.14.1" + resolved "https://registry.npm.taobao.org/serve-static/download/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9" + integrity sha1-Zm5jbcTwEPfvKZcKiKZ0MgiYsvk= + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.17.1" + +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/set-blocking/download/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= + +set-value@^2.0.0, set-value@^2.0.1: + version "2.0.1" + resolved "https://registry.npm.taobao.org/set-value/download/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" + integrity sha1-oY1AUw5vB95CKMfe/kInr4ytAFs= + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.3" + split-string "^3.0.1" + +setimmediate@^1.0.4: + version "1.0.5" + resolved "https://registry.npm.taobao.org/setimmediate/download/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= + +setprototypeof@1.1.0: + version "1.1.0" + resolved "https://registry.npm.taobao.org/setprototypeof/download/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" + integrity sha1-0L2FU2iHtv58DYGMuWLZ2RxU5lY= + +setprototypeof@1.1.1: + version "1.1.1" + resolved "https://registry.npm.taobao.org/setprototypeof/download/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" + integrity sha1-fpWsskqpL1iF4KvvW6ExMw1K5oM= + +sha.js@^2.4.0, sha.js@^2.4.8: + version "2.4.11" + resolved "https://registry.npm.taobao.org/sha.js/download/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" + integrity sha1-N6XPC4HsvGlD3hCbopYNGyZYSuc= + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +shallow-equal@^1.0.0: + version "1.2.1" + resolved "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz#4c16abfa56043aa20d050324efa68940b0da79da" + integrity sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA== + +sharedb@^1.9.2: + version "1.9.2" + resolved "https://registry.yarnpkg.com/sharedb/-/sharedb-1.9.2.tgz#136d06a4bbdd481a98f961cbeaf6fc367435fef8" + integrity sha512-YioIGNOjITm+loRrGYSCF1S6tbid15MUHloQN7035tAbDlJuveHmxnQ/6e/CguAFsGvFom2zfj3xUJLXGaTyYg== + dependencies: + arraydiff "^0.1.1" + async "^2.6.3" + fast-deep-equal "^2.0.1" + hat "0.0.3" + ot-json0 "^1.0.1" + +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.npm.taobao.org/shebang-command/download/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= + dependencies: + shebang-regex "^1.0.0" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/shebang-command/download/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha1-zNCvT4g1+9wmW4JGGq8MNmY/NOo= + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/shebang-regex/download/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/shebang-regex/download/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha1-rhbxZE2HPsrYQ7AwexQzYtTEIXI= + +shell-quote@^1.6.1: + version "1.7.2" + resolved "https://registry.npm.taobao.org/shell-quote/download/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2" + integrity sha1-Z6fQLHbJ2iT5nSCAj8re0ODgS+I= + +signal-exit@^3.0.0, signal-exit@^3.0.2: + version "3.0.3" + resolved "https://registry.npm.taobao.org/signal-exit/download/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" + integrity sha1-oUEMLt2PB3sItOJTyOrPyvBXRhw= + +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "https://registry.npm.taobao.org/simple-swizzle/download/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo= + dependencies: + is-arrayish "^0.3.1" + +slash@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/slash/download/slash-1.0.0.tgz?cache=0&sync_timestamp=1593529703136&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fslash%2Fdownload%2Fslash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" + integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU= + +slash@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/slash/download/slash-2.0.0.tgz?cache=0&sync_timestamp=1593529703136&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fslash%2Fdownload%2Fslash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" + integrity sha1-3lUoUaF1nfOo8gZTVEL17E3eq0Q= + +snapdragon-node@^2.0.1: + version "2.1.1" + resolved "https://registry.npm.taobao.org/snapdragon-node/download/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" + integrity sha1-bBdfhv8UvbByRWPo88GwIaKGhTs= + dependencies: + define-property "^1.0.0" + isobject "^3.0.0" + snapdragon-util "^3.0.1" + +snapdragon-util@^3.0.1: + version "3.0.1" + resolved "https://registry.npm.taobao.org/snapdragon-util/download/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" + integrity sha1-+VZHlIbyrNeXAGk/b3uAXkWrVuI= + dependencies: + kind-of "^3.2.0" + +snapdragon@^0.8.1: + version "0.8.2" + resolved "https://registry.npm.taobao.org/snapdragon/download/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" + integrity sha1-ZJIufFZbDhQgS6GqfWlkJ40lGC0= + dependencies: + base "^0.11.1" + debug "^2.2.0" + define-property "^0.2.5" + extend-shallow "^2.0.1" + map-cache "^0.2.2" + source-map "^0.5.6" + source-map-resolve "^0.5.0" + use "^3.1.0" + +sockjs-client@^1.5.0: + version "1.5.1" + resolved "https://registry.npm.taobao.org/sockjs-client/download/sockjs-client-1.5.1.tgz#256908f6d5adfb94dabbdbd02c66362cca0f9ea6" + integrity sha1-JWkI9tWt+5Tau9vQLGY2LMoPnqY= + dependencies: + debug "^3.2.6" + eventsource "^1.0.7" + faye-websocket "^0.11.3" + inherits "^2.0.4" + json3 "^3.3.3" + url-parse "^1.5.1" + +sockjs@^0.3.21: + version "0.3.21" + resolved "https://registry.npm.taobao.org/sockjs/download/sockjs-0.3.21.tgz?cache=0&sync_timestamp=1596167301825&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsockjs%2Fdownload%2Fsockjs-0.3.21.tgz#b34ffb98e796930b60a0cfa11904d6a339a7d417" + integrity sha1-s0/7mOeWkwtgoM+hGQTWozmn1Bc= + dependencies: + faye-websocket "^0.11.3" + uuid "^3.4.0" + websocket-driver "^0.7.4" + +sort-keys@^1.0.0: + version "1.1.2" + resolved "https://registry.npm.taobao.org/sort-keys/download/sort-keys-1.1.2.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsort-keys%2Fdownload%2Fsort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" + integrity sha1-RBttTTRnmPG05J6JIK37oOVD+a0= + dependencies: + is-plain-obj "^1.0.0" + +source-list-map@^2.0.0: + version "2.0.1" + resolved "https://registry.npm.taobao.org/source-list-map/download/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" + integrity sha1-OZO9hzv8SEecyp6jpUeDXHwVSzQ= + +source-map-resolve@^0.5.0: + version "0.5.3" + resolved "https://registry.npm.taobao.org/source-map-resolve/download/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" + integrity sha1-GQhmvs51U+H48mei7oLGBrVQmho= + dependencies: + atob "^2.1.2" + decode-uri-component "^0.2.0" + resolve-url "^0.2.1" + source-map-url "^0.4.0" + urix "^0.1.0" + +source-map-support@~0.5.12: + version "0.5.19" + resolved "https://registry.npm.taobao.org/source-map-support/download/source-map-support-0.5.19.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsource-map-support%2Fdownload%2Fsource-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" + integrity sha1-qYti+G3K9PZzmWSMCFKRq56P7WE= + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map-url@^0.4.0: + version "0.4.1" + resolved "https://registry.npm.taobao.org/source-map-url/download/source-map-url-0.4.1.tgz?cache=0&sync_timestamp=1612210508484&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsource-map-url%2Fdownload%2Fsource-map-url-0.4.1.tgz#0af66605a745a5a2f91cf1bbf8a7afbc283dec56" + integrity sha1-CvZmBadFpaL5HPG7+KevvCg97FY= + +source-map@^0.5.0, source-map@^0.5.6: + version "0.5.7" + resolved "https://registry.npm.taobao.org/source-map/download/source-map-0.5.7.tgz?cache=0&sync_timestamp=1593529658602&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsource-map%2Fdownload%2Fsource-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= + +source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.npm.taobao.org/source-map/download/source-map-0.6.1.tgz?cache=0&sync_timestamp=1593529658602&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsource-map%2Fdownload%2Fsource-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha1-dHIq8y6WFOnCh6jQu95IteLxomM= + +source-map@^0.7.3: + version "0.7.3" + resolved "https://registry.npm.taobao.org/source-map/download/source-map-0.7.3.tgz?cache=0&sync_timestamp=1593529658602&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsource-map%2Fdownload%2Fsource-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" + integrity sha1-UwL4FpAxc1ImVECS5kmB91F1A4M= + +sourcemap-codec@^1.4.4: + version "1.4.8" + resolved "https://registry.npm.taobao.org/sourcemap-codec/download/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" + integrity sha1-6oBL2UhXQC5pktBaOO8a41qatMQ= + +spdx-correct@^3.0.0: + version "3.1.1" + resolved "https://registry.npm.taobao.org/spdx-correct/download/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" + integrity sha1-3s6BrJweZxPl99G28X1Gj6U9iak= + dependencies: + spdx-expression-parse "^3.0.0" + spdx-license-ids "^3.0.0" + +spdx-exceptions@^2.1.0: + version "2.3.0" + resolved "https://registry.npm.taobao.org/spdx-exceptions/download/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" + integrity sha1-PyjOGnegA3JoPq3kpDMYNSeiFj0= + +spdx-expression-parse@^3.0.0: + version "3.0.1" + resolved "https://registry.npm.taobao.org/spdx-expression-parse/download/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" + integrity sha1-z3D1BILu/cmOPOCmgz5KU87rpnk= + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.7" + resolved "https://registry.npm.taobao.org/spdx-license-ids/download/spdx-license-ids-3.0.7.tgz#e9c18a410e5ed7e12442a549fbd8afa767038d65" + integrity sha1-6cGKQQ5e1+EkQqVJ+9ivp2cDjWU= + +spdy-transport@^3.0.0: + version "3.0.0" + resolved "https://registry.npm.taobao.org/spdy-transport/download/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31" + integrity sha1-ANSGOmQArXXfkzYaFghgXl3NzzE= + dependencies: + debug "^4.1.0" + detect-node "^2.0.4" + hpack.js "^2.1.6" + obuf "^1.1.2" + readable-stream "^3.0.6" + wbuf "^1.7.3" + +spdy@^4.0.2: + version "4.0.2" + resolved "https://registry.npm.taobao.org/spdy/download/spdy-4.0.2.tgz#b74f466203a3eda452c02492b91fb9e84a27677b" + integrity sha1-t09GYgOj7aRSwCSSuR+56EonZ3s= + dependencies: + debug "^4.1.0" + handle-thing "^2.0.0" + http-deceiver "^1.2.7" + select-hose "^2.0.0" + spdy-transport "^3.0.0" + +split-string@^3.0.1, split-string@^3.0.2: + version "3.1.0" + resolved "https://registry.npm.taobao.org/split-string/download/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" + integrity sha1-fLCd2jqGWFcFxks5pkZgOGguj+I= + dependencies: + extend-shallow "^3.0.0" + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.npm.taobao.org/sprintf-js/download/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= + +sshpk@^1.7.0: + version "1.16.1" + resolved "https://registry.npm.taobao.org/sshpk/download/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" + integrity sha1-+2YcC+8ps520B2nuOfpwCT1vaHc= + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + bcrypt-pbkdf "^1.0.0" + dashdash "^1.12.0" + ecc-jsbn "~0.1.1" + getpass "^0.1.1" + jsbn "~0.1.0" + safer-buffer "^2.0.2" + tweetnacl "~0.14.0" + +ssri@^6.0.1: + version "6.0.2" + resolved "https://registry.npm.taobao.org/ssri/download/ssri-6.0.2.tgz?cache=0&sync_timestamp=1617826725566&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fssri%2Fdownload%2Fssri-6.0.2.tgz#157939134f20464e7301ddba3e90ffa8f7728ac5" + integrity sha1-FXk5E08gRk5zAd26PpD/qPdyisU= + dependencies: + figgy-pudding "^3.5.1" + +ssri@^7.0.0, ssri@^7.1.0: + version "7.1.0" + resolved "https://registry.npm.taobao.org/ssri/download/ssri-7.1.0.tgz?cache=0&sync_timestamp=1617826725566&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fssri%2Fdownload%2Fssri-7.1.0.tgz#92c241bf6de82365b5c7fb4bd76e975522e1294d" + integrity sha1-ksJBv23oI2W1x/tL126XVSLhKU0= + dependencies: + figgy-pudding "^3.5.1" + minipass "^3.1.1" + +stable@^0.1.8: + version "0.1.8" + resolved "https://registry.npm.taobao.org/stable/download/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" + integrity sha1-g26zyDgv4pNv6vVEYxAXzn1Ho88= + +stackframe@^1.1.1: + version "1.2.0" + resolved "https://registry.npm.taobao.org/stackframe/download/stackframe-1.2.0.tgz#52429492d63c62eb989804c11552e3d22e779303" + integrity sha1-UkKUktY8YuuYmATBFVLj0i53kwM= + +static-extend@^0.1.1: + version "0.1.2" + resolved "https://registry.npm.taobao.org/static-extend/download/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" + integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY= + dependencies: + define-property "^0.2.5" + object-copy "^0.1.0" + +"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@~1.5.0: + version "1.5.0" + resolved "https://registry.npm.taobao.org/statuses/download/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" + integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= + +stream-browserify@^2.0.1: + version "2.0.2" + resolved "https://registry.npm.taobao.org/stream-browserify/download/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b" + integrity sha1-h1IdOKRKp+6RzhzSpH3wy0ndZgs= + dependencies: + inherits "~2.0.1" + readable-stream "^2.0.2" + +stream-each@^1.1.0: + version "1.2.3" + resolved "https://registry.npm.taobao.org/stream-each/download/stream-each-1.2.3.tgz#ebe27a0c389b04fbcc233642952e10731afa9bae" + integrity sha1-6+J6DDibBPvMIzZClS4Qcxr6m64= + dependencies: + end-of-stream "^1.1.0" + stream-shift "^1.0.0" + +stream-http@^2.7.2: + version "2.8.3" + resolved "https://registry.npm.taobao.org/stream-http/download/stream-http-2.8.3.tgz#b2d242469288a5a27ec4fe8933acf623de6514fc" + integrity sha1-stJCRpKIpaJ+xP6JM6z2I95lFPw= + dependencies: + builtin-status-codes "^3.0.0" + inherits "^2.0.1" + readable-stream "^2.3.6" + to-arraybuffer "^1.0.0" + xtend "^4.0.0" + +stream-shift@^1.0.0: + version "1.0.1" + resolved "https://registry.npm.taobao.org/stream-shift/download/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" + integrity sha1-1wiCgVWasneEJCebCHfaPDktWj0= + +strict-uri-encode@^1.0.0: + version "1.1.0" + resolved "https://registry.npm.taobao.org/strict-uri-encode/download/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" + integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= + +string-hash@^1.1.1: + version "1.1.3" + resolved "https://registry.npm.taobao.org/string-hash/download/string-hash-1.1.3.tgz#e8aafc0ac1855b4666929ed7dd1275df5d6c811b" + integrity sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs= + +string-width@^2.0.0: + version "2.1.1" + resolved "https://registry.npm.taobao.org/string-width/download/string-width-2.1.1.tgz?cache=0&sync_timestamp=1614522158257&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fstring-width%2Fdownload%2Fstring-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha1-q5Pyeo3BPSjKyBXEYhQ6bZASrp4= + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + +string-width@^3.0.0, string-width@^3.1.0: + version "3.1.0" + resolved "https://registry.npm.taobao.org/string-width/download/string-width-3.1.0.tgz?cache=0&sync_timestamp=1614522158257&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fstring-width%2Fdownload%2Fstring-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" + integrity sha1-InZ74htirxCBV0MG9prFG2IgOWE= + dependencies: + emoji-regex "^7.0.1" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^5.1.0" + +string-width@^4.1.0, string-width@^4.2.0: + version "4.2.2" + resolved "https://registry.npm.taobao.org/string-width/download/string-width-4.2.2.tgz?cache=0&sync_timestamp=1614522158257&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fstring-width%2Fdownload%2Fstring-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5" + integrity sha1-2v1PlVmnWFz7pSnGoKT3NIjr1MU= + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.0" + +string.prototype.trimend@^1.0.4: + version "1.0.4" + resolved "https://registry.npm.taobao.org/string.prototype.trimend/download/string.prototype.trimend-1.0.4.tgz?cache=0&sync_timestamp=1614127438583&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fstring.prototype.trimend%2Fdownload%2Fstring.prototype.trimend-1.0.4.tgz#e75ae90c2942c63504686c18b287b4a0b1a45f80" + integrity sha1-51rpDClCxjUEaGwYsoe0oLGkX4A= + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + +string.prototype.trimstart@^1.0.4: + version "1.0.4" + resolved "https://registry.npm.taobao.org/string.prototype.trimstart/download/string.prototype.trimstart-1.0.4.tgz?cache=0&sync_timestamp=1614127299808&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fstring.prototype.trimstart%2Fdownload%2Fstring.prototype.trimstart-1.0.4.tgz#b36399af4ab2999b4c9c648bd7a3fb2bb26feeed" + integrity sha1-s2OZr0qymZtMnGSL16P7K7Jv7u0= + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + +string_decoder@^1.0.0, string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.npm.taobao.org/string_decoder/download/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha1-QvEUWUpGzxqOMLCoT1bHjD7awh4= + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.npm.taobao.org/string_decoder/download/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha1-nPFhG6YmhdcDCunkujQUnDrwP8g= + dependencies: + safe-buffer "~5.1.0" + +strip-ansi@^3.0.0, strip-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.npm.taobao.org/strip-ansi/download/strip-ansi-3.0.1.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fstrip-ansi%2Fdownload%2Fstrip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= + dependencies: + ansi-regex "^2.0.0" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/strip-ansi/download/strip-ansi-4.0.0.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fstrip-ansi%2Fdownload%2Fstrip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= + dependencies: + ansi-regex "^3.0.0" + +strip-ansi@^5, strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: + version "5.2.0" + resolved "https://registry.npm.taobao.org/strip-ansi/download/strip-ansi-5.2.0.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fstrip-ansi%2Fdownload%2Fstrip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" + integrity sha1-jJpTb+tq/JYr36WxBKUJHBrZwK4= + dependencies: + ansi-regex "^4.1.0" + +strip-ansi@^6.0.0: + version "6.0.0" + resolved "https://registry.npm.taobao.org/strip-ansi/download/strip-ansi-6.0.0.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fstrip-ansi%2Fdownload%2Fstrip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" + integrity sha1-CxVx3XZpzNTz4G4U7x7tJiJa5TI= + dependencies: + ansi-regex "^5.0.0" + +strip-eof@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/strip-eof/download/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" + integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= + +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/strip-final-newline/download/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha1-ibhS+y/L6Tb29LMYevsKEsGrWK0= + +strip-indent@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/strip-indent/download/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68" + integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g= + +stylehacks@^4.0.0: + version "4.0.3" + resolved "https://registry.npm.taobao.org/stylehacks/download/stylehacks-4.0.3.tgz?cache=0&sync_timestamp=1618056391120&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fstylehacks%2Fdownload%2Fstylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5" + integrity sha1-Zxj8r00eB9ihMYaQiB6NlnJqcdU= + dependencies: + browserslist "^4.0.0" + postcss "^7.0.0" + postcss-selector-parser "^3.0.0" + +supports-color@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/supports-color/download/supports-color-2.0.0.tgz?cache=0&sync_timestamp=1611394023277&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsupports-color%2Fdownload%2Fsupports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" + integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.npm.taobao.org/supports-color/download/supports-color-5.5.0.tgz?cache=0&sync_timestamp=1611394023277&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsupports-color%2Fdownload%2Fsupports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha1-4uaaRKyHcveKHsCzW2id9lMO/I8= + dependencies: + has-flag "^3.0.0" + +supports-color@^6.1.0: + version "6.1.0" + resolved "https://registry.npm.taobao.org/supports-color/download/supports-color-6.1.0.tgz?cache=0&sync_timestamp=1611394023277&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsupports-color%2Fdownload%2Fsupports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" + integrity sha1-B2Srxpxj1ayELdSGfo0CXogN+PM= + dependencies: + has-flag "^3.0.0" + +supports-color@^7.0.0, supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.npm.taobao.org/supports-color/download/supports-color-7.2.0.tgz?cache=0&sync_timestamp=1611394023277&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fsupports-color%2Fdownload%2Fsupports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha1-G33NyzK4E4gBs+R4umpRyqiWSNo= + dependencies: + has-flag "^4.0.0" + +svg-tags@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/svg-tags/download/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764" + integrity sha1-WPcc7jvVGbWdSyqEO2x95krAR2Q= + +svgo@^1.0.0: + version "1.3.2" + resolved "https://registry.npm.taobao.org/svgo/download/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167" + integrity sha1-ttxRHAYzRsnkFbgeQ0ARRbltQWc= + dependencies: + chalk "^2.4.1" + coa "^2.0.2" + css-select "^2.0.0" + css-select-base-adapter "^0.1.1" + css-tree "1.0.0-alpha.37" + csso "^4.0.2" + js-yaml "^3.13.1" + mkdirp "~0.5.1" + object.values "^1.1.0" + sax "~1.2.4" + stable "^0.1.8" + unquote "~1.1.1" + util.promisify "~1.0.0" + +tapable@^1.0.0, tapable@^1.1.3: + version "1.1.3" + resolved "https://registry.npm.taobao.org/tapable/download/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" + integrity sha1-ofzMBrWNth/XpF2i2kT186Pme6I= + +terser-webpack-plugin@^1.4.3: + version "1.4.5" + resolved "https://registry.npm.taobao.org/terser-webpack-plugin/download/terser-webpack-plugin-1.4.5.tgz?cache=0&sync_timestamp=1610194199773&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fterser-webpack-plugin%2Fdownload%2Fterser-webpack-plugin-1.4.5.tgz#a217aefaea330e734ffacb6120ec1fa312d6040b" + integrity sha1-oheu+uozDnNP+sthIOwfoxLWBAs= + dependencies: + cacache "^12.0.2" + find-cache-dir "^2.1.0" + is-wsl "^1.1.0" + schema-utils "^1.0.0" + serialize-javascript "^4.0.0" + source-map "^0.6.1" + terser "^4.1.2" + webpack-sources "^1.4.0" + worker-farm "^1.7.0" + +terser-webpack-plugin@^2.3.6: + version "2.3.8" + resolved "https://registry.npm.taobao.org/terser-webpack-plugin/download/terser-webpack-plugin-2.3.8.tgz?cache=0&sync_timestamp=1610194199773&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fterser-webpack-plugin%2Fdownload%2Fterser-webpack-plugin-2.3.8.tgz#894764a19b0743f2f704e7c2a848c5283a696724" + integrity sha1-iUdkoZsHQ/L3BOfCqEjFKDppZyQ= + dependencies: + cacache "^13.0.1" + find-cache-dir "^3.3.1" + jest-worker "^25.4.0" + p-limit "^2.3.0" + schema-utils "^2.6.6" + serialize-javascript "^4.0.0" + source-map "^0.6.1" + terser "^4.6.12" + webpack-sources "^1.4.3" + +terser@^4.1.2, terser@^4.6.12: + version "4.8.0" + resolved "https://registry.npm.taobao.org/terser/download/terser-4.8.0.tgz?cache=0&sync_timestamp=1616002306469&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fterser%2Fdownload%2Fterser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17" + integrity sha1-YwVjQ9fHC7KfOvZlhlpG/gOg3xc= + dependencies: + commander "^2.20.0" + source-map "~0.6.1" + source-map-support "~0.5.12" + +thenify-all@^1.0.0: + version "1.6.0" + resolved "https://registry.npm.taobao.org/thenify-all/download/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" + integrity sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY= + dependencies: + thenify ">= 3.1.0 < 4" + +"thenify@>= 3.1.0 < 4": + version "3.3.1" + resolved "https://registry.npm.taobao.org/thenify/download/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f" + integrity sha1-iTLmhqQGYDigFt2eLKRq3Zg4qV8= + dependencies: + any-promise "^1.0.0" + +thread-loader@^2.1.3: + version "2.1.3" + resolved "https://registry.npm.taobao.org/thread-loader/download/thread-loader-2.1.3.tgz?cache=0&sync_timestamp=1603809365048&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fthread-loader%2Fdownload%2Fthread-loader-2.1.3.tgz#cbd2c139fc2b2de6e9d28f62286ab770c1acbdda" + integrity sha1-y9LBOfwrLebp0o9iKGq3cMGsvdo= + dependencies: + loader-runner "^2.3.1" + loader-utils "^1.1.0" + neo-async "^2.6.0" + +through2@^2.0.0: + version "2.0.5" + resolved "https://registry.npm.taobao.org/through2/download/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" + integrity sha1-AcHjnrMdB8t9A6lqcIIyYLIxMs0= + dependencies: + readable-stream "~2.3.6" + xtend "~4.0.1" + +thunky@^1.0.2: + version "1.1.0" + resolved "https://registry.npm.taobao.org/thunky/download/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d" + integrity sha1-Wrr3FKlAXbBQRzK7zNLO3Z75U30= + +timers-browserify@^2.0.4: + version "2.0.12" + resolved "https://registry.npm.taobao.org/timers-browserify/download/timers-browserify-2.0.12.tgz?cache=0&sync_timestamp=1603793667345&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ftimers-browserify%2Fdownload%2Ftimers-browserify-2.0.12.tgz#44a45c11fbf407f34f97bccd1577c652361b00ee" + integrity sha1-RKRcEfv0B/NPl7zNFXfGUjYbAO4= + dependencies: + setimmediate "^1.0.4" + +timsort@^0.3.0: + version "0.3.0" + resolved "https://registry.npm.taobao.org/timsort/download/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" + integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= + +tinycolor2@^1.4.2: + version "1.4.2" + resolved "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803" + integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA== + +to-arraybuffer@^1.0.0: + version "1.0.1" + resolved "https://registry.npm.taobao.org/to-arraybuffer/download/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" + integrity sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M= + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/to-fast-properties/download/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= + +to-object-path@^0.3.0: + version "0.3.0" + resolved "https://registry.npm.taobao.org/to-object-path/download/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" + integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68= + dependencies: + kind-of "^3.0.2" + +to-regex-range@^2.1.0: + version "2.1.1" + resolved "https://registry.npm.taobao.org/to-regex-range/download/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" + integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg= + dependencies: + is-number "^3.0.0" + repeat-string "^1.6.1" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.npm.taobao.org/to-regex-range/download/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha1-FkjESq58jZiKMmAY7XL1tN0DkuQ= + dependencies: + is-number "^7.0.0" + +to-regex@^3.0.1, to-regex@^3.0.2: + version "3.0.2" + resolved "https://registry.npm.taobao.org/to-regex/download/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" + integrity sha1-E8/dmzNlUvMLUfM6iuG0Knp1mc4= + dependencies: + define-property "^2.0.2" + extend-shallow "^3.0.2" + regex-not "^1.0.2" + safe-regex "^1.1.0" + +toggle-selection@^1.0.6: + version "1.0.6" + resolved "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" + integrity sha1-bkWxJj8gF/oKzH2J14sVuL932jI= + +toidentifier@1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/toidentifier/download/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" + integrity sha1-fhvjRw8ed5SLxD2Uo8j013UrpVM= + +toposort@^1.0.0: + version "1.0.7" + resolved "https://registry.npm.taobao.org/toposort/download/toposort-1.0.7.tgz#2e68442d9f64ec720b8cc89e6443ac6caa950029" + integrity sha1-LmhELZ9k7HILjMieZEOsbKqVACk= + +tough-cookie@~2.5.0: + version "2.5.0" + resolved "https://registry.npm.taobao.org/tough-cookie/download/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" + integrity sha1-zZ+yoKodWhK0c72fuW+j3P9lreI= + dependencies: + psl "^1.1.28" + punycode "^2.1.1" + +tryer@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/tryer/download/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" + integrity sha1-8shUBoALmw90yfdGW4HqrSQSUvg= + +ts-loader@^6.2.2: + version "6.2.2" + resolved "https://registry.npm.taobao.org/ts-loader/download/ts-loader-6.2.2.tgz?cache=0&sync_timestamp=1616925171460&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fts-loader%2Fdownload%2Fts-loader-6.2.2.tgz#dffa3879b01a1a1e0a4b85e2b8421dc0dfff1c58" + integrity sha1-3/o4ebAaGh4KS4XiuEIdwN//HFg= + dependencies: + chalk "^2.3.0" + enhanced-resolve "^4.0.0" + loader-utils "^1.0.2" + micromatch "^4.0.0" + semver "^6.0.0" + +ts-pnp@^1.1.6: + version "1.2.0" + resolved "https://registry.npm.taobao.org/ts-pnp/download/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92" + integrity sha1-pQCtCEsHmPHDBxrzkeZZEshrypI= + +tslib@^1.10.0, tslib@^1.8.0, tslib@^1.8.1: + version "1.14.1" + resolved "https://registry.npm.taobao.org/tslib/download/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha1-zy04vcNKE0vK8QkcQfZhni9nLQA= + +tslint@^5.20.1: + version "5.20.1" + resolved "https://registry.npm.taobao.org/tslint/download/tslint-5.20.1.tgz?cache=0&sync_timestamp=1600702535226&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ftslint%2Fdownload%2Ftslint-5.20.1.tgz#e401e8aeda0152bc44dd07e614034f3f80c67b7d" + integrity sha1-5AHortoBUrxE3QfmFANPP4DGe30= + dependencies: + "@babel/code-frame" "^7.0.0" + builtin-modules "^1.1.1" + chalk "^2.3.0" + commander "^2.12.1" + diff "^4.0.1" + glob "^7.1.1" + js-yaml "^3.13.1" + minimatch "^3.0.4" + mkdirp "^0.5.1" + resolve "^1.3.2" + semver "^5.3.0" + tslib "^1.8.0" + tsutils "^2.29.0" + +tsutils@^2.29.0: + version "2.29.0" + resolved "https://registry.npm.taobao.org/tsutils/download/tsutils-2.29.0.tgz?cache=0&sync_timestamp=1615138114650&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ftsutils%2Fdownload%2Ftsutils-2.29.0.tgz#32b488501467acbedd4b85498673a0812aca0b99" + integrity sha1-MrSIUBRnrL7dS4VJhnOggSrKC5k= + dependencies: + tslib "^1.8.1" + +tty-browserify@0.0.0: + version "0.0.0" + resolved "https://registry.npm.taobao.org/tty-browserify/download/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" + integrity sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY= + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.npm.taobao.org/tunnel-agent/download/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= + dependencies: + safe-buffer "^5.0.1" + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.npm.taobao.org/tweetnacl/download/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= + +type-fest@^0.6.0: + version "0.6.0" + resolved "https://registry.npm.taobao.org/type-fest/download/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" + integrity sha1-jSojcNPfiG61yQraHFv2GIrPg4s= + +type-is@~1.6.17, type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.npm.taobao.org/type-is/download/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha1-TlUs0F3wlGfcvE73Od6J8s83wTE= + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.npm.taobao.org/typedarray/download/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= + +typescript@~4.1.5: + version "4.1.5" + resolved "https://registry.npm.taobao.org/typescript/download/typescript-4.1.5.tgz#123a3b214aaff3be32926f0d8f1f6e704eb89a72" + integrity sha1-Ejo7IUqv874ykm8Njx9ucE64mnI= + +uglify-js@3.4.x: + version "3.4.10" + resolved "https://registry.npm.taobao.org/uglify-js/download/uglify-js-3.4.10.tgz?cache=0&sync_timestamp=1618185908437&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fuglify-js%2Fdownload%2Fuglify-js-3.4.10.tgz#9ad9563d8eb3acdfb8d38597d2af1d815f6a755f" + integrity sha1-mtlWPY6zrN+404WX0q8dgV9qdV8= + dependencies: + commander "~2.19.0" + source-map "~0.6.1" + +unbox-primitive@^1.0.0: + version "1.0.1" + resolved "https://registry.npm.taobao.org/unbox-primitive/download/unbox-primitive-1.0.1.tgz?cache=0&sync_timestamp=1616706427948&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Funbox-primitive%2Fdownload%2Funbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471" + integrity sha1-CF4hViXsMWJXTciFmr7nilmxRHE= + dependencies: + function-bind "^1.1.1" + has-bigints "^1.0.1" + has-symbols "^1.0.2" + which-boxed-primitive "^1.0.2" + +unicode-canonical-property-names-ecmascript@^1.0.4: + version "1.0.4" + resolved "https://registry.npm.taobao.org/unicode-canonical-property-names-ecmascript/download/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818" + integrity sha1-JhmADEyCWADv3YNDr33Zkzy+KBg= + +unicode-match-property-ecmascript@^1.0.4: + version "1.0.4" + resolved "https://registry.npm.taobao.org/unicode-match-property-ecmascript/download/unicode-match-property-ecmascript-1.0.4.tgz#8ed2a32569961bce9227d09cd3ffbb8fed5f020c" + integrity sha1-jtKjJWmWG86SJ9Cc0/+7j+1fAgw= + dependencies: + unicode-canonical-property-names-ecmascript "^1.0.4" + unicode-property-aliases-ecmascript "^1.0.4" + +unicode-match-property-value-ecmascript@^1.2.0: + version "1.2.0" + resolved "https://registry.npm.taobao.org/unicode-match-property-value-ecmascript/download/unicode-match-property-value-ecmascript-1.2.0.tgz#0d91f600eeeb3096aa962b1d6fc88876e64ea531" + integrity sha1-DZH2AO7rMJaqlisdb8iIduZOpTE= + +unicode-property-aliases-ecmascript@^1.0.4: + version "1.1.0" + resolved "https://registry.npm.taobao.org/unicode-property-aliases-ecmascript/download/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4" + integrity sha1-3Vepn2IHvt/0Yoq++5TFDblByPQ= + +union-value@^1.0.0: + version "1.0.1" + resolved "https://registry.npm.taobao.org/union-value/download/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" + integrity sha1-C2/nuDWuzaYcbqTU8CwUIh4QmEc= + dependencies: + arr-union "^3.1.0" + get-value "^2.0.6" + is-extendable "^0.1.1" + set-value "^2.0.1" + +uniq@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/uniq/download/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" + integrity sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8= + +uniqs@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/uniqs/download/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" + integrity sha1-/+3ks2slKQaW5uFl1KWe25mOawI= + +unique-filename@^1.1.1: + version "1.1.1" + resolved "https://registry.npm.taobao.org/unique-filename/download/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" + integrity sha1-HWl2k2mtoFgxA6HmrodoG1ZXMjA= + dependencies: + unique-slug "^2.0.0" + +unique-slug@^2.0.0: + version "2.0.2" + resolved "https://registry.npm.taobao.org/unique-slug/download/unique-slug-2.0.2.tgz#baabce91083fc64e945b0f3ad613e264f7cd4e6c" + integrity sha1-uqvOkQg/xk6UWw861hPiZPfNTmw= + dependencies: + imurmurhash "^0.1.4" + +universalify@^0.1.0: + version "0.1.2" + resolved "https://registry.npm.taobao.org/universalify/download/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + integrity sha1-tkb2m+OULavOzJ1mOcgNwQXvqmY= + +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/universalify/download/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha1-daSYTv7cSwiXXFrrc/Uw0C3yVxc= + +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/unpipe/download/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= + +unquote@~1.1.1: + version "1.1.1" + resolved "https://registry.npm.taobao.org/unquote/download/unquote-1.1.1.tgz#8fded7324ec6e88a0ff8b905e7c098cdc086d544" + integrity sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ= + +unset-value@^1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/unset-value/download/unset-value-1.0.0.tgz?cache=0&sync_timestamp=1616088640915&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Funset-value%2Fdownload%2Funset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" + integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk= + dependencies: + has-value "^0.3.1" + isobject "^3.0.0" + +upath@^1.1.1: + version "1.2.0" + resolved "https://registry.npm.taobao.org/upath/download/upath-1.2.0.tgz?cache=0&sync_timestamp=1604768535464&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fupath%2Fdownload%2Fupath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" + integrity sha1-j2bbzVWog6za5ECK+LA1pQRMGJQ= + +upper-case@^1.1.1: + version "1.1.3" + resolved "https://registry.npm.taobao.org/upper-case/download/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598" + integrity sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg= + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.npm.taobao.org/uri-js/download/uri-js-4.4.1.tgz?cache=0&sync_timestamp=1610237517218&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Furi-js%2Fdownload%2Furi-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha1-mxpSWVIlhZ5V9mnZKPiMbFfyp34= + dependencies: + punycode "^2.1.0" + +urix@^0.1.0: + version "0.1.0" + resolved "https://registry.npm.taobao.org/urix/download/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" + integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= + +url-loader@^2.2.0: + version "2.3.0" + resolved "https://registry.npm.taobao.org/url-loader/download/url-loader-2.3.0.tgz?cache=0&sync_timestamp=1615984386883&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Furl-loader%2Fdownload%2Furl-loader-2.3.0.tgz#e0e2ef658f003efb8ca41b0f3ffbf76bab88658b" + integrity sha1-4OLvZY8APvuMpBsPP/v3a6uIZYs= + dependencies: + loader-utils "^1.2.3" + mime "^2.4.4" + schema-utils "^2.5.0" + +url-parse@^1.4.3, url-parse@^1.5.1: + version "1.5.1" + resolved "https://registry.npm.taobao.org/url-parse/download/url-parse-1.5.1.tgz?cache=0&sync_timestamp=1613659698159&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Furl-parse%2Fdownload%2Furl-parse-1.5.1.tgz#d5fa9890af8a5e1f274a2c98376510f6425f6e3b" + integrity sha1-1fqYkK+KXh8nSiyYN2UQ9kJfbjs= + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + +url@^0.11.0: + version "0.11.0" + resolved "https://registry.npm.taobao.org/url/download/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" + integrity sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE= + dependencies: + punycode "1.3.2" + querystring "0.2.0" + +use@^3.1.0: + version "3.1.1" + resolved "https://registry.npm.taobao.org/use/download/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" + integrity sha1-1QyMrHmhn7wg8pEfVuuXP04QBw8= + +util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.npm.taobao.org/util-deprecate/download/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + +util.promisify@1.0.0: + version "1.0.0" + resolved "https://registry.npm.taobao.org/util.promisify/download/util.promisify-1.0.0.tgz?cache=0&sync_timestamp=1610159866228&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Futil.promisify%2Fdownload%2Futil.promisify-1.0.0.tgz#440f7165a459c9a16dc145eb8e72f35687097030" + integrity sha1-RA9xZaRZyaFtwUXrjnLzVocJcDA= + dependencies: + define-properties "^1.1.2" + object.getownpropertydescriptors "^2.0.3" + +util.promisify@~1.0.0: + version "1.0.1" + resolved "https://registry.npm.taobao.org/util.promisify/download/util.promisify-1.0.1.tgz?cache=0&sync_timestamp=1610159866228&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Futil.promisify%2Fdownload%2Futil.promisify-1.0.1.tgz#6baf7774b80eeb0f7520d8b81d07982a59abbaee" + integrity sha1-a693dLgO6w91INi4HQeYKlmruu4= + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.2" + has-symbols "^1.0.1" + object.getownpropertydescriptors "^2.1.0" + +util@0.10.3: + version "0.10.3" + resolved "https://registry.npm.taobao.org/util/download/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" + integrity sha1-evsa/lCAUkZInj23/g7TeTNqwPk= + dependencies: + inherits "2.0.1" + +util@^0.11.0: + version "0.11.1" + resolved "https://registry.npm.taobao.org/util/download/util-0.11.1.tgz#3236733720ec64bb27f6e26f421aaa2e1b588d61" + integrity sha1-MjZzNyDsZLsn9uJvQhqqLhtYjWE= + dependencies: + inherits "2.0.3" + +utila@~0.4: + version "0.4.0" + resolved "https://registry.npm.taobao.org/utila/download/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" + integrity sha1-ihagXURWV6Oupe7MWxKk+lN5dyw= + +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/utils-merge/download/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= + +uuid@^3.3.2, uuid@^3.4.0: + version "3.4.0" + resolved "https://registry.npm.taobao.org/uuid/download/uuid-3.4.0.tgz?cache=0&sync_timestamp=1607458532020&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fuuid%2Fdownload%2Fuuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" + integrity sha1-sj5DWK+oogL+ehAK8fX4g/AgB+4= + +validate-npm-package-license@^3.0.1: + version "3.0.4" + resolved "https://registry.npm.taobao.org/validate-npm-package-license/download/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" + integrity sha1-/JH2uce6FchX9MssXe/uw51PQQo= + dependencies: + spdx-correct "^3.0.0" + spdx-expression-parse "^3.0.0" + +vary@~1.1.2: + version "1.1.2" + resolved "https://registry.npm.taobao.org/vary/download/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= + +vendors@^1.0.0: + version "1.0.4" + resolved "https://registry.npm.taobao.org/vendors/download/vendors-1.0.4.tgz?cache=0&sync_timestamp=1615203397897&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fvendors%2Fdownload%2Fvendors-1.0.4.tgz#e2b800a53e7a29b93506c3cf41100d16c4c4ad8e" + integrity sha1-4rgApT56Kbk1BsPPQRANFsTErY4= + +verror@1.10.0: + version "1.10.0" + resolved "https://registry.npm.taobao.org/verror/download/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + +vm-browserify@^1.0.1: + version "1.1.2" + resolved "https://registry.npm.taobao.org/vm-browserify/download/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" + integrity sha1-eGQcSIuObKkadfUR56OzKobl3aA= + +vue-class-component@^8.0.0-0: + version "8.0.0-rc.1" + resolved "https://registry.npm.taobao.org/vue-class-component/download/vue-class-component-8.0.0-rc.1.tgz#db692cd97656eb9a08206c03d0b7398cdb1d9420" + integrity sha1-22ks2XZW65oIIGwD0Lc5jNsdlCA= + +vue-hot-reload-api@^2.3.0: + version "2.3.4" + resolved "https://registry.npm.taobao.org/vue-hot-reload-api/download/vue-hot-reload-api-2.3.4.tgz#532955cc1eb208a3d990b3a9f9a70574657e08f2" + integrity sha1-UylVzB6yCKPZkLOp+acFdGV+CPI= + +"vue-loader-v16@npm:vue-loader@^16.1.0": + version "16.2.0" + resolved "https://registry.npm.taobao.org/vue-loader/download/vue-loader-16.2.0.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fvue-loader%2Fdownload%2Fvue-loader-16.2.0.tgz#046a53308dd47e58efe20ddec1edec027ce3b46e" + integrity sha1-BGpTMI3Ufljv4g3ewe3sAnzjtG4= + dependencies: + chalk "^4.1.0" + hash-sum "^2.0.0" + loader-utils "^2.0.0" + +vue-loader@^15.9.2: + version "15.9.6" + resolved "https://registry.npm.taobao.org/vue-loader/download/vue-loader-15.9.6.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fvue-loader%2Fdownload%2Fvue-loader-15.9.6.tgz#f4bb9ae20c3a8370af3ecf09b8126d38ffdb6b8b" + integrity sha1-9Lua4gw6g3CvPs8JuBJtOP/ba4s= + dependencies: + "@vue/component-compiler-utils" "^3.1.0" + hash-sum "^1.0.2" + loader-utils "^1.1.0" + vue-hot-reload-api "^2.3.0" + vue-style-loader "^4.1.0" + +vue-router@^4.0.0-0: + version "4.0.6" + resolved "https://registry.npm.taobao.org/vue-router/download/vue-router-4.0.6.tgz?cache=0&sync_timestamp=1617697726574&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fvue-router%2Fdownload%2Fvue-router-4.0.6.tgz#91750db507d26642f225b0ec6064568e5fe448d6" + integrity sha1-kXUNtQfSZkLyJbDsYGRWjl/kSNY= + +vue-style-loader@^4.1.0, vue-style-loader@^4.1.2: + version "4.1.3" + resolved "https://registry.npm.taobao.org/vue-style-loader/download/vue-style-loader-4.1.3.tgz?cache=0&sync_timestamp=1614758661292&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fvue-style-loader%2Fdownload%2Fvue-style-loader-4.1.3.tgz#6d55863a51fa757ab24e89d9371465072aa7bc35" + integrity sha1-bVWGOlH6dXqyTonZNxRlByqnvDU= + dependencies: + hash-sum "^1.0.2" + loader-utils "^1.0.2" + +vue-template-es2015-compiler@^1.9.0: + version "1.9.1" + resolved "https://registry.npm.taobao.org/vue-template-es2015-compiler/download/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825" + integrity sha1-HuO8mhbsv1EYvjNLsV+cRvgvWCU= + +vue-types@^3.0.0: + version "3.0.2" + resolved "https://registry.npmjs.org/vue-types/-/vue-types-3.0.2.tgz#ec16e05d412c038262fc1efa4ceb9647e7fb601d" + integrity sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw== + dependencies: + is-plain-object "3.0.1" + +vue@^3.2.9: + version "3.2.9" + resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.9.tgz#f8373c0d78136e331ad079e18ed72deaa95be5f2" + integrity sha512-mqRzh3Qp0Jg66foZz9F70e6xoe/mIjegyPKjC8gXBBfTP3gt4QxA3b8pXtKJRlXk894CteUIhkFnHU8xcobrXA== + dependencies: + "@vue/compiler-dom" "3.2.9" + "@vue/runtime-dom" "3.2.9" + "@vue/shared" "3.2.9" + +warning@^4.0.0: + version "4.0.3" + resolved "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" + integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== + dependencies: + loose-envify "^1.0.0" + +watchpack-chokidar2@^2.0.1: + version "2.0.1" + resolved "https://registry.npm.taobao.org/watchpack-chokidar2/download/watchpack-chokidar2-2.0.1.tgz#38500072ee6ece66f3769936950ea1771be1c957" + integrity sha1-OFAAcu5uzmbzdpk2lQ6hdxvhyVc= + dependencies: + chokidar "^2.1.8" + +watchpack@^1.7.4: + version "1.7.5" + resolved "https://registry.npm.taobao.org/watchpack/download/watchpack-1.7.5.tgz?cache=0&sync_timestamp=1612715822561&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fwatchpack%2Fdownload%2Fwatchpack-1.7.5.tgz#1267e6c55e0b9b5be44c2023aed5437a2c26c453" + integrity sha1-EmfmxV4Lm1vkTCAjrtVDeiwmxFM= + dependencies: + graceful-fs "^4.1.2" + neo-async "^2.5.0" + optionalDependencies: + chokidar "^3.4.1" + watchpack-chokidar2 "^2.0.1" + +wbuf@^1.1.0, wbuf@^1.7.3: + version "1.7.3" + resolved "https://registry.npm.taobao.org/wbuf/download/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df" + integrity sha1-wdjRSTFtPqhShIiVy2oL/oh7h98= + dependencies: + minimalistic-assert "^1.0.0" + +wcwidth@^1.0.1: + version "1.0.1" + resolved "https://registry.npm.taobao.org/wcwidth/download/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" + integrity sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g= + dependencies: + defaults "^1.0.3" + +webpack-bundle-analyzer@^3.8.0: + version "3.9.0" + resolved "https://registry.npm.taobao.org/webpack-bundle-analyzer/download/webpack-bundle-analyzer-3.9.0.tgz?cache=0&sync_timestamp=1611221513167&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fwebpack-bundle-analyzer%2Fdownload%2Fwebpack-bundle-analyzer-3.9.0.tgz#f6f94db108fb574e415ad313de41a2707d33ef3c" + integrity sha1-9vlNsQj7V05BWtMT3kGicH0z7zw= + dependencies: + acorn "^7.1.1" + acorn-walk "^7.1.1" + bfj "^6.1.1" + chalk "^2.4.1" + commander "^2.18.0" + ejs "^2.6.1" + express "^4.16.3" + filesize "^3.6.1" + gzip-size "^5.0.0" + lodash "^4.17.19" + mkdirp "^0.5.1" + opener "^1.5.1" + ws "^6.0.0" + +webpack-chain@^6.4.0: + version "6.5.1" + resolved "https://registry.npm.taobao.org/webpack-chain/download/webpack-chain-6.5.1.tgz?cache=0&sync_timestamp=1595813261846&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fwebpack-chain%2Fdownload%2Fwebpack-chain-6.5.1.tgz#4f27284cbbb637e3c8fbdef43eef588d4d861206" + integrity sha1-TycoTLu2N+PI+970Pu9YjU2GEgY= + dependencies: + deepmerge "^1.5.2" + javascript-stringify "^2.0.1" + +webpack-dev-middleware@^3.7.2: + version "3.7.3" + resolved "https://registry.npm.taobao.org/webpack-dev-middleware/download/webpack-dev-middleware-3.7.3.tgz#0639372b143262e2b84ab95d3b91a7597061c2c5" + integrity sha1-Bjk3KxQyYuK4SrldO5GnWXBhwsU= + dependencies: + memory-fs "^0.4.1" + mime "^2.4.4" + mkdirp "^0.5.1" + range-parser "^1.2.1" + webpack-log "^2.0.0" + +webpack-dev-server@^3.11.0: + version "3.11.2" + resolved "https://registry.npm.taobao.org/webpack-dev-server/download/webpack-dev-server-3.11.2.tgz?cache=0&sync_timestamp=1617728561824&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fwebpack-dev-server%2Fdownload%2Fwebpack-dev-server-3.11.2.tgz#695ebced76a4929f0d5de7fd73fafe185fe33708" + integrity sha1-aV687Xakkp8NXef9c/r+GF/jNwg= + dependencies: + ansi-html "0.0.7" + bonjour "^3.5.0" + chokidar "^2.1.8" + compression "^1.7.4" + connect-history-api-fallback "^1.6.0" + debug "^4.1.1" + del "^4.1.1" + express "^4.17.1" + html-entities "^1.3.1" + http-proxy-middleware "0.19.1" + import-local "^2.0.0" + internal-ip "^4.3.0" + ip "^1.1.5" + is-absolute-url "^3.0.3" + killable "^1.0.1" + loglevel "^1.6.8" + opn "^5.5.0" + p-retry "^3.0.1" + portfinder "^1.0.26" + schema-utils "^1.0.0" + selfsigned "^1.10.8" + semver "^6.3.0" + serve-index "^1.9.1" + sockjs "^0.3.21" + sockjs-client "^1.5.0" + spdy "^4.0.2" + strip-ansi "^3.0.1" + supports-color "^6.1.0" + url "^0.11.0" + webpack-dev-middleware "^3.7.2" + webpack-log "^2.0.0" + ws "^6.2.1" + yargs "^13.3.2" + +webpack-log@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/webpack-log/download/webpack-log-2.0.0.tgz?cache=0&sync_timestamp=1615477493300&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fwebpack-log%2Fdownload%2Fwebpack-log-2.0.0.tgz#5b7928e0637593f119d32f6227c1e0ac31e1b47f" + integrity sha1-W3ko4GN1k/EZ0y9iJ8HgrDHhtH8= + dependencies: + ansi-colors "^3.0.0" + uuid "^3.3.2" + +webpack-merge@^4.2.2: + version "4.2.2" + resolved "https://registry.npm.taobao.org/webpack-merge/download/webpack-merge-4.2.2.tgz?cache=0&sync_timestamp=1608705550275&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fwebpack-merge%2Fdownload%2Fwebpack-merge-4.2.2.tgz#a27c52ea783d1398afd2087f547d7b9d2f43634d" + integrity sha1-onxS6ng9E5iv0gh/VH17nS9DY00= + dependencies: + lodash "^4.17.15" + +webpack-sources@^1.1.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: + version "1.4.3" + resolved "https://registry.npm.taobao.org/webpack-sources/download/webpack-sources-1.4.3.tgz?cache=0&sync_timestamp=1603965237859&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fwebpack-sources%2Fdownload%2Fwebpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" + integrity sha1-7t2OwLko+/HL/plOItLYkPMwqTM= + dependencies: + source-list-map "^2.0.0" + source-map "~0.6.1" + +webpack@^4.0.0: + version "4.46.0" + resolved "https://registry.npm.taobao.org/webpack/download/webpack-4.46.0.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fwebpack%2Fdownload%2Fwebpack-4.46.0.tgz#bf9b4404ea20a073605e0a011d188d77cb6ad542" + integrity sha1-v5tEBOogoHNgXgoBHRiNd8tq1UI= + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-module-context" "1.9.0" + "@webassemblyjs/wasm-edit" "1.9.0" + "@webassemblyjs/wasm-parser" "1.9.0" + acorn "^6.4.1" + ajv "^6.10.2" + ajv-keywords "^3.4.1" + chrome-trace-event "^1.0.2" + enhanced-resolve "^4.5.0" + eslint-scope "^4.0.3" + json-parse-better-errors "^1.0.2" + loader-runner "^2.4.0" + loader-utils "^1.2.3" + memory-fs "^0.4.1" + micromatch "^3.1.10" + mkdirp "^0.5.3" + neo-async "^2.6.1" + node-libs-browser "^2.2.1" + schema-utils "^1.0.0" + tapable "^1.1.3" + terser-webpack-plugin "^1.4.3" + watchpack "^1.7.4" + webpack-sources "^1.4.1" + +websocket-driver@>=0.5.1, websocket-driver@^0.7.4: + version "0.7.4" + resolved "https://registry.npm.taobao.org/websocket-driver/download/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" + integrity sha1-ia1Slbv2S0gKvLox5JU6ynBvV2A= + dependencies: + http-parser-js ">=0.5.1" + safe-buffer ">=5.1.0" + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.4" + resolved "https://registry.npm.taobao.org/websocket-extensions/download/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" + integrity sha1-f4RzvIOd/YdgituV1+sHUhFXikI= + +which-boxed-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.npm.taobao.org/which-boxed-primitive/download/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" + integrity sha1-E3V7yJsgmwSf5dhkMOIc9AqJqOY= + dependencies: + is-bigint "^1.0.1" + is-boolean-object "^1.1.0" + is-number-object "^1.0.4" + is-string "^1.0.5" + is-symbol "^1.0.3" + +which-module@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/which-module/download/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" + integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= + +which@^1.2.9: + version "1.3.1" + resolved "https://registry.npm.taobao.org/which/download/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha1-pFBD1U9YBTFtqNYvn1CRjT2nCwo= + dependencies: + isexe "^2.0.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.npm.taobao.org/which/download/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha1-fGqN0KY2oDJ+ELWckobu6T8/UbE= + dependencies: + isexe "^2.0.0" + +worker-farm@^1.7.0: + version "1.7.0" + resolved "https://registry.npm.taobao.org/worker-farm/download/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8" + integrity sha1-JqlMU5G7ypJhUgAvabhKS/dy5ag= + dependencies: + errno "~0.1.7" + +worker-rpc@^0.1.0: + version "0.1.1" + resolved "https://registry.npm.taobao.org/worker-rpc/download/worker-rpc-0.1.1.tgz#cb565bd6d7071a8f16660686051e969ad32f54d5" + integrity sha1-y1Zb1tcHGo8WZgaGBR6WmtMvVNU= + dependencies: + microevent.ts "~0.1.1" + +wrap-ansi@^5.1.0: + version "5.1.0" + resolved "https://registry.npm.taobao.org/wrap-ansi/download/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" + integrity sha1-H9H2cjXVttD+54EFYAG/tpTAOwk= + dependencies: + ansi-styles "^3.2.0" + string-width "^3.0.0" + strip-ansi "^5.0.0" + +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.npm.taobao.org/wrap-ansi/download/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha1-6Tk7oHEC5skaOyIUePAlfNKFblM= + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npm.taobao.org/wrap-ansi/download/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha1-Z+FFz/UQpqaYS98RUpEdadLrnkM= + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.npm.taobao.org/wrappy/download/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +ws@^6.0.0, ws@^6.2.1: + version "6.2.1" + resolved "https://registry.npm.taobao.org/ws/download/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb" + integrity sha1-RC/fCkftZPWbal2P8TD0dI7VJPs= + dependencies: + async-limiter "~1.0.0" + +xtend@^4.0.0, xtend@~4.0.1: + version "4.0.2" + resolved "https://registry.npm.taobao.org/xtend/download/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha1-u3J3n1+kZRhrH0OPZ0+jR/2121Q= + +y18n@^4.0.0: + version "4.0.3" + resolved "https://registry.npm.taobao.org/y18n/download/y18n-4.0.3.tgz?cache=0&sync_timestamp=1617822642544&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fy18n%2Fdownload%2Fy18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" + integrity sha1-tfJZyCzW4zaSHv17/Yv1YN6e7t8= + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.npm.taobao.org/y18n/download/y18n-5.0.8.tgz?cache=0&sync_timestamp=1617822642544&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fy18n%2Fdownload%2Fy18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha1-f0k00PfKjFb5UxSTndzS3ZHOHVU= + +yallist@^2.1.2: + version "2.1.2" + resolved "https://registry.npm.taobao.org/yallist/download/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" + integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.npm.taobao.org/yallist/download/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha1-27fa+b/YusmrRev2ArjLrQ1dCP0= + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.npm.taobao.org/yallist/download/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha1-m7knkNnA7/7GO+c1GeEaNQGaOnI= + +yaml@^1.7.2: + version "1.10.2" + resolved "https://registry.npm.taobao.org/yaml/download/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" + integrity sha1-IwHF/78StGfejaIzOkWeKeeSDks= + +yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.npm.taobao.org/yargs-parser/download/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha1-Ew8JcC667vJlDVTObj5XBvek+zg= + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^20.2.2: + version "20.2.7" + resolved "https://registry.npm.taobao.org/yargs-parser/download/yargs-parser-20.2.7.tgz#61df85c113edfb5a7a4e36eb8aa60ef423cbc90a" + integrity sha1-Yd+FwRPt+1p6TjbriqYO9CPLyQo= + +yargs@^13.3.2: + version "13.3.2" + resolved "https://registry.npm.taobao.org/yargs/download/yargs-13.3.2.tgz?cache=0&sync_timestamp=1618195370004&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fyargs%2Fdownload%2Fyargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha1-rX/+/sGqWVZayRX4Lcyzipwxot0= + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.2" + +yargs@^16.0.0: + version "16.2.0" + resolved "https://registry.npm.taobao.org/yargs/download/yargs-16.2.0.tgz?cache=0&sync_timestamp=1618195370004&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fyargs%2Fdownload%2Fyargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha1-HIK/D2tqZur85+8w43b0mhJHf2Y= + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yorkie@^2.0.0: + version "2.0.0" + resolved "https://registry.npm.taobao.org/yorkie/download/yorkie-2.0.0.tgz#92411912d435214e12c51c2ae1093e54b6bb83d9" + integrity sha1-kkEZEtQ1IU4SxRwq4Qk+VLa7g9k= + dependencies: + execa "^0.8.0" + is-ci "^1.0.10" + normalize-path "^1.0.0" + strip-indent "^2.0.0" diff --git a/lerna.json b/lerna.json new file mode 100644 index 00000000..c241f4ca --- /dev/null +++ b/lerna.json @@ -0,0 +1,12 @@ +{ + "version": "independent", + "npmClient": "yarn", + "useWorkspaces": true, + "ignoreChanges": [ + "**/*.md", + "**/*.test.ts", + "**/*.e2e.ts", + "**/fixtures/**", + "**/test/**" + ] +} diff --git a/locale/en-HK.json b/locale/en-HK.json new file mode 100644 index 00000000..632b7cb4 --- /dev/null +++ b/locale/en-HK.json @@ -0,0 +1,360 @@ +{ + "dnd": { + "title": "Drag to reposition" + }, + "copy": { + "title": "Copy", + "success": "Copied successfully", + "error": "Copy error" + }, + "delete": { + "title": "Delete" + }, + "copyAnchor": { + "title": "Copy anchor link" + }, + "link": { + "placeholder": "Please enter a link or anchor and press Enter to confirm", + "save": "Apply", + "edit": "Change", + "delete": "Remove link", + "open": "Open link", + "text": "Text", + "link": "Link", + "text_placeholder": "Description text", + "link_placeholder": "Link address", + "link_open": "Open link", + "link_edit": "Edit link", + "link_remove": "Remove link", + "ok_button": "OK" + }, + "copyContent": { + "title": "copy content" + }, + "maximize": { + "title": "Maximize", + "back": "Back to document" + }, + "expand": { + "title": "Embedded preview" + }, + "collapse": { + "title": "Compact display" + }, + "card": { + "lockAlert": "Please wait for the other user to finish editing" + }, + "preferences": { + "title": "Preferences" + }, + "download": { + "title": "Download" + }, + "more": { + "title": "More" + }, + "checkMarkdown": { + "title": "It is detected that the paste content conforms to the Markdown syntax. Do you need to do style conversion?" + }, + "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" + }, + "table": { + "insertColLeft": "Insert column(s) $data left", + "insertColRight": "Insert column(s) $data right", + "insertRowUp": "Insert row(s) $data up", + "insertRowDown": "Insert row(s) $data down", + "mergeCell": "Merge cells", + "splitCell": "Unmerge cells", + "removeCol": "Delete selected column(s)", + "removeRow": "Delete selected row(s)", + "removeTable": "Delete table", + "copy": "Copy", + "cut": "Cut", + "paste": "Paste", + "clear": "Clear", + "color": { + "title": "Cell background color", + "nonFillText": "No fill color" + }, + "noBorder": "Hide border", + "verticalAlign": { + "title": "Vertical align", + "top": "Align top", + "middle": "Align middle", + "bottom": "Align bottom" + } + }, + "file": { + "errorMessageCopy": "Copy error message", + "loadError": "The file failed to load!", + "uploadError": "The picture failed to upload!", + "uploadLimitError": "Upload file size is limited to $size", + "download": "Download", + "preview": "Preview" + }, + "video": { + "errorMessageCopy": "Copy error message", + "loadError": "The video failed to load!", + "uploadError": "The picture failed to upload!", + "uploadLimitError": "Upload video size is limited to $size", + "download": "Download", + "preview": "Preview", + "loading": "Loading...", + "transcoding": "Transcoding..." + }, + "math": { + "errorMessageCopy": "Copy error message", + "getError": "Failed to get svg code", + "placeholder": "Add Tex formula", + "ok": "Ok", + "buttonTips": "Ctrl + Enter", + "tips": { + "text": "Understand LaTeX syntax", + "href": "https://math.meta.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference" + } + }, + "toolbar": { + "collapse": { + "title": "Type Ctrl + / to quickly insert a card" + }, + "undo": { + "title": "Undo" + }, + "redo": { + "title": "Redo" + }, + "paintformat": { + "title": "Format brush" + }, + "removeformat": { + "title": "Clear format" + }, + "heading": { + "title": "Text and title", + "p": "Text", + "h1": "Heading 1", + "h2": "Heading 2", + "h3": "Heading 3", + "h4": "Heading 4", + "h5": "Heading 5", + "h6": "Heading 6" + }, + "fontfamily": { + "title": "Font family", + "notInstalled": "The font may not be installed", + "items": { + "default": "Default", + "arial": "Arial", + "comicSansMS": "Comic Sans MS", + "courierNew": "Courier New", + "georgia": "Georgia", + "helvetica": "Helvetica", + "impact": "Impact", + "timesNewRoman": "Times New Roman", + "trebuchetMS": "Trebuchet MS", + "verdana": "Verdana", + "fangSong": "FangSong", + "stFangsong": "STFangsong", + "stSong": "STSong", + "stKaiti": "STKaiti", + "simSun": "SimSum", + "microsoftYaHei": "Microsoft YaHei", + "kaiTi": "KaiTi", + "kaitiSC": "KaiTi SC", + "simHei": "SimHei", + "heitiSC": "Heiti SC", + "fzHei": "FZHeiTi", + "fzKai": "FZKaiTi", + "fzFangSong": "FZFangSong" + } + }, + "fontsize": { + "title": "Font size" + }, + "fontcolor": { + "title": "Font color", + "more": "More colors" + }, + "backcolor": { + "title": "Background color", + "more": "More colors" + }, + "bold": { + "title": "Bold" + }, + "italic": { + "title": "Italic" + }, + "strikethrough": { + "title": "Strikethrough" + }, + "underline": { + "title": "Underline" + }, + "moremark": { + "title": "More text styles", + "sup": "Sup", + "sub": "Sub", + "code": "Inline code" + }, + "alignment": { + "title": "Alignment", + "left": "Align left", + "center": "Align center", + "right": "Align right", + "justify": "Align justify" + }, + "unorderedlist": { + "title": "Unordered list" + }, + "orderedlist": { + "title": "Ordered list" + }, + "tasklist": { + "title": "Task list" + }, + "indent": { + "title": "Ident", + "in": "Increase indent", + "out": "Reduce indent" + }, + "line-height": { + "title": "Line height", + "default": "Default" + }, + "link": { + "title": "Insert Link" + }, + "quote": { + "title": "Insert reference" + }, + "hr": { + "title": "Insert dividing line" + }, + "colorPicker": { + "defaultText": "Default Color", + "nonFillText": "No fill color", + "#000000": "Black", + "#262626": "Dark Gray 3", + "#595959": "Dark Gray 2", + "#8C8C8C": "Dark Gray 1", + "#BFBFBF": "Gray", + "#D9D9D9": "Light Gray 4", + "#E9E9E9": "Light Gray 3", + "#F5F5F5": "Light Gray 2", + "#FAFAFA": "Light Gray 1", + "#FFFFFF": "White", + "#F5222D": "Red", + "#FA541C": "Chinese Red", + "#FA8C16": "Orange", + "#FADB14": "Yellow", + "#52C41A": "Green", + "#13C2C2": "Cyan", + "#1890FF": "Light Blue", + "#2F54EB": "Blue", + "#722ED1": "Purple", + "#EB2F96": "Magenta", + "#FFE8E6": "Red 1", + "#FFECE0": "Chinese Red 1", + "#FFEFD1": "Orange 1", + "#FCFCCA": "Yellow 1", + "#E4F7D2": "Green 1", + "#D3F5F0": "Cyan 1", + "#D4EEFC": "Light Blue 1", + "#DEE8FC": "Blue 1", + "#EFE1FA": "Purple 1", + "#FAE1EB": "Magenta 1", + "#FFA39E": "Red 2", + "#FFBB96": "Chinese Red 2", + "#FFD591": "Orange 2", + "#FFFB8F": "Yellow 2", + "#B7EB8F": "Green 2", + "#87E8DE": "Cyan 2", + "#91D5FF": "Light Blue 2", + "#ADC6FF": "Blue 2", + "#D3ADF7": "Purple 2", + "#FFADD2": "Magenta 2", + "#FF4D4F": "Red 3", + "#FF7A45": "Chinese Red 3", + "#FFA940": "Orange 3", + "#FFEC3D": "Yellow 3", + "#73D13D": "Green 3", + "#36CFC9": "Cyan 3", + "#40A9FF": "Light Blue 3", + "#597EF7": "Blue 3", + "#9254DE": "Purple 3", + "#F759AB": "Magenta 3", + "#CF1322": "Red 4", + "#D4380D": "Chinese Red 4", + "#D46B08": "Orange 4", + "#D4B106": "Yellow 4", + "#389E0D": "Green 4", + "#08979C": "Cyan 4", + "#096DD9": "Light Blue 4", + "#1D39C4": "Blue 4", + "#531DAB": "Purple 4", + "#C41D7F": "Magenta 4", + "#820014": "Red 5", + "#871400": "Chinese Red 5", + "#873800": "Orange 5", + "#614700": "Yellow 5", + "#135200": "Green 5", + "#00474F": "Cyan 5", + "#003A8C": "Light Blue 5", + "#061178": "Blue 5", + "#22075E": "Purple 5", + "#780650": "Magenta 5" + }, + "component": { + "placeholder": "Card name" + }, + "image": { + "title": "Image" + }, + "codeblock": { + "title": "Codeblock" + }, + "table": { + "title": "Table" + }, + "file": { + "title": "File" + }, + "video": { + "title": "Video" + }, + "math": { + "title": "Formula" + }, + "status": { + "title": "Status" + }, + "mind": { + "title": "Mind Map" + }, + "commonlyUsed": { + "title": "Commonly used" + } + }, + "status": { + "defaultValue": "SET A STATUS" + }, + "mention": { + "placeholder": "User name" + } +} diff --git a/locale/en-US.json b/locale/en-US.json new file mode 100644 index 00000000..87cc8cf3 --- /dev/null +++ b/locale/en-US.json @@ -0,0 +1,362 @@ +{ + "dnd": { + "title": "Drag to reposition" + }, + "copy": { + "title": "Copy", + "success": "Copied successfully", + "error": "Copy error" + }, + "delete": { + "title": "Delete" + }, + "copyAnchor": { + "title": "Copy anchor link" + }, + "link": { + "placeholder": "Please enter a link or anchor and press Enter to confirm", + "save": "Apply", + "edit": "Change", + "delete": "Remove link", + "open": "Open link", + "text": "Text", + "link": "Link", + "text_placeholder": "Description text", + "link_placeholder": "Link address", + "link_open": "Open link", + "link_edit": "Edit link", + "link_remove": "Remove link", + "ok_button": "OK" + }, + "copyContent": { + "title": "copy content" + }, + "maximize": { + "title": "Maximize", + "back": "Back to document" + }, + "expand": { + "title": "Embedded preview" + }, + "collapse": { + "title": "Compact display" + }, + "card": { + "lockAlert": "Please wait for the other user to finish editing" + }, + "preferences": { + "title": "Preferences" + }, + "download": { + "title": "Download" + }, + "more": { + "title": "More" + }, + "checkMarkdown": { + "title": "It is detected that the paste content conforms to the Markdown syntax. Do you need to do style conversion?" + }, + "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" + }, + "table": { + "insertColLeft": "Insert column(s) $data left", + "insertColRight": "Insert column(s) $data right", + "insertRowUp": "Insert row(s) $data up", + "insertRowDown": "Insert row(s) $data down", + "mergeCell": "Merge cells", + "splitCell": "Unmerge cells", + "removeCol": "Delete selected column(s)", + "removeRow": "Delete selected row(s)", + "removeTable": "Delete table", + "copy": "Copy", + "cut": "Cut", + "paste": "Paste", + "clear": "Clear", + "draggingCol": "Moving $data column", + "draggingRow": "Moving $data row", + "color": { + "title": "Cell background color", + "nonFillText": "No fill color" + }, + "noBorder": "Hide border", + "verticalAlign": { + "title": "Vertical align", + "top": "Align top", + "middle": "Align middle", + "bottom": "Align bottom" + } + }, + "file": { + "errorMessageCopy": "Copy error message", + "loadError": "The file failed to load!", + "uploadError": "The picture failed to upload!", + "uploadLimitError": "Upload file size is limited to $size", + "download": "Download", + "preview": "Preview" + }, + "video": { + "errorMessageCopy": "Copy error message", + "loadError": "The video failed to load!", + "uploadError": "The picture failed to upload!", + "uploadLimitError": "Upload video size is limited to $size", + "download": "Download", + "preview": "Preview", + "loading": "Loading...", + "transcoding": "Transcoding..." + }, + "math": { + "errorMessageCopy": "Copy error message", + "getError": "Failed to get svg code", + "placeholder": "Add Tex formula", + "ok": "Ok", + "buttonTips": "Ctrl + Enter", + "tips": { + "text": "Understand LaTeX syntax", + "href": "https://math.meta.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference" + } + }, + "toolbar": { + "collapse": { + "title": "Type Ctrl + / to quickly insert a card" + }, + "undo": { + "title": "Undo" + }, + "redo": { + "title": "Redo" + }, + "paintformat": { + "title": "Format brush" + }, + "removeformat": { + "title": "Clear format" + }, + "heading": { + "title": "Text and title", + "p": "Text", + "h1": "Heading 1", + "h2": "Heading 2", + "h3": "Heading 3", + "h4": "Heading 4", + "h5": "Heading 5", + "h6": "Heading 6" + }, + "fontfamily": { + "title": "Font family", + "notInstalled": "The font may not be installed", + "items": { + "default": "Default", + "arial": "Arial", + "comicSansMS": "Comic Sans MS", + "courierNew": "Courier New", + "georgia": "Georgia", + "helvetica": "Helvetica", + "impact": "Impact", + "timesNewRoman": "Times New Roman", + "trebuchetMS": "Trebuchet MS", + "verdana": "Verdana", + "fangSong": "FangSong", + "stFangsong": "STFangsong", + "stSong": "STSong", + "stKaiti": "STKaiti", + "simSun": "SimSum", + "microsoftYaHei": "Microsoft YaHei", + "kaiTi": "KaiTi", + "kaitiSC": "KaiTi SC", + "simHei": "SimHei", + "heitiSC": "Heiti SC", + "fzHei": "FZHeiTi", + "fzKai": "FZKaiTi", + "fzFangSong": "FZFangSong" + } + }, + "fontsize": { + "title": "Font size" + }, + "fontcolor": { + "title": "Font color", + "more": "More colors" + }, + "backcolor": { + "title": "Background color", + "more": "More colors" + }, + "bold": { + "title": "Bold" + }, + "italic": { + "title": "Italic" + }, + "strikethrough": { + "title": "Strikethrough" + }, + "underline": { + "title": "Underline" + }, + "moremark": { + "title": "More text styles", + "sup": "Sup", + "sub": "Sub", + "code": "Inline code" + }, + "alignment": { + "title": "Alignment", + "left": "Align left", + "center": "Align center", + "right": "Align right", + "justify": "Align justify" + }, + "unorderedlist": { + "title": "Unordered list" + }, + "orderedlist": { + "title": "Ordered list" + }, + "tasklist": { + "title": "Task list" + }, + "indent": { + "title": "Ident", + "in": "Increase indent", + "out": "Reduce indent" + }, + "line-height": { + "title": "Line height", + "default": "Default" + }, + "link": { + "title": "Insert Link" + }, + "quote": { + "title": "Insert reference" + }, + "hr": { + "title": "Insert dividing line" + }, + "colorPicker": { + "defaultText": "Default Color", + "nonFillText": "No fill color", + "#000000": "Black", + "#262626": "Dark Gray 3", + "#595959": "Dark Gray 2", + "#8C8C8C": "Dark Gray 1", + "#BFBFBF": "Gray", + "#D9D9D9": "Light Gray 4", + "#E9E9E9": "Light Gray 3", + "#F5F5F5": "Light Gray 2", + "#FAFAFA": "Light Gray 1", + "#FFFFFF": "White", + "#F5222D": "Red", + "#FA541C": "Chinese Red", + "#FA8C16": "Orange", + "#FADB14": "Yellow", + "#52C41A": "Green", + "#13C2C2": "Cyan", + "#1890FF": "Light Blue", + "#2F54EB": "Blue", + "#722ED1": "Purple", + "#EB2F96": "Magenta", + "#FFE8E6": "Red 1", + "#FFECE0": "Chinese Red 1", + "#FFEFD1": "Orange 1", + "#FCFCCA": "Yellow 1", + "#E4F7D2": "Green 1", + "#D3F5F0": "Cyan 1", + "#D4EEFC": "Light Blue 1", + "#DEE8FC": "Blue 1", + "#EFE1FA": "Purple 1", + "#FAE1EB": "Magenta 1", + "#FFA39E": "Red 2", + "#FFBB96": "Chinese Red 2", + "#FFD591": "Orange 2", + "#FFFB8F": "Yellow 2", + "#B7EB8F": "Green 2", + "#87E8DE": "Cyan 2", + "#91D5FF": "Light Blue 2", + "#ADC6FF": "Blue 2", + "#D3ADF7": "Purple 2", + "#FFADD2": "Magenta 2", + "#FF4D4F": "Red 3", + "#FF7A45": "Chinese Red 3", + "#FFA940": "Orange 3", + "#FFEC3D": "Yellow 3", + "#73D13D": "Green 3", + "#36CFC9": "Cyan 3", + "#40A9FF": "Light Blue 3", + "#597EF7": "Blue 3", + "#9254DE": "Purple 3", + "#F759AB": "Magenta 3", + "#CF1322": "Red 4", + "#D4380D": "Chinese Red 4", + "#D46B08": "Orange 4", + "#D4B106": "Yellow 4", + "#389E0D": "Green 4", + "#08979C": "Cyan 4", + "#096DD9": "Light Blue 4", + "#1D39C4": "Blue 4", + "#531DAB": "Purple 4", + "#C41D7F": "Magenta 4", + "#820014": "Red 5", + "#871400": "Chinese Red 5", + "#873800": "Orange 5", + "#614700": "Yellow 5", + "#135200": "Green 5", + "#00474F": "Cyan 5", + "#003A8C": "Light Blue 5", + "#061178": "Blue 5", + "#22075E": "Purple 5", + "#780650": "Magenta 5" + }, + "component": { + "placeholder": "Card name" + }, + "image": { + "title": "Image" + }, + "codeblock": { + "title": "Codeblock" + }, + "table": { + "title": "Table" + }, + "file": { + "title": "File" + }, + "video": { + "title": "Video" + }, + "math": { + "title": "Formula" + }, + "status": { + "title": "Status" + }, + "mind": { + "title": "Mind Map" + }, + "commonlyUsed": { + "title": "Commonly used" + } + }, + "status": { + "defaultValue": "SET A STATUS" + }, + "mention": { + "placeholder": "User name" + } +} diff --git a/locale/ja-JP.json b/locale/ja-JP.json new file mode 100644 index 00000000..1627715b --- /dev/null +++ b/locale/ja-JP.json @@ -0,0 +1,362 @@ +{ + "dnd": { + "title": "ドラッグして位置を調整" + }, + "copy": { + "title": "コピー", + "success": "コピーは成功しました", + "error": "コピーに失敗しました" + }, + "delete": { + "title": "削除" + }, + "copyAnchor": { + "title": "アンカーリンクをコピー" + }, + "link": { + "placeholder": "リンクまたはアンカーを入力して、Enterキーを押して確認してください", + "save": "保存", + "edit": "編集", + "delete": "リンクを解除", + "open": "リンクを開く", + "text": "テキスト", + "link": "リンク", + "text_placeholder": "説明テキスト", + "link_placeholder": "リンクアドレス", + "link_open": "リンクを開く", + "link_edit": "リンクを編集", + "link_remove": "リンクを解除", + "ok_button": "OK" + }, + "copyContent": { + "title": "コンテンツをコピー" + }, + "maximize": { + "title": "最大化", + "back": "ドキュメントに戻る" + }, + "expand": { + "title": "埋め込みプレビュー" + }, + "collapse": { + "title": "コンパクト" + }, + "card": { + "lockAlert": "編集に入る前に、相手が編集を終了するのを待ってください" + }, + "preferences": { + "title": "設定" + }, + "download": { + "title": "ダウンロード" + }, + "more": { + "title": "もっと表示" + }, + "checkMarkdown": { + "title": "切り取られたコンテンツが Markdown 構文に準拠していることが検出されましたが、変換しますか?" + }, + "image": { + "next": "次", + "prev": "前", + "zoomIn": "拡大", + "zoomOut": "縮小", + "originSize": "原寸大", + "bestSize": "画面に適応", + "errorMessageCopy": "エラーメッセージをコピー", + "loadError": "画像を読み込めませんでした。", + "uploadError": "写真のアップロードに失敗しました。", + "uploadLimitError": "アップロード画像のサイズは $size に制限されています。", + "toolbarReductionTitle": "復元", + "toolbarWidthTitle": "幅", + "toolbarHeightTitle": "幅", + "displayBlockTitle": "ラインを所有する", + "displayInlineTitle": "埋め込まれたインライン" + }, + "table": { + "insertColLeft": "左側に $data 列を挿入", + "insertColRight": "右側に $data 列を挿入", + "insertRowUp": "上に $data 行を挿入", + "insertRowDown": "下に $data 行を挿入", + "mergeCell": "セルを結合", + "splitCell": "セルを分割", + "removeCol": "選択した列を削除", + "removeRow": "選択した行を削除", + "removeTable": "テーブルを削除", + "copy": "コピー", + "cut": "切る", + "paste": "ペースト", + "clear": "空のコンテンツ", + "draggingCol": "$data 列を移動しています", + "draggingRow": "$data 行を移動しています", + "color": { + "title": "セルの背景色", + "nonFillText": "塗りつぶしの色なし" + }, + "noBorder": "境界線を隠す", + "verticalAlign": { + "title": "垂直方向に整列", + "top": "上揃え", + "middle": "中央揃え", + "bottom": "下揃え" + } + }, + "file": { + "errorMessageCopy": "エラーメッセージをコピー", + "loadError": "ファイルの読み込みに失敗しました。", + "uploadError": "ファイルのアップロードに失敗しました。", + "uploadLimitError": "アップロードファイルのサイズは $size に制限されています。", + "download": "ダウンロード", + "preview": "プレビュー" + }, + "video": { + "errorMessageCopy": "エラーメッセージをコピー", + "loadError": "動画を読み込めませんでした。", + "uploadError": "動画のアップロードに失敗しました。", + "uploadLimitError": "アップロード動画のサイズは $size に制限されています。", + "download": "ダウンロード", + "preview": "プレビュー", + "loading": "読み込み中...", + "transcoding": "トランスコーディング中..." + }, + "math": { + "errorMessageCopy": "エラーメッセージをコピー", + "getError": "svg コードの取得に失敗しました", + "placeholder": "Tex 式を追加", + "ok": "OK", + "buttonTips": "Ctrl + Enter", + "tips": { + "text": "LaTeX 構文とは何ですか?", + "href": "https://math.meta.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference" + } + }, + "toolbar": { + "collapse": { + "title": "Ctrl + / を入力してコードブロックが挿入できます" + }, + "undo": { + "title": "元に戻す" + }, + "redo": { + "title": "やり直す" + }, + "paintformat": { + "title": "フォーマットペインター" + }, + "removeformat": { + "title": "クリアフォーマット" + }, + "heading": { + "title": "スタイル", + "p": "標準", + "h1": "見出し 1", + "h2": "見出し 2", + "h3": "見出し 3", + "h4": "見出し 4", + "h5": "見出し 5", + "h6": "見出し 6" + }, + "fontfamily": { + "title": "フォント", + "notInstalled": "インストールされていないフォント", + "items": { + "default": "デフォルト", + "arial": "Arial", + "comicSansMS": "Comic Sans MS", + "courierNew": "Courier New", + "georgia": "Georgia", + "helvetica": "Helvetica", + "impact": "Impact", + "timesNewRoman": "Times New Roman", + "trebuchetMS": "Trebuchet MS", + "verdana": "Verdana", + "fangSong": "FangSong", + "stFangsong": "STFangsong", + "stSong": "STSong", + "stKaiti": "STKaiTi", + "simSun": "SimSun", + "microsoftYaHei": "Microsoft YaHei", + "kaiTi": "KaiTi", + "kaitiSC": "KaiTi SC", + "simHei": "SimHei", + "heitiSC": "HeiTi SC", + "fzHei": "FZHei", + "fzKai": "FZKai", + "fzFangSong": "FZFangSong" + } + }, + "fontsize": { + "title": "フォントサイズ" + }, + "fontcolor": { + "title": "フォントの色", + "more": "より多くの色" + }, + "backcolor": { + "title": "背景颜色", + "more": "より多くの色" + }, + "bold": { + "title": "太字" + }, + "italic": { + "title": "イタリック" + }, + "strikethrough": { + "title": "打ち消し線" + }, + "underline": { + "title": "アンダースコア" + }, + "moremark": { + "title": "その他のテキストスタイル", + "sup": "上付き文字", + "sub": "下付き文字", + "code": "インラインコード" + }, + "alignment": { + "title": "アラインメント", + "left": "左揃え", + "center": "中央に揃え", + "right": "右揃え", + "justify": "両端揃え" + }, + "unorderedlist": { + "title": "順序なしリスト" + }, + "orderedlist": { + "title": "順序付きリスト" + }, + "tasklist": { + "title": "タスクリスト" + }, + "indent": { + "title": "インデント", + "in": "インデントを増やす", + "out": "インデントを減らす" + }, + "line-height": { + "title": "行の高さ", + "default": "デフォルト" + }, + "link": { + "title": "リンク" + }, + "quote": { + "title": "参照を挿入" + }, + "hr": { + "title": "分割線を挿入" + }, + "colorPicker": { + "defaultText": "デフォルト", + "nonFillText": "塗りつぶしの色なし", + "#000000": "ブラック", + "#262626": "ダークグレー 3", + "#595959": "ダークグレー 2", + "#8C8C8C": "ダークグレー 1", + "#BFBFBF": "グレー", + "#D9D9D9": "ライトグレー 4", + "#E9E9E9": "ライトグレー 3", + "#F5F5F5": "ライトグレー 2", + "#FAFAFA": "ライトグレー 1", + "#FFFFFF": "ホワイト", + "#F5222D": "赤", + "#FA541C": "紅色", + "#FA8C16": "オレンジ", + "#FADB14": "黄色", + "#52C41A": "グリーン", + "#13C2C2": "ブルー", + "#1890FF": "ライトブルー", + "#2F54EB": "ダークブルー", + "#722ED1": "紫色", + "#EB2F96": "赤紫色", + "#FFE8E6": "赤色 1", + "#FFECE0": "紅色 1", + "#FFEFD1": "オレンジ 1", + "#FCFCCA": "黄色 1", + "#E4F7D2": "グリーン 1", + "#D3F5F0": "ブルー 1", + "#D4EEFC": "ライトブルー 1", + "#DEE8FC": "ダークブルー 1", + "#EFE1FA": "紫色 1", + "#FAE1EB": "赤紫色 1", + "#FFA39E": "赤色 2", + "#FFBB96": "紅色 2", + "#FFD591": "オレンジ 2", + "#FFFB8F": "黄色 2", + "#B7EB8F": "グリーン 2", + "#87E8DE": "ブルー 2", + "#91D5FF": "ライトブルー 2", + "#ADC6FF": "ダークブルー 2", + "#D3ADF7": "紫色 2", + "#FFADD2": "赤紫色 2", + "#FF4D4F": "赤色 3", + "#FF7A45": "紅色 3", + "#FFA940": "オレンジ 3", + "#FFEC3D": "黄色 3", + "#73D13D": "グリーン 3", + "#36CFC9": "ブルー 3", + "#40A9FF": "ライトブルー 3", + "#597EF7": "ダークブルー 3", + "#9254DE": "紫色 3", + "#F759AB": "赤紫色 3", + "#CF1322": "赤色 4", + "#D4380D": "紅色 4", + "#D46B08": "オレンジ 4", + "#D4B106": "黄色 4", + "#389E0D": "グリーン 4", + "#08979C": "ブルー 4", + "#096DD9": "ライトブルー 4", + "#1D39C4": "ダークブルー 4", + "#531DAB": "紫色 4", + "#C41D7F": "赤紫色 4", + "#820014": "赤色 5", + "#871400": "紅色 5", + "#873800": "オレンジ 5", + "#614700": "黄色 5", + "#135200": "グリーン 5", + "#00474F": "ブルー 5", + "#003A8C": "ライトブルー 5", + "#061178": "ダークブルー 5", + "#22075E": "紫色 5", + "#780650": "赤紫色 5" + }, + "component": { + "placeholder": "ブロック名" + }, + "image": { + "title": "画像" + }, + "codeblock": { + "title": "コードブロック" + }, + "table": { + "title": "表" + }, + "file": { + "title": "添付ファイル" + }, + "video": { + "title": "動画" + }, + "math": { + "title": "式" + }, + "status": { + "title": "状態" + }, + "mind": { + "title": "マインドマップ" + }, + "commonlyUsed": { + "title": "よく使用される" + } + }, + "status": { + "defaultValue": "状態を設定" + }, + "mention": { + "placeholder": "言及" + } +} diff --git a/locale/zh-HK.json b/locale/zh-HK.json new file mode 100644 index 00000000..eae0ed66 --- /dev/null +++ b/locale/zh-HK.json @@ -0,0 +1,362 @@ +{ + "dnd": { + "title": "拖動調整位置" + }, + "copy": { + "title": "復製", + "success": "復製成功", + "error": "復製失敗" + }, + "delete": { + "title": "刪除" + }, + "copyAnchor": { + "title": "復製錨點鏈接" + }, + "link": { + "placeholder": "請輸入鏈接或錨點,回車確認", + "save": "保存", + "edit": "編輯", + "delete": "取消鏈接", + "open": "打開鏈接", + "text": "文本", + "link": "鏈接", + "text_placeholder": "描述文本", + "link_placeholder": "鏈接地址", + "link_open": "打開鏈接", + "link_edit": "編輯鏈接", + "link_remove": "移除鏈接", + "ok_button": "確定" + }, + "copyContent": { + "title": "復製內容" + }, + "maximize": { + "title": "最大化", + "back": "返回文檔" + }, + "expand": { + "title": "嵌入預覽" + }, + "collapse": { + "title": "緊湊展示" + }, + "card": { + "lockAlert": "請等待對方編輯完畢後,再進入編輯" + }, + "preferences": { + "title": "設置" + }, + "download": { + "title": "下載" + }, + "more": { + "title": "更多" + }, + "checkMarkdown": { + "title": "檢測到粘貼內容符合 Markdown 語法,是否需要轉換?" + }, + "image": { + "next": "下一張", + "prev": "上一張", + "zoomIn": "放大", + "zoomOut": "縮小", + "originSize": "實際尺寸", + "bestSize": "適應屏幕", + "errorMessageCopy": "復製錯誤信息", + "loadError": "圖片加載失敗!", + "uploadError": "上傳圖片失敗!", + "uploadLimitError": "上傳圖片大小限製為 $size", + "toolbarReductionTitle": "還原", + "toolbarWidthTitle": "寬度", + "toolbarHeightTitle": "寬度", + "displayBlockTitle": "獨占一行", + "displayInlineTitle": "嵌入行內" + }, + "table": { + "insertColLeft": "左邊插入 $data 列", + "insertColRight": "右邊插入 $data 列", + "insertRowUp": "上方插入 $data 行", + "insertRowDown": "下方插入 $data 行", + "mergeCell": "合並單元格", + "splitCell": "拆分單元格", + "removeCol": "刪除選中列", + "removeRow": "刪除選中行", + "removeTable": "刪除表格", + "copy": "復製", + "cut": "剪切", + "paste": "粘貼", + "clear": "清空內容", + "draggingCol": "正在移動 $data 列", + "draggingRow": "正在移動 $data 行", + "color": { + "title": "單元格背景色", + "nonFillText": "無填充色" + }, + "noBorder": "隱藏邊框", + "verticalAlign": { + "title": "垂直對齊", + "top": "頂對齊", + "middle": "垂直居中", + "bottom": "底對齊" + } + }, + "file": { + "errorMessageCopy": "復製錯誤信息", + "loadError": "文件加載失敗!", + "uploadError": "上傳文件失敗!", + "uploadLimitError": "上傳文件大小限製為 $size", + "download": "下載", + "preview": "預覽" + }, + "video": { + "errorMessageCopy": "復製錯誤信息", + "loadError": "視頻加載失敗!", + "uploadError": "上傳視頻失敗!", + "uploadLimitError": "上傳視頻大小限製為 $size", + "download": "下載", + "preview": "預覽", + "loading": "加載中...", + "transcoding": "轉碼中..." + }, + "math": { + "errorMessageCopy": "復製錯誤信息", + "getError": "獲取svg代碼失敗", + "placeholder": "添加 Tex 公式", + "ok": "確定", + "buttonTips": "Ctrl + Enter", + "tips": { + "text": "了解 LaTeX 語法", + "href": "https://math.meta.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference" + } + }, + "toolbar": { + "collapse": { + "title": "輸入 Ctrl + / 快速插入卡片" + }, + "undo": { + "title": "撤銷" + }, + "redo": { + "title": "重做" + }, + "paintformat": { + "title": "格式刷" + }, + "removeformat": { + "title": "清除格式" + }, + "heading": { + "title": "正文與標題", + "p": "正文", + "h1": "標題 1", + "h2": "標題 2", + "h3": "標題 3", + "h4": "標題 4", + "h5": "標題 5", + "h6": "標題 6" + }, + "fontfamily": { + "title": "字體", + "notInstalled": "可能未安裝該字體", + "items": { + "default": "默認", + "arial": "Arial", + "comicSansMS": "Comic Sans MS", + "courierNew": "Courier New", + "georgia": "Georgia", + "helvetica": "Helvetica", + "impact": "Impact", + "timesNewRoman": "Times New Roman", + "trebuchetMS": "Trebuchet MS", + "verdana": "Verdana", + "fangSong": "仿宋", + "stFangsong": "華文仿宋", + "stSong": "華文宋體", + "stKaiti": "華文楷體", + "simSun": "宋體", + "microsoftYaHei": "微軟雅黑", + "kaiTi": "楷體", + "kaitiSC": "楷體-簡", + "simHei": "黑體", + "heitiSC": "黑體-簡", + "fzHei": "方正黑體", + "fzKai": "方正楷體", + "fzFangSong": "方正仿宋" + } + }, + "fontsize": { + "title": "字號" + }, + "fontcolor": { + "title": "字體顏色", + "more": "更多顏色" + }, + "backcolor": { + "title": "背景顏色", + "more": "更多顏色" + }, + "bold": { + "title": "粗體" + }, + "italic": { + "title": "斜體" + }, + "strikethrough": { + "title": "刪除線" + }, + "underline": { + "title": "下劃線" + }, + "moremark": { + "title": "更多文本樣式", + "sup": "上標", + "sub": "下標", + "code": "行內代碼" + }, + "alignment": { + "title": "對齊方式", + "left": "左對齊", + "center": "居中對齊", + "right": "右對齊", + "justify": "兩端對齊" + }, + "unorderedlist": { + "title": "無序列表" + }, + "orderedlist": { + "title": "有序列表" + }, + "tasklist": { + "title": "任務列表" + }, + "indent": { + "title": "縮進", + "in": "增加縮進", + "out": "減少縮進" + }, + "line-height": { + "title": "行高", + "default": "默認" + }, + "link": { + "title": "鏈接" + }, + "quote": { + "title": "插入引用" + }, + "hr": { + "title": "插入分割線" + }, + "colorPicker": { + "defaultText": "默認", + "nonFillText": "無填充色", + "#000000": "黑色", + "#262626": "深灰 3", + "#595959": "深灰 2", + "#8C8C8C": "深灰 1", + "#BFBFBF": "灰色", + "#D9D9D9": "淺灰 4", + "#E9E9E9": "淺灰 3", + "#F5F5F5": "淺灰 2", + "#FAFAFA": "淺灰 1", + "#FFFFFF": "白色", + "#F5222D": "紅色", + "#FA541C": "朱紅", + "#FA8C16": "橙色", + "#FADB14": "黃色", + "#52C41A": "綠色", + "#13C2C2": "青色", + "#1890FF": "淺藍", + "#2F54EB": "藍色", + "#722ED1": "紫色", + "#EB2F96": "玫紅", + "#FFE8E6": "紅色 1", + "#FFECE0": "朱紅 1", + "#FFEFD1": "橙色 1", + "#FCFCCA": "黃色 1", + "#E4F7D2": "綠色 1", + "#D3F5F0": "青色 1", + "#D4EEFC": "淺藍 1", + "#DEE8FC": "藍色 1", + "#EFE1FA": "紫色 1", + "#FAE1EB": "玫紅 1", + "#FFA39E": "紅色 2", + "#FFBB96": "朱紅 2", + "#FFD591": "橙色 2", + "#FFFB8F": "黃色 2", + "#B7EB8F": "綠色 2", + "#87E8DE": "青色 2", + "#91D5FF": "淺藍 2", + "#ADC6FF": "藍色 2", + "#D3ADF7": "紫色 2", + "#FFADD2": "玫紅 2", + "#FF4D4F": "紅色 3", + "#FF7A45": "朱紅 3", + "#FFA940": "橙色 3", + "#FFEC3D": "黃色 3", + "#73D13D": "綠色 3", + "#36CFC9": "青色 3", + "#40A9FF": "淺藍 3", + "#597EF7": "藍色 3", + "#9254DE": "紫色 3", + "#F759AB": "玫紅 3", + "#CF1322": "紅色 4", + "#D4380D": "朱紅 4", + "#D46B08": "橙色 4", + "#D4B106": "黃色 4", + "#389E0D": "綠色 4", + "#08979C": "青色 4", + "#096DD9": "淺藍 4", + "#1D39C4": "藍色 4", + "#531DAB": "紫色 4", + "#C41D7F": "玫紅 4", + "#820014": "紅色 5", + "#871400": "朱紅 5", + "#873800": "橙色 5", + "#614700": "黃色 5", + "#135200": "綠色 5", + "#00474F": "青色 5", + "#003A8C": "淺藍 5", + "#061178": "藍色 5", + "#22075E": "紫色 5", + "#780650": "玫紅 5" + }, + "component": { + "placeholder": "卡片名稱" + }, + "image": { + "title": "圖片" + }, + "codeblock": { + "title": "代碼塊" + }, + "table": { + "title": "表格" + }, + "file": { + "title": "附件" + }, + "video": { + "title": "視頻" + }, + "math": { + "title": "公式" + }, + "status": { + "title": "狀態" + }, + "mind": { + "title": "腦圖" + }, + "commonlyUsed": { + "title": "常用" + } + }, + "status": { + "defaultValue": "設置狀態" + }, + "mention": { + "placeholder": "用戶名" + } +} diff --git a/locale/zh-TW.json b/locale/zh-TW.json new file mode 100644 index 00000000..eae0ed66 --- /dev/null +++ b/locale/zh-TW.json @@ -0,0 +1,362 @@ +{ + "dnd": { + "title": "拖動調整位置" + }, + "copy": { + "title": "復製", + "success": "復製成功", + "error": "復製失敗" + }, + "delete": { + "title": "刪除" + }, + "copyAnchor": { + "title": "復製錨點鏈接" + }, + "link": { + "placeholder": "請輸入鏈接或錨點,回車確認", + "save": "保存", + "edit": "編輯", + "delete": "取消鏈接", + "open": "打開鏈接", + "text": "文本", + "link": "鏈接", + "text_placeholder": "描述文本", + "link_placeholder": "鏈接地址", + "link_open": "打開鏈接", + "link_edit": "編輯鏈接", + "link_remove": "移除鏈接", + "ok_button": "確定" + }, + "copyContent": { + "title": "復製內容" + }, + "maximize": { + "title": "最大化", + "back": "返回文檔" + }, + "expand": { + "title": "嵌入預覽" + }, + "collapse": { + "title": "緊湊展示" + }, + "card": { + "lockAlert": "請等待對方編輯完畢後,再進入編輯" + }, + "preferences": { + "title": "設置" + }, + "download": { + "title": "下載" + }, + "more": { + "title": "更多" + }, + "checkMarkdown": { + "title": "檢測到粘貼內容符合 Markdown 語法,是否需要轉換?" + }, + "image": { + "next": "下一張", + "prev": "上一張", + "zoomIn": "放大", + "zoomOut": "縮小", + "originSize": "實際尺寸", + "bestSize": "適應屏幕", + "errorMessageCopy": "復製錯誤信息", + "loadError": "圖片加載失敗!", + "uploadError": "上傳圖片失敗!", + "uploadLimitError": "上傳圖片大小限製為 $size", + "toolbarReductionTitle": "還原", + "toolbarWidthTitle": "寬度", + "toolbarHeightTitle": "寬度", + "displayBlockTitle": "獨占一行", + "displayInlineTitle": "嵌入行內" + }, + "table": { + "insertColLeft": "左邊插入 $data 列", + "insertColRight": "右邊插入 $data 列", + "insertRowUp": "上方插入 $data 行", + "insertRowDown": "下方插入 $data 行", + "mergeCell": "合並單元格", + "splitCell": "拆分單元格", + "removeCol": "刪除選中列", + "removeRow": "刪除選中行", + "removeTable": "刪除表格", + "copy": "復製", + "cut": "剪切", + "paste": "粘貼", + "clear": "清空內容", + "draggingCol": "正在移動 $data 列", + "draggingRow": "正在移動 $data 行", + "color": { + "title": "單元格背景色", + "nonFillText": "無填充色" + }, + "noBorder": "隱藏邊框", + "verticalAlign": { + "title": "垂直對齊", + "top": "頂對齊", + "middle": "垂直居中", + "bottom": "底對齊" + } + }, + "file": { + "errorMessageCopy": "復製錯誤信息", + "loadError": "文件加載失敗!", + "uploadError": "上傳文件失敗!", + "uploadLimitError": "上傳文件大小限製為 $size", + "download": "下載", + "preview": "預覽" + }, + "video": { + "errorMessageCopy": "復製錯誤信息", + "loadError": "視頻加載失敗!", + "uploadError": "上傳視頻失敗!", + "uploadLimitError": "上傳視頻大小限製為 $size", + "download": "下載", + "preview": "預覽", + "loading": "加載中...", + "transcoding": "轉碼中..." + }, + "math": { + "errorMessageCopy": "復製錯誤信息", + "getError": "獲取svg代碼失敗", + "placeholder": "添加 Tex 公式", + "ok": "確定", + "buttonTips": "Ctrl + Enter", + "tips": { + "text": "了解 LaTeX 語法", + "href": "https://math.meta.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference" + } + }, + "toolbar": { + "collapse": { + "title": "輸入 Ctrl + / 快速插入卡片" + }, + "undo": { + "title": "撤銷" + }, + "redo": { + "title": "重做" + }, + "paintformat": { + "title": "格式刷" + }, + "removeformat": { + "title": "清除格式" + }, + "heading": { + "title": "正文與標題", + "p": "正文", + "h1": "標題 1", + "h2": "標題 2", + "h3": "標題 3", + "h4": "標題 4", + "h5": "標題 5", + "h6": "標題 6" + }, + "fontfamily": { + "title": "字體", + "notInstalled": "可能未安裝該字體", + "items": { + "default": "默認", + "arial": "Arial", + "comicSansMS": "Comic Sans MS", + "courierNew": "Courier New", + "georgia": "Georgia", + "helvetica": "Helvetica", + "impact": "Impact", + "timesNewRoman": "Times New Roman", + "trebuchetMS": "Trebuchet MS", + "verdana": "Verdana", + "fangSong": "仿宋", + "stFangsong": "華文仿宋", + "stSong": "華文宋體", + "stKaiti": "華文楷體", + "simSun": "宋體", + "microsoftYaHei": "微軟雅黑", + "kaiTi": "楷體", + "kaitiSC": "楷體-簡", + "simHei": "黑體", + "heitiSC": "黑體-簡", + "fzHei": "方正黑體", + "fzKai": "方正楷體", + "fzFangSong": "方正仿宋" + } + }, + "fontsize": { + "title": "字號" + }, + "fontcolor": { + "title": "字體顏色", + "more": "更多顏色" + }, + "backcolor": { + "title": "背景顏色", + "more": "更多顏色" + }, + "bold": { + "title": "粗體" + }, + "italic": { + "title": "斜體" + }, + "strikethrough": { + "title": "刪除線" + }, + "underline": { + "title": "下劃線" + }, + "moremark": { + "title": "更多文本樣式", + "sup": "上標", + "sub": "下標", + "code": "行內代碼" + }, + "alignment": { + "title": "對齊方式", + "left": "左對齊", + "center": "居中對齊", + "right": "右對齊", + "justify": "兩端對齊" + }, + "unorderedlist": { + "title": "無序列表" + }, + "orderedlist": { + "title": "有序列表" + }, + "tasklist": { + "title": "任務列表" + }, + "indent": { + "title": "縮進", + "in": "增加縮進", + "out": "減少縮進" + }, + "line-height": { + "title": "行高", + "default": "默認" + }, + "link": { + "title": "鏈接" + }, + "quote": { + "title": "插入引用" + }, + "hr": { + "title": "插入分割線" + }, + "colorPicker": { + "defaultText": "默認", + "nonFillText": "無填充色", + "#000000": "黑色", + "#262626": "深灰 3", + "#595959": "深灰 2", + "#8C8C8C": "深灰 1", + "#BFBFBF": "灰色", + "#D9D9D9": "淺灰 4", + "#E9E9E9": "淺灰 3", + "#F5F5F5": "淺灰 2", + "#FAFAFA": "淺灰 1", + "#FFFFFF": "白色", + "#F5222D": "紅色", + "#FA541C": "朱紅", + "#FA8C16": "橙色", + "#FADB14": "黃色", + "#52C41A": "綠色", + "#13C2C2": "青色", + "#1890FF": "淺藍", + "#2F54EB": "藍色", + "#722ED1": "紫色", + "#EB2F96": "玫紅", + "#FFE8E6": "紅色 1", + "#FFECE0": "朱紅 1", + "#FFEFD1": "橙色 1", + "#FCFCCA": "黃色 1", + "#E4F7D2": "綠色 1", + "#D3F5F0": "青色 1", + "#D4EEFC": "淺藍 1", + "#DEE8FC": "藍色 1", + "#EFE1FA": "紫色 1", + "#FAE1EB": "玫紅 1", + "#FFA39E": "紅色 2", + "#FFBB96": "朱紅 2", + "#FFD591": "橙色 2", + "#FFFB8F": "黃色 2", + "#B7EB8F": "綠色 2", + "#87E8DE": "青色 2", + "#91D5FF": "淺藍 2", + "#ADC6FF": "藍色 2", + "#D3ADF7": "紫色 2", + "#FFADD2": "玫紅 2", + "#FF4D4F": "紅色 3", + "#FF7A45": "朱紅 3", + "#FFA940": "橙色 3", + "#FFEC3D": "黃色 3", + "#73D13D": "綠色 3", + "#36CFC9": "青色 3", + "#40A9FF": "淺藍 3", + "#597EF7": "藍色 3", + "#9254DE": "紫色 3", + "#F759AB": "玫紅 3", + "#CF1322": "紅色 4", + "#D4380D": "朱紅 4", + "#D46B08": "橙色 4", + "#D4B106": "黃色 4", + "#389E0D": "綠色 4", + "#08979C": "青色 4", + "#096DD9": "淺藍 4", + "#1D39C4": "藍色 4", + "#531DAB": "紫色 4", + "#C41D7F": "玫紅 4", + "#820014": "紅色 5", + "#871400": "朱紅 5", + "#873800": "橙色 5", + "#614700": "黃色 5", + "#135200": "綠色 5", + "#00474F": "青色 5", + "#003A8C": "淺藍 5", + "#061178": "藍色 5", + "#22075E": "紫色 5", + "#780650": "玫紅 5" + }, + "component": { + "placeholder": "卡片名稱" + }, + "image": { + "title": "圖片" + }, + "codeblock": { + "title": "代碼塊" + }, + "table": { + "title": "表格" + }, + "file": { + "title": "附件" + }, + "video": { + "title": "視頻" + }, + "math": { + "title": "公式" + }, + "status": { + "title": "狀態" + }, + "mind": { + "title": "腦圖" + }, + "commonlyUsed": { + "title": "常用" + } + }, + "status": { + "defaultValue": "設置狀態" + }, + "mention": { + "placeholder": "用戶名" + } +} diff --git a/locale/zh-cn.json b/locale/zh-cn.json new file mode 100644 index 00000000..c2c82612 --- /dev/null +++ b/locale/zh-cn.json @@ -0,0 +1,362 @@ +{ + "dnd": { + "title": "拖动调整位置" + }, + "copy": { + "title": "复制", + "success": "复制成功", + "error": "复制失败" + }, + "delete": { + "title": "删除" + }, + "copyAnchor": { + "title": "复制锚点链接" + }, + "link": { + "placeholder": "请输入链接或锚点,回车确认", + "save": "保存", + "edit": "编辑", + "delete": "取消链接", + "open": "打开链接", + "text": "文本", + "link": "链接", + "text_placeholder": "描述文本", + "link_placeholder": "链接地址", + "link_open": "打开链接", + "link_edit": "编辑链接", + "link_remove": "移除链接", + "ok_button": "确定" + }, + "copyContent": { + "title": "复制内容" + }, + "maximize": { + "title": "最大化", + "back": "返回文档" + }, + "expand": { + "title": "嵌入预览" + }, + "collapse": { + "title": "紧凑展示" + }, + "card": { + "lockAlert": "请等待对方编辑完毕后,再进入编辑" + }, + "preferences": { + "title": "设置" + }, + "download": { + "title": "下载" + }, + "more": { + "title": "更多" + }, + "checkMarkdown": { + "title": "检测到粘贴内容符合 Markdown 语法,是否需要转换?" + }, + "image": { + "next": "下一张", + "prev": "上一张", + "zoomIn": "放大", + "zoomOut": "缩小", + "originSize": "实际尺寸", + "bestSize": "适应屏幕", + "errorMessageCopy": "复制错误信息", + "loadError": "图片加载失败!", + "uploadError": "上传图片失败!", + "uploadLimitError": "上传图片大小限制为 $size", + "toolbarReductionTitle": "还原", + "toolbarWidthTitle": "宽度", + "toolbarHeightTitle": "宽度", + "displayBlockTitle": "独占一行", + "displayInlineTitle": "嵌入行内" + }, + "table": { + "insertColLeft": "左边插入 $data 列", + "insertColRight": "右边插入 $data 列", + "insertRowUp": "上方插入 $data 行", + "insertRowDown": "下方插入 $data 行", + "mergeCell": "合并单元格", + "splitCell": "拆分单元格", + "removeCol": "删除选中列", + "removeRow": "删除选中行", + "removeTable": "删除表格", + "copy": "复制", + "cut": "剪切", + "paste": "粘贴", + "clear": "清空内容", + "draggingCol": "正在移动 $data 列", + "draggingRow": "正在移动 $data 行", + "color": { + "title": "单元格背景色", + "nonFillText": "无填充色" + }, + "noBorder": "隐藏边框", + "verticalAlign": { + "title": "垂直对齐", + "top": "顶对齐", + "middle": "垂直居中", + "bottom": "底对齐" + } + }, + "file": { + "errorMessageCopy": "复制错误信息", + "loadError": "文件加载失败!", + "uploadError": "上传文件失败!", + "uploadLimitError": "上传文件大小限制为 $size", + "download": "下载", + "preview": "预览" + }, + "video": { + "errorMessageCopy": "复制错误信息", + "loadError": "视频加载失败!", + "uploadError": "上传视频失败!", + "uploadLimitError": "上传视频大小限制为 $size", + "download": "下载", + "preview": "预览", + "loading": "加载中...", + "transcoding": "转码中..." + }, + "math": { + "errorMessageCopy": "复制错误信息", + "getError": "获取svg代码失败", + "placeholder": "添加 Tex 公式", + "ok": "确定", + "buttonTips": "Ctrl + Enter", + "tips": { + "text": "了解 LaTeX 语法", + "href": "https://math.meta.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference" + } + }, + "toolbar": { + "collapse": { + "title": "输入 Ctrl + / 快速插入卡片" + }, + "undo": { + "title": "撤销" + }, + "redo": { + "title": "重做" + }, + "paintformat": { + "title": "格式刷" + }, + "removeformat": { + "title": "清除格式" + }, + "heading": { + "title": "正文与标题", + "p": "正文", + "h1": "标题 1", + "h2": "标题 2", + "h3": "标题 3", + "h4": "标题 4", + "h5": "标题 5", + "h6": "标题 6" + }, + "fontfamily": { + "title": "字体", + "notInstalled": "可能未安装该字体", + "items": { + "default": "默认", + "arial": "Arial", + "comicSansMS": "Comic Sans MS", + "courierNew": "Courier New", + "georgia": "Georgia", + "helvetica": "Helvetica", + "impact": "Impact", + "timesNewRoman": "Times New Roman", + "trebuchetMS": "Trebuchet MS", + "verdana": "Verdana", + "fangSong": "仿宋", + "stFangsong": "华文仿宋", + "stSong": "华文宋体", + "stKaiti": "华文楷体", + "simSun": "宋体", + "microsoftYaHei": "微软雅黑", + "kaiTi": "楷体", + "kaitiSC": "楷体-简", + "simHei": "黑体", + "heitiSC": "黑体-简", + "fzHei": "方正黑体", + "fzKai": "方正楷体", + "fzFangSong": "方正仿宋" + } + }, + "fontsize": { + "title": "字号" + }, + "fontcolor": { + "title": "字体颜色", + "more": "更多颜色" + }, + "backcolor": { + "title": "背景颜色", + "more": "更多颜色" + }, + "bold": { + "title": "粗体" + }, + "italic": { + "title": "斜体" + }, + "strikethrough": { + "title": "删除线" + }, + "underline": { + "title": "下划线" + }, + "moremark": { + "title": "更多文本样式", + "sup": "上标", + "sub": "下标", + "code": "行内代码" + }, + "alignment": { + "title": "对齐方式", + "left": "左对齐", + "center": "居中对齐", + "right": "右对齐", + "justify": "两端对齐" + }, + "unorderedlist": { + "title": "无序列表" + }, + "orderedlist": { + "title": "有序列表" + }, + "tasklist": { + "title": "任务列表" + }, + "indent": { + "title": "缩进", + "in": "增加缩进", + "out": "减少缩进" + }, + "line-height": { + "title": "行高", + "default": "默认" + }, + "link": { + "title": "链接" + }, + "quote": { + "title": "插入引用" + }, + "hr": { + "title": "插入分割线" + }, + "colorPicker": { + "defaultText": "默认", + "nonFillText": "无填充色", + "#000000": "黑色", + "#262626": "深灰 3", + "#595959": "深灰 2", + "#8C8C8C": "深灰 1", + "#BFBFBF": "灰色", + "#D9D9D9": "浅灰 4", + "#E9E9E9": "浅灰 3", + "#F5F5F5": "浅灰 2", + "#FAFAFA": "浅灰 1", + "#FFFFFF": "白色", + "#F5222D": "红色", + "#FA541C": "朱红", + "#FA8C16": "橙色", + "#FADB14": "黄色", + "#52C41A": "绿色", + "#13C2C2": "青色", + "#1890FF": "浅蓝", + "#2F54EB": "蓝色", + "#722ED1": "紫色", + "#EB2F96": "玫红", + "#FFE8E6": "红色 1", + "#FFECE0": "朱红 1", + "#FFEFD1": "橙色 1", + "#FCFCCA": "黄色 1", + "#E4F7D2": "绿色 1", + "#D3F5F0": "青色 1", + "#D4EEFC": "浅蓝 1", + "#DEE8FC": "蓝色 1", + "#EFE1FA": "紫色 1", + "#FAE1EB": "玫红 1", + "#FFA39E": "红色 2", + "#FFBB96": "朱红 2", + "#FFD591": "橙色 2", + "#FFFB8F": "黄色 2", + "#B7EB8F": "绿色 2", + "#87E8DE": "青色 2", + "#91D5FF": "浅蓝 2", + "#ADC6FF": "蓝色 2", + "#D3ADF7": "紫色 2", + "#FFADD2": "玫红 2", + "#FF4D4F": "红色 3", + "#FF7A45": "朱红 3", + "#FFA940": "橙色 3", + "#FFEC3D": "黄色 3", + "#73D13D": "绿色 3", + "#36CFC9": "青色 3", + "#40A9FF": "浅蓝 3", + "#597EF7": "蓝色 3", + "#9254DE": "紫色 3", + "#F759AB": "玫红 3", + "#CF1322": "红色 4", + "#D4380D": "朱红 4", + "#D46B08": "橙色 4", + "#D4B106": "黄色 4", + "#389E0D": "绿色 4", + "#08979C": "青色 4", + "#096DD9": "浅蓝 4", + "#1D39C4": "蓝色 4", + "#531DAB": "紫色 4", + "#C41D7F": "玫红 4", + "#820014": "红色 5", + "#871400": "朱红 5", + "#873800": "橙色 5", + "#614700": "黄色 5", + "#135200": "绿色 5", + "#00474F": "青色 5", + "#003A8C": "浅蓝 5", + "#061178": "蓝色 5", + "#22075E": "紫色 5", + "#780650": "玫红 5" + }, + "component": { + "placeholder": "卡片名称" + }, + "image": { + "title": "图片" + }, + "codeblock": { + "title": "代码块" + }, + "table": { + "title": "表格" + }, + "file": { + "title": "附件" + }, + "video": { + "title": "视频" + }, + "math": { + "title": "公式" + }, + "status": { + "title": "状态" + }, + "mind": { + "title": "脑图" + }, + "commonlyUsed": { + "title": "常用" + } + }, + "status": { + "defaultValue": "设置状态" + }, + "mention": { + "placeholder": "用户名" + } +} diff --git a/ot-server/.gitignore b/ot-server/.gitignore new file mode 100644 index 00000000..a659b8a4 --- /dev/null +++ b/ot-server/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/npm-debug.log* +/yarn-error.log +/yarn.lock +/package-lock.json + +# production +/app/public +/docs-dist +/logs +/run +/config/prod.json + +# misc +.DS_Store +.idea +# umi +.umi +.umi-production +.umi-test +.env.local + +# log +*.log \ No newline at end of file diff --git a/ot-server/README.md b/ot-server/README.md new file mode 100644 index 00000000..22c9b09a --- /dev/null +++ b/ot-server/README.md @@ -0,0 +1,14 @@ +# am-ot-server + +## Install + +```sh +$ yarn +``` + +## Usage + +```sh +$ yarn start +$ open http://localhost:8080/ +``` diff --git a/ot-server/config/dev.json b/ot-server/config/dev.json new file mode 100644 index 00000000..8ea737a4 --- /dev/null +++ b/ot-server/config/dev.json @@ -0,0 +1,8 @@ +{ + "mongodb": { + "user": "yanmao", + "pwd": "yanmao123456", + "db": "yanmao", + "url": "localhost:27017" + } +} diff --git a/ot-server/nodemon.json b/ot-server/nodemon.json new file mode 100644 index 00000000..20a88d7f --- /dev/null +++ b/ot-server/nodemon.json @@ -0,0 +1,4 @@ +{ + "ignore": [".git", "node_modules/**/node_modules"], + "watch": ["./src", "./config"] +} diff --git a/ot-server/package.json b/ot-server/package.json new file mode 100755 index 00000000..6529662d --- /dev/null +++ b/ot-server/package.json @@ -0,0 +1,30 @@ +{ + "name": "am-ot-server", + "version": "1.0.0", + "description": "AoMao编辑器协作服务", + "private": true, + "scripts": { + "dev": "nodemon src/index.js", + "start": "cross-env NODE_ENV=production nodemon src/index.js" + }, + "engines": { + "node": ">=10.0.0" + }, + "dependencies": { + "@teamwork/websocket-json-stream": "^2.0.0", + "cross-env": "^7.0.3", + "express": "^4.17.1", + "nodemon": "^2.0.12", + "sharedb": "^1.9.2", + "sharedb-mongo": "^1.0.0-beta.20", + "url": "^0.11.0", + "uuid": "^8.3.2", + "ws": "^7.4.2" + }, + "repository": { + "type": "git", + "url": "" + }, + "author": "me@yanmao.cc", + "license": "MIT" +} diff --git a/ot-server/src/client.js b/ot-server/src/client.js new file mode 100644 index 00000000..4359d46d --- /dev/null +++ b/ot-server/src/client.js @@ -0,0 +1,143 @@ +const { NODE_ENV } = process.env; +const WebSocketJSONStream = require('@teamwork/websocket-json-stream'); +const MongoClient = require('mongodb').MongoClient; + +const fs = require('fs'); +const { join } = require('path'); +const isDev = NODE_ENV !== 'production'; +const configPath = join(__dirname, `../config/${isDev ? 'dev' : 'prod'}.json`); +const configString = fs.readFileSync(`${configPath}`, 'utf-8'); +let config = {}; +try { + config = JSON.parse(configString).mongodb; +} catch (error) { + console.log(error); +} + +const { user, pwd, db, url } = config; +const mongodb = require('sharedb-mongo')({ + mongo: function (callback) { + let connectUrl = user + ? `mongodb://${user}:${pwd}@${url}` + : `mongodb://${url}`; + if (db) connectUrl += `/${db}`; + MongoClient.connect(connectUrl, callback); + }, +}); +const ShareDB = require('sharedb'); +const { v3 } = require('uuid'); +const Doc = require('./doc'); + +class Client { + constructor(backend = new ShareDB({ db: mongodb })) { + this.docs = []; + this.timeouts = {}; + this.backend = backend; + this.handleMessage(); + } + + handleMessage() { + try { + // 中间件处理 action 消息 + this.backend.use('receive', (context, next) => { + const { action, data } = context.data || {}; + // 自定义消息 + if (!!action) { + const { doc_id, uuid } = data; + const doc = this.getDoc(doc_id); + if (!doc) return; + //广播消息 + if (action === 'broadcast') { + doc.broadcast('broadcast', data); + } + //心跳检测 + else if (action === 'heartbeat') { + const key = `${doc_id}-${uuid}`; + const timeout = this.timeouts[key]; + if (timeout) clearTimeout(timeout); + this.timeouts[key] = setTimeout(() => { + doc.removeMember(uuid); + }, 300000); + doc.sendMessage( + uuid, + 'heartbeat', + new Date().getTime(), + ); + } + return; + } + // sharedb消息 + try { + next(); + } catch (error) { + console.error(error); + } + }); + } catch (error) { + console.error(error); + } + } + + getDoc(docId) { + return this.docs.find((doc) => doc.id === docId); + } + + getUUID(docId, id) { + return v3(docId.toString().concat('/' + id), v3.URL); + } + + hasUUID(docId, uuid) { + const doc = this.getDoc(docId); + if (!doc) return false; + return doc.hasMember(uuid); + } + + listen(ws) { + try { + // 建立协作 socket 连接 + const stream = new WebSocketJSONStream(ws); + stream.on('error', (error) => { + console.log(error); + }); + // 监听消息 + this.backend.listen(stream); + } catch (error) { + console.log(error); + } + } + + add(ws, docId, member) { + if (!member.uuid) { + member.uuid = this.getUUID(docId, member.id); + } + + const doc = + this.getDoc(docId) || + new Doc(docId, () => { + //注销 + const index = this.docs.findIndex((doc) => doc.id === docId); + if (index > -1) { + doc.doc.destroy(); + this.docs.splice(index, 1); + } + }); + // 如果用户之前有连接到,那么就会移除之前的连接 + // doc.removeMember(member.uuid); + //创建获取文档实例 + const reuslt = doc.create(this.backend.connect(), 'yanmao', () => { + doc.addMember(ws, member); + if (!this.getDoc(docId)) this.docs.push(doc); + }); + + if (!reuslt) { + try { + ws.close(); + } catch (error) { + console.log(error); + } + return; + } + } +} + +module.exports = Client; diff --git a/ot-server/src/doc.js b/ot-server/src/doc.js new file mode 100644 index 00000000..b86054fb --- /dev/null +++ b/ot-server/src/doc.js @@ -0,0 +1,110 @@ +class Doc { + constructor(id, destroy = function () {}) { + this.id = id.toString(); + this.members = []; + this.sockets = {}; + this.destroy = destroy; + this.indexCount = 0; + this.doc = undefined; + } + + create(connection, collectionName = 'yanmao', callback = function () {}) { + try { + const doc = connection.get(collectionName, this.id); + doc.fetch((err) => { + if (err) { + console.error(err); + return; + } + if (!doc.type) { + doc.create([], callback); + return; + } + callback(true); + }); + this.doc = doc; + return doc; + } catch (error) { + console.error(error); + return null; + } + } + + getMembers() { + return this.members; + } + + broadcast(action, message, callback) { + this.members.forEach((member) => { + if (!callback || callback(member) !== false) + this.sendMessage(member.uuid, action, message); + }); + } + + sendMessage(uuid, action, message, callback = function () {}) { + const member = this.members.find((member) => member.uuid === uuid); + const socket = this.sockets[uuid]; + if (member && socket) { + try { + socket.send( + JSON.stringify({ + action, + data: message, + }), + callback, + ); + } catch (error) { + console.error(err); + } + } + } + + hasMember(uuid) { + const member = this.members.find((member) => member.uuid === uuid); + return !!member && !!this.sockets[uuid]; + } + + removeMember(uuid) { + try { + if (this.hasMember(uuid)) { + this.sockets[uuid].close(); + delete this.sockets[uuid]; + } + } catch (error) { + console.log(error); + } + } + + addMember(ws, member) { + this.indexCount++; + // 设置用户序号 + member.index = this.indexCount; + this.members.push(member); + this.sockets[member.uuid] = ws; + //连接关闭 + try { + ws.on('close', () => { + const index = this.members.findIndex( + (m) => m.uuid === member.uuid, + ); + if (index > -1) { + const leaveMember = this.members[index]; + this.members.splice(index, 1); + this.broadcast('leave', leaveMember); + delete this.sockets[member.uuid]; + } + if (Object.keys(this.sockets).length === 0) this.destroy(); + }); + } catch (error) { + console.error(error); + } + // 广播通知用户加入了 + this.broadcast('join', member, (m) => m.uuid !== member.uuid); + // 通知用户当前文档的所有用户 + this.sendMessage(member.uuid, 'members', this.members); + // 通知用户准备好了 + this.sendMessage(member.uuid, 'ready', member); + } +} + +module.exports = Doc; diff --git a/ot-server/src/index.js b/ot-server/src/index.js new file mode 100644 index 00000000..22a9365b --- /dev/null +++ b/ot-server/src/index.js @@ -0,0 +1,67 @@ +const http = require('http'); +const url = require('url'); +const express = require('express'); +const WebSocket = require('ws'); +const bodyParser = require('body-parser'); +const queryString = require('querystring'); +const Client = require('./client'); + +startServer(); + +const getParams = (request) => { + return queryString.parse(url.parse(request.url).query); +}; + +function startServer() { + // Create a web server to serve files and listen to WebSocket connections + const app = express(); + + app.use(bodyParser.json({ limit: '10mb' })); + app.use( + bodyParser.urlencoded({ + extended: false, + }), + ); + + const server = http.createServer(app); + try { + var wss = new WebSocket.Server({ server }); + const client = new Client(); + let id = 1; + const getId = (docId, userId) => { + let uuid = client.getUUID(docId, userId); + while (client.hasUUID(docId, uuid)) { + id++; + userId = id; + console.log('userId', userId); + uuid = client.getUUID(docId, userId); + } + return userId; + }; + wss.on('connection', function (ws, request) { + //用户连接到 socket,此处应根据request获取到相关参数,并且处理用户token,传递到api,效验数据合法性 + //此处为模拟演示数据 + const params = getParams(request); + let { uid } = params; + if (!uid) uid = id; + client.listen(ws); + ws.addEventListener('message', (event) => { + try { + if (!event.data) return; + const { data, action } = JSON.parse(event.data); + if (action === 'ready') { + client.add(ws, data.doc_id, { + id: getId(data.doc_id, uid), + name: `Guest-${uid}`, + }); + } + } catch (error) {} + }); + if (!params.uid) id++; + }); + } catch (error) { + console.log(error); + } + server.listen(8080); + console.log('OT Server Listening on http://localhost:8080'); +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..01d62b28 --- /dev/null +++ b/package.json @@ -0,0 +1,59 @@ +{ + "name": "am-editor", + "private": true, + "license": "MIT", + "workspaces": [ + "packages/*", + "plugins/*" + ], + "scripts": { + "start": "dumi dev", + "ssr": "cd site-ssr && yarn dev", + "docs:build": "dumi build", + "docs:deploy": "gh-pages -d docs-dist", + "build": "node ./scripts/build", + "deploy": "npm run docs:build && npm run docs:deploy", + "release": "npm run build && npm publish", + "prettier": "prettier --write \"**/*.{js,jsx,tsx,ts,less,md,json}\"", + "test": "umi-test", + "test:coverage": "umi-test --coverage" + }, + "main": "dist/index.js", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "author": "me@yanmao.cc", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "gitHooks": { + "pre-commit": "lint-staged" + }, + "lint-staged": { + "*.{js,jsx,less,md,json}": [ + "prettier --write" + ], + "*.ts?(x)": [ + "prettier --parser=typescript --write" + ] + }, + "devDependencies": { + "@types/sharedb": "^1.0.14", + "@umijs/test": "^3.0.5", + "antd": "^4.15.5", + "dumi": "^1.1.17", + "father-build": "^1.19.4", + "gh-pages": "^3.0.0", + "lerna": "^3.22.1", + "lint-staged": "^10.0.7", + "prettier": "2.3.1", + "reconnecting-websocket": "^4.4.0", + "rollup-plugin-vue": "^6.0.0", + "sharedb": "^1.9.2", + "yorkie": "^2.0.0" + } +} diff --git a/packages/engine/README.md b/packages/engine/README.md new file mode 100644 index 00000000..492885d0 --- /dev/null +++ b/packages/engine/README.md @@ -0,0 +1,332 @@ +# am-editor + +

+ 一个富文本协同编辑器框架,可以使用ReactVue自定义插件 +

+ +

+ English · + Demo · + 文档 · + 插件 · + QQ群 907664876 · +

+ +![aomao-preview](https://user-images.githubusercontent.com/55792257/125074830-62d79300-e0f0-11eb-8d0f-bb96a7775568.png) + +

+ + + + + + + + + + + + + + + +

+ +`广告`:[科学上网,方便、快捷的上网冲浪](https://xiyou4you.us/r/?s=18517120) 稳定、可靠,访问 Github 或者其它外网资源很方便。 + +使用浏览器提供的 `contenteditable` 属性让一个 DOM 节点具有可编辑能力。 + +引擎接管了浏览器大部分光标、事件等默认行为。 + +可编辑器区域内的节点通过 `schema` 规则,制定了 `mark` `inline` `block` `card` 4 种组合节点,他们由不同的属性、样式或 `html` 结构组成,并对它们的嵌套进行了一定的约束。 + +通过 `MutationObserver` 监听编辑区域内的 `DOM` 树的改变,并生成 `json0` 类型的数据格式与 [ShareDB](https://github.com/share/sharedb) 库进行交互,从而达到协同编辑的需要。 + +**`Vue2`** 案例 [https://github.com/zb201307/am-editor-vue2](https://github.com/zb201307/am-editor-vue2) + +**`Vue3`** 案例 [https://github.com/yanmao-cc/am-editor/tree/master/examples/vue](https://github.com/yanmao-cc/am-editor/tree/master/examples/vue) + +**`React`** 案例 [https://github.com/yanmao-cc/am-editor/tree/master/examples/react](https://github.com/yanmao-cc/am-editor/tree/master/examples/react) + +## 特性 + +- 开箱即用,提供几十种丰富的插件来满足大部分需求 +- 高扩展性,除了 `mark` `inline` `block` 类型基础插件外,我们还提供 `card` 组件结合`React` `Vue`等前端库渲染插件 UI +- 丰富的多媒体支持,不仅支持图片和音视频,更支持插入嵌入式多媒体内容 +- 支持 Markdown 语法 +- 支持国际化 +- 引擎纯 JavaScript 编写,不依赖任何前端库,插件可以使用 `React` `Vue` 等前端库渲染。复杂架构轻松应对 +- 内置协同编辑方案,轻量配置即可使用 +- 兼容大部分最新移动端浏览器 + +## 插件 + +| **包** | **版本** | **大小** | **描述** | +| :------------------------------------------------------- | -----------------------------------------------------------------------------------------------------------------------------------------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------- | +| [`@aomao/toolbar`](./packages/toolbar) | [![](https://img.shields.io/npm/v/@aomao/toolbar.svg?maxAge=3600&label=&colorB=007ec6)](./packages/toolbar/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/toolbar/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/toolbar/dist/index.js) | 工具栏, 适用于 `React` | +| [`@aomao/toolbar-vue`](./packages/toolbar-vue) | [![](https://img.shields.io/npm/v/@aomao/toolbar-vue.svg?maxAge=3600&label=&colorB=007ec6)](./packages/toolbar-vue/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/toolbar-vue/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/toolbar-vue/dist/index.js) | 工具栏, 适用于 `Vue3` | +| [`@aomao/plugin-alignment`](./plugins/alignment) | [![](https://img.shields.io/npm/v/@aomao/plugin-alignment.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/alignment/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-alignment/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-alignment/dist/index.js) | 对齐方式 | +| [`@aomao/plugin-backcolor`](./plugins/backcolor) | [![](https://img.shields.io/npm/v/@aomao/plugin-backcolor.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/backcolor/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-backcolor/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-backcolor/dist/index.js) | 背景色 | +| [`@aomao/plugin-bold`](./plugins/bold) | [![](https://img.shields.io/npm/v/@aomao/plugin-bold.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/bold/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-bold/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-bold/dist/index.js) | 加粗 | +| [`@aomao/plugin-code`](./plugins/code) | [![](https://img.shields.io/npm/v/@aomao/plugin-code.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/code/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-code/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-code/dist/index.js) | 行内代码 | +| [`@aomao/plugin-codeblock`](./plugins/codeblock) | [![](https://img.shields.io/npm/v/@aomao/plugin-codeblock.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/codeblock/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-codeblock/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-codeblock/dist/index.js) | 代码块, 适用于 `React` | +| [`@aomao/plugin-codeblock-vue`](./plugins/codeblock-vue) | [![](https://img.shields.io/npm/v/@aomao/plugin-codeblock-vue.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/codeblock-vue/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-codeblock-vue/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-codeblock-vue/dist/index.js) | 代码块, 适用于 `Vue3` | +| [`@aomao/plugin-fontcolor`](./plugins/fontcolor) | [![](https://img.shields.io/npm/v/@aomao/plugin-fontcolor.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/fontcolor/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-fontcolor/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-fontcolor/dist/index.js) | 前景色 | +| [`@aomao/plugin-fontfamily`](./plugins/fontfamily) | [![](https://img.shields.io/npm/v/@aomao/plugin-fontfamily.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/fontfamily/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-fontfamily/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-fontfamily/dist/index.js) | 字体 | +| [`@aomao/plugin-fontsize`](./plugins/fontsize) | [![](https://img.shields.io/npm/v/@aomao/plugin-fontsize.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/fontsize/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-fontsize/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-fontsize/dist/index.js) | 字体大小 | +| [`@aomao/plugin-heading`](./plugins/heading) | [![](https://img.shields.io/npm/v/@aomao/plugin-heading.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/heading/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-heading/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-heading/dist/index.js) | 标题 | +| [`@aomao/plugin-hr`](./plugins/hr) | [![](https://img.shields.io/npm/v/@aomao/plugin-hr.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/hr/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-hr/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-hr/dist/index.js) | 分割线 | +| [`@aomao/plugin-indent`](./plugins/indent) | [![](https://img.shields.io/npm/v/@aomao/plugin-indent.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/indent/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-indent/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-indent/dist/index.js) | 缩进 | +| [`@aomao/plugin-italic`](./plugins/italic) | [![](https://img.shields.io/npm/v/@aomao/plugin-italic.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/italic/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-italic/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-italic/dist/index.js) | 斜体 | +| [`@aomao/plugin-link`](./plugins/link) | [![](https://img.shields.io/npm/v/@aomao/plugin-link.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/link/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-link/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-link/dist/index.js) | 链接, 适用于 `React` | +| [`@aomao/plugin-link-vue`](./plugins/link-vue) | [![](https://img.shields.io/npm/v/@aomao/plugin-link-vue.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/link-vue/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-link-vue/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-link-vue/dist/index.js) | 链接, 适用于 `Vue3` | +| [`@aomao/plugin-line-height`](./plugins/line-height) | [![](https://img.shields.io/npm/v/@aomao/plugin-line-height.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/line-height/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-line-height/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-line-height/dist/index.js) | 行高 | +| [`@aomao/plugin-mark`](./plugins/mark) | [![](https://img.shields.io/npm/v/@aomao/plugin-mark.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/mark/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-mark/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-mark/dist/index.js) | 标记 | +| [`@aomao/plugin-mention`](./plugins/mention) | [![](https://img.shields.io/npm/v/@aomao/plugin-mention.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/mention/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-mention/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-mention/dist/index.js) | 提及 | +| [`@aomao/plugin-orderedlist`](./plugins/orderedlist) | [![](https://img.shields.io/npm/v/@aomao/plugin-orderedlist.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/orderedlist/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-orderedlist/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-orderedlist/dist/index.js) | 有序列表 | +| [`@aomao/plugin-paintformat`](./plugins/paintformat) | [![](https://img.shields.io/npm/v/@aomao/plugin-paintformat.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/paintformat/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-paintformat/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-paintformat/dist/index.js) | 格式刷 | +| [`@aomao/plugin-quote`](./plugins/quote) | [![](https://img.shields.io/npm/v/@aomao/plugin-quote.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/quote/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-quote/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-quote/dist/index.js) | 引用块 | +| [`@aomao/plugin-redo`](./plugins/redo) | [![](https://img.shields.io/npm/v/@aomao/plugin-redo.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/redo/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-redo/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-redo/dist/index.js) | 重做 | +| [`@aomao/plugin-removeformat`](./plugins/removeformat) | [![](https://img.shields.io/npm/v/@aomao/plugin-removeformat.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/removeformat/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-removeformat/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-removeformat/dist/index.js) | 移除样式 | +| [`@aomao/plugin-selectall`](./plugins/selectall) | [![](https://img.shields.io/npm/v/@aomao/plugin-selectall.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/selectall/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-selectall/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-selectall/dist/index.js) | 全选 | +| [`@aomao/plugin-status`](./plugins/status) | [![](https://img.shields.io/npm/v/@aomao/plugin-status.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/status/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-status/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-status/dist/index.js) | 状态 | +| [`@aomao/plugin-strikethrough`](./plugins/strikethrough) | [![](https://img.shields.io/npm/v/@aomao/plugin-strikethrough.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/strikethrough/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-strikethrough/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-strikethrough/dist/index.js) | 删除线 | +| [`@aomao/plugin-sub`](./plugins/sub) | [![](https://img.shields.io/npm/v/@aomao/plugin-sub.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/sub/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-sub/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-sub/dist/index.js) | 下标 | +| [`@aomao/plugin-sup`](./plugins/sup) | [![](https://img.shields.io/npm/v/@aomao/plugin-sup.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/sup/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-sup/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-sup/dist/index.js) | 上标 | +| [`@aomao/plugin-tasklist`](./plugins/tasklist) | [![](https://img.shields.io/npm/v/@aomao/plugin-tasklist.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/tasklist/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-tasklist/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-tasklist/dist/index.js) | 任务列表 | +| [`@aomao/plugin-underline`](./plugins/underline) | [![](https://img.shields.io/npm/v/@aomao/plugin-underline.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/underline/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-underline/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-underline/dist/index.js) | 下划线 | +| [`@aomao/plugin-undo`](./plugins/undo) | [![](https://img.shields.io/npm/v/@aomao/plugin-undo.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/undo/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-undo/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-undo/dist/index.js) | 撤销 | +| [`@aomao/plugin-unorderedlist`](./plugins/unorderedlist) | [![](https://img.shields.io/npm/v/@aomao/plugin-unorderedlist.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/unorderedlist/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-unorderedlist/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-unorderedlist/dist/index.js) | 无序列表 | +| [`@aomao/plugin-image`](./plugins/image) | [![](https://img.shields.io/npm/v/@aomao/plugin-image.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/image/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-image/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-image/dist/index.js) | 图片 | +| [`@aomao/plugin-table`](./plugins/table) | [![](https://img.shields.io/npm/v/@aomao/plugin-table.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/table/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-table/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-table/dist/index.js) | 表格 | +| [`@aomao/plugin-file`](./plugins/file) | [![](https://img.shields.io/npm/v/@aomao/plugin-file.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/file/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-file/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-file/dist/index.js) | 文件 | +| [`@aomao/plugin-mark-range`](./plugins/mark-range) | [![](https://img.shields.io/npm/v/@aomao/plugin-mark-range.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/mark-range/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-mark-range/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-mark-range/dist/index.js) | 标记光标, 例如: 批注. | +| [`@aomao/plugin-math`](./plugins/math) | [![](https://img.shields.io/npm/v/@aomao/plugin-math.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/math/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-math/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-math/dist/index.js) | 数学公式 | +| [`@aomao/plugin-video`](./plugins/video) | [![](https://img.shields.io/npm/v/@aomao/plugin-video.svg?maxAge=3600&label=&colorB=007ec6)](./plugins/video/package.json) | [![](http://img.badgesize.io/https://unpkg.com/@aomao/plugin-video/dist/index.js?compression=gzip&label=%20)](https://unpkg.com/@aomao/plugin-video/dist/index.js) | 视频 | + +## 快速上手 + +### 安装 + +编辑器由 `引擎`、`工具栏`、`插件` 组成。`引擎` 为我们提供了核心的编辑能力。 + +使用 npm 或者 yarn 安装引擎包 + +```bash +$ npm install @aomao/engine +# or +$ yarn add @aomao/engine +``` + +### 使用 + +我们按照惯例先输出一个`Hello word!` + +```tsx +import React, { useEffect, useRef, useState } from 'react'; +import Engine, { EngineInterface } from '@aomao/engine'; + +const EngineDemo = () => { + //编辑器容器 + const ref = useRef(null); + //引擎实例 + const [engine, setEngine] = useState(); + //编辑器内容 + const [content, setContent] = useState('

Hello word!

'); + + useEffect(() => { + if (!ref.current) return; + //实例化引擎 + const engine = new Engine(ref.current); + //设置编辑器值 + engine.setValue(content); + //监听编辑器值改变事件 + engine.on('change', (value) => { + setContent(value); + console.log(`value:${value}`); + }); + //设置引擎实例 + setEngine(engine); + }, []); + + return
; +}; +export default EngineDemo; +``` + +### 插件 + +引入 `@aomao/plugin-bold` 加粗插件 + +```tsx +import Bold from '@aomao/plugin-bold'; +``` + +把 `Bold` 插件加入引擎 + +```tsx +//实例化引擎 +const engine = new Engine(ref.current, { + plugins: [Bold], +}); +``` + +### 卡片 + +卡片是编辑器中单独划分的一个区域,其 UI 以及逻辑在卡片内部可以使用 React、Vue 或其它前端库自定义渲染内容,最后再挂载到编辑器上。 + +引入 `@aomao/plugin-codeblock` 代码块插件,这个插件的 `语言下拉框` 使用 `React` 渲染,所以有区分。 `Vue3` 使用 `@aomao/plugin-codeblock-vue` + +```tsx +import CodeBlock, { CodeBlockComponent } from '@aomao/plugin-codeblock'; +``` + +把 `CodeBlock` 插件和 `CodeBlockComponent` 卡片组件加入引擎 + +```tsx +//实例化引擎 +const engine = new Engine(ref.current, { + plugins: [CodeBlock], + cards: [CodeBlockComponent], +}); +``` + +`CodeBlock` 插件默认支持 `markdown`,在编辑器一行开头位置输入代码块语法` ```javascript ` 回车后即可触发。 + +### 工具栏 + +引入 `@aomao/toolbar` 工具栏,工具栏由于交互复杂,基本上都是使用 `React` + `Antd` UI 组件渲染,`Vue3` 使用 `@aomao/toolbar-vue` + +工具栏除了 UI 交互外,大部分工作只是对不同的按钮事件触发后调用了引擎执行对应的插件命令,在需求比较复杂或需要重新定制 UI 的情况下,Fork 后修改起来也比较容易。 + +```tsx +import Toolbar, { ToolbarPlugin, ToolbarComponent } from '@aomao/toolbar'; +``` + +把 `ToolbarPlugin` 插件和 `ToolbarComponent` 卡片组件加入引擎,它可以让我们在编辑器中可以使用快捷键 `/` 唤醒出卡片工具栏 + +```tsx +//实例化引擎 +const engine = new Engine(ref.current, { + plugins: [ToolbarPlugin], + cards: [ToolbarComponent], +}); +``` + +渲染工具栏,工具栏已配置好所有插件,这里我们只需要传入插件名称即可 + +```tsx +return ( + ... + { + engine && ( + + ) + } + ... +) +``` + +更复杂的工具栏配置请查看文档 [https://editor.yanmao.cc/zh-CN/config/toolbar](https://editor.yanmao.cc/zh-CN/config/toolbar) + +### 协同编辑 + +协同编辑基于 [ShareDB](https://github.com/share/sharedb) 开源库实现,比较陌生的朋友可以先了解它。 + +#### 交互模式 + +每位编辑者作为 [客户端](https://github.com/yanmao-cc/am-editor/tree/master/examples/react/components/editor/ot/client.ts) 通过 `WebSocket` 与 [服务端](https://github.com/yanmao-cc/am-editor/tree/master/ot-server) 通信交换由编辑器生成的 `json0` 格式的数据。 + +服务端会保留一份 `json` 格式的 `html` 结构数据,接收到来自客户端的指令后,再去修改这份数据,最后再转发到每个客户端。 + +在启用协同编辑前,我们需要配置好 [客户端](https://github.com/yanmao-cc/am-editor/tree/master/examples/react/components/editor/ot/client.ts) 和 [服务端](https://github.com/yanmao-cc/am-editor/tree/master/ot-server) + +服务端是 `NodeJs` 环境,使用 `express` + `WebSocket` 搭建的网络服务。 + +#### 案例 + +案例中我们已经一份比较基础的客户端代码 + +[查看 React 完整案例](https://github.com/yanmao-cc/am-editor/tree/master/examples/react) + +[查看 Vue3 完整案例](https://github.com/yanmao-cc/am-editor/tree/master/examples/vue) + +[查看 Vue2 完整案例](https://github.com/zb201307/am-editor-vue2) + +```tsx +//实例化协作编辑客户端,传入当前编辑器引擎实例 +const otClient = new OTClient(engine); +//连接到协作服务端,`demo` 与服务端文档ID相同 +otClient.connect( + `ws://127.0.0.1:8080${currentMember ? '?uid=' + currentMember.id : ''}`, + 'demo', +); +``` + +### 项目图标 + +[Iconfont](https://at.alicdn.com/t/project/1456030/0cbd04d3-3ca1-4898-b345-e0a9150fcc80.html?spm=a313x.7781069.1998910419.35) + +## 开发 + +### React + +需要在 `am-editor 根目录` `site-ssr` `ot-server` 中分别安装依赖 + +```base +//依赖安装好后,只需要在根目录执行以下命令 + +yarn ssr +``` + +- `packages` 引擎和工具栏 +- `plugins` 所有的插件 +- `site-ssr` 所有的后端 API 和 SSR 配置。使用的 egg 。在 am-editor 根目录下使用 yarn ssr 自动启动 `site-ssr` +- `ot-server` 协同服务端。启动:yarn start + +启动后访问 localhost:7001 + +### Vue3 + +只需要进入 examples/vue 目录安装依赖 + +```base +//依赖安装好后,在 examples/vue 目录执行以下命令 + +yarn serve +``` + +在 Vue 运行环境中,默认是安装的已发布到 npm 上的代码。如果需要修改引擎或者插件的代码后立即看到效果,我们需要做以下步骤: + +- 删除 examples/vue/node_modules/@aomao 文件夹 +- 删除 examples/vue/node_modules/vue 文件夹。因为有插件依赖了 Vue,所以 Vue 的包会在项目根目录中安装。如果不删除 examples/vue 中的 Vue 包,和插件的 Vue 包不在一个环境中,就无法加载插件 +- 在 am-editor 根目录下执行安装所有依赖命令,例如:`yarn` +- 最后在 examples/vue 中重新启动 + +`Vue` 案例中没有配置任何后端 API,具体可以参考 `React` 和 `site-ssr` + +## 贡献 + +感谢 [pleasedmi](https://github.com/pleasedmi)、[Elena211314](https://github.com/Elena211314) 的捐赠 + +如果您愿意,可以在这里留下你的名字。 + +### 支付宝 + +![alipay](https://cdn-object.yanmao.cc/contribution/alipay.png?x-oss-process=image/resize,w_200) + +### 微信支付 + +![wechat](https://cdn-object.yanmao.cc/contribution/weichat.png?x-oss-process=image/resize,w_200) + +### PayPal + +[https://paypal.me/aomaocom](https://paypal.me/aomaocom) diff --git a/packages/engine/package.json b/packages/engine/package.json new file mode 100644 index 00000000..fa867d0c --- /dev/null +++ b/packages/engine/package.json @@ -0,0 +1,43 @@ +{ + "name": "@aomao/engine", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@babel/runtime": "^7.13.10", + "blueimp-md5": "^2.18.0", + "copy-to-clipboard": "^3.3.1", + "diff-match-patch": "^1.0.5", + "dom-align": "^1.12.2", + "eventemitter2": "^6.4.4", + "filesize": "^6.3.0", + "is-hotkey": "^0.2.0", + "lodash-es": "^4.17.21", + "ot-json0": "^1.1.0", + "tinycolor2": "^1.4.2" + }, + "devDependencies": { + "@types/blueimp-md5": "^2.7.0", + "@types/diff-match-patch": "^1.0.32", + "@types/is-hotkey": "^0.1.2", + "@types/lodash-es": "^4.17.4", + "@types/sharedb": "^1.0.14", + "@types/tinycolor2": "^1.4.2" + } +} diff --git a/packages/engine/src/@types/ot-json0.d.ts b/packages/engine/src/@types/ot-json0.d.ts new file mode 100644 index 00000000..048788dd --- /dev/null +++ b/packages/engine/src/@types/ot-json0.d.ts @@ -0,0 +1,16 @@ +declare module 'ot-json0' { + import { Op } from 'sharedb'; + + namespace OTJSON { + export let type: { + transform: ( + op: Op[], + otherOp: Op[], + type: 'left' | 'right', + ) => Op[]; + invert: (op: Op[]) => Op[]; + apply: (snapshot: any, op: Op[]) => any; + }; + } + export default OTJSON; +} diff --git a/packages/engine/src/block/index.ts b/packages/engine/src/block/index.ts new file mode 100644 index 00000000..b966de06 --- /dev/null +++ b/packages/engine/src/block/index.ts @@ -0,0 +1,1353 @@ +import { + CARD_KEY, + CARD_SELECTOR, + CURSOR, + CURSOR_SELECTOR, + DATA_ELEMENT, + DATA_ID, + READY_CARD_KEY, + ROOT_SELECTOR, +} from '../constants'; +import Range from '../range'; +import { + EditorInterface, + NodeInterface, + RangeInterface, + PluginEntry, +} from '../types'; +import { BlockInterface, BlockModelInterface } from '../types/block'; +import { getDocument, getWindow, isEngine } from '../utils'; +import { Backspace, Enter } from './typing'; +import { $ } from '../node'; +import { isBlockPlugin } from '../plugin'; +import { isNode } from '../node/utils'; + +class Block implements BlockModelInterface { + private editor: EditorInterface; + + constructor(editor: EditorInterface) { + this.editor = editor; + } + + init() { + const editor = this.editor; + if (isEngine(editor)) { + const { typing, event } = editor; + //绑定回车事件 + const enter = new Enter(editor); + typing + .getHandleListener('enter', 'keydown') + ?.on((event) => enter.trigger(event)); + //删除事件 + const backspace = new Backspace(editor); + typing + .getHandleListener('backspace', 'keydown') + ?.on((event) => backspace.trigger(event)); + + event.on('keydown:space', (event) => this.triggerMarkdown(event)); + } + } + + /** + * 解析markdown + * @param event 事件 + */ + triggerMarkdown(event: KeyboardEvent) { + const editor = this.editor; + if (!isEngine(editor)) return; + const { change, block } = editor; + let range = change.range.get(); + if (!range.collapsed || change.isComposing()) return; + const { startNode, startOffset } = range; + const node = + startNode.type === Node.TEXT_NODE + ? startNode + : startNode.children().eq(startOffset - 1); + if (!node) return; + const blockNode = this.closest(node); + if (!editor.node.isRootBlock(blockNode)) return; + const text = block.getLeftText(blockNode); + const cacheRange = range.toPath(); + const result = !Object.keys(editor.plugin.components).some( + (pluginName) => { + const plugin = editor.plugin.components[pluginName]; + if (isBlockPlugin(plugin) && !!plugin.markdown) { + const reuslt = plugin.markdown( + event, + text, + blockNode, + node, + ); + if (reuslt === false) return true; + } + return; + }, + ); + if (!result) change.rangePathBeforeCommand = cacheRange; + return result; + } + /** + * 根据节点查找block插件实例 + * @param node 节点 + */ + findPlugin(block: NodeInterface): BlockInterface | undefined { + const { node, schema, plugin } = this.editor; + if (!node.isBlock(block)) return; + let result: BlockInterface | undefined = undefined; + Object.keys(plugin.components).some((pluginName) => { + const blockPlugin = plugin.components[pluginName]; + if ( + isBlockPlugin(blockPlugin) && + (!blockPlugin.tagName || typeof blockPlugin.tagName === 'string' + ? block.name === blockPlugin.tagName + : blockPlugin.tagName.indexOf(block.name) > -1) + ) { + const schemaRule = blockPlugin.schema(); + if ( + !(Array.isArray(schemaRule) + ? schemaRule.find((rule) => + schema.checkNode(block, rule.attributes), + ) + : schema.checkNode(block, schemaRule.attributes)) + ) + return; + result = blockPlugin; + return true; + } + return; + }); + return result; + } + /** + * 查找Block节点的一级节点。如 div -> H2 返回 H2节点 + * @param parentNode 父节点 + * @param childNode 子节点 + */ + findTop(parentNode: NodeInterface, childNode: NodeInterface) { + const { schema, node, list } = this.editor; + const topParentName = schema.closest(parentNode.name); + const topChildName = schema.closest(childNode.name); + //如果父节点没有级别或者子节点没有级别就返回子节点 + if (topParentName === parent.name || topChildName === childNode.name) + return childNode; + //如果父节点的级别大于子节点的级别就返回父节点 + if (schema.isAllowIn(parentNode.name, childNode.name)) + return parentNode; + //如果父节点是 ul、ol 这样的List列表,并且子节点也是这样的列表,设置ident + if (node.isList(parentNode) && node.isList(childNode)) { + const childIndent = + parseInt(childNode.attributes(list.INDENT_KEY), 10) || 0; + const parentIndent = + parseInt(parentNode.attributes(list.INDENT_KEY), 10) || 0; + childNode.attributes( + list.INDENT_KEY, + parentIndent ? parentIndent + 1 : childIndent + 1, + ); + } + //默认返回子节点 + return childNode; + } + /** + * 获取最近的block节点,找不到返回 node + * @param node 节点 + */ + closest(node: NodeInterface) { + const originNode = node; + while (node) { + if (node.isEditable() || this.editor.node.isBlock(node)) { + return node; + } + const parentNode = node.parent(); + if (!parentNode) break; + node = parentNode; + } + return originNode; + } + /** + * 在光标位置包裹一个block节点 + * @param block 节点 + * @param range 光标 + */ + wrap(block: NodeInterface | Node | string, range?: RangeInterface) { + if (!isEngine(this.editor)) return; + const { change, node, schema, list, mark } = this.editor; + const safeRange = range || change.range.toTrusty(); + const doc = getDocument(safeRange.startContainer); + if (typeof block === 'string' || isNode(block)) { + block = $(block, doc); + } else block = block; + + if (!node.isBlock(block)) return; + + let blocks: Array = this.getBlocks(safeRange); + const targetPlugin = this.findPlugin(block); + //一样的block插件不嵌套 + blocks = blocks + .map((blockNode) => { + if (!blockNode || blockNode.isCard()) return null; + const wrapBlock = block as NodeInterface; + let blockParent = blockNode?.parent(); + while (blockParent && !blockParent.isEditable()) { + blockNode = blockParent; + const parent = blockParent.parent(); + if (parent && node.isBlock(parent)) { + blockParent = parent; + } else break; + } + //|| blockParent && !blockParent.equal(blockNode) && !blockParent.isRoot() && node.isBlock(blockParent) && !schema.isAllowIn(wrapBlock.name, blockParent.name) + if (!schema.isAllowIn(wrapBlock.name, blockNode.name)) { + //一样的插件,返回子级 + if (this.findPlugin(blockNode) === targetPlugin) { + return blockNode.children(); + } + return null; + } + return blockNode; + }) + .filter((block) => block !== null); + + // 不在段落内 + if (blocks.length === 0) { + const root = this.closest(safeRange.startNode); + if ( + root.isCard() || + root.isEditable() || + !schema.isAllowIn(block.name, root.name) + ) + return; + const selection = safeRange.createSelection(); + root.children().each((node) => { + (block as NodeInterface).append(node); + }); + root.append(block); + selection.move(); + return; + } + + const selection = safeRange.createSelection(); + blocks[0]?.before(block); + blocks.forEach((child) => { + if (child) { + //先移除不能放入块级节点的mark标签 + if (targetPlugin) { + child.allChildren().forEach((markNode) => { + if (node.isMark(markNode)) { + const markPlugin = mark.findPlugin(markNode); + if (!markPlugin) return; + if ( + targetPlugin.disableMark?.indexOf( + (markPlugin.constructor as PluginEntry) + .pluginName, + ) + ) { + node.unwrap(markNode); + } + } + }); + } + (block as NodeInterface).append(child); + } + }); + selection.move(); + this.merge(safeRange); + list.merge(undefined, safeRange); + if (!range) change.apply(safeRange); + } + /** + * 移除光标所在block节点包裹 + * @param block 节点 + * @param range 光标 + */ + unwrap(block: NodeInterface | Node | string, range?: RangeInterface) { + if (!isEngine(this.editor)) return; + const { change, node } = this.editor; + const safeRange = range || change.range.toTrusty(); + const doc = getDocument(safeRange.startContainer); + if (typeof block === 'string' || isNode(block)) { + block = $(block, doc); + } else block = block; + + if (!node.isBlock(block)) return; + const blocks = this.getSiblings(safeRange, block); + if (blocks.length === 0) { + return; + } + + const firstNodeParent = blocks[0].node.parent(); + if (!firstNodeParent?.inEditor()) { + return; + } + const hasLeft = blocks.some((item) => item.position === 'left'); + const hasRight = blocks.some((item) => item.position === 'right'); + let leftParent: NodeInterface | undefined = undefined; + + if (hasLeft) { + const parent = firstNodeParent; + leftParent = node.clone(parent, false); + parent.before(leftParent); + } + + let rightParent: NodeInterface | undefined = undefined; + if (hasRight) { + const _parent = blocks[blocks.length - 1].node.parent(); + if (_parent) { + rightParent = node.clone(_parent, false); + _parent?.after(rightParent); + } + } + + // 插入范围的开始和结束标记 + const selection = safeRange.createSelection(); + const nodeApi = node; + blocks.forEach((item) => { + const status = item.position, + node = item.node, + parent = node.parent(); + + if (status === 'left') { + leftParent?.append(node); + } + + if (status === 'center') { + if ( + parent?.name === (block as NodeInterface)?.name && + parent?.inEditor() + ) { + nodeApi.unwrap(parent); + } + } + + if (status === 'right') { + rightParent?.append(node); + } + }); + // 有序列表被从中间拆开后,剩余的两个部分的需要保持序号连续 + if ( + leftParent && + leftParent.name === 'ol' && + rightParent && + rightParent.name === 'ol' + ) { + rightParent.attributes( + 'start', + (parseInt(leftParent.attributes('start'), 10) || 1) + + leftParent.find('li').length, + ); + } + selection.move(); + if (!range) change.apply(safeRange); + } + + /** + * 获取节点相对于光标开始位置、结束位置下的兄弟节点集合 + * @param range 光标 + * @param block 节点 + */ + getSiblings(range: RangeInterface, block: NodeInterface) { + const blocks: Array<{ + node: NodeInterface; + position: 'left' | 'center' | 'right'; + }> = []; + const nodeApi = this.editor.node; + if (!nodeApi.isBlock(block)) return blocks; + const getTargetBlock = (node: NodeInterface, tagName: string) => { + let block = this.closest(node); + while (block) { + const parent = block.parent(); + if (!parent) break; + if (!block.inEditor()) break; + if (block.text().trim() !== parent.text().trim()) break; + if (parent.name === tagName) break; + block = parent; + } + + return block; + }; + const startBlock = getTargetBlock(range.startNode, block.name); + const endBlock = getTargetBlock(range.endNode, block.name); + const parentBlock = startBlock.parent(); + let position: 'left' | 'center' | 'right' = 'left'; + let node = parentBlock?.first(); + + while (node) { + node = $(node); + if (!nodeApi.isBlock(node)) return blocks; + + if (!node.inEditor()) return blocks; + + if (node[0] === startBlock[0]) { + position = 'center'; + } + + blocks.push({ + position, + node, + }); + + if (node[0] === endBlock[0]) { + position = 'right'; + } + node = node.next(); + } + return blocks; + } + /** + * 分割当前光标选中的block节点 + * @param range 光标 + */ + split(range?: RangeInterface) { + if (!isEngine(this.editor)) return; + const { change, mark, nodeId } = this.editor; + const safeRange = range || change.range.toTrusty(); + // 范围为展开状态时先删除内容 + if (!safeRange.collapsed) { + change.delete(safeRange); + } + // 获取上面第一个 Block + const block = this.closest(safeRange.startNode); + // 获取的 block 超出编辑范围 + if (!block.isEditable() && !block.inEditor()) { + return; + } + + if (block.isEditable()) { + //

wo

other

+ // to + //

wo

other

+ const sc = safeRange.getStartOffsetNode(); + if (sc) { + safeRange + .select(sc, true) + .shrinkToElementNode() + .collapse(false); + } + if (!range) change.apply(safeRange); + return; + } + const cloneRange = safeRange.cloneRange(); + cloneRange.shrinkToElementNode().shrinkToTextNode().collapse(true); + const activeMarks = mark.findMarks(cloneRange).filter((mark) => { + // 回车后,默认是否复制makr样式 + const plugin = this.editor.mark.findPlugin(mark); + return ( + plugin?.copyOnEnter !== false && plugin?.followStyle !== false + ); + }); + + const sideBlock = this.getBlockByRange({ + block: block[0], + range: safeRange, + isLeft: false, + keepDataId: true, + }); + const nodeApi = this.editor.node; + sideBlock.traverse((node) => { + if ( + !nodeApi.isVoid(node) && + (nodeApi.isInline(node) || nodeApi.isMark(node)) && + nodeApi.isEmpty(node) + ) { + node.remove(); + } + }, true); + const isEmptyElement = (node: Node) => { + return ( + nodeApi.isBlock(node) && + (node.childNodes.length === 0 || + (node as HTMLElement).innerText === '') + ); + }; + if (isEmptyElement(block[0]) && !isEmptyElement(sideBlock[0])) { + nodeId.generate(block, true); + } else { + nodeId.generate(sideBlock, true); + } + block.after(sideBlock); + //

里面必须要有节点,插入 BR 之后输入文字自动消失 + if (nodeApi.isEmpty(block)) { + nodeApi.html( + block, + nodeApi.getBatchAppendHTML( + activeMarks, + activeMarks.length > 0 ? '​' : '
', + ), + ); + } + + if (nodeApi.isEmpty(sideBlock)) { + nodeApi.html( + sideBlock, + nodeApi.getBatchAppendHTML( + activeMarks, + activeMarks.length > 0 ? '​' : '
', + ), + ); + } + block.children().each((child) => { + if (nodeApi.isInline(child)) { + this.editor.inline.repairCursor(child); + } + }); + sideBlock.children().each((child) => { + if (nodeApi.isInline(child)) { + this.editor.inline.repairCursor(child); + } + }); + // 重新设置当前选中范围 + safeRange.select(sideBlock, true).shrinkToElementNode(); + + if ( + sideBlock.children().length === 1 && + sideBlock.first()?.name === 'br' + ) { + safeRange.collapse(false); + } else { + safeRange.collapse(true); + } + + if (!range) change.apply(safeRange); + return sideBlock; + } + /** + * 在当前光标位置插入block节点 + * @param block 节点 + * @param range 光标 + * @param splitNode 分割节点,默认为光标开始位置的block节点 + */ + insert( + block: NodeInterface | Node | string, + range?: RangeInterface, + splitNode?: (node: NodeInterface) => NodeInterface, + ) { + if (!isEngine(this.editor)) return; + const { change, node, list, inline } = this.editor; + const safeRange = range || change.range.toTrusty(); + const doc = getDocument(safeRange.startContainer); + if (typeof block === 'string' || isNode(block)) { + block = $(block, doc); + } else block = block; + + if (!node.isBlock(block)) return; + + // 范围为折叠状态时先删除内容 + if (!safeRange.collapsed) { + change.delete(safeRange); + } + + // 获取上面第一个 Block + let container = this.closest(safeRange.startNode); + // 超出编辑范围 + if (!container.isEditable() && !container.inEditor()) { + if (!range) change.apply(safeRange); + return; + } + // 当前选择范围在段落外面 + if (container.isEditable()) { + node.insert(block, safeRange); + safeRange.collapse(false); + if (!range) change.apply(safeRange); + return; + } + //


+ // to + //


+ if ( + container.children().length === 1 && + container.first()?.name === 'br' + ) { + safeRange.select(container, true).collapse(false); + } + // 插入范围的开始和结束标记 + const selection = safeRange.enlargeToElementNode().createSelection(); + if (!selection.has()) { + if (!range) change.apply(safeRange); + return; + } + container = splitNode ? splitNode(container) : container; + // 切割 Block + let leftNodes = selection.getNode(container, 'left'); + leftNodes.traverse((leftNode) => { + if (leftNode.equal(leftNodes)) return; + if ( + node.isBlock(leftNode) && + (node.isEmpty(leftNode) || list.isEmptyItem(leftNode)) + ) { + leftNode.remove(); + } + }); + let rightNodes = selection.getNode( + container, + 'right', + true, + (child) => { + if (child.isCard()) { + const parent = child.parent(); + if (parent && node.isCustomize(parent)) return false; + } + return true; + }, + ); + // 清空原父容器,用新的内容代替 + const children = container.children(); + if (!node.isEmpty(container)) { + children.each((_, index) => { + const child = children.eq(index); + if (!child?.isCard()) { + children.eq(index)?.remove(); + } + }); + } + + rightNodes.traverse((rightNode) => { + if (!rightNode.equal(rightNodes)) return; + if ( + node.isBlock(rightNode) && + (node.isEmpty(rightNode) || list.isEmptyItem(rightNode)) + ) { + rightNode.remove(); + } else if (node.isList(rightNode)) { + list.addBr(rightNode); + } + }); + if ( + rightNodes.length > 0 && + !node.isEmpty(rightNodes) && + !list.isEmptyItem(rightNodes) + ) { + const right = rightNodes.clone(false); + const rightChildren = rightNodes.children(); + rightChildren.each((child, index) => { + if (rightChildren.eq(index)?.isCard()) { + const card = this.editor.card.find(child); + if (card) right.append(card.root); + } else right.append(child); + }); + rightNodes = right; + container.after(right); + } + if ( + leftNodes.length > 0 && + !node.isEmpty(leftNodes) && + !list.isEmptyItem(leftNodes) + ) { + let appendChild: NodeInterface | undefined | null = undefined; + const appendToParent = (childrenNodes: NodeInterface) => { + childrenNodes.each((child, index) => { + const childNode = childrenNodes.eq(index); + if (childNode && node.isInline(childNode)) { + inline.repairCursor(childNode); + } + if (childNode?.isCard()) { + appendChild = appendChild + ? appendChild.next() + : container.first(); + if (appendChild) childrenNodes[index] = appendChild[0]; + return; + } + if (appendChild) { + appendChild.after(child); + appendChild = childNode; + } else { + appendChild = childNode; + container.prepend(child); + } + }); + }; + appendToParent(leftNodes.children()); + } + + if (container && container.length > 0) { + if (rightNodes.length > 0) { + safeRange.setStartAfter(container); + safeRange.collapse(true); + } else { + safeRange.select(container, true); + safeRange.collapse(false); + } + } + if (selection.focus) selection.focus.remove(); + if (selection.anchor) selection.anchor.remove(); + // 插入新 Block + node.insert(block, safeRange); + if (!range) change.apply(safeRange); + } + /** + * 设置当前光标所在的所有block节点为新的节点或设置新属性 + * @param block 需要设置的节点或者节点属性 + * @param range 光标 + */ + setBlocks(block: string | { [k: string]: any }, range?: RangeInterface) { + if (!isEngine(this.editor)) return; + const { node, schema, mark } = this.editor; + const { change } = this.editor; + const safeRange = range || change.range.toTrusty(); + const doc = getDocument(safeRange.startContainer); + let targetNode: NodeInterface | null = null; + let attributes: { [k: string]: any } = {}; + if (typeof block === 'string') { + targetNode = $(block, doc); + attributes = targetNode.attributes(); + attributes.style = targetNode.css(); + } else { + attributes = block; + } + + const blocks = this.getBlocks(safeRange); + // 编辑器根节点,无段落 + const { startNode } = safeRange; + if (startNode.isEditable() && blocks.length === 0) { + if (startNode.isCard() || startNode.isEditable()) return; + const newBlock = targetNode || $('

'); + if (!schema.isAllowIn(newBlock.name, startNode.name)) return; + + node.setAttributes(newBlock, attributes); + + const selection = safeRange.createSelection(); + + startNode.children().each((node) => { + newBlock.append(node); + }); + // 复制全局属性 + const globals = schema.data.globals['block'] || {}; + const oldAttributes = startNode.attributes(); + Object.keys(oldAttributes).forEach((name) => { + if (name !== DATA_ID && name !== 'id' && globals['name']) { + newBlock.attributes(name, oldAttributes[name]); + } + }); + // 复制全局样式,及生成 text-align + const globalStyles = globals.style || {}; + const styles = startNode.css(); + Object.keys(styles).forEach((name) => { + if (!globalStyles[name]) delete styles[name]; + }); + newBlock.css(styles); + startNode.append(newBlock); + selection.move(); + if (!range) change.apply(safeRange); + return; + } + const targetPlugin = targetNode + ? this.findPlugin(targetNode) + : undefined; + const selection = safeRange.createSelection(); + blocks.forEach((child) => { + // Card 不做处理 + if (child.attributes(CARD_KEY)) { + return; + } + if (targetNode) { + // 复制全局属性 + const globals = schema.data.globals['block'] || {}; + const oldAttributes = child.attributes(); + Object.keys(oldAttributes).forEach((name) => { + if (name !== DATA_ID && name !== 'id' && globals['name']) { + targetNode?.attributes(name, oldAttributes[name]); + } + }); + // 复制全局样式,及生成 text-align + const globalStyles = globals.style || {}; + const styles = child.css(); + Object.keys(styles).forEach((name) => { + if (!globalStyles[name]) delete styles[name]; + }); + targetNode.css(styles); + } + // 相同标签,或者只传入样式属性 + if ( + !targetNode || + (this.findPlugin(child) === targetPlugin && + child.name === targetNode.name) + ) { + if (targetNode) attributes = targetNode.attributes(); + node.setAttributes(child, attributes); + return; + } + //如果要包裹的节点可以放入到当前节点中,就不操作 + if ( + targetNode.name !== 'p' && + schema.isAllowIn(child.name, targetNode.name) + ) { + return; + } + //先移除不能放入块级节点的mark标签 + if (targetPlugin) { + child.allChildren().forEach((markNode) => { + if (node.isMark(markNode)) { + const markPlugin = mark.findPlugin(markNode); + if (!markPlugin) return; + if ( + targetPlugin.disableMark && + targetPlugin.disableMark.indexOf( + (markPlugin.constructor as PluginEntry) + .pluginName, + ) > -1 + ) { + node.unwrap(markNode); + } + } + }); + } + + const newNode = node.replace(child, targetNode); + const parent = newNode.parent(); + if ( + parent && + !parent.isEditable() && + !schema.isAllowIn(parent.name, newNode.name) + ) { + node.unwrap(parent); + } + }); + selection.move(); + if (!range) change.apply(safeRange); + } + /** + * 合并当前光标位置相邻的block + * @param range 光标 + */ + merge(range?: RangeInterface) { + if (!isEngine(this.editor)) return; + const { change, schema } = this.editor; + const safeRange = range || change.range.toTrusty(); + const blocks = this.getBlocks(safeRange); + if (0 === blocks.length) return; + const root = blocks[0].closest(ROOT_SELECTOR); + const tags = schema.getCanMergeTags(); + if (tags.length === 0) return; + const block = root.find(tags.join(',')); + if (block.length > 0) { + const selection = safeRange.createSelection(); + let nextNode = block.next(); + while (nextNode && tags.indexOf(nextNode.name) > 0) { + const prevNode = nextNode.prev(); + const nextAttributes = nextNode.attributes(); + const prevAttributes = prevNode?.attributes(); + if ( + nextNode.name === prevNode?.name && + nextAttributes['class'] === + (prevAttributes + ? prevAttributes['class'] + : undefined) && + Object.keys(nextAttributes).join(',') === + Object.keys(prevAttributes || {}).join(',') + ) { + this.editor.node.merge(prevNode, nextNode); + } + nextNode = nextNode.next(); + } + selection.move(); + } + if (!range) change.apply(safeRange); + } + + /** + * 获取对范围有效果的所有 Block + */ + findBlocks(range: RangeInterface) { + range = range.cloneRange(); + if (range.startNode.isRoot()) range.shrinkToElementNode(); + if (!range.startNode.inEditor()) return []; + const sc = range.startContainer; + const so = range.startOffset; + const ec = range.endContainer; + const eo = range.endOffset; + let startNode = sc; + let endNode = ec; + + if (sc.nodeType === getWindow().Node.ELEMENT_NODE) { + if (sc.childNodes[so]) { + startNode = sc.childNodes[so] || sc; + } + } + + if (ec.nodeType === getWindow().Node.ELEMENT_NODE) { + if (eo > 0 && ec.childNodes[eo - 1]) { + endNode = ec.childNodes[eo - 1] || sc; + } + } + // 折叠状态时,按右侧位置的方式处理 + if (range.collapsed) { + startNode = endNode; + } + // 不存在时添加 + const addNode = ( + nodes: Array, + nodeB: NodeInterface, + preppend?: boolean, + ) => { + if ( + !nodes.some((nodeA) => { + return nodeA[0] === nodeB[0]; + }) + ) { + if (preppend) { + nodes.unshift(nodeB); + } else { + nodes.push(nodeB); + } + } + }; + // 向上寻找 + const findNodes = (node: NodeInterface) => { + const nodes = []; + while (node) { + if (node.isEditable()) { + break; + } + if (this.editor.node.isBlock(node)) { + nodes.push(node); + } + const parent = node.parent(); + if (!parent) break; + node = parent; + } + return nodes; + }; + + const nodes = this.getBlocks(range); + // rang头部应该往数组头部插入节点 + findNodes($(startNode)).forEach((node) => { + return addNode(nodes, node, true); + }); + const { commonAncestorNode } = range; + const card = this.editor.card.find(commonAncestorNode, true); + let isEditable = card?.isEditable; + const selectionNodes = isEditable + ? card?.getSelectionNodes + ? card.getSelectionNodes() + : [] + : []; + if (selectionNodes.length === 0) { + isEditable = false; + } + if (!range.collapsed || isEditable) { + findNodes($(endNode)).forEach((node) => { + return addNode(nodes, node); + }); + selectionNodes.forEach((commonAncestorNode) => { + commonAncestorNode.traverse( + (child) => { + if ( + child.isElement() && + !child.isCard() && + this.editor.node.isBlock(child) + ) { + addNode(nodes, child); + } + }, + true, + true, + ); + }); + } + return nodes; + } + + /** + * 判断范围的 {Edge}Offset 是否在 Block 的开始位置 + * @param range 光标 + * @param edge start | end + */ + isFirstOffset(range: RangeInterface, edge: 'start' | 'end') { + const { startNode, endNode, startOffset, endOffset } = range; + const container = edge === 'start' ? startNode : endNode; + const offset = edge === 'start' ? startOffset : endOffset; + range = range.cloneRange(); + const block = this.closest(container); + range.select(block, true); + range.setEnd(container[0], offset); + if (!this.editor.node.isBlock(container)) range.enlargeToElementNode(); + const fragment = range.cloneContents(); + + if (!fragment.firstChild) { + return true; + } + const { node } = this.editor; + if ( + fragment.childNodes.length === 1 && + $(fragment.firstChild).name === 'br' + ) { + return true; + } + + const emptyNode = $('
'); + emptyNode.append(fragment); + return node.isEmpty(emptyNode); + } + + /** + * 判断范围的 {Edge}Offset 是否在 Block 的最后位置 + * @param range 光标 + * @param edge start | end + */ + isLastOffset(range: RangeInterface, edge: 'start' | 'end') { + const { startNode, endNode, startOffset, endOffset } = range; + const container = edge === 'start' ? startNode : endNode; + const offset = edge === 'start' ? startOffset : endOffset; + range = range.cloneRange(); + const block = this.closest(container); + range.select(block, true); + range.setStart(container, offset); + if (!this.editor.node.isBlock(container)) range.enlargeToElementNode(); + const fragment = range.cloneContents(); + + if (!fragment.firstChild) { + return true; + } + const { node } = this.editor; + const emptyNode = $('
'); + emptyNode.append(fragment); + + return 0 >= emptyNode.find('br').length && node.isEmpty(emptyNode); + } + + /** + * 获取范围内的所有 Block + * @param range 光标s + */ + getBlocks(range: RangeInterface) { + range = range.cloneRange(); + range.shrinkToElementNode(); + range.shrinkToTextNode(); + + const { node } = this.editor; + + let startBlock = this.closest(range.startNode); + if (range.startNode.isRoot()) { + startBlock = $(range.getStartOffsetNode()); + } + let endBlock = this.closest(range.endNode); + if (range.endNode.isRoot()) { + endBlock = $(range.getEndOffsetNode()); + } + + const closest = this.closest(range.commonAncestorNode); + const blocks: Array = []; + let started = false; + const { commonAncestorNode } = range; + const card = this.editor.card.find(commonAncestorNode, true); + let isEditable = card?.isEditable; + const selectionNodes = isEditable + ? card?.getSelectionNodes + ? card.getSelectionNodes() + : [] + : [closest]; + if (selectionNodes.length === 0) { + isEditable = false; + selectionNodes.push(closest); + } + selectionNodes.forEach((selectionNode) => { + selectionNode.traverse( + (node) => { + const child = $(node); + if (child.equal(startBlock)) { + started = true; + } + if ( + (started || isEditable) && + this.editor.node.isBlock(child) && + !child.isCard() && + child.inEditor() + ) { + blocks.push(child); + } + if (child.equal(endBlock)) { + started = false; + return false; + } + return; + }, + true, + true, + ); + }); + + // 未选中文本时忽略该 Block + // 示例:

word

another

+ if ( + blocks.length > 1 && + this.isFirstOffset(range, 'end') && + !node.isEmpty(endBlock) + ) { + blocks.pop(); + } + return blocks; + } + + /** + * 获取block节点到光标所在位置的blcok节点 + * @param options { block, range, isLeft, clone, keepDataId } + * @returns + */ + getBlockByRange({ + block, + range, + isLeft, + clone = false, + keepDataId = false, + }: { + block: NodeInterface | Node; + range: RangeInterface; + isLeft: boolean; + clone?: boolean; + keepDataId?: boolean; + }) { + if (isNode(block)) block = $(block); + const newRange = Range.create(this.editor, block.document!); + + if (isLeft) { + newRange.select(block, true); + newRange.setEnd(range.startContainer, range.startOffset); + } else { + newRange.select(block, true); + newRange.setStart(range.endContainer, range.endOffset); + } + + const fragement = clone + ? newRange.cloneContents() + : newRange.extractContents(); + const cloneBlock = keepDataId + ? block.clone(false) + : this.editor.node.clone(block, false); + cloneBlock.append(fragement); + if (clone) { + cloneBlock.find(CARD_SELECTOR).each((card) => { + const domCard = $(card); + const cardName = domCard.attributes(CARD_KEY); + domCard.attributes(READY_CARD_KEY, cardName); + domCard.removeAttributes(CARD_KEY); + }); + } + return cloneBlock; + } + + /** + * 获取 Block 左侧文本 + * @param block 节点 + */ + getLeftText(block: NodeInterface | Node, range?: RangeInterface) { + if (!isEngine(this.editor)) return ''; + range = range || this.editor.change.range.get(); + const leftBlock = this.getBlockByRange({ + block, + range, + isLeft: true, + clone: true, + }); + return leftBlock + .text() + .trim() + .replace(/\u200B/g, ''); + } + + /** + * 删除 Block 左侧文本 + * @param block 节点 + */ + removeLeftText(block: NodeInterface | Node, range?: RangeInterface) { + if (!isEngine(this.editor)) return; + range = range || this.editor.change.range.get(); + if (isNode(block)) block = $(block); + range.createSelection(); + const cursor = block.find(CURSOR_SELECTOR); + let isRemove = false; + // 删除左侧文本节点 + block.traverse((node) => { + const child = $(node); + if (child.equal(cursor)) { + cursor.remove(); + isRemove = true; + return; + } + if (isRemove && child.isText()) { + child.remove(); + } + }, false); + } + + /** + * 扁平化block节点,防止错误嵌套 + * @param block 节点 + * @param root 根节点 + */ + flat(block: NodeInterface, root: NodeInterface) { + if (!isEngine(this.editor)) return; + const { schema, node } = this.editor; + const mergeTags = schema.getCanMergeTags(); + //获取父级节点 + let parentNode = block.parent(); + const rootElement = root.fragment ? root[0].parentNode : root; + //在根节点内循环 + while ( + parentNode && + rootElement && + !parentNode.equal(rootElement) && + parentNode.inEditor() + ) { + //如果是卡片节点,就在父节点前面插入 + if (block.isCard()) parentNode.before(block); + else if ( + //如果是li标签,并且父级是 ol、ul 列表标签 + (node.isList(parentNode) && 'li' === block.name) || + //如果是父级可合并标签,并且当前节点是根block节点,并且不是 父节点一样的block节点 + (mergeTags.indexOf(parentNode.name) > -1 && + node.isBlock(block) && + parentNode.name !== block.name) + ) { + //复制节点 + const cloneNode = node.clone(parentNode, false); + //追加到复制的节点 + cloneNode.append(block); + //设置新的节点 + block = cloneNode; + //将新的节点插入到父节点之前 + parentNode.before(block); + } else { + block = node.replace( + block, + node.clone(this.findTop(parentNode, block), false), + ); + parentNode.before(block); + } + //如果没有子节点就移除 + if (!parentNode.first()) parentNode.remove(); + //设置新的父节点 + parentNode = block.parent(); + } + } + + /** + * br 换行改成段落 + * @param block 节点 + */ + brToBlock(block: NodeInterface) { + // 没有子节点 + if (!block.first()) { + return; + } + // 只有一个节点 + if (block.children().length === 1) { + const node = block.first(); + //\n换成 br + if (node && node.isText() && /^\n+$/g.test(node.text())) { + this.editor.node.replace(node, $('
')); + } + return; + } + if ('li' === block.name) return; + // 只有一个节点(有光标标记节点) + if ( + (block.children().length === 2 && + block.first()?.attributes(DATA_ELEMENT) === CURSOR) || + block.last()?.attributes(DATA_ELEMENT) === CURSOR + ) { + return; + } + + let container; + let prevContainer; + let node = block.first(); + while (node) { + const next = node.next(); + if (!container || node.name === 'br') { + prevContainer = container; + container = this.editor.node.clone(block, false); + block.before(container); + } + if (node.name !== 'br') { + container.append(node); + } + if ( + (node.name === 'br' || !next) && + prevContainer && + !prevContainer.first() + ) { + prevContainer.append($('
')); + } + node = next; + } + + if (container && !container.first()) { + container.remove(); + } + block.remove(); + } + + /** + * 插入一个空的block节点 + * @param range 光标所在位置 + * @param block 节点 + * @returns + */ + insertEmptyBlock(range: RangeInterface, block: NodeInterface) { + const editor = this.editor; + if (!isEngine(editor)) return; + const { change } = editor; + const { blocks, marks } = change; + const nodeApi = editor.node; + this.insert(block); + if (blocks[0]) { + const styles = blocks[0].css(); + block.css(styles); + } + let node = block.find('br'); + marks.forEach((mark) => { + // 回车后,默认是否复制makr样式 + const plugin = editor.mark.findPlugin(mark); + mark = nodeApi.clone(mark); + //插件判断 + if ( + plugin?.copyOnEnter !== false && + plugin?.followStyle !== false + ) { + mark = nodeApi.clone(mark); + node.before(mark); + mark.append(node); + node = mark; + } + }); + node = block.find('br'); + const parent = node.parent(); + if (parent && nodeApi.isMark(parent)) { + node = nodeApi.replace(node, $('\u200b', null)); + } + range.select(node).shrinkToTextNode(); + range.collapse(false); + range.scrollIntoView(); + change.range.select(range); + } + /** + * 在光标位置插入或分割节点 + * @param range 光标所在位置 + * @param block 节点 + */ + insertOrSplit(range: RangeInterface, block: NodeInterface) { + const cloneRange = range.cloneRange(); + cloneRange.enlargeFromTextNode(); + if ( + this.isLastOffset(range, 'end') || + (cloneRange.endNode.type === getWindow().Node.ELEMENT_NODE && + block.children().length > 0 && + cloneRange.endContainer.childNodes[cloneRange.endOffset] === + block.last()?.get() && + 'br' === block.first()?.name) + ) { + const emptyElement = $(`


`); + if (block.name === 'p') { + const attributes = block.attributes(); + Object.keys(attributes).forEach((attributeName) => { + if (attributeName === DATA_ID) return; + emptyElement.attributes( + attributeName, + attributes[attributeName], + ); + }); + } + this.insertEmptyBlock(range, emptyElement); + } else { + this.split(); + } + } +} +export default Block; diff --git a/packages/engine/src/block/typing/backspace.ts b/packages/engine/src/block/typing/backspace.ts new file mode 100644 index 00000000..195d7e94 --- /dev/null +++ b/packages/engine/src/block/typing/backspace.ts @@ -0,0 +1,129 @@ +import { EngineInterface, RangeInterface } from '../../types'; +import { getWindow } from '../../utils'; + +class Backspace { + private engine: EngineInterface; + constructor(engine: EngineInterface) { + this.engine = engine; + } + + trigger(event: KeyboardEvent) { + const { change, node, block, card } = this.engine; + const range = change.range.get(); + if (!range.collapsed) return; + const prevNode = range.getPrevNode(); + if ( + prevNode && + node.isBlock(prevNode) && + node.isEmptyWithTrim(prevNode) + ) { + event.preventDefault(); + const parent = prevNode.parent(); + prevNode.remove(); + if (parent && this.engine.node.isEmpty(parent)) { + if (parent.isEditable()) { + this.engine.node.html(parent, '


'); + range + .select(parent, true) + .shrinkToElementNode() + .collapse(false); + } else { + this.engine.node.html(parent, '
'); + range.select(parent, true).collapse(false); + } + change.apply(range); + } + return false; + } + // 光标不在段落开始位置时 + const isCard = !!card.closest(range.startNode); + if (!isCard && !block.isFirstOffset(range, 'start')) { + let cloneRange = range + .cloneRange() + .shrinkToElementNode() + .shrinkToTextNode(); + if ( + cloneRange.startContainer.nodeType === + getWindow().Node.TEXT_NODE && + (function (range: RangeInterface) { + const { commonAncestorContainer } = range; + if ( + range.collapsed && + 1 === range.startOffset && + range.startContainer === commonAncestorContainer && + commonAncestorContainer.nodeType === + getWindow().Node.TEXT_NODE + ) { + range = range.cloneRange(); + if ( + (commonAncestorContainer.parentNode?.childNodes + ?.length || 0) <= 1 && + 1 === commonAncestorContainer.textContent?.length + ) { + const { startNode, startOffset } = range; + let markNode = startNode.parent(); + //开始节点在mark标签内 + if ( + markNode && + node.isMark(markNode) && + startOffset > 0 + ) { + const text = startNode.text(); + const leftText = text.substr( + startOffset - 1, + 1, + ); + //不位于零宽字符后,不处理 + if (/^\u200b$/.test(leftText)) { + //选中上一个节点 + if (startOffset === 1) { + const prev = markNode.prev(); + if (prev && !node.isEmpty(prev)) { + const { startNode, startOffset } = + range + .cloneRange() + .select(prev, true) + .shrinkToTextNode() + .collapse(false); + range.setStart( + startNode, + startOffset - 1, + ); + } + } else { + range.setStart( + startNode, + startOffset - 1, + ); + } + } + } + if (range.collapsed) + range.select(commonAncestorContainer, true); + change.delete(range, true); + change.apply(range); + return true; + } + } + return false; + })(cloneRange) + ) { + event.preventDefault(); + event['isDelete'] = true; + change.change(); + } + return; + } + const blockNode = block.closest(range.startNode); + // 在正文里 + if (!isCard && node.isRootBlock(blockNode)) { + event.preventDefault(); + // 空的节点就清空所有的mark空节点以及inline节点,避免重复的合并到上一级节点上 + if (node.isEmpty(blockNode)) blockNode.html('
'); + change.mergeAfterDelete(blockNode); + return false; + } + return true; + } +} +export default Backspace; diff --git a/packages/engine/src/block/typing/enter.ts b/packages/engine/src/block/typing/enter.ts new file mode 100644 index 00000000..13884e72 --- /dev/null +++ b/packages/engine/src/block/typing/enter.ts @@ -0,0 +1,91 @@ +import { EngineInterface } from '../../types'; +import { $ } from '../../node'; +import { DATA_ID } from '../../constants'; + +class Enter { + private engine: EngineInterface; + constructor(engine: EngineInterface) { + this.engine = engine; + } + + trigger(event: KeyboardEvent) { + const { change, node, list } = this.engine; + const range = change.range + .get() + .shrinkToElementNode() + .shrinkToTextNode(); + // 选区选中最后的节点 + const blockApi = this.engine.block; + let block = blockApi.closest(range.endNode); + // 嵌套 block + const parent = block.parent(); + if (parent && parent.inEditor() && node.isBlock(parent)) { + if ('li' === parent.name && 'p' === block.name) { + if ( + 1 === block.children().length && + 'br' === block.first()?.name + ) { + block.first()!.remove(); + } + const selection = range.createSelection(); + change.unwrap(block); + selection.move(); + block = blockApi.closest(range.endNode); + } + if ( + range.collapsed && + ((range.startContainer.childNodes.length === 1 && + 'BR' === range.startContainer.firstChild?.nodeName) || + (blockApi.isLastOffset(range, 'end') && + blockApi.isFirstOffset(range, 'end'))) + ) { + event.preventDefault(); + if (['li'].indexOf(parent.name) >= 0) { + blockApi.unwrap('<'.concat(parent.name!, ' />')); + blockApi.setBlocks('<'.concat(parent.name!, ' />')); + } else { + blockApi.unwrap('<'.concat(parent.name!, ' />')); + blockApi.setBlocks('

'); + } + return false; + } + } + if ( + node.isBlock(block) && + (!parent || !node.isList(parent)) && + !block.isCard() + ) { + event.preventDefault(); + blockApi.insertOrSplit(range, block); + return false; + } + // 列表 + if (block.name === 'li') { + if (node.isCustomize(block)) { + return; + } + event.preventDefault(); + if (blockApi.isLastOffset(range, 'end')) { + if (range.collapsed && blockApi.isFirstOffset(range, 'end')) { + const listRoot = block.closest('ul,ol'); + blockApi.unwrap('<'.concat(listRoot.name!, ' />')); + blockApi.setBlocks('

'); + } else { + const li = $('


  • '); + const attributes = block.attributes(); + delete attributes[DATA_ID]; + li.attributes(attributes); + blockApi.insertEmptyBlock(range, li); + } + } else { + blockApi.split(); + } + list.merge(); + range.scrollIntoView(); + return false; + } + return true; + } +} + +export default Enter; diff --git a/packages/engine/src/block/typing/index.ts b/packages/engine/src/block/typing/index.ts new file mode 100644 index 00000000..69d40ccd --- /dev/null +++ b/packages/engine/src/block/typing/index.ts @@ -0,0 +1,4 @@ +import Enter from './enter'; +import Backspace from './backspace'; + +export { Enter, Backspace }; diff --git a/packages/engine/src/card/entry.ts b/packages/engine/src/card/entry.ts new file mode 100644 index 00000000..fb6e8d39 --- /dev/null +++ b/packages/engine/src/card/entry.ts @@ -0,0 +1,371 @@ +import { + CARD_ELEMENT_KEY, + CARD_KEY, + CARD_LEFT_SELECTOR, + CARD_RIGHT_SELECTOR, + CARD_TYPE_KEY, + CARD_VALUE_KEY, +} from '../constants/card'; +import { + CardOptions, + CardInterface, + MaximizeInterface, + CardToolbarItemOptions, + CardEntry as CardEntryType, + CardToolbarInterface, + ResizeInterface, + CardValue, +} from '../types/card'; +import { EditorInterface } from '../types/engine'; +import { NodeInterface } from '../types/node'; +import { RangeInterface } from '../types/range'; +import { ToolbarItemOptions } from '../types/toolbar'; +import { TinyCanvasInterface } from '../types/tiny-canvas'; +import { decodeCardValue, encodeCardValue, isEngine, random } from '../utils'; +import Maximize from './maximize'; +import Resize from './resize'; +import Toolbar from './toolbar'; +import { $ } from '../node'; +import { CardType } from './enum'; + +abstract class CardEntry implements CardInterface { + protected readonly editor: EditorInterface; + readonly root: NodeInterface; + toolbarModel?: CardToolbarInterface; + resizeModel?: ResizeInterface; + activatedByOther: string | false = false; + selectedByOther: string | false = false; + /** + * 可编辑的节点 + */ + readonly contenteditable: Array = []; + static readonly cardName: string; + static readonly cardType: CardType; + static readonly autoActivate: boolean; + static readonly autoSelected: boolean = true; + static readonly singleSelectable: boolean; + static readonly collab: boolean = true; + static readonly focus: boolean; + static readonly selectStyleType: 'border' | 'background' = 'border'; + static readonly toolbarFollowMouse: boolean = false; + private defaultMaximize: MaximizeInterface; + isMaximize: boolean = false; + + get isEditable() { + return this.contenteditable.length > 0; + } + + get activated() { + return this.root.hasClass('card-activated'); + } + + private setActivated(activated: boolean) { + activated + ? this.root.addClass('card-activated') + : this.root.removeClass('card-activated'); + } + + get selected() { + return this.root.hasClass('card-selected'); + } + + private setSelected(selected: boolean) { + selected + ? this.root.addClass('card-selected') + : this.root.removeClass('card-selected'); + } + + get id() { + const value = this.getValue(); + return typeof value === 'object' ? value.id : ''; + } + + get name() { + return this.root.attributes(CARD_KEY); + } + + get type() { + return ( + this.getValue()?.type || + (this.root.attributes(CARD_TYPE_KEY) as CardType) + ); + } + + set type(type: CardType) { + if (!this.name || type === this.type) return; + // 替换后重新渲染 + const { card } = this.editor; + const component = card.replace(this, this.name, { + ...this.getValue(), + type, + }); + card.render(component.root); + component.activate(false); + card.activate(component.root); + } + + constructor({ editor, value, root }: CardOptions) { + this.editor = editor; + const type = + value?.type || (this.constructor as CardEntryType).cardType; + const tagName = type === 'inline' ? 'span' : 'div'; + this.root = root ? root : $('<'.concat(tagName, ' />')); + if (typeof value === 'string') value = decodeCardValue(value); + + value = value || {}; + value.id = this.getId(value.id); + value.type = type; + this.setValue(value as T); + this.defaultMaximize = new Maximize(this.editor, this); + } + + init() { + this.toolbarModel?.hide(); + this.toolbarModel?.destroy(); + if (this.toolbar) { + this.toolbarModel = new Toolbar(this.editor, this); + } + if (this.resize) { + this.resizeModel = new Resize(this.editor, this); + } + } + + private getId(curId?: string) { + const idCache: Array = []; + this.editor.card.each((card) => { + idCache.push(card.id); + }); + if (curId && idCache.indexOf(curId) < 0) return curId; + let id = random(); + while (idCache.indexOf(id) >= 0) id = random(); + return id; + } + + // 设置 DOM 属性里的数据 + setValue(value: Partial) { + if (value == null) { + return; + } + const currentValue = this.getValue(); + if (!!currentValue?.id) delete value['id']; + const oldValue = this.getValue(); + value = { ...oldValue, ...value } as T; + if (value.type && oldValue?.type !== value.type) { + this.type = value.type; + } + + this.root.attributes(CARD_VALUE_KEY, encodeCardValue(value)); + } + // 获取 DOM 属性里的数据 + getValue(): (T & { id: string }) | undefined { + const value = this.root.attributes(CARD_VALUE_KEY); + if (!value) return; + + return decodeCardValue(value) as T & { id: string }; + } + + /** + * 获取Card内的 DOM 节点 + * @param selector + */ + find(selector: string) { + return this.root.find(selector); + } + + findByKey(key: string) { + const body = this.root.first() || $([]); + if (key === 'body' || body.length === 0) return body; + const children = body.children(); + if (['center', 'left', 'right'].includes(key)) + return ( + children + .toArray() + .find( + (child) => child.attributes(CARD_ELEMENT_KEY) === key, + ) || $([]) + ); + const tag = this.type === CardType.BLOCK ? 'div' : 'span'; + return this.find(`${tag}[${CARD_ELEMENT_KEY}=${key}]`); + } + + activate(activated: boolean) { + if (activated) { + if (!this.activated) { + this.setActivated(activated); + this.onActivate(activated); + } + } else if (this.activated) { + this.setActivated(activated); + this.onActivate(false); + } + } + + select(selected: boolean) { + if (!isEngine(this.editor) || this.activatedByOther) { + return; + } + if (selected) { + if (!this.selected && !this.isMaximize) { + this.setSelected(selected); + this.onSelect(selected); + } + } else if (this.selected) { + this.setSelected(selected); + this.onSelect(false); + } + } + + getCenter() { + return this.findByKey('center'); + } + + isCenter(node: NodeInterface) { + const center = node.closest( + this.type === CardType.BLOCK + ? `div[${CARD_ELEMENT_KEY}=center]` + : `span[${CARD_ELEMENT_KEY}=center]`, + ); + return center.length > 0 && center.equal(this.findByKey('center')); + } + + isCursor(node: NodeInterface) { + return this.isLeftCursor(node) || this.isRightCursor(node); + } + + isLeftCursor(node: NodeInterface) { + if (node.isElement() && node.attributes(CARD_ELEMENT_KEY) !== 'left') + return false; + const cursor = node.closest(CARD_LEFT_SELECTOR); + return cursor.length > 0 && cursor.equal(this.findByKey('left')); + } + + isRightCursor(node: NodeInterface) { + if (node.isElement() && node.attributes(CARD_ELEMENT_KEY) !== 'right') + return false; + const cursor = node.closest(CARD_RIGHT_SELECTOR); + return cursor.length > 0 && cursor.equal(this.findByKey('right')); + } + + focus(range: RangeInterface, toStart?: boolean) { + const cardLeft = this.findByKey('left'); + const cardRight = this.findByKey('right'); + + if (cardLeft.length === 0 || cardRight.length === 0) { + return; + } + + range.select(toStart ? cardLeft : cardRight, true); + range.collapse(false); + if (this.onFocus) this.onFocus(); + } + + /** + * 当卡片聚焦时触发 + */ + onFocus?(): void; + + maximize() { + this.isMaximize = true; + this.defaultMaximize.maximize(); + this.toolbarModel?.show(); + } + + minimize() { + this.isMaximize = false; + this.defaultMaximize.restore(); + this.toolbarModel?.show(); + } + + /** + * 工具栏配置项 + */ + toolbar?(): Array; + /** + * 是否可改变卡片大小,或者传入渲染节点 + */ + resize?: boolean | (() => NodeInterface | void); + + onSelect(selected: boolean): void { + const selectedClass = `data-card-${ + (this.constructor as CardEntryType).selectStyleType + }-selected`; + const center = this.getCenter(); + if (selected) { + center.addClass(selectedClass); + } else { + center.removeClass(selectedClass); + } + } + onSelectByOther( + selected: boolean, + value?: { + color: string; + rgb: string; + }, + ): NodeInterface | void { + const center = this.getCenter(); + if ( + (this.constructor as CardEntryType).selectStyleType === 'background' + ) { + center.css('background-color', selected ? value!.rgb : ''); + } else { + center.css('outline', selected ? '2px solid ' + value!.color : ''); + } + const className = 'card-selected-other'; + if (selected) this.root.addClass(className); + else this.root.removeClass(className); + } + onActivate(activated: boolean) { + if (!this.resize) return; + if (activated) this.resizeModel?.show(); + else this.resizeModel?.hide(); + } + onActivateByOther( + activated: boolean, + value?: { + color: string; + rgb: string; + }, + ): NodeInterface | void { + this.onSelectByOther(activated, value); + } + onChange?(trigger: 'remote' | 'local', node: NodeInterface): void; + destroy() { + this.toolbarModel?.hide(); + this.toolbarModel?.destroy(); + this.resizeModel?.hide(); + this.resizeModel?.destroy(); + } + didInsert?(): void; + didUpdate?(): void; + didRender() { + if (this.resize) { + const container = + typeof this.resize === 'function' + ? this.resize() + : this.findByKey('body'); + if (container && container.length > 0) { + this.resizeModel?.render(container); + } + } + if (this.contenteditable.length > 0) { + this.editor.nodeId.generateAll(this.getCenter().get()!); + } + } + abstract render(): NodeInterface | string | void; + + updateBackgroundSelection?(range: RangeInterface): void; + + drawBackground?( + node: NodeInterface, + range: RangeInterface, + targetCanvas: TinyCanvasInterface, + ): DOMRect | RangeInterface[] | void | false; + + /** + * 获取可编辑区域选中的所有节点 + */ + getSelectionNodes?(): Array; +} + +export default CardEntry; diff --git a/packages/engine/src/card/enum.ts b/packages/engine/src/card/enum.ts new file mode 100644 index 00000000..fcafec2e --- /dev/null +++ b/packages/engine/src/card/enum.ts @@ -0,0 +1,13 @@ +/** + * 卡片类型 + */ +export enum CardType { + INLINE = 'inline', + BLOCK = 'block', +} + +export enum CardActiveTrigger { + CARD_CHANGE = 'card_change', + CLICK = 'click', + MOUSE_DOWN = 'mouse_down', +} diff --git a/packages/engine/src/card/index.css b/packages/engine/src/card/index.css new file mode 100644 index 00000000..cadb4ca9 --- /dev/null +++ b/packages/engine/src/card/index.css @@ -0,0 +1,12 @@ +.am-engine .card-selected [data-card-element="center"].data-card-background-selected { + background: rgba(27, 162, 227, 0.2); +} + +.am-engine .card-selected [data-card-element="center"].data-card-border-selected { + outline: 2px solid #1890FF; + border-radius: 2px; +} + +.am-engine .card-selected [data-card-element="center"].data-card-border-selected::selection { + background: transparent; +} \ No newline at end of file diff --git a/packages/engine/src/card/index.ts b/packages/engine/src/card/index.ts new file mode 100644 index 00000000..64fb6521 --- /dev/null +++ b/packages/engine/src/card/index.ts @@ -0,0 +1,819 @@ +import { + CARD_ELEMENT_KEY, + CARD_KEY, + CARD_SELECTOR, + CARD_TYPE_KEY, + CARD_VALUE_KEY, + READY_CARD_KEY, + READY_CARD_SELECTOR, + DATA_ELEMENT, + EDITABLE, + EDITABLE_SELECTOR, + DATA_TRANSIENT_ELEMENT, + DATA_TRANSIENT_ATTRIBUTES, +} from '../constants'; +import { + CardEntry, + CardInterface, + CardModelInterface, + CardValue, +} from '../types/card'; +import { NodeInterface } from '../types/node'; +import { RangeInterface } from '../types/range'; +import { EditorInterface } from '../types/engine'; +import { + decodeCardValue, + encodeCardValue, + isEngine, + transformCustomTags, +} from '../utils'; +import { Backspace, Enter, Left, Right, Up, Down, Default } from './typing'; +import { $ } from '../node'; +import { isNode, isNodeEntry } from '../node/utils'; +import { CardActiveTrigger, CardType } from './enum'; +import './index.css'; + +class CardModel implements CardModelInterface { + classes: { + [k: string]: CardEntry; + }; + components: Array; + private editor: EditorInterface; + + constructor(editor: EditorInterface) { + this.classes = {}; + this.components = []; + this.editor = editor; + } + + get active() { + return this.components.find((component) => component.activated); + } + + get length() { + return this.components.length; + } + + init(cards: Array) { + const editor = this.editor; + if (isEngine(editor)) { + const { typing } = editor; + //绑定回车事件 + const enter = new Enter(editor); + typing + .getHandleListener('enter', 'keydown') + ?.on((event) => enter.trigger(event)); + //删除事件 + const backspace = new Backspace(editor); + typing + .getHandleListener('backspace', 'keydown') + ?.on((event) => backspace.trigger(event)); + //方向键事件 + const left = new Left(editor); + typing + .getHandleListener('left', 'keydown') + ?.on((event) => left.trigger(event)); + + const right = new Right(editor); + typing + .getHandleListener('right', 'keydown') + ?.on((event) => right.trigger(event)); + + const up = new Up(editor); + typing + .getHandleListener('up', 'keydown') + ?.on((event) => up.trigger(event)); + + const down = new Down(editor); + typing + .getHandleListener('down', 'keydown') + ?.on((event) => down.trigger(event)); + + const _default = new Default(editor); + typing + .getHandleListener('default', 'keydown') + ?.on((event) => _default.trigger(event)); + } + + cards.forEach((card) => { + this.classes[card.cardName] = card; + }); + } + + add(clazz: CardEntry) { + this.classes[clazz.cardName] = clazz; + } + + each( + callback: (card: CardInterface, index?: number) => boolean | void, + ): void { + this.components.every((card, index) => { + if (callback && callback(card, index) === false) return false; + return true; + }); + } + + closest( + selector: Node | NodeInterface, + ignoreEditable?: boolean, + ): NodeInterface | undefined { + if (isNode(selector)) selector = $(selector); + if (isNodeEntry(selector) && !selector.isCard()) { + const card = selector.closest(CARD_SELECTOR, (node: Node) => { + if ( + node && ignoreEditable + ? $(node).isRoot() + : $(node).isEditable() + ) { + return; + } + return node.parentNode || undefined; + }); + if (!card || card.length === 0) return; + selector = card; + } + return selector; + } + + find( + selector: string | Node | NodeInterface, + ignoreEditable?: boolean, + ): CardInterface | undefined { + if (typeof selector !== 'string') { + const cardNode = this.closest(selector, ignoreEditable); + if (!cardNode) return; + selector = cardNode; + } + + const getValue = ( + node: Node | NodeInterface, + ): CardValue & { id: string } => { + if (isNode(node)) node = $(node); + const value = node.attributes(CARD_VALUE_KEY); + return value ? decodeCardValue(value) : {}; + }; + const cards = this.components.filter((item) => { + if (typeof selector === 'string') return item.id === selector; + if ( + item.root.name !== + (isNode(selector) + ? selector.nodeName.toString() + : selector.name) + ) + return false; + return ( + item.root.equal(selector) || item.id === getValue(selector).id + ); + }); + if (cards.length === 0) return; + + return cards[0]; + } + + findBlock(selector: Node | NodeInterface): CardInterface | undefined { + if (isNode(selector)) selector = $(selector); + if (!selector.get()) return; + const parent = selector.parent(); + if (!parent) return; + const card = this.find(parent); + if (!card) return; + if (card.type === CardType.BLOCK) return card; + return this.findBlock(card.root); + } + + getSingleCard(range: RangeInterface) { + let card = this.find(range.commonAncestorNode); + if (!card) card = this.getSingleSelectedCard(range); + return card; + } + + getSingleSelectedCard(range: RangeInterface) { + const elements = range.findElements(); + let node = elements[0]; + if (elements.length === 1 && node) { + const domNode = $(node); + if (domNode.isCard()) { + return this.find(domNode); + } + } + return; + } + + // 插入Card + insertNode(range: RangeInterface, card: CardInterface, ...args: any) { + const isInline = card.type === 'inline'; + const editor = this.editor; + // 范围为折叠状态时先删除内容 + if (!range.collapsed && isEngine(editor)) { + editor.change.delete(range); + } + this.gc(); + const { inline, block, node } = editor; + // 插入新 Card + if (isInline) { + inline.insert(card.root, range); + } else { + block.insert(card.root, range, (container) => { + //获取最外层的block嵌套节点 + let blockParent = container.parent(); + while (blockParent && !blockParent.isEditable()) { + container = blockParent; + const parent = blockParent.parent(); + if (parent && node.isBlock(parent)) { + blockParent = parent; + } else break; + } + return container; + }); + } + this.components.push(card); + card.focus(range); + // 矫正错误 HTML 结构 + const rootParent = card.root.parent(); + if ( + !isInline && + rootParent && + rootParent.inEditor() && + node.isBlock(rootParent) + ) { + block.unwrap(rootParent, range); + } + const result = card.render(...args); + const center = card.getCenter(); + if (result !== undefined) { + card.getCenter().append( + typeof result === 'string' ? $(result) : result, + ); + } + if (card.contenteditable.length > 0) { + center.find(card.contenteditable.join(',')).each((node) => { + const child = $(node); + child.attributes( + 'contenteditable', + !isEngine(this.editor) || this.editor.readonly + ? 'false' + : 'true', + ); + child.attributes(DATA_ELEMENT, EDITABLE); + }); + } + //创建工具栏 + card.didRender(); + if (card.didInsert) { + card.didInsert(); + } + return card; + } + + // 移除Card + removeNode(card: CardInterface) { + if (card.destroy) card.destroy(); + this.removeComponent(card); + card.root.remove(); + } + + // 更新Card + updateNode(card: CardInterface, value: CardValue, ...args: any) { + if (card.destroy) card.destroy(); + const container = card.findByKey('center'); + container.empty(); + card.setValue(value); + const result = card.render(...args); + if (result !== undefined) { + card.getCenter().append( + typeof result === 'string' ? $(result) : result, + ); + } + if (card.didUpdate) { + card.didUpdate(); + } + } + // 将指定节点替换成等待创建的Card DOM 节点 + replaceNode(node: NodeInterface, name: string, value?: CardValue) { + const clazz = this.classes[name]; + if (!clazz) throw ''.concat(name, ': This card does not exist'); + const type = value?.type || clazz.cardType; + const cardNode = transformCustomTags( + ``, + ); + const readyCard = $(cardNode); + node.before(readyCard); + readyCard.append(node); + return readyCard; + } + + activate( + node: NodeInterface, + trigger?: CardActiveTrigger, + event?: MouseEvent, + ) { + const editor = this.editor; + if (!isEngine(editor) || editor.readonly) return; + //获取当前卡片所在编辑器的根节点 + const container = node.getRoot(); + //如果当前编辑器根节点和引擎的根节点不匹配就不执行,主要是子父编辑器的情况 + if (!container.get() || editor.container.equal(container)) { + let card = this.find(node); + const editableElement = node.closest(EDITABLE_SELECTOR); + if (!card && editableElement.length > 0) { + const editableParent = editableElement.parent(); + card = editableParent ? this.find(editableParent) : undefined; + } + const blockCard = card ? this.findBlock(card.root) : undefined; + if (blockCard) { + card = blockCard; + } + if (card && card.isCursor(node)) { + if (editableElement.length > 0) { + const editableParent = editableElement.parent(); + card = editableParent + ? this.find(editableParent) + : undefined; + } else card = undefined; + } + const isCurrentActiveCard = + card && this.active && this.active.root.equal(card.root); + // 当前是卡片,但是与当前激活的卡片不一致,就取消当前的卡片激活状态 + if (this.active && !isCurrentActiveCard) { + this.active.toolbarModel?.hide(); + this.active.select(false); + this.active.activate(false); + } + if (card) { + if (card.activatedByOther) return; + if (!isCurrentActiveCard) { + card!.toolbarModel?.show(event); + if ( + (card.constructor as CardEntry).singleSelectable !== + false && + (trigger !== CardActiveTrigger.CLICK || + isEngine(this.editor)) + ) { + this.select(card); + } + if ( + !card.isEditable && + (card.constructor as CardEntry).autoSelected !== false + ) + card.select(!card.isEditable); + card.activate(true); + } else if (card.isEditable) { + card.select(false); + } + if ( + !isCurrentActiveCard && + trigger === CardActiveTrigger.MOUSE_DOWN + ) { + editor.trigger('focus'); + } + editor.change.onSelect(); + } + } + } + + select(card: CardInterface) { + const editor = this.editor; + if (!isEngine(editor)) return; + if ( + (card.constructor as CardEntry).singleSelectable !== false && + (card.type !== CardType.BLOCK || !card.activated) + ) { + const range = editor.change.range.get(); + if ( + range.startNode.closest(EDITABLE_SELECTOR).length > 0 || + (card.isEditable && range.collapsed) || + card.isMaximize + ) + return; + const root = card.root; + const parentNode = root.parent()!; + const index = parentNode + .children() + .toArray() + .findIndex((child) => child.equal(root)); + range.setStart(parentNode, index); + range.setEnd(parentNode, index + 1); + editor.change.range.select(range); + } + } + + focus(card: CardInterface, toStart: boolean = false) { + const editor = this.editor; + if (!isEngine(editor)) return; + const { change, container, scrollNode } = editor; + const range = change.range.get(); + card.focus(range, toStart); + change.range.select(range); + this.activate(range.startNode, CardActiveTrigger.MOUSE_DOWN); + change.onSelect(); + if (scrollNode) range.scrollIntoViewIfNeeded(container, scrollNode); + } + + insert(name: string, value?: CardValue, ...args: any) { + if (!isEngine(this.editor)) throw 'Engine not found'; + const component = this.create(name, { + value, + }); + const { change } = this.editor; + const range = change.range.toTrusty(); + const card = this.insertNode(range, component, ...args); + const type = component.type; + if (type === 'inline') { + card.focus(range, false); + } + change.range.select(range); + if ( + type === 'block' && + (component.constructor as CardEntry).autoActivate !== false + ) { + this.activate(card.root, CardActiveTrigger.CARD_CHANGE); + } + change.change(); + return card; + } + + update( + selector: NodeInterface | Node | string, + value: CardValue, + ...args: any + ) { + if (!isEngine(this.editor)) return; + const { change } = this.editor; + const card = this.find(selector); + if (card) { + this.updateNode(card, value, ...args); + const range = change.range.get(); + card.focus(range, false); + change.change(); + } + } + + replace( + source: CardInterface, + name: string, + value?: CardValue, + ...args: any + ) { + this.remove(source.root); + return this.insert(name, value, ...args); + } + + remove(selector: NodeInterface | Node | string, hasModify: boolean = true) { + if (!isEngine(this.editor)) return; + const { change, list, node } = this.editor; + const range = change.range.get(); + const card = this.find(selector); + if (!card) return; + if (card.type === CardType.INLINE) { + range.setEndAfter(card.root[0]); + range.collapse(false); + } else { + this.focusPrevBlock(card, range, hasModify); + } + const parent = card.root.parent(); + this.removeNode(card); + list.addBr(range.startNode); + if (parent && node.isEmpty(parent)) { + if (parent.isEditable()) { + node.html(parent, '


    '); + range.select(parent, true); + range.shrinkToElementNode(); + range.collapse(false); + } else { + node.html(parent, '
    '); + range.select(parent, true); + range.collapse(false); + } + } + if (hasModify) change.apply(range); + else { + // 远程移除时,如果调用 change.apply() 会把字符合并在一起,这样就会少一个text节点,后续的ops命令无法找到节点删除 + change.range.select(range); + change.change(); + } + } + + removeRemote(selector: NodeInterface | Node | string) { + if (!isEngine(this.editor)) return; + const { node } = this.editor; + const card = this.find(selector); + if (!card) return; + + const parent = card.root.parent(); + this.removeNode(card); + if (parent && node.isEmpty(parent)) { + if (parent.isEditable()) { + node.html(parent, '


    '); + } else { + node.html(parent, '
    '); + } + } + } + + // 创建Card DOM 节点 + create( + name: string, + options?: { + value?: CardValue; + root?: NodeInterface; + }, + ): CardInterface { + const clazz = this.classes[name]; + if (!clazz) throw ''.concat(name, ': This card does not exist'); + const type = options?.value?.type || clazz.cardType; + if (['inline', 'block'].indexOf(type) < 0) { + throw ''.concat( + name, + ': the type of card must be "inline", "block"', + ); + } + if (options?.root) options.root.empty(); + const component = new clazz({ + editor: this.editor, + value: options?.value, + root: options?.root, + }); + + component.root.attributes(CARD_TYPE_KEY, type); + component.root.attributes(CARD_KEY, name); + //如果没有指定是否能聚集,那么当card不是只读的时候就可以聚焦 + const hasFocus = + clazz.focus !== undefined + ? clazz.focus + : isEngine(this.editor) && !this.editor.readonly; + const tagName = type === CardType.INLINE ? 'span' : 'div'; + //center + const center = $( + `<${tagName} ${ + component.isEditable ? DATA_TRANSIENT_ATTRIBUTES + "='*'" : '' + }/>`, + ); + center.attributes(CARD_ELEMENT_KEY, 'center'); + + if (hasFocus) { + center.attributes('contenteditable', 'false'); + } else { + component.root.attributes('contenteditable', 'false'); + } + //body + const body = $( + '<'.concat(tagName, ' ').concat(CARD_ELEMENT_KEY, '="body" />'), + ); + //可以聚焦的情况下,card左右两边添加光标位置 + if (hasFocus) { + //left + const left = $( + ``, + ); + //right + const right = $( + ``, + ); + body.append(left); + body.append(center); + body.append(right); + } else { + body.append(center); + } + if (type === CardType.BLOCK) { + this.editor.nodeId.generate(component.root); + } + + component.root.append(body); + component.init(); + return component; + } + + reRender(...cards: Array) { + if (cards.length === 0) cards = this.components; + const render = (card: CardInterface) => { + const result = card.render(); + const center = card.getCenter(); + if (result !== undefined) { + center.append(typeof result === 'string' ? $(result) : result); + } + if (card.contenteditable.length > 0) { + center.find(card.contenteditable.join(',')).each((node) => { + const child = $(node); + child.attributes( + 'contenteditable', + !isEngine(this.editor) || this.editor.readonly + ? 'false' + : 'true', + ); + child.attributes(DATA_ELEMENT, EDITABLE); + }); + } + card.didRender(); + }; + cards.forEach((card) => { + if (card.destroy) card.destroy(); + card.init(); + render(card); + }); + } + + /** + * 渲染 + * @param container 需要重新渲染包含卡片的节点,如果不传,则渲染全部待创建的卡片节点 + * @param options 是否异步渲染, 全部异步渲染完成后触发 + */ + render(container?: NodeInterface, callback?: (count: number) => void) { + const cards = container + ? container.isCard() + ? container + : container.find(`${READY_CARD_SELECTOR}`) + : this.editor.container.find(READY_CARD_SELECTOR); + this.gc(); + let setp = 0; + const render = (card: CardInterface) => { + const result = card.render(); + const center = card.getCenter(); + if (result !== undefined) { + center.append(typeof result === 'string' ? $(result) : result); + } + if (card.contenteditable.length > 0) { + center.find(card.contenteditable.join(',')).each((node) => { + const child = $(node); + if (!child.attributes('contenteditable')) + child.attributes( + 'contenteditable', + !isEngine(this.editor) || this.editor.readonly + ? 'false' + : 'true', + ); + child.attributes(DATA_ELEMENT, EDITABLE); + }); + this.render(center); + } + card.didRender(); + }; + + const asyncRenderCards: Array = []; + cards.each((node) => { + const cardNode = $(node); + const readyKey = cardNode.attributes(READY_CARD_KEY); + const key = cardNode.attributes(CARD_KEY); + const name = readyKey || key; + if (this.classes[name]) { + const value = cardNode.attributes(CARD_VALUE_KEY); + const attributes = cardNode.attributes(); + + let card: CardInterface | undefined; + if (key) { + card = this.find(cardNode); + if (card && card.root.equal(cardNode)) { + if (card.destroy) card.destroy(); + this.removeComponent(card); + } + } + //ready_card_key 待创建的需要重新生成节点,并替换当前待创建节点 + card = this.create(name, { + value: decodeCardValue(value), + root: key ? cardNode : undefined, + }); + Object.keys(attributes).forEach((attributesName) => { + if ( + attributesName.indexOf('data-') === 0 && + attributesName.indexOf('data-card') !== 0 + ) { + card!.root.attributes( + attributesName, + attributes[attributesName], + ); + } + }); + if (readyKey) cardNode.replaceWith(card.root); + this.components.push(card); + + // 重新渲染 + asyncRenderCards.push(card); + + if (readyKey) { + card.root.removeAttributes(READY_CARD_KEY); + } + } + }); + + asyncRenderCards.forEach(async (card) => { + render(card); + setp++; + if (setp === asyncRenderCards.length) { + if (callback) callback(asyncRenderCards.length); + } + }); + if (asyncRenderCards.length === 0) { + if (callback) callback(0); + } + } + + removeComponent(card: CardInterface): void { + this.each((c, index) => { + if (c.root.equal(card.root)) { + this.components.splice(index!, 1); + return false; + } + return; + }); + } + + gc() { + for (let i = 0; i < this.components.length; i++) { + const component = this.components[i]; + if ( + !component.root[0] || + component.root.closest('body').length === 0 + ) { + if (component.destroy) component.destroy(); + this.components.splice(i, 1); + i--; + } + } + } + + // 焦点移动到上一个 Block + focusPrevBlock( + card: CardInterface, + range: RangeInterface, + hasModify: boolean, + ) { + if (!isEngine(this.editor)) throw 'Engine not initialized'; + let prevBlock; + if (card.type === 'inline') { + const block = this.editor.block.closest(card.root); + if (block.isEditable()) { + prevBlock = card.root.prevElement(); + } else { + prevBlock = block.prevElement(); + } + } else { + prevBlock = card.root.prevElement(); + } + + if (hasModify) { + if (!prevBlock || prevBlock.attributes(CARD_KEY)) { + const _block = $('


    '); + card.root.before(_block); + range.select(_block, true); + range.collapse(false); + return; + } + } else { + if (!prevBlock) { + return; + } + + if (prevBlock.attributes(CARD_KEY)) { + this.editor.card.find(prevBlock)?.focus(range, false); + return; + } + } + + range.select(prevBlock, true).shrinkToElementNode().collapse(false); + } + // 焦点移动到下一个 Block + focusNextBlock( + card: CardInterface, + range: RangeInterface, + hasModify: boolean, + ) { + if (!isEngine(this.editor)) throw 'Engine not initialized'; + let nextBlock; + if (card.type === 'inline') { + const block = this.editor.block.closest(card.root); + + if (block.isEditable()) { + nextBlock = card.root.nextElement(); + } else { + nextBlock = block.nextElement(); + } + } else { + nextBlock = card.root.nextElement(); + } + + if (hasModify) { + if (!nextBlock || nextBlock.attributes(CARD_KEY)) { + const _block = $('


    '); + card.root.after(_block); + range.select(_block, true); + range.collapse(false); + return; + } + } else { + if (!nextBlock) { + return; + } + + if (nextBlock.attributes(CARD_KEY)) { + this.editor.card.find(nextBlock)?.focus(range, false); + return; + } + } + + range.select(nextBlock, true).shrinkToElementNode().collapse(true); + } +} + +export default CardModel; diff --git a/packages/engine/src/card/maximize/index.css b/packages/engine/src/card/maximize/index.css new file mode 100644 index 00000000..811710be --- /dev/null +++ b/packages/engine/src/card/maximize/index.css @@ -0,0 +1,63 @@ +.card-maximize-header { + position: fixed !important; + top: 0; + right: 0; + left: 0; + z-index: 9999; + height: 56px; + background: #fff; + border-bottom: 1px solid #e8e8e8; + width: 100%; +} + +.card-maximize-header .header-crumb { + float: left; + line-height: 32px; + display: flex; + height: 100%; + align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; +} + +.card-maximize-header .header-crumb a +{ + color: #595959 !important; + font-size: 14px; + cursor: pointer; +} + +.card-maximize-header .header-crumb .split { + display: inline-block; + vertical-align: middle; + padding: 0 15px; + font-size: 20px; + padding-right: 8px; + font-weight: 200; + margin: 0 8px; +} + +.card-maximize-header .header-crumb .split + a { + display: inline-block; + vertical-align: middle; +} + +.card-maximize-header .header-crumb .split + a:hover { + color: #8C8C8C; +} + +.data-card-block-max > [data-card-element="body"] > [data-card-element="center"], .data-card-block-max > [data-card-element="body"] > [data-card-element="center"].data-card-background-selected { + top: 96px; + background: #fafafa !important; + position: fixed!important; + right: 0; + bottom: 0; + left: 0; + z-index: 124; + overflow: auto; + padding: 20px; +} + +.am-engine-mobile .data-card-block-max > [data-card-element="body"] > [data-card-element="center"],.am-engine-view .data-card-block-max > [data-card-element="body"] > [data-card-element="center"] { + top:56px; +} \ No newline at end of file diff --git a/packages/engine/src/card/maximize/index.ts b/packages/engine/src/card/maximize/index.ts new file mode 100644 index 00000000..14656c79 --- /dev/null +++ b/packages/engine/src/card/maximize/index.ts @@ -0,0 +1,68 @@ +import { NodeInterface } from '../../types/node'; +import { CardInterface, MaximizeInterface } from '../../types/card'; +import { EditorInterface } from '../../types/engine'; +import { $ } from '../../node'; +import { DATA_ELEMENT, DATA_TRANSIENT_ELEMENT, UI } from '../../constants'; +import { isEngine } from '../../utils'; +import './index.css'; + +class Maximize implements MaximizeInterface { + protected card: CardInterface; + protected node?: NodeInterface; + private editor: EditorInterface; + + constructor(editor: EditorInterface, card: CardInterface) { + this.editor = editor; + this.card = card; + } + + restore() { + this.card.root.removeClass('data-card-block-max'); + if (this.node) { + this.node.remove(); + this.node = undefined; + } + const editor = this.editor; + if (isEngine(editor)) { + editor.trigger('card:minimize', this.card); + editor.history.reset(); + } + } + + maximize() { + if (this.node) return; + const editor = this.editor; + const { language } = editor; + const lang = language.get('maximize', 'back').toString(); + const node = + $(`
    + +
    `); + + node.on('click', (event: MouseEvent) => { + event.stopPropagation(); + }); + this.card.root.addClass('data-card-block-max'); + + const crumnNode = node.find('.header-crumb'); + crumnNode.on('click', () => { + this.card.minimize(); + }); + + const body = this.card.findByKey('body'); + body.prepend(node); + + if (isEngine(editor)) { + editor.trigger('card:maximize', this.card); + editor.history.reset(); + } + this.node = node; + } +} + +export default Maximize; diff --git a/packages/engine/src/card/resize/index.css b/packages/engine/src/card/resize/index.css new file mode 100644 index 00000000..735ac85f --- /dev/null +++ b/packages/engine/src/card/resize/index.css @@ -0,0 +1,28 @@ +.data-card-resize { + position: absolute; + bottom: -3px; + right: 0px; + left: 0px; + margin: 0 auto; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; +} + +.data-card-resize-btn{ + background: #d9dbdd; + border-radius: 3px; + height: 6px; + padding: 0 50px; + display: block; +} + +.data-card-resize-btn svg { + display: block; + color: #999; +} + +.data-card-resize-btn:hover { + cursor: row-resize; +} \ No newline at end of file diff --git a/packages/engine/src/card/resize/index.ts b/packages/engine/src/card/resize/index.ts new file mode 100644 index 00000000..dfd0abc4 --- /dev/null +++ b/packages/engine/src/card/resize/index.ts @@ -0,0 +1,149 @@ +import { + CardInterface, + EditorInterface, + NodeInterface, + ResizeCreateOptions, + ResizeInterface, +} from '../../types'; +import { $ } from '../../node'; +import './index.css'; +import { isMobile } from '../../utils'; + +class Resize implements ResizeInterface { + private editor: EditorInterface; + private card: CardInterface; + private point?: { x: number; y: number }; + private options: ResizeCreateOptions = {}; + private component?: NodeInterface; + + constructor(editor: EditorInterface, card: CardInterface) { + this.editor = editor; + this.card = card; + } + + create(options: ResizeCreateOptions) { + this.options = options; + this.component = $( + '
    ', + ); + + if (isMobile) { + this.component.on('touchstart', this.touchStart); + this.component.on('touchmove', this.touchMove); + this.component.on('touchend', this.dragEnd); + this.component.on('touchcancel', this.dragEnd); + } else { + this.component.on('dragstart', this.dragStart); + document.addEventListener('mousemove', this.dragMove); + document.addEventListener('mouseup', this.dragEnd); + } + this.component.on('click', (event: MouseEvent) => { + event.stopPropagation(); + }); + } + + render(container: NodeInterface = this.card.root, minHeight: number = 80) { + let start: boolean = false; + let height: number = 0, + moveHeight: number = 0; + this.create({ + dragStart: () => { + height = container.height(); + start = true; + }, + dragMove: (y) => { + if (start) { + moveHeight = height + y; + moveHeight = + moveHeight < minHeight ? minHeight : moveHeight; + container.css('height', `${moveHeight}px`); + } + }, + dragEnd: () => { + if (start) { + this.card.setValue({ + height: container.height(), + }); + start = false; + } + }, + }); + if (!this.component) return; + this.component.hide(); + container.append(this.component); + const value: any = this.card.getValue() || {}; + if (value.height) { + container.css('height', `${value.height}px`); + } + } + + touchStart = (event: TouchEvent) => { + event.preventDefault(); + event.cancelBubble = true; + this.point = { + x: event.targetTouches[0].clientX, + y: event.targetTouches[0].clientY, + }; + const { dragStart } = this.options; + if (dragStart) dragStart(this.point); + }; + + dragStart = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + event.cancelBubble = true; + this.point = { + x: event.clientX, + y: event.clientY, + }; + const { dragStart } = this.options; + if (dragStart) dragStart(this.point); + }; + + dragMove = (event: MouseEvent) => { + if (this.point) { + const { dragMove } = this.options; + if (dragMove) dragMove(event.clientY - this.point.y); + } + }; + + touchMove = (event: TouchEvent) => { + event.preventDefault(); + if (this.point) { + const { dragMove } = this.options; + if (dragMove) + dragMove(event.targetTouches[0].clientY - this.point.y); + } + }; + + dragEnd = (event: MouseEvent) => { + this.point = undefined; + const { dragEnd } = this.options; + if (dragEnd) dragEnd(); + }; + + show() { + this.component?.show(); + } + + hide() { + this.component?.hide(); + } + + destroy() { + if (isMobile) { + if (!this.component) return; + this.component.off('touchstart', this.touchStart); + this.component.off('touchmove', this.touchMove); + this.component.off('touchend', this.dragEnd); + this.component.off('touchcancel', this.dragEnd); + } else { + this.component?.off('dragstart', this.dragStart); + document.removeEventListener('mousemove', this.dragMove); + document.removeEventListener('mouseup', this.dragEnd); + } + this.component?.remove(); + } +} + +export default Resize; diff --git a/packages/engine/src/card/toolbar/index.css b/packages/engine/src/card/toolbar/index.css new file mode 100644 index 00000000..a9a67859 --- /dev/null +++ b/packages/engine/src/card/toolbar/index.css @@ -0,0 +1,59 @@ +.data-card-dnd { + position: absolute; + top: 0; + left: -21px; + right: auto; + bottom: auto; + width: 18px; + height: 24px; + line-height: 24px; + font-size: 14px; + font-weight: normal; + display: none; + opacity: 0; + z-index: 125; + transition: all 0.3s ease-in-out; + background: rgba(255, 255, 255, 0.9); +} + +.data-card-dnd-active { + display: block; + opacity: 1; + cursor: pointer; +} + +.data-card-dnd:hover { + background: #f4f4f4; + color: #595959; +} + +.data-card-dnd-trigger { + width: 18px; + height: 24px; + text-align: center; + color: #BFBFBF; + font-size: 16px; + border-radius: 2px 2px; + cursor: move; + cursor: grab; + cursor: -moz-grab; + cursor: -webkit-grab; +} + +.am-engine-view .data-toolbar-active { + min-width: 200px; +} + +.data-card-toolbar.data-toolbar-block { + top: -48px; + bottom: auto; + display: none; +} + +.data-card-toolbar.data-card-toolbar-active { + display: block; +} + +.data-card-toolbar.data-toolbar-active { + display: block; +} \ No newline at end of file diff --git a/packages/engine/src/card/toolbar/index.ts b/packages/engine/src/card/toolbar/index.ts new file mode 100644 index 00000000..dc931f40 --- /dev/null +++ b/packages/engine/src/card/toolbar/index.ts @@ -0,0 +1,362 @@ +import Toolbar, { Tooltip } from '../../toolbar'; +import { + CardEntry, + CardInterface, + CardToolbarInterface, + CardToolbarItemOptions, +} from '../../types/card'; +import { + ToolbarItemOptions, + ToolbarInterface as ToolbarBaseInterface, +} from '../../types/toolbar'; +import { EditorInterface } from '../../types/engine'; +import { DATA_ELEMENT, TRIGGER_CARD_ID, UI } from '../../constants'; +import { $ } from '../../node'; +import { isEngine, isMobile } from '../../utils'; +import Position from '../../position'; +import './index.css'; + +export const isCardToolbarItemOptions = ( + item: ToolbarItemOptions | CardToolbarItemOptions, +): item is CardToolbarItemOptions => { + return ['button', 'input', 'dropdown', 'node'].indexOf(item.type) === -1; +}; + +class CardToolbar implements CardToolbarInterface { + private card: CardInterface; + private toolbar?: ToolbarBaseInterface; + private editor: EditorInterface; + private offset?: Array; + private position: Position; + #hideTimeout: NodeJS.Timeout | null = null; + #showTimeout: NodeJS.Timeout | null = null; + + constructor(editor: EditorInterface, card: CardInterface) { + this.editor = editor; + this.card = card; + this.position = new Position(this.editor); + this.unbindEnterShow(); + if (!isEngine(this.editor) || this.editor.readonly) { + this.bindEnterShow(); + } + } + + clearHide = () => { + if (this.#hideTimeout) clearTimeout(this.#hideTimeout); + this.#hideTimeout = null; + }; + + clearShow = () => { + if (this.#showTimeout) clearTimeout(this.#showTimeout); + this.#showTimeout = null; + }; + + enterHide = () => { + this.clearShow(); + this.#hideTimeout = setTimeout(() => { + this.hide(); + this.#hideTimeout = null; + this.toolbar?.root?.off('mouseenter', this.clearHide); + this.toolbar?.root?.off('mouseleave', this.enterHide); + }, 200); + }; + + enterShow = () => { + this.clearHide(); + this.#showTimeout = setTimeout(() => { + this.#showTimeout = null; + this.show(); + this.toolbar?.root?.on('mouseenter', this.clearHide); + this.toolbar?.root?.on('mouseleave', this.enterHide); + }, 200); + }; + + bindEnterShow() { + this.card.root.on('mouseenter', this.enterShow); + this.card.root.on('mouseleave', this.enterHide); + } + + unbindEnterShow() { + this.card.root.off('mouseenter', this.enterShow); + this.card.root.off('mouseleave', this.enterHide); + } + + /** + * 设置工具栏偏移量[上x,上y,下x,下y] + * @param offset 偏移量 [tx,ty,bx,by] + */ + setOffset(offset: Array) { + this.offset = offset; + } + + getContainer() { + return this.toolbar?.root; + } + + getDefaultItem( + item: CardToolbarItemOptions, + ): ToolbarItemOptions | undefined { + const editor = this.editor; + const { language, clipboard, card } = editor; + switch (item.type) { + case 'separator': + return { + type: 'node', + node: + item.node || + $(``), + }; + case 'copy': + return { + type: 'button', + content: + item.content || + ``, + title: + item.title || language.get('copy', 'title').toString(), + onClick: () => { + const result = clipboard.copy(this.card.root[0], true); + if (result) + editor.messageSuccess( + language.get('copy', 'success').toString(), + ); + else + editor.messageError( + language.get('copy', 'error').toString(), + ); + }, + }; + case 'delete': + return { + type: 'button', + content: + item.content || + ``, + title: + item.title || + language.get('delete', 'title').toString(), + onClick: () => { + card.remove(this.card.root); + }, + }; + case 'maximize': + return { + type: 'button', + content: + item.content || + ``, + title: + item.title || + language.get('maximize', 'title').toString(), + onClick: () => { + this.card.maximize(); + }, + }; + case 'more': + return { + type: 'dropdown', + content: + item.content || + ``, + title: + item.title || language.get('more', 'title').toString(), + items: item.items, + }; + } + return; + } + + create() { + if (this.card.toolbar) { + //获取客户端配置 + const config = this.card.toolbar(); + //获取渲染节点 + const { root, language } = this.editor; + this.hide(); + const items: Array = []; + config.forEach((item) => { + //默认项 + if (isCardToolbarItemOptions(item)) { + switch (item.type) { + case 'dnd': + if (isMobile) return; + const dndNode = this.createDnd( + item.content || + '', + item.title || + language.get('dnd', 'title').toString(), + ); + root.append(dndNode); + break; + default: + const resultItem = this.getDefaultItem(item); + if (resultItem) items.push(resultItem); + } + } else { + items.push(item); + } + }); + + if (items.length > 0) { + const toolbar = new Toolbar({ + items, + }); + toolbar.root.addClass('data-card-toolbar'); + toolbar.root.attributes(TRIGGER_CARD_ID, this.card.id); + //渲染工具栏 + toolbar.render($(document.body)); + toolbar.hide(); + this.toolbar = toolbar; + } + } + } + + hide() { + const { root } = this.editor; + root.find('.data-card-dnd').remove(); + this.hideCardToolbar(); + } + + show(event?: MouseEvent) { + this.showCardToolbar(event); + } + + hideCardToolbar(): void { + this.toolbar?.destroy(); + this.position.destroy(); + } + + showCardToolbar(event?: MouseEvent): void { + this.create(); + const container = this.getContainer(); + if (container && container.length > 0) { + const element = container.get()!; + element.style.left = '0px'; + if (event) { + const { clientX } = event; + const groupElement = container.first(); + const cardRect = this.card.root + .get()! + .getBoundingClientRect(); + if ( + groupElement && + clientX >= cardRect.left && + clientX <= cardRect.right + ) { + const groupRect = groupElement + .get()! + .getBoundingClientRect(); + const space = cardRect.width - groupRect.width; + if (space > 0) { + const left = + clientX - cardRect.width - groupRect.width / 2; + element.style.left = + Math.max(Math.min(left, space), 0) + 'px'; + } + } + } else { + const cardRect = this.card.root + .get() + ?.getBoundingClientRect() || { + left: 0, + top: 0, + }; + const { root } = this.editor; + const rootRect = root + .get() + ?.getBoundingClientRect() || { + left: 0, + top: 0, + }; + const top = cardRect.top - rootRect.top; + const left = cardRect.left - rootRect.left; + + const dnd = root.find('.data-card-dnd'); + if (dnd.length > 0) { + dnd.css({ + top: `${top}px`, + left: `${left - dnd.width() - 4}px`, + }); + dnd.addClass('data-card-dnd-active'); + } + } + + container.addClass('data-toolbar-active'); + container.attributes( + 'toolbar-trigger-key', + (this.card.constructor as CardEntry).cardName, + ); + if (this.toolbar) this.toolbar.show(); + let prevAlign = 'topLeft'; + setTimeout(() => { + this.position.bind( + container, + this.card.isMaximize + ? this.card.getCenter().first()! + : this.card.root, + 'topLeft', + this.offset, + (rect) => { + if ( + this.offset && + this.offset.length === 4 && + rect.align === 'bottomLeft' && + rect.align !== prevAlign + ) { + this.position.setOffset([ + this.offset[2], + this.offset[3], + ]); + prevAlign = rect.align; + this.position.update(false); + } else if ( + this.offset && + rect.align === 'topLeft' && + rect.align !== prevAlign + ) { + this.position.setOffset(this.offset); + prevAlign = rect.align; + this.position.update(false); + } + prevAlign = rect.align; + }, + ); + }, 10); + } + } + + private createDnd(content: string, title: string) { + const dndNode = $( + `
    +
    + ${content} +
    +
    `, + ); + dndNode.on('mouseenter', () => { + Tooltip.show(dndNode, title); + }); + dndNode.on('mouseleave', () => { + Tooltip.hide(); + }); + dndNode.on('mousedown', (e) => { + e.stopPropagation(); + Tooltip.hide(); + this.hideCardToolbar(); + }); + + dndNode.on('mouseup', () => { + this.showCardToolbar(); + }); + return dndNode; + } + + destroy() { + this.unbindEnterShow(); + this.position.destroy(); + } +} + +export default CardToolbar; diff --git a/packages/engine/src/card/typing/backspace.ts b/packages/engine/src/card/typing/backspace.ts new file mode 100644 index 00000000..6516e9a5 --- /dev/null +++ b/packages/engine/src/card/typing/backspace.ts @@ -0,0 +1,122 @@ +import { CARD_LEFT_SELECTOR, CARD_RIGHT_SELECTOR } from '../../constants'; +import { EngineInterface, NodeInterface } from '../../types'; +import { CardType } from '../enum'; + +class Backspace { + private engine: EngineInterface; + constructor(engine: EngineInterface) { + this.engine = engine; + } + /** + * 焦点移动到当前光标最接近的block节点或传入的节点前一个 Block + * @param block 节点 + * @param isRemoveEmptyBlock 如果前一个block为空是否删除,默认为否 + */ + focusPrevBlock(block?: NodeInterface, isRemoveEmptyBlock: boolean = false) { + const { change } = this.engine; + const range = change.range.get(); + block = block || this.engine.block.closest(range.startNode); + let prevBlock = block.prev(); + if (!prevBlock) { + return; + } + // 前面是Card + if (prevBlock.isCard()) { + const card = this.engine.card.find(prevBlock); + if (card) card.focus(range); + return; + } + // 前面是列表 + if (this.engine.node.isList(prevBlock)) { + prevBlock = prevBlock.last(); + } + + if (!prevBlock) { + return; + } + + if (isRemoveEmptyBlock && this.engine.node.isEmptyWithTrim(prevBlock)) { + prevBlock.remove(); + return; + } + + range.select(prevBlock, true); + range.collapse(false); + change.range.select(range); + } + /** + * 在卡片节点处按下backspace键 + */ + trigger(event: KeyboardEvent) { + const { change } = this.engine; + const range = change.range.get(); + if (!range.collapsed) return; + // 查找当前光标所在卡片 + const card = this.engine.card.find(range.startNode); + if (!card) { + // 光标前面有Card,并且不是自定义列表,移除卡片 + const prevNode = range.getPrevNode(); + if ( + !event['isDelete'] && + prevNode && + prevNode.isCard() && + !this.engine.node.isCustomize(prevNode) + ) { + event.preventDefault(); + this.engine.card.remove(prevNode); + return false; + } + return true; + } + // inline 卡片 + if (card.type === CardType.INLINE) { + // 左侧光标 + const cardLeft = range.startNode.closest(CARD_LEFT_SELECTOR); + if (cardLeft.length > 0) { + const prev = card.root.prev(); + if (!prev) { + event.preventDefault(); + change.mergeAfterDelete(); + return false; + } else { + range.select(card.root).collapse(true); + } + change.range.select(range); + } + // 右侧光标 + const cardRight = range.startNode.closest(CARD_RIGHT_SELECTOR); + if (cardRight.length > 0) { + event.preventDefault(); + this.engine.card.remove(card.id); + range.handleBr(); + return false; + } + } else { + // 左侧光标 + const cardLeft = range.startNode.closest(CARD_LEFT_SELECTOR); + if (cardLeft.length > 0) { + event.preventDefault(); + if (card.root.parent()?.inEditor()) { + change.unwrap(card.root.parent()!); + } else { + this.focusPrevBlock(card.root, true); + } + return false; + } + // 右侧光标 + const cardRight = range.startNode.closest(CARD_RIGHT_SELECTOR); + + if (cardRight.length > 0) { + event.preventDefault(); + this.focusPrevBlock(card.root); + this.engine.card.remove(card.id); + return false; + } + } + //改变了光标选区,再次触发事件 + if (this.engine.trigger('keydown:backspace', event) === false) + return false; + return true; + } +} +export default Backspace; diff --git a/packages/engine/src/card/typing/default.ts b/packages/engine/src/card/typing/default.ts new file mode 100644 index 00000000..c208ae76 --- /dev/null +++ b/packages/engine/src/card/typing/default.ts @@ -0,0 +1,45 @@ +import { CARD_LEFT_SELECTOR, CARD_RIGHT_SELECTOR } from '../../constants'; +import { CardInterface, EngineInterface } from '../../types'; +import { CardType } from '../enum'; + +class Default { + private engine: EngineInterface; + constructor(engine: EngineInterface) { + this.engine = engine; + } + + block(component: CardInterface, event: KeyboardEvent) { + const { change, card } = this.engine; + const range = change.range.get(); + // 左侧光标 + const cardLeft = range.commonAncestorNode.closest(CARD_LEFT_SELECTOR); + if (cardLeft.length > 0) { + // 其它情况 + if (!event.metaKey && !event.ctrlKey) { + card.focusPrevBlock(component, range, true); + change.range.select(range); + } + return true; + } + // 右侧光标 + const cardRight = range.commonAncestorNode.closest(CARD_RIGHT_SELECTOR); + if (cardRight.length > 0) { + // 其它情况 + if (!event.metaKey && !event.ctrlKey) { + card.focusNextBlock(component, range, true); + change.range.select(range); + } + } + return true; + } + + trigger(event: KeyboardEvent) { + const { change } = this.engine; + const range = change.range.get(); + const card = this.engine.card.getSingleCard(range); + if (!card) return true; + if (card.type === CardType.BLOCK) return this.block(card, event); + return true; + } +} +export default Default; diff --git a/packages/engine/src/card/typing/down.ts b/packages/engine/src/card/typing/down.ts new file mode 100644 index 00000000..df6fbd0c --- /dev/null +++ b/packages/engine/src/card/typing/down.ts @@ -0,0 +1,43 @@ +import isHotkey from 'is-hotkey'; +import { CardInterface, EngineInterface } from '../../types'; +import { CardType } from '../enum'; + +class Down { + private engine: EngineInterface; + constructor(engine: EngineInterface) { + this.engine = engine; + } + + inline(component: CardInterface, event: KeyboardEvent) { + const { change, card } = this.engine; + const range = change.range.get(); + event.preventDefault(); + card.focusNextBlock(component, range, false); + change.range.select(range); + return false; + } + + block(component: CardInterface, event: KeyboardEvent) { + const { change, card } = this.engine; + const range = change.range.get(); + + event.preventDefault(); + card.focusNextBlock(component, range, false); + change.range.select(range); + return false; + } + + trigger(event: KeyboardEvent) { + const { change } = this.engine; + const range = change.range.get(); + const card = this.engine.card.getSingleCard(range); + if (!card) return true; + if (isHotkey('shift+down', event)) { + return true; + } + return card.type === CardType.INLINE + ? this.inline(card, event) + : this.block(card, event); + } +} +export default Down; diff --git a/packages/engine/src/card/typing/enter.ts b/packages/engine/src/card/typing/enter.ts new file mode 100644 index 00000000..d9dfc703 --- /dev/null +++ b/packages/engine/src/card/typing/enter.ts @@ -0,0 +1,95 @@ +import { CARD_LEFT_SELECTOR, CARD_RIGHT_SELECTOR } from '../../constants'; +import { CardInterface, EngineInterface, RangeInterface } from '../../types'; +import { $ } from '../../node'; +import { CardType } from '../enum'; + +class Enter { + private engine: EngineInterface; + constructor(engine: EngineInterface) { + this.engine = engine; + } + + /** + * 在卡片处插入空段落 + * @param range 光标 + * @param card 卡片 + * @param isStart 是否聚焦到开始位置 + */ + insertNewline( + range: RangeInterface, + card: CardInterface, + isStart: boolean, + ) { + const { change, block } = this.engine; + range.select(card.root); + range.collapse(isStart); + change.range.select(range); + const emptyBlock = $('


    '); + block.insert(emptyBlock); + + if (isStart) { + card.focus(range, true); + } else { + range.select(emptyBlock, true); + range.collapse(false); + } + change.range.select(range); + } + /** + * 在卡片节点处按下enter键 + */ + trigger(event: KeyboardEvent) { + const { change, card } = this.engine; + const range = change.range.get(); + // 查找当前光标所在卡片 + const component = card.find(range.startNode); + if (!component) return true; + + if (component.type === CardType.INLINE) { + // 左侧光标 + const cardLeft = range.startNode.closest(CARD_LEFT_SELECTOR); + if (cardLeft.length > 0) { + range.select(component.root); + range.collapse(true); + change.range.select(range); + } + // 右侧光标 + const cardRight = range.startNode.closest(CARD_RIGHT_SELECTOR); + if (cardRight.length > 0) { + range.select(component.root); + range.collapse(false); + change.range.select(range); + } + } else { + // 左侧光标 + const cardLeft = range.startNode.closest(CARD_LEFT_SELECTOR); + if (cardLeft.length > 0) { + event.preventDefault(); + const prev = component.root.prev(); + if (!prev || prev.isCard()) { + card.focusPrevBlock(component, range, true); + change.range.select(range); + } else { + this.insertNewline(range, component, true); + } + return false; + } + // 右侧光标 + const cardRight = range.startNode.closest(CARD_RIGHT_SELECTOR); + if (cardRight.length > 0) { + event.preventDefault(); + const next = component.root.next(); + if (!next || next.isCard()) { + card.focusNextBlock(component, range, true); + change.range.select(range); + } else { + this.insertNewline(range, component, false); + } + return false; + } + } + return true; + } +} + +export default Enter; diff --git a/packages/engine/src/card/typing/index.ts b/packages/engine/src/card/typing/index.ts new file mode 100644 index 00000000..c824f2ee --- /dev/null +++ b/packages/engine/src/card/typing/index.ts @@ -0,0 +1,9 @@ +import Enter from './enter'; +import Backspace from './backspace'; +import Left from './left'; +import Right from './right'; +import Up from './up'; +import Down from './down'; +import Default from './default'; + +export { Enter, Backspace, Left, Right, Up, Down, Default }; diff --git a/packages/engine/src/card/typing/left.ts b/packages/engine/src/card/typing/left.ts new file mode 100644 index 00000000..284a58e1 --- /dev/null +++ b/packages/engine/src/card/typing/left.ts @@ -0,0 +1,92 @@ +import isHotkey from 'is-hotkey'; +import { + CARD_CENTER_SELECTOR, + CARD_LEFT_SELECTOR, + CARD_RIGHT_SELECTOR, +} from '../../constants'; +import { CardEntry, CardInterface, EngineInterface } from '../../types'; +import { CardType } from '../enum'; + +class Left { + private engine: EngineInterface; + constructor(engine: EngineInterface) { + this.engine = engine; + } + + inline(card: CardInterface, event: KeyboardEvent) { + const { change } = this.engine; + const range = change.range.get(); + const { singleSelectable } = card.constructor as CardEntry; + // 左侧光标 + const cardLeft = range.commonAncestorNode.closest(CARD_LEFT_SELECTOR); + if (cardLeft.length > 0) { + const prev = card.root.prev(); + if (!prev) { + card.focus(range, true); + } else { + range.setStartBefore(card.root[0]); + range.collapse(true); + } + change.range.select(range); + return true; + } + // 右侧光标 + const cardRight = range.commonAncestorNode.closest(CARD_RIGHT_SELECTOR); + const isCenter = cardLeft.length === 0 && cardRight.length === 0; + if (cardRight.length > 0 || isCenter) { + event.preventDefault(); + if (isCenter) { + card.select(false); + } + if (!isCenter && singleSelectable !== false) { + this.engine.card.select(card); + } else { + card.focus(range, true); + change.range.select(range); + } + return false; + } + return true; + } + + block(component: CardInterface, event: KeyboardEvent) { + const { change, card } = this.engine; + const range = change.range.get(); + // 左侧光标 + const cardLeft = range.commonAncestorNode.closest(CARD_LEFT_SELECTOR); + if (cardLeft.length > 0) { + event.preventDefault(); + card.focusPrevBlock(component, range, false); + change.range.select(range); + return false; + } + // 右侧光标 + const cardRight = range.commonAncestorNode.closest(CARD_RIGHT_SELECTOR); + if (cardRight.length > 0) { + event.preventDefault(); + card.select(component); + return false; + } + if (card.getSingleSelectedCard(range)) { + event.preventDefault(); + component.focus(range, true); + change.range.select(range); + return false; + } + return true; + } + + trigger(event: KeyboardEvent) { + const { change } = this.engine; + const range = change.range.get(); + const card = this.engine.card.getSingleCard(range); + if (!card) return true; + if (isHotkey('shift+left', event)) { + return false; + } + return card.type === CardType.INLINE + ? this.inline(card, event) + : this.block(card, event); + } +} +export default Left; diff --git a/packages/engine/src/card/typing/right.ts b/packages/engine/src/card/typing/right.ts new file mode 100644 index 00000000..527375d7 --- /dev/null +++ b/packages/engine/src/card/typing/right.ts @@ -0,0 +1,88 @@ +import isHotkey from 'is-hotkey'; +import { CARD_LEFT_SELECTOR, CARD_RIGHT_SELECTOR } from '../../constants'; +import { CardEntry, CardInterface, EngineInterface } from '../../types'; +import { CardType } from '../enum'; + +class Right { + private engine: EngineInterface; + constructor(engine: EngineInterface) { + this.engine = engine; + } + + inline(card: CardInterface, event: KeyboardEvent) { + const { change } = this.engine; + const range = change.range.get(); + const { singleSelectable } = card.constructor as CardEntry; + // 左侧光标 + const cardLeft = range.commonAncestorNode.closest(CARD_LEFT_SELECTOR); + const cardRight = range.commonAncestorNode.closest(CARD_RIGHT_SELECTOR); + const isCenter = cardLeft.length === 0 && cardRight.length === 0; + if (cardLeft.length > 0 || isCenter) { + event.preventDefault(); + if (isCenter) { + card.select(false); + } + if (!isCenter && singleSelectable !== false) { + this.engine.card.select(card); + } else { + card.focus(range, false); + change.range.select(range); + } + return false; + } + // 右侧光标 + if (cardRight.length > 0) { + const next = card.root.next(); + if (!next) { + card.focus(range, false); + } else { + range.setEndAfter(card.root[0]); + range.collapse(false); + } + change.range.select(range); + } + return true; + } + + block(component: CardInterface, event: KeyboardEvent) { + const { change, card } = this.engine; + const range = change.range.get(); + + // 左侧光标 + const cardLeft = range.commonAncestorNode.closest(CARD_LEFT_SELECTOR); + if (cardLeft.length > 0) { + event.preventDefault(); + card.select(component); + return false; + } + // 右侧光标 + const cardRight = range.commonAncestorNode.closest(CARD_RIGHT_SELECTOR); + if (cardRight.length > 0) { + event.preventDefault(); + card.focusNextBlock(component, range, false); + change.range.select(range); + return false; + } + if (this.engine.card.getSingleSelectedCard(range)) { + event.preventDefault(); + component.focus(range, false); + change.range.select(range); + return false; + } + return true; + } + + trigger(event: KeyboardEvent) { + const { change } = this.engine; + const range = change.range.get(); + const card = this.engine.card.getSingleCard(range); + if (!card) return true; + if (isHotkey('shift+right', event)) { + return; + } + return card.type === CardType.INLINE + ? this.inline(card, event) + : this.block(card, event); + } +} +export default Right; diff --git a/packages/engine/src/card/typing/up.ts b/packages/engine/src/card/typing/up.ts new file mode 100644 index 00000000..0bd2845b --- /dev/null +++ b/packages/engine/src/card/typing/up.ts @@ -0,0 +1,42 @@ +import isHotkey from 'is-hotkey'; +import { CardInterface, EngineInterface } from '../../types'; +import { CardType } from '../enum'; + +class Up { + private engine: EngineInterface; + constructor(engine: EngineInterface) { + this.engine = engine; + } + + inline(component: CardInterface, event: KeyboardEvent) { + const { change, card } = this.engine; + const range = change.range.get(); + event.preventDefault(); + card.focusPrevBlock(component, range, false); + change.range.select(range); + return false; + } + + block(component: CardInterface, event: KeyboardEvent) { + const { change, card } = this.engine; + const range = change.range.get(); + event.preventDefault(); + card.focusPrevBlock(component, range, false); + change.range.select(range); + return false; + } + + trigger(event: KeyboardEvent) { + const { change } = this.engine; + const range = change.range.get(); + const card = this.engine.card.getSingleCard(range); + if (!card) return true; + if (isHotkey('shift+up', event)) { + return; + } + return card.type === CardType.INLINE + ? this.inline(card, event) + : this.block(card, event); + } +} +export default Up; diff --git a/packages/engine/src/change/dragover/index.css b/packages/engine/src/change/dragover/index.css new file mode 100644 index 00000000..73d64a8c --- /dev/null +++ b/packages/engine/src/change/dragover/index.css @@ -0,0 +1,9 @@ +.data-drop-cursor { + position: absolute; + width: 2px; + background-color: #347EFF; +} + +div.data-drag-image { + background-color: #f9f9f9; +} \ No newline at end of file diff --git a/packages/engine/src/change/dragover/index.ts b/packages/engine/src/change/dragover/index.ts new file mode 100644 index 00000000..e9325226 --- /dev/null +++ b/packages/engine/src/change/dragover/index.ts @@ -0,0 +1,189 @@ +import { RangeInterface } from '../../types/range'; +import Range from '../../range'; +import { EngineInterface } from '../../types/engine'; +import { CardInterface } from '../../types/card'; +import { DragoverOptions } from '../../types/change'; +import { $ } from '../../node'; +import './index.css'; + +class DragoverHelper { + private x: number = 0; + private y: number = 0; + private doc: Document = document; + private range: RangeInterface | undefined; + private caretRange: RangeInterface | undefined; + private targetCard: CardInterface | undefined; + private caretCard: CardInterface | undefined; + private isCardLeftRange: boolean = false; + private engine: EngineInterface; + private options: DragoverOptions = { + className: 'data-drop-cursor', + }; + + constructor(engine: EngineInterface, options?: DragoverOptions) { + this.engine = engine; + this.options = { ...this.options, ...options }; + } + + /** + * 获取当前坐标点的选区 + * @returns + */ + getRangeForPoint(): RangeInterface | undefined { + // https://developer.mozilla.org/zh-CN/docs/Web/API/DocumentOrShadowRoot/caretPositionFromPoint + // https://developer.mozilla.org/en-US/docs/Web/API/Document/caretRangeFromPoint + const { doc, x, y } = this; + // caretRangeFromPoint 已弃用 + if (doc.caretRangeFromPoint !== undefined) { + const range = Range.create(this.engine, doc, { x, y }); + if (range) return range; + } + if (event && event['rangeParent'] !== undefined) { + const range = Range.create(this.engine, doc); + range.setStart(event['rangeParent'], event['rangeOffset']); + range.collapse(true); + return range; + } + return; + } + + /** + * 获取卡片 + */ + getCard() { + return this.targetCard || this.caretCard; + } + + /** + * 解析事件参数 + * @param e 事件 + */ + parseEvent(e: DragEvent) { + // 文件从 Finder 拖进来,不触发 dragstart 事件 + // Card拖动,禁止浏览器行为 + // 禁止拖图进浏览器,浏览器默认打开图片文件 + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'move'; + } + const { card } = this.engine; + + this.x = e.clientX; + this.y = e.clientY; + const target = $(e.target || []); + this.doc = target.document || document; + this.targetCard = card.find(target); + // 当前鼠标精确击中的Card + this.caretRange = this.getRangeForPoint(); + this.caretCard = this.caretRange + ? card.find(this.caretRange.commonAncestorContainer) + : undefined; + } + // 有选区时获得的最近Card + getRange() { + const { caretRange, doc, x } = this; + + const card = this.getCard(); + + let cardCaretRange; + + if (card && card.root.length > 0) { + cardCaretRange = Range.create(this.engine, doc); + const { left, right } = card.root.getBoundingClientRect() || { + left: 0, + right: 0, + }; + const centerX = (left + right) / 2; + cardCaretRange.select(card.root.get()!); + // 以卡中点为中心为分割线,逼近两侧可插入的区间 + if (centerX < x) { + cardCaretRange.collapse(false); + this.isCardLeftRange = false; + } else { + cardCaretRange.collapse(true); + this.isCardLeftRange = true; + } + } + + this.range = cardCaretRange || caretRange; + return this.range; + } + + /** + * 获取光标位置 + * @param width 光标宽度,默认为2 + */ + getRect(width: number = 2) { + const { isCardLeftRange, range } = this; + + const card = this.getCard(); + + if (card && card.root.length > 0 && range) { + if (isCardLeftRange) { + // 如果选区在Card左侧,则向后选取一个元素,选中Card区域 + range.setEnd( + range.commonAncestorContainer, + range.endOffset + 1, + ); + const { left, bottom, top } = range.getBoundingClientRect(); + range.setEnd( + range.commonAncestorContainer, + range.endOffset - 1, + ); + return { + x: left - width, + y: top, + height: bottom - top, + }; + } + // 如果选区在Card右侧,则向前选取一个元素,选中Card区域 + range.setStart( + range.commonAncestorContainer, + range.startOffset - 1, + ); + const { right, top, bottom } = range.getBoundingClientRect(); + range.setStart( + range.commonAncestorContainer, + range.startOffset + 1, + ); + return { + x: right - width, + y: top, + height: bottom - top, + }; + } + // 如果Card根节点不存在,则原逻辑不变 + let rect = this.range?.getBoundingClientRect(); + if (rect?.height === 0) { + const node = this.range?.startContainer; + rect = (node as Element).getBoundingClientRect(); + } + + const { left, top, bottom } = rect || {}; + + return { + x: left, + y: top, + height: (bottom || 0) - (top || 0), + }; + } + + getCursor() { + const { className } = this.options; + return $(`body > div.${className}`); + } + + removeCursor() { + this.getCursor().remove(); + } + + setCursor() { + this.removeCursor(); + const { className } = this.options; + const cursor = $(`
    `); + $(document.body).append(cursor); + } +} + +export default DragoverHelper; diff --git a/packages/engine/src/change/event.ts b/packages/engine/src/change/event.ts new file mode 100644 index 00000000..56cf745a --- /dev/null +++ b/packages/engine/src/change/event.ts @@ -0,0 +1,525 @@ +import DragoverHelper from './dragover'; +import { EventListener, NodeInterface } from '../types/node'; +import isHotkey from 'is-hotkey'; +import { ChangeEventInterface, ChangeEventOptions } from '../types/change'; +import { CardInterface } from '../types/card'; +import { EngineInterface } from '../types/engine'; +import { RangeInterface } from '../types/range'; +import Range from '../range'; +import { CARD_ELEMENT_KEY } from '../constants/card'; +import { ClipboardData } from '../types/clipboard'; +import { DATA_ELEMENT, UI } from '../constants'; +import { $ } from '../node'; +import { isMobile, isSafari } from '../utils'; + +type GlobalEventType = 'root' | 'window' | 'container' | 'document'; +class ChangeEvent implements ChangeEventInterface { + private events: { + [key: string]: { type: string; listener: EventListener }[]; + } = {}; + private globalEvents: { + [key: string]: { type: string; listener: EventListener }[]; + } = {}; + private engine: EngineInterface; + isComposing: boolean; + isSelecting: boolean; + private dragoverHelper: DragoverHelper; + private options: ChangeEventOptions; + private keydownRange: RangeInterface | null = null; + + constructor(engine: EngineInterface, options: ChangeEventOptions = {}) { + this.engine = engine; + // 中文输入状态 + this.isComposing = false; + // 选择范围状态 + this.isSelecting = false; + this.dragoverHelper = new DragoverHelper(engine); + this.options = options; + } + + // return true:焦点在Card里的其它输入框 + // return false:焦点在编辑区域,触发 change、select 事件 + isCardInput(e: Event) { + let node = e.target ? $(e.target as Node) : null; + while (node) { + if (node.isEditable()) { + return false; + } + if (node.attributes(CARD_ELEMENT_KEY) === 'center') { + return true; + } + if (node.attributes(DATA_ELEMENT) === UI) { + return true; + } + const parent = node.parent(); + if (!parent) break; + node = parent; + } + return false; + } + + onInput(callback: EventListener) { + const { bindInput } = this.options; + if (bindInput && !bindInput()) return; + // 处理中文输入法状态 + // https://developer.mozilla.org/en-US-US/docs/Web/Events/compositionstart + this.onContainer('compositionstart', (event) => { + if (this.engine.readonly) { + return; + } + if (!this.isCardInput(event)) { + this.engine.ot.startMutationCache(); + } + // 组合输入法缓存协同 + const { change, node, block, list } = this.engine; + const range = change.range + .get() + .cloneRange() + .shrinkToTextNode() + .enlargeToElementNode(); + // 如果光标在自定义列表项节点前输入先自定义删除,不然排版不对 + if (!range.collapsed) { + const startBlock = block.closest(range.startNode); + const endBlock = block.closest(range.endNode); + if ( + (node.isCustomize(startBlock) || + node.isCustomize(endBlock)) && + !startBlock.equal(endBlock) + ) { + list.backspaceEvent?.trigger(new KeyboardEvent('')); + } + } + this.isComposing = true; + }); + this.onContainer('compositionend', () => { + if (this.engine.readonly) { + return; + } + this.isComposing = false; + }); + //对系统工具栏操作拦截,一般针对移动端的文本上下文工具栏 + //https://rawgit.com/w3c/input-events/v1/index.html#interface-InputEvent-Attributes + this.onContainer('beforeinput', (event: InputEvent) => { + if (this.engine.readonly) return; + const { change, card, node, block, list } = this.engine; + if (!change.rangePathBeforeCommand) + change.cacheRangeBeforeCommand(); + // 单独选中卡片或者selection处于卡片边缘,手动删除卡片 + const range = change.range + .get() + .cloneRange() + .shrinkToTextNode() + .enlargeToElementNode(); + // 修复 safari 浏览器在列表首次输入组合输入法时会删除li节点 + const { startNode } = range; + if ( + isSafari && + startNode.name === 'li' && + !node.isCustomize(startNode) + ) { + if (startNode.first()?.name !== 'br') + startNode.prepend('
    '); + } + + if (!range.collapsed) { + if ( + range.commonAncestorNode.attributes(CARD_ELEMENT_KEY) === + 'body' + ) + card.remove(range.commonAncestorNode); + else { + if (range.startNode.attributes(CARD_ELEMENT_KEY) === 'body') + card.remove(range.startNode); + if (range.endNode.attributes(CARD_ELEMENT_KEY) === 'body') + card.remove(range.endNode); + } + } + // 如果光标在自定义列表项节点前输入先自定义删除,不然排版不对 + if (!range.collapsed && !this.isComposing) { + const startBlock = block.closest(range.startNode); + const endBlock = block.closest(range.endNode); + if ( + (node.isCustomize(startBlock) || + node.isCustomize(endBlock)) && + !startBlock.equal(endBlock) + ) { + list.backspaceEvent?.trigger(new KeyboardEvent('')); + node.insertText(event.data || ''); + } + } + const { inputType } = event; + // 在组合输入法未正常执行结束命令插入就先提交协同 + if (this.isComposing && !inputType.includes('Composition')) { + this.engine.ot.submitMutationCache(); + } + const commandTypes = ['format', 'history']; + commandTypes.forEach((type) => { + if (inputType.indexOf(type) === 0) { + event.preventDefault(); + const commandName = inputType + .substring(type.length) + .toLowerCase(); + this.engine.command.execute(commandName); + } + }); + }); + let inputTimeout: NodeJS.Timeout | null = null; + this.onContainer('input', (e: Event) => { + if (this.engine.readonly) { + return; + } + + if (this.isCardInput(e)) { + return; + } + if (this.engine.isEmpty()) { + this.engine.showPlaceholder(); + } else { + this.engine.hidePlaceholder(); + } + if (inputTimeout) clearTimeout(inputTimeout); + inputTimeout = setTimeout(() => { + if (!this.isComposing) { + callback(e); + // 组合输入法结束后提交协同 + this.engine.ot.submitMutationCache(); + } + }, 10); + }); + } + + onSelect(callback: EventListener) { + const { bindSelect } = this.options; + if (bindSelect && !bindSelect()) return; + // 模拟 selection change 事件 + this.onContainer( + isMobile ? 'touchstart' : 'mousedown', + (event: MouseEvent | TouchEvent) => { + if (this.isCardInput(event)) { + return; + } + this.isSelecting = true; + }, + ); + this.onDocument(isMobile ? 'touchend' : 'mouseup', (e) => { + if (!this.isSelecting) { + return; + } + this.isSelecting = false; + // mouseup 瞬间选择状态不会马上被取消,需要延迟 + window.setTimeout(() => { + return callback(e); + }, 10); + }); + this.onContainer('keydown', () => { + const range = Range.from(this.engine); + this.keydownRange = range; + }); + // 补齐通过键盘选中的情况 + this.onContainer('keyup', (e) => { + if (this.engine.readonly) { + return; + } + + if (this.isCardInput(e)) { + return; + } + // command + 方向键不会触发 keyup 事件,所以先用 e.key === 'Meta' 代替 isHotkey('mod+方向键',e) + if ( + isHotkey('left', e) || + isHotkey('right', e) || + isHotkey('up', e) || + isHotkey('down', e) || + e.key === 'Meta' || + isHotkey('shift+left', e) || + isHotkey('shift+right', e) || + isHotkey('shift+up', e) || + isHotkey('shift+down', e) || + isHotkey('ctrl+b', e) || + isHotkey('ctrl+f', e) || + isHotkey('ctrl+n', e) || + isHotkey('ctrl+p', e) || + isHotkey('ctrl+a', e) || + isHotkey('ctrl+e', e) || + isHotkey('home', e) || + isHotkey('end', e) + ) { + const range = Range.from(this.engine); + if ( + this.keydownRange && + range && + range.equal(this.keydownRange) + ) + return; + if (!this.isComposing) { + callback(e); + } + } + }); + } + + onPaste( + callback: (data: ClipboardData & { isPasteText: boolean }) => void, + ) { + const { bindPaste } = this.options; + if (bindPaste && !bindPaste()) return; + let isPasteText = false; + this.onContainer('keydown', (e) => { + if (this.engine.readonly) { + return; + } + + if ( + !isHotkey('mod', e) || + !isHotkey('shift', e) || + !isHotkey('v', e) + ) { + isPasteText = false; + } + + if (isHotkey('mod+shift+v', e) || isHotkey('mod+alt+shift+v', e)) { + isPasteText = true; + } + }); + // https://developer.mozilla.org/en-US-US/docs/Web/Events/paste + this.onDocument('paste', (e) => { + const range = this.engine.change.range.get(); + if (!this.engine.container.contains(range.commonAncestorNode)) + return; + if (this.engine.readonly) { + return; + } + + if (this.isCardInput(e)) { + return; + } + + e.preventDefault(); + const data = this.engine.clipboard.getData(e); + const dataIsPasteText = isPasteText; + isPasteText = false; + callback({ + ...data, + isPasteText: dataIsPasteText, + }); + }); + } + + onDrop( + callback: (params: { + event: DragEvent; + range?: RangeInterface; + card?: CardInterface; + files: Array; + }) => void, + ) { + const { bindDrop } = this.options; + if (bindDrop && !bindDrop()) return; + let cardComponet: CardInterface | undefined; + let dragImage: NodeInterface | undefined; + let dropRange: RangeInterface | undefined; + + const dragStart = (e: DragEvent) => { + if (!e.target || this.engine.readonly) return; + e.stopPropagation(); + this.dragoverHelper.setCursor(); + const targetNode = $(e.target); + // 拖动Card + const dragCardTrigger = targetNode.attributes('drag-card-trigger'); + cardComponet = this.engine.card.find( + !!dragCardTrigger ? dragCardTrigger : targetNode, + ); + + if (cardComponet) { + cardComponet.toolbarModel?.hideCardToolbar(); + // https://kryogenix.org/code/browser/custom-drag-image.html + dragImage = cardComponet.find('img.data-drag-image'); + + if (dragImage.length > 0) { + dragImage = this.engine.node.clone(dragImage); + } else { + dragImage = $('
    '); + const cardRootElement = cardComponet.root.get(); + if (cardRootElement) { + dragImage.css({ + width: cardRootElement.clientWidth + 'px', + height: cardRootElement.clientHeight + 'px', + }); + } + } + + dragImage.css({ + position: 'absolute', + top: '-99999px', + right: '-99999px', + }); + $(document.body).append(dragImage); + e.dataTransfer?.setDragImage(dragImage[0] as Element, 0, 0); + } + }; + this.onRoot('dragstart', dragStart); + this.onContainer('dragstart', dragStart); + this.onContainer('dragover', (e: DragEvent) => { + if (this.engine.readonly) return; + const { dragoverHelper } = this; + const cursor = dragoverHelper.getCursor(); + if (cursor.length !== 0) { + dragoverHelper.parseEvent(e); + dropRange = dragoverHelper.getRange(); + const rect = dragoverHelper.getRect(); + cursor.css({ + height: rect.height + 'px', + top: Math.round(window.pageYOffset + (rect.y || 0)) + 'px', + left: Math.round(window.pageXOffset + (rect.x || 0)) + 'px', + }); + } else this.dragoverHelper.setCursor(); + }); + this.onContainer('dragleave', () => { + this.dragoverHelper.removeCursor(); + }); + this.onContainer('dragend', () => { + this.dragoverHelper.removeCursor(); + if (dragImage) { + dragImage.remove(); + dragImage = undefined; + } + }); + this.onContainer('drop', (e: DragEvent) => { + if (this.engine.readonly) return; + // 禁止拖图进浏览器,浏览器默认打开图片文件 + e.preventDefault(); + + this.dragoverHelper.removeCursor(); + if (dragImage) { + dragImage.remove(); + dragImage = undefined; + } + + const transfer = e.dataTransfer; + let files: Array = []; + // Edge 兼容性处理 + try { + if (transfer && transfer.items && transfer.items.length > 0) { + Array.from(transfer.items).forEach((item) => { + if (item.kind === 'file') { + const file = item.getAsFile(); + if (file) files.push(file); + } + }); + } else if ( + transfer && + transfer.files && + transfer.files.length > 0 + ) { + files = Array.from(transfer.files); + } + } catch (err) { + if (transfer && transfer.files && transfer.files.length > 0) { + files = Array.from(transfer.files); + } + } + + const data = { + event: e, + range: dropRange, + card: cardComponet, + files, + }; + callback(data); + cardComponet = undefined; + }); + } + + onDocument(eventType: string, listener: EventListener, index?: number) { + this.addEvent('document', eventType, listener, index); + } + + onWindow(eventType: string, listener: EventListener, index?: number) { + this.addEvent('window', eventType, listener, index); + } + + onContainer( + eventType: string, + listener: EventListener, + index?: number, + ): void { + this.addEvent('container', eventType, listener, index); + } + + onRoot(eventType: string, listener: EventListener, index?: number): void { + this.addEvent('root', eventType, listener, index); + } + + addEvent( + type: GlobalEventType, + eventType: string, + listener: EventListener, + index?: number, + ) { + if (!this.globalEvents[type]) { + this.globalEvents[type] = []; + } + if ( + !this.globalEvents[type].find((event) => event.type === eventType) + ) { + const globalListener = (...args: any) => { + const listeners = this.events[type].filter( + (event) => event.type === eventType, + ); + let result; + for (let i = 0; i < listeners.length; i++) { + result = listeners[i].listener(...args); + + if (result === false) { + break; + } + } + + return result; + }; + switch (type) { + case 'container': + this.engine.container.on(eventType, globalListener); + break; + case 'root': + this.engine.root.on(eventType, globalListener); + break; + case 'document': + document.addEventListener(eventType, globalListener); + break; + case 'window': + window.addEventListener(eventType, globalListener); + break; + } + this.globalEvents[type].push({ + type: eventType, + listener: globalListener, + }); + } + if (!this.events[type]) this.events[type] = []; + if (index !== undefined) { + this.events[type].splice(index, 0, { type: eventType, listener }); + } else { + this.events[type].push({ type: eventType, listener }); + } + } + + destroy() { + Object.keys(this.globalEvents).forEach((type) => { + const events = this.globalEvents[type]; + events.forEach((event) => { + if (type === 'window') { + window.removeEventListener(event.type, event.listener); + } else if (type === 'document') { + document.removeEventListener(event.type, event.listener); + } else if (type === 'container') { + this.engine.container.off(event.type, event.listener); + } else if (type === 'root') { + this.engine.root.off(event.type, event.listener); + } + }); + }); + } +} + +export default ChangeEvent; diff --git a/packages/engine/src/change/index.ts b/packages/engine/src/change/index.ts new file mode 100644 index 00000000..3addd83f --- /dev/null +++ b/packages/engine/src/change/index.ts @@ -0,0 +1,886 @@ +import { NodeInterface } from '../types/node'; +import { + ChangeInterface, + ChangeOptions, + ChangeRangeInterface, +} from '../types/change'; +import { EngineInterface } from '../types/engine'; +import { RangeInterface, RangePath } from '../types/range'; +import ChangeEvent from './event'; +import Parser from '../parser'; +import { ANCHOR_SELECTOR, CURSOR_SELECTOR, FOCUS_SELECTOR } from '../constants'; +import { combinText, getWindow } from '../utils'; +import { TRIGGER_CARD_ID } from '../constants/card'; +import { DATA_ID, EDITABLE_SELECTOR, UI_SELECTOR } from '../constants/root'; +import { SelectionInterface } from '../types/selection'; +import Selection from '../selection'; +import { $ } from '../node'; +import NativeEvent from './native-event'; +import ChangeRange from './range'; + +class ChangeModel implements ChangeInterface { + private engine: EngineInterface; + private options: ChangeOptions; + private changeTimer: NodeJS.Timeout | null = null; + event: ChangeEvent; + valueCached: string | null = null; + onChange: (value: string, trigger: 'remote' | 'local' | 'both') => void; + onRealtimeChange: (trigger: 'remote' | 'local') => void; + onSelect: () => void; + onSetValue: () => void; + rangePathBeforeCommand?: { start: RangePath; end: RangePath }; + marks: Array = []; + blocks: Array = []; + inlines: Array = []; + changeTrigger: Array = []; + range: ChangeRangeInterface; + #nativeEvent: NativeEvent; + + constructor(engine: EngineInterface, options: ChangeOptions = {}) { + this.options = options; + this.engine = engine; + this.event = new ChangeEvent(engine, {}); + + this.onChange = this.options.onChange || function () {}; + this.onRealtimeChange = this.options.onRealtimeChange || function () {}; + this.onSelect = this.options.onSelect || function () {}; + this.onSetValue = this.options.onSetValue || function () {}; + this.range = new ChangeRange(engine, { + onSelect: (range) => { + const { mark, block, inline } = engine; + this.marks = mark.findMarks(range); + this.blocks = block.findBlocks(range); + this.inlines = inline.findInlines(range); + }, + }); + this.#nativeEvent = new NativeEvent(engine); + } + + init() { + this.#nativeEvent.init(); + } + + private _change() { + if (!this.isComposing()) { + this.engine.card.gc(); + const value = this.getValue({ + ignoreCursor: true, + }); + if (!this.valueCached || value !== this.valueCached) { + const trigger = + this.changeTrigger.length === 2 + ? 'both' + : this.changeTrigger[0] === 'remote' + ? 'remote' + : 'local'; + this.onChange(value, trigger); + this.changeTrigger = []; + this.valueCached = value; + } + } + } + + change(isRemote?: boolean, applyNodes?: Array) { + const trigger = isRemote ? 'remote' : 'local'; + //动态触发可编辑卡片的onChange事件 + let editableElement: NodeInterface | undefined = undefined; + if (isRemote) { + applyNodes?.forEach((node) => { + editableElement = node.closest(EDITABLE_SELECTOR); + if (editableElement && editableElement.length > 0) { + const card = this.engine.card.find(editableElement, true); + if (card?.onChange) + card?.onChange(trigger, editableElement); + } + }); + } else { + const range = this.range.get(); + const { startNode } = range; + //如果开始节点在编辑器中就查找可编辑器卡片节点。如果是UI节点,就找到 trigger-card-id 属性,再找到card + if (startNode.inEditor()) { + editableElement = startNode.closest(EDITABLE_SELECTOR); + } else { + const uiElement = startNode.closest(UI_SELECTOR); + const cardId = uiElement.attributes(TRIGGER_CARD_ID); + if (cardId) { + editableElement = this.engine.card + .find(cardId) + ?.root.closest(EDITABLE_SELECTOR); + } + } + if (editableElement && editableElement.length > 0) { + const card = this.engine.card.find(editableElement, true); + if (card?.onChange) card?.onChange(trigger, editableElement); + } + } + + this.onRealtimeChange(trigger); + if (this.changeTrigger.indexOf(trigger) < 0) + this.changeTrigger.push(trigger); + this.clearChangeTimer(); + this.changeTimer = setTimeout(() => { + this._change(); + }, 200); + } + + private clearChangeTimer() { + if (this.changeTimer) clearTimeout(this.changeTimer); + } + + isComposing() { + return this.event.isComposing; + } + + isSelecting() { + return this.event.isSelecting; + } + + initValue(range?: RangeInterface, apply: boolean = true) { + const html = this.engine.container.html(); + const defaultHtml = '


    '; + if (html === defaultHtml || this.engine.container.children().length > 0) + return; + const emptyHtml = html || defaultHtml; + const node = $(emptyHtml); + if (node.children().length === 0) node.html('
    '); + this.engine.container.empty().append(node); + const safeRange = range || this.range.get(); + + if (!range && apply) { + safeRange.select(node, true).collapse(false); + this.apply(safeRange); + } + } + + setValue( + value: string, + onParse?: (node: NodeInterface) => void, + callback?: (count: number) => void, + ) { + const range = this.range.get(); + const { schema, conversion, container, history, mark, card } = + this.engine; + if (value === '') { + this.engine.container.html(value); + this.initValue(undefined, false); + } else { + const parser = new Parser(value, this.engine, (root) => { + mark.removeEmptyMarks(root); + root.allChildren(true).forEach((child) => { + if (onParse) { + onParse(child); + } + }); + }); + container.html(parser.toValue(schema, conversion, false, true)); + card.render(undefined, (count) => { + if (callback) callback(count); + }); + const cursor = container.find(CURSOR_SELECTOR); + const selection: SelectionInterface = new Selection( + this.engine, + range, + ); + + if (cursor.length > 0) { + selection.anchor = cursor; + selection.focus = cursor; + } + + const anchor = container.find(ANCHOR_SELECTOR); + const focus = container.find(FOCUS_SELECTOR); + + if (anchor.length > 0 && focus.length > 0) { + selection.anchor = anchor; + selection.focus = focus; + } + + if (selection.anchor && selection.focus) { + selection.move(); + this.range.select(range); + this.onSelect(); + } + this.onSetValue(); + history.clear(); + } + this.change(); + } + + setHtml(html: string, callback?: (count: number) => void) { + const { card, container } = this.engine; + this.#nativeEvent.paste( + html, + undefined, + callback, + true, + ( + fragment: DocumentFragment, + _range?: RangeInterface, + _rangeCallback?: (range: RangeInterface) => void, + _followActiveMark?: boolean, + ) => { + container.empty().append(fragment); + card.render(undefined, (count) => { + this.initValue(undefined, false); + if (callback) callback(count); + }); + }, + ); + } + + getOriginValue() { + const { container, schema, conversion } = this.engine; + return new Parser( + container.get()?.outerHTML || '', + this.engine, + ).toValue(schema, conversion); + } + + getValue( + options: { + ignoreCursor?: boolean; + } = {}, + ) { + let value; + if (options.ignoreCursor || this.isComposing()) { + value = this.getOriginValue(); + } else { + const range = this.range.get(); + let selection; + if (!range.inCard()) { + selection = range.createSelection(); + } + value = this.getOriginValue(); + selection?.move(); + } + return value; + } + + cacheRangeBeforeCommand() { + this.rangePathBeforeCommand = this.range.get().toPath(); + } + + getRangePathBeforeCommand() { + const rangePath = this.rangePathBeforeCommand; + this.rangePathBeforeCommand = undefined; + return rangePath; + } + + isEmpty() { + const { container, node, schema } = this.engine; + const tags = schema.getAllowInTags(); + return ( + node.isEmptyWithTrim(container) && + !container.allChildren().some((child) => tags.includes(child.name)) + ); + } + + combinText() { + combinText(this.engine.container); + } + + /** + * 应用一个具有改变dom结构的操作 + * @param range 光标 + */ + apply(range?: RangeInterface) { + this.combinText(); + const { inline, mark, nodeId } = this.engine; + if (range) { + const selection = range.createSelection('change-apply'); + inline + .findInlines(range) + .forEach((inlineNode) => inline.repairCursor(inlineNode)); + mark.findMarks(range).forEach((markNode) => + mark.repairCursor(markNode), + ); + selection.move(); + this.range.select(range); + } + this.change(); + + nodeId.generateAll(this.engine.container); + } + + /** + * 插入片段 + * @param fragment 片段 + * @param range 指定光标区域 + * @param callback 插入后的回调函数 + * @param followActiveMark 删除后空标签是否跟随当前激活的mark样式 + */ + insert( + fragment: DocumentFragment, + range?: RangeInterface, + callback: (range: RangeInterface) => void = () => {}, + followActiveMark: boolean = true, + ) { + const { block, list, card, schema, mark, inline } = this.engine; + const nodeApi = this.engine.node; + range = range || this.range.toTrusty(); + const firstBlock = block.closest(range.startNode); + const lastBlock = block.closest(range.endNode); + const onlyOne = lastBlock[0] === firstBlock[0]; + const isBlockLast = block.isLastOffset(range, 'end'); + const mergeTags = schema.getCanMergeTags(); + const allowInTags = schema.getAllowInTags(); + const mergeNode = firstBlock.closest(mergeTags.join(',')); + const isCollapsed = range.collapsed; + const childNodes = fragment.childNodes; + const firstNode = $(fragment.firstChild || []); + if (!isCollapsed) { + this.delete(range, onlyOne || !isBlockLast, followActiveMark); + } else if (range.startNode.isText()) { + const text = range.startNode.text(); + if (/^\u200B/.test(text)) range.startNode.text(text.substr(1)); + } + + const apply = (range: RangeInterface) => { + block.merge(range); + list.merge(undefined, range); + mark.merge(range); + inline.flat(range); + if (callback) callback(range); + this.apply(range); + }; + if ( + nodeApi.isList(range.startNode) || + range.startNode.closest('li').length > 0 + ) { + list.insert(fragment, range); + apply(range); + return; + } + if (!firstNode[0]) { + apply(range); + return; + } + // 第一个子节点不是block节点就追加到当前节点下 + let startRange: { node: NodeInterface; offset: number } | undefined = + undefined; + if (!nodeApi.isBlock(firstNode)) { + range.shrinkToElementNode(); + if (childNodes.length > 0) { + startRange = { + node: range.startNode, + offset: range.startOffset, + }; + } + let nextNode = firstNode.next(); + nodeApi.insert(firstNode, range); + while (nextNode && !nodeApi.isBlock(nextNode)) { + range.enlargeToElementNode().collapse(false); + const newNext = nextNode.next(); + nodeApi.insert(nextNode, range); + nextNode = newNext; + } + if (childNodes.length === 0) { + apply(range); + return; + } + } + const cloneRange = range + .cloneRange() + .enlargeToElementNode(true) + .collapse(false); + const startNode = + cloneRange.startContainer.childNodes[range.startOffset - 1]; + const endNode = cloneRange.startContainer.childNodes[range.startOffset]; + + if (childNodes.length !== 0) { + let lastNode = $(childNodes[childNodes.length - 1]); + if ('br' === lastNode.name) { + lastNode.remove(); + lastNode = $(childNodes[childNodes.length - 1]); + } + let node: NodeInterface | null = $(childNodes[0]); + let prev: NodeInterface | null = null; + const appendNodes = []; + while (node && node.length > 0) { + nodeApi.removeSide(node); + const next: NodeInterface | null = node.next(); + if (!next) { + lastNode = node; + } + + appendNodes.push(node); + if (prev) { + prev.after(node); + } else { + nodeApi.insert(node, range); + } + prev = node; + if (!next) { + range.select(node, true).collapse(false); + } + node = next; + } + if (mergeNode[0]) { + appendNodes.forEach((element) => { + if ( + mergeTags.indexOf(element.name) < 0 && + element.closest(mergeNode.name).length === 0 + ) { + nodeApi.wrap(element, nodeApi.clone(mergeNode, false)); + } + }); + } + //range.shrinkToElementNode().collapse(false); + const component = card.find(range.startNode); + if (component) component.focus(range, false); + } + const getFirstChild = (node: NodeInterface) => { + let child = node.first(); + if (!child || !nodeApi.isBlock(child)) return node; + while (allowInTags.indexOf(child ? child.name : '') > -1) { + child = child!.first(); + } + return child; + }; + + const getLastChild = (node: NodeInterface) => { + let child = node.last(); + if (!child || !nodeApi.isBlock(child)) return node; + while (allowInTags.indexOf(child ? child.name : '') > -1) { + child = child!.last(); + } + return child; + }; + + const isSameListChild = ( + _lastNode: NodeInterface, + _firstNode: NodeInterface, + ) => { + if (_lastNode.isCard() || firstNode.isCard()) return; + const fParent = _firstNode.parent(); + const lParent = _lastNode.parent(); + const isSameParent = + fParent && + !fParent.isEditable() && + lParent && + !lParent.isEditable() && + fParent.name === lParent.name; + return ( + ('p' === _firstNode.name && isSameParent) || + (_lastNode.name === _firstNode.name && + isSameParent && + !( + 'li' === _lastNode.name && + !list.isSame(_lastNode.parent()!, _firstNode.parent()!) + )) + ); + }; + + const removeEmptyNode = (node: NodeInterface) => { + while (!node.isEditable()) { + const parent = node.parent(); + node.remove(); + if (!parent || !nodeApi.isEmpty(parent)) break; + node = parent; + } + }; + + const clearList = ( + lastNode: NodeInterface, + nextNode: NodeInterface, + ) => { + if (lastNode.name === nextNode.name && 'p' === lastNode.name) { + const attr = nextNode.attributes(); + if (attr[DATA_ID]) delete attr[DATA_ID]; + lastNode.attributes(attr); + } + if ( + nodeApi.isEmptyWidthChild(lastNode) && + !nodeApi.isEmptyWidthChild(nextNode) + ) { + lastNode.get()!.innerHTML = ''; + } + if (nodeApi.isCustomize(lastNode) === nodeApi.isCustomize(nextNode)) + list.unwrapCustomize(nextNode); + }; + if (startNode) { + const _firstNode = getFirstChild($(startNode.nextSibling || []))!; + const _lastNode = getLastChild($(startNode))!; + if ( + _lastNode.name === 'p' && + _firstNode.name !== _lastNode.name && + isSameListChild(_lastNode, _firstNode) + ) { + clearList(_lastNode, _firstNode); + nodeApi.merge(_lastNode, _firstNode, false); + removeEmptyNode(_firstNode); + } + } + if (endNode) { + const prevNode = getLastChild($(endNode.previousSibling || [])); + const nextNode = getFirstChild($(endNode))!; + if (prevNode && isSameListChild(prevNode, nextNode)) { + nodeApi.merge(prevNode, nextNode, false); + removeEmptyNode(nextNode); + } + } + if (startRange && startRange.node.parent()) { + range + .shrinkToElementNode() + .setStart(startRange.node, startRange.offset); + range.enlargeToElementNode(); + } + apply(range); + } + + /** + * 删除内容 + * @param range 光标,默认获取当前光标 + * @param isDeepMerge 删除后是否合并 + * @param followActiveMark 删除后空标签是否跟随当前激活的mark样式 + */ + delete( + range?: RangeInterface, + isDeepMerge?: boolean, + followActiveMark: boolean = true, + ) { + const safeRange = range || this.range.toTrusty(); + if (safeRange.collapsed) { + if (this.isEmpty()) this.initValue(safeRange); + if (!range) this.apply(safeRange); + return; + } + const { mark, inline, card } = this.engine; + const nodeApi = this.engine.node; + const blockApi = this.engine.block; + let cloneRange = safeRange.cloneRange(); + cloneRange.collapse(true); + const activeMarks = followActiveMark ? mark.findMarks(cloneRange) : []; + safeRange.enlargeToElementNode(); + // 获取上面第一个 Block + let block = blockApi.closest( + safeRange + .cloneRange() + .shrinkToElementNode() + .shrinkToTextNode() + .enlargeToElementNode().startNode, + ); + // 获取的 block 超出编辑范围 + if (!block.inEditor() && !block.isRoot()) { + if (this.isEmpty()) this.initValue(safeRange); + if (!range) this.apply(safeRange); + return; + } + // 选中开始节点是卡片,并且光标位置在根节点,就先删除卡片 + if (block.isRoot()) { + let child = block.children().eq(safeRange.startOffset); + while (child?.isCard()) { + const isBreak = + child.equal(safeRange.endNode) || + child.contains(safeRange.endNode); + const next = child.next() || undefined; + const component = card.find(child); + if (component) card.removeNode(component); + else child.remove(); + if (isBreak) { + child = undefined; + return; + } + child = next; + } + if (!child) { + if (this.isEmpty()) this.initValue(safeRange); + if (!range) this.apply(safeRange); + return; + } + block = child; + } + const isMoreLine = !blockApi + .closest(safeRange.startNode) + .equal(blockApi.closest(safeRange.endNode)); + // 先删除范围内的所有内容 + safeRange.extractContents(); + if ( + safeRange.startNode.isEditable() && + safeRange.startNode.children().length === 0 + ) { + safeRange.startNode.html('


    '); + } + safeRange.collapse(true); + // 后续处理 + let { startNode } = safeRange + .shrinkToElementNode() + .shrinkToTextNode() + .enlargeToElementNode(); + // 只删除了文本,不做处理 + if (startNode.isText() || !block.inEditor()) { + if (this.isEmpty()) this.initValue(safeRange); + if (!range) this.apply(safeRange); + return; + } + let isRemoveStartNode = false; + if (isMoreLine && startNode.children().length === 0) { + const selection = safeRange.createSelection(); + startNode.remove(); + selection.move(); + isRemoveStartNode = true; + startNode = safeRange.startNode; + } + + const prevNode = block; + const nextNode = startNode; + let isEmptyNode = startNode.children().length === 0; + if (!isEmptyNode && startNode.length > 0 && startNode.inEditor()) { + if ( + startNode[0].childNodes.length === 1 && + startNode[0].firstChild?.nodeType === + getWindow().Node.ELEMENT_NODE && + nodeApi.isCustomize(startNode) && + startNode.first()?.isCard() + ) + isEmptyNode = true; + } + if (isEmptyNode && nodeApi.isBlock(startNode) && startNode.inEditor()) { + let html = nodeApi.getBatchAppendHTML(activeMarks, '
    '); + if (startNode.isEditable()) { + html = `

    ${html}

    `; + } + startNode.append($(html)); + const br = startNode.find('br'); + const parent = br.parent(); + if (parent && nodeApi.isMark(parent)) { + nodeApi.replace(br, $('\u200b', null)); + } + safeRange + .select(startNode, true) + .shrinkToElementNode() + .shrinkToTextNode(); + safeRange.collapse(false); + if (this.isEmpty()) this.initValue(safeRange); + if (!range) this.apply(safeRange); + return; + } + //深度合并 + const deepMergeNode = ( + range: RangeInterface, + prevNode: NodeInterface, + nextNode: NodeInterface, + marks: Array, + isDeepMerge: boolean = false, + ) => { + if ( + nodeApi.isBlock(prevNode) && + !nodeApi.isVoid(prevNode) && + !prevNode.isCard() + ) { + range.select(prevNode, true); + range.collapse(false); + const selection = range + .shrinkToElementNode() + .shrinkToTextNode() + .createSelection(); + let parent = nextNode.parent(); + nodeApi.merge(prevNode, nextNode); + while ( + parent && + nodeApi.isBlock(parent) && + nodeApi.isEmpty(parent) + ) { + parent.remove(); + parent = parent.parent(); + } + selection.move(); + range.enlargeToElementNode(true); + const prev = range.getPrevNode(); + const next = range.getNextNode(); + // 合并之后变成空 Block + const { startNode } = range; + if (!prev && !next && nodeApi.isBlock(startNode)) { + startNode.append( + $(nodeApi.getBatchAppendHTML(marks, '
    ')), + ); + range.select(startNode.find('br'), true); + range.collapse(false); + } + + if ( + prev && + next && + !prev.isCard() && + !next.isCard() && + isDeepMerge + ) { + deepMergeNode(range, prev, next, marks); + } + } + }; + if ( + prevNode && + nextNode && + nodeApi.isBlock(prevNode) && + nodeApi.isBlock(nextNode) && + !prevNode.equal(nextNode) && + nextNode.inEditor() + ) { + deepMergeNode( + safeRange, + prevNode, + nextNode, + activeMarks, + isDeepMerge, + ); + } + + startNode.children().each((node) => { + const domNode = $(node); + if ( + !nodeApi.isVoid(domNode) && + domNode.isElement() && + '' === nodeApi.html(domNode) + ) + domNode.remove(); + //给inline节点添加零宽字符,用于光标选择 + if (nodeApi.isInline(domNode)) { + inline.repairCursor(domNode); + } + }); + //移除空列表 + if (nodeApi.isList(startNode) && nodeApi.isEmpty(startNode)) { + startNode.remove(); + } + //修复inline节点光标选择在最后的零宽字符上时,将光标位置移到inline节点末尾 + cloneRange = safeRange.cloneRange().shrinkToTextNode(); + if ( + cloneRange.startNode.isText() && + /^\u200B/g.test(cloneRange.startNode.text()) && + cloneRange.startOffset === 0 + ) { + const prev = cloneRange.startNode.prev(); + if (prev && this.engine.node.isInline(prev)) { + safeRange.select(prev, true); + safeRange.collapse(false); + } + } + if (nodeApi.isBlock(startNode) && startNode.children().length === 0) { + startNode.html('
    '); + } + + if (isRemoveStartNode) { + if (nodeApi.isBlock(prevNode) && prevNode.children().length === 0) { + prevNode.html('
    '); + } + if (prevNode.inEditor()) + safeRange.select(prevNode, true).collapse(false); + } + if (this.isEmpty()) this.initValue(safeRange); + if (!range) this.apply(safeRange); + } + + /** + * 去除当前光标最接近的block节点或传入的节点外层包裹 + * @param node 节点 + */ + unwrap(node?: NodeInterface) { + const { block } = this.engine; + const range = this.range.get(); + node = node || block.closest(range.startNode); + if (!node.inEditor()) { + return; + } + + const selection = range.createSelection(); + this.engine.node.unwrap(node); + selection.move(); + this.range.select(range); + } + + /** + * 删除当前光标最接近的block节点或传入的节点后与前面一个节点后合并 + * @param node 节点 + */ + mergeAfterDelete(node?: NodeInterface) { + const { block, card, list, mark } = this.engine; + const nodeApi = this.engine.node; + const range = this.range.get(); + node = node || block.closest(range.startNode); + const children = node.children(); + if (children.length === 0) { + node.append($('
    ')); + this.apply(range); + return; + } + //


    abc

    ,先删除 br 標簽 + const first = node.first(); + if (children.length > 1 && first?.name === 'br') { + first?.remove(); + return; + } + let prevBlock = node.prev(); + // 前面没有节点 + if (!prevBlock) { + const parent = node.parent(); + if (parent?.inEditor() && !parent?.isEditable()) { + this.unwrap(node); + } + return; + } + // 前面是Card + if (prevBlock.isCard()) { + if ( + (children.length === 1 && first?.name === 'br') || + nodeApi.isEmpty(node) + ) { + node.remove(); + } + const component = card.find(prevBlock); + if (component) { + card.focus(component); + return; + } + } + // 前面是 void 节点 + if (nodeApi.isVoid(prevBlock)) { + prevBlock.remove(); + return; + } + // 前面是空段落 + if (nodeApi.isRootBlock(prevBlock) && nodeApi.isEmpty(prevBlock)) { + prevBlock.remove(); + return; + } + + // 前面是文本节点 + if (prevBlock.isText()) { + const paragraph = $('

    '); + prevBlock.before(paragraph); + paragraph.append(prevBlock); + prevBlock = paragraph; + } + if (nodeApi.isList(prevBlock)) { + prevBlock = prevBlock.last(); + } + // 只有一个
    时先删除 + if (children.length === 1 && first?.name === 'br') { + first?.remove(); + } else if ( + prevBlock && + prevBlock.children().length === 1 && + prevBlock.first()?.name === 'br' + ) { + prevBlock.first()?.remove(); + } + + if (!prevBlock || prevBlock.isText()) { + this.unwrap(node); + } else { + const selection = range.createSelection(); + nodeApi.merge(prevBlock, node); + selection.move(); + this.range.select(range); + mark.merge(); + list.merge(); + } + } + + destroy() { + this.event.destroy(); + this.clearChangeTimer(); + } +} + +export default ChangeModel; diff --git a/packages/engine/src/change/native-event.ts b/packages/engine/src/change/native-event.ts new file mode 100644 index 00000000..7a4394d2 --- /dev/null +++ b/packages/engine/src/change/native-event.ts @@ -0,0 +1,542 @@ +import { isHotkey } from 'is-hotkey'; +import { + CARD_LEFT_SELECTOR, + CARD_RIGHT_SELECTOR, + CARD_SELECTOR, + DATA_ELEMENT, + EDITABLE, + ROOT, + ROOT_SELECTOR, + UI_SELECTOR, +} from '../constants'; +import { + CardEntry, + EngineInterface, + NodeInterface, + RangeInterface, +} from '../types'; +import Range from '../range'; +import { $ } from '../node'; +import Parser, { TextParser } from '../parser'; +import Paste from './paste'; +import { CardActiveTrigger, CardType } from '../card/enum'; + +class NativeEvent { + engine: EngineInterface; + #lastePasteRange?: RangeInterface; + + constructor(engine: EngineInterface) { + this.engine = engine; + } + + repairInput(event: InputEvent, range: RangeInterface) { + const { commonAncestorNode } = range; + const card = this.engine.card.find(commonAncestorNode); + const { node, mark, change } = this.engine; + if (card && card.type === CardType.INLINE) { + if (card.isLeftCursor(commonAncestorNode)) { + const cardLeft = commonAncestorNode.closest(CARD_LEFT_SELECTOR); + let cardLeftText = cardLeft.text().replace(/\u200B/g, ''); + if (cardLeftText) { + cardLeftText = escape(cardLeftText); + range.setStartBefore(card.root); + range.collapse(true); + node.html(cardLeft, '​'); + node.insertText(cardLeftText, range); + change.apply(range); + } + } else if (card.isRightCursor(commonAncestorNode)) { + const cardRight = + commonAncestorNode.closest(CARD_RIGHT_SELECTOR); + let cardRightText = cardRight.text().replace(/\u200B/g, ''); + if (cardRightText) { + cardRightText = escape(cardRightText); + range.setEndAfter(card.root); + range.collapse(false); + node.html(cardRight, '​'); + node.insertText(cardRightText, range); + change.apply(range); + } + } else change.range.toTrusty(range); + } + + let { startNode, startOffset } = range.cloneRange().shrinkToTextNode(); + const parent = startNode.parent(); + //输入时删除mark标签内零宽字符。 + if (startNode.isText() && parent && node.isMark(parent)) { + let textNode = startNode.get()!; + let text = startNode.text(); + + //mark 插件禁止跟随样式时,将输入字符设置到mark标签外 + //输入光标在mark节点末尾 + if ( + startOffset === text.length && + event.data && + event.inputType.indexOf('insert') === 0 + ) { + let markParent: NodeInterface | undefined = parent; + let markTops: Array = []; + //循环查找 + while (markParent && node.isMark(markParent)) { + const markPlugin = mark.findPlugin(markParent); + //插件禁止跟随 + if (markPlugin && !markPlugin.followStyle) { + markTops.push(markParent); + } + markParent = markParent.parent(); + //如果还有位于下方的同级节点,并且父级节点也是mark节点,说明当前光标不在末尾了 + const markParentP = markParent?.parent(); + if ( + markParent?.next() && + markParentP && + node.isMark(markParentP) + ) { + break; + } + } + //查看下一个节点是否是紧紧挨着的相同样式如果有,那就继续跟随样式 + const startNext = startNode.next(); + markTops.forEach((markTop, index) => { + //第一种:abc123 或者 abc123 继续跟随 + //第二种:abc123 或者 abc123 继续跟随 + //第三种: abc123 继续跟随 + + //是开始节点所在的mark节点,如果开始节点后面有节点就继续跟随 + if (parent.equal(markTop) && startNext) { + markTops.splice(index, 1); + return; + } + let next = markTop.next(); + let curNode: NodeInterface | undefined = markTop; + //循环找到下一个节点,如果没有下一级节点,从父级节点查找父级的下一级。如果有下一级节点,并且父节点 + while (!next && curNode) { + //找到父节点 + const parent: NodeInterface | undefined = + curNode.parent(); + //如果父节点是块级节点,就不找了 + if (parent && node.isBlock(parent)) break; + //找到父级节点的下一级 + next = parent?.next() || null; + curNode = parent; + } + let first = next; + while (first && !first.isText()) { + if ( + node.isMark(first) && + mark.compare(first, markTop) + ) { + markTops.splice(index, 1); + break; + } + first = first.first(); + } + }); + if (markTops.length > 0) { + const lastText = textNode.splitText( + text.length - event.data.length, + ); + lastText.remove(); + if (node.isEmpty(parent)) parent.remove(); + mark.unwrap(markTops.map((mark) => mark.clone())); + node.insertText( + text.substr(text.length - event.data.length), + ); + mark.merge(); + range = change.range.get().cloneRange().shrinkToTextNode(); + startNode = range.startNode; + startOffset = range.startOffset; + textNode = startNode.get()!; + text = startNode.text(); + } + } + //输入光标在mark节点开始位置 + else if ( + event.data && + startOffset === event.data.length && + event.inputType.indexOf('insert') === 0 + ) { + let markParent: NodeInterface | undefined = parent; + let markTops: Array = []; + //循环查找 + while (markParent && node.isMark(markParent)) { + const markPlugin = mark.findPlugin(markParent); + //插件禁止跟随 + if (markPlugin && !markPlugin.followStyle) { + markTops.push(markParent); + } + markParent = markParent.parent(); + //如果还有位于下方的同级节点,并且父级节点也是mark节点,说明当前光标不在末尾了 + const markParentP = markParent?.parent(); + if ( + markParent?.prev() && + markParentP && + node.isMark(markParentP) + ) { + break; + } + } + //查看上一个节点是否是紧紧挨着的相同样式如果有,那就继续跟随样式 + const startPrev = startNode.prev(); + markTops.forEach((markTop, index) => { + //第一种:abc123 或者 abc123 继续跟随 + //第二种:abc123 或者 abc123 继续跟随 + //第三种: 123abc 继续跟随 + + //是开始节点所在的mark节点,如果开始节点后面有节点就继续跟随 + if (parent.equal(markTop) && startPrev) { + markTops.splice(index, 1); + return; + } + let prev = markTop.prev(); + let curNode: NodeInterface | undefined = markTop; + //循环找到上一个节点,如果没有上一级节点,从父级节点查找父级的上一级。如果有上一级节点,并且父节点 + while (!prev && curNode) { + //找到父节点 + const parent: NodeInterface | undefined = + curNode.parent(); + //如果父节点是块级节点,就不找了 + if (parent && node.isBlock(parent)) break; + //找到父级节点的下一级 + prev = parent?.prev() || null; + curNode = parent; + } + let last = prev; + while (last && !last.isText()) { + if (node.isMark(last) && mark.compare(last, markTop)) { + markTops.splice(index, 1); + break; + } + last = last.last(); + } + }); + if (markTops.length > 0) { + textNode.splitText(event.data.length); + textNode.remove(); + if (node.isEmpty(parent)) parent.remove(); + mark.unwrap(markTops.map((mark) => mark.clone())); + node.insertText(event.data); + mark.merge(); + range = change.range.get().cloneRange().shrinkToTextNode(); + startNode = range.startNode; + startOffset = range.startOffset; + textNode = startNode.get()!; + text = startNode.text(); + } + } + //输入时删除mark标签内零宽字符。 + if (text.length > 0 && /^\u200B$/g.test(text.substr(0, 1))) { + textNode.splitText(1); + textNode.remove(); + } + } + //输入时删除mark标签外最后的零宽字符 + const prev = startNode.prev(); + if (startNode.isText() && prev && node.isMark(prev)) { + const textNode = startNode.get()!; + const text = startNode.text(); + if (text.length > 0 && /^\u200B$/g.test(text.substr(0, 1))) { + textNode.splitText(1); + textNode.remove(); + } + } + } + + init() { + const { change, container, card, clipboard } = this.engine; + + change.event.onInput((event: InputEvent) => { + const range = change.range.get(); + this.repairInput(event, range); + change.range.select(range); + change.onSelect(); + change.change(); + }); + + change.event.onDocument('selectionchange', () => { + if (change.isComposing()) return; + const { window } = container; + const selection = window?.getSelection(); + + if (selection && selection.anchorNode) { + const range = Range.from(this.engine, selection)!; + // 判断当前光标是否包含卡片或者在卡片内部 + let containsCard = + range.containsCard() || + (range.commonAncestorNode.closest(CARD_SELECTOR).length > + 0 && + range.startNode.closest( + `${CARD_LEFT_SELECTOR},${CARD_RIGHT_SELECTOR},${UI_SELECTOR}`, + ).length === 0); + + let isSingle = range.collapsed; + if (!isSingle) { + const { startNode, endNode, startOffset, endOffset } = + range; + const startElement = + startNode.isElement() && !startNode.isCard() + ? startNode.children().eq(startOffset) + : startNode; + const endElement = + endNode.isElement() && !endNode.isCard() + ? endNode.children().eq(endOffset - 1) + : endNode; + if ( + startElement && + endElement && + startElement.isCard() && + startElement.equal(endElement) + ) { + isSingle = true; + } + } + card.each((card) => { + const center = card.getCenter(); + if (center && center.length > 0) { + let isSelect = selection.containsNode(center[0]); + if (!isSelect && containsCard && selection.focusNode) { + const focusCard = this.engine.card.find( + selection.focusNode, + ); + if (focusCard) { + isSingle = + !selection.anchorNode || + focusCard.root.contains( + selection.anchorNode, + ); + if ( + isSingle && + card.root.equal(focusCard.root) + ) { + isSelect = true; + } + } + // 找到一次其它的就不需要再去比对了 + if (isSelect && isSingle) containsCard = false; + } + const { autoSelected } = card.constructor as CardEntry; + card.select( + isSelect && (!isSingle || autoSelected !== false), + ); + } + }); + } + }); + change.event.onSelect((event: any) => { + const range = change.range.get(); + if (range.startNode.closest(ROOT_SELECTOR).length === 0) return; + if (range.collapsed && range.containsCard()) { + change.range.toTrusty(range); + } + change.range.select(range); + // 方向键选择不触发 card 激活 + if ( + !isHotkey('shift+left', event) && + !isHotkey('shift+right', event) && + !isHotkey('shift+up', event) && + !isHotkey('shift+down', event) + ) { + card.activate(range.commonAncestorNode); + } + change.onSelect(); + }); + + change.event.onDocument('mousedown', (e: MouseEvent | TouchEvent) => { + if (!e.target) return; + const targetNode = $(e.target); + // 点击元素已被移除 + if (targetNode.closest('body').length === 0) { + return; + } + // 阅读模式节点 + if (targetNode.closest('.am-view').length > 0) { + return; + } + // 工具栏、侧边栏、内嵌工具栏的点击 + let node: NodeInterface | undefined = targetNode; + while (node) { + const attrValue = node.attributes(DATA_ELEMENT); + if (attrValue && [ROOT, EDITABLE].indexOf(attrValue) < 0) { + return; + } + node = node.parent(); + } + card.activate(targetNode, CardActiveTrigger.MOUSE_DOWN); + }); + + change.event.onDocument('copy', (event) => { + const range = change.range.get(); + if (!this.engine.container.contains(range.commonAncestorNode)) + return; + clipboard.write(event); + }); + + change.event.onDocument('cut', (event) => { + const range = change.range.get(); + if (!this.engine.container.contains(range.commonAncestorNode)) + return; + event.stopPropagation(); + clipboard.write(event, undefined, () => { + clipboard.cut(); + change.change(); + }); + }); + + const pasteMarkdown = async (html: string, text: string) => { + // 先解析html + let parser = new Parser(html, this.engine); + const schema = this.engine.schema.clone(); + //转换Text,没那么严格,加入以下规则,以免被过滤掉,并且 div后面会加换行符 + schema.add([ + { + name: 'span', + type: 'mark', + }, + { + name: 'div', + type: 'block', + }, + ]); + // 不遍历卡片,不对 ol 节点格式化,以免复制列表就去提示检测到markdown + let parserText = parser.toText(schema, false, false); + // html中没有解析到文本 + if (!parserText) { + parser = new Parser(text, this.engine); + parserText = parser.toText(schema); + } + const textNode = $(document.createTextNode(parserText)); + // 如果没有符合的语法就返回 + const result = this.engine.trigger( + 'paste:markdown-check', + textNode, + ); + if (result !== false) return; + // 提示是否要转换 + this.engine + .messageConfirm( + this.engine.language.get('checkMarkdown', 'title'), + ) + .then(() => { + change.cacheRangeBeforeCommand(); + this.engine.trigger('paste:markdown-before', textNode); + this.engine.trigger('paste:markdown', textNode); + this.engine.trigger('paste:markdown-after', textNode); + + textNode.get()?.normalize(); + this.paste( + textNode.text(), + this.#lastePasteRange, + undefined, + false, + ); + }) + .catch(() => {}); + }; + + change.event.onPaste((data) => { + const { html, text, files, isPasteText } = data; + let source = ''; + if (files.length === 0) { + // 纯文本粘贴 + if (isPasteText) { + let value = ''; + if (text) value = text; + else if (html) + value = new Parser(html, this.engine).toText(); + source = new TextParser(value).toHTML(); + } else { + // 富文本粘贴 + if ( + html && + html.indexOf('') > + -1 + ) { + source = html; + } else if (text && /^https?:\/\/\S+$/.test(text)) { + const value = escape(text); + source = `${value}`; + } else if (html) { + source = html; + } else if (text) { + source = new TextParser(text).toHTML(); + } + } + } + if (this.engine.trigger('paste:event', data, source) === false) + return; + if (files.length === 0) { + change.cacheRangeBeforeCommand(); + setTimeout(() => { + // 如果 text 和 html 都有,就解析 text + pasteMarkdown(source, text || ''); + }, 200); + this.paste(source); + } + }); + + const canInsert = (range?: RangeInterface) => { + // 找不到目标位置 + // TODO: 临时解决,如果 drop Range 在Card里则不触发 + return !range || card.closest(range.commonAncestorContainer); + }; + + change.event.onDrop(({ event, range, card, files }) => { + if (card) { + event.preventDefault(); + if (canInsert(range)) return; + const cardEntry = card.constructor as CardEntry; + const cardName = cardEntry.cardName; + const cardValue = card.getValue(); + this.engine.card.remove(card.root); + change.range.select(range!); + this.engine.card.insert(cardName, cardValue); + } + if (files.length > 0) { + event.preventDefault(); + if (canInsert(range)) return; + change.range.select(range!); + this.engine.trigger('drop:files', files); + } + }); + } + + paste( + source: string, + range?: RangeInterface, + callback?: (count: number) => void, + followActiveMark: boolean = true, + insert?: ( + fragment: DocumentFragment, + range?: RangeInterface, + callback?: (range: RangeInterface) => void, + followActiveMark?: boolean, + ) => void, + ) { + const { change } = this.engine; + const fragment = new Paste(source, this.engine).normalize(); + this.engine.trigger('paste:before', fragment); + if (insert) insert(fragment, range, undefined, followActiveMark); + else + change.insert( + fragment, + range, + (range) => { + this.engine.trigger('paste:insert', range); + this.#lastePasteRange = range.cloneRange(); + range.collapse(false); + const selection = range.createSelection(); + this.engine.card.render(undefined, (count) => { + selection.move(); + range.scrollRangeIntoView(); + change.range.select(range); + if (callback) { + callback(count); + } + this.engine.trigger('paste:after'); + }); + }, + followActiveMark, + ); + } +} + +export default NativeEvent; diff --git a/packages/engine/src/change/paste.ts b/packages/engine/src/change/paste.ts new file mode 100644 index 00000000..d4e40fa9 --- /dev/null +++ b/packages/engine/src/change/paste.ts @@ -0,0 +1,374 @@ +import tinycolor2 from 'tinycolor2'; +import { NodeInterface, SchemaInterface } from '../types'; +import { READY_CARD_KEY, READY_CARD_SELECTOR } from '../constants/card'; +import Parser from '../parser'; +import { EngineInterface } from '../types/engine'; +import { $ } from '../node'; + +export default class Paste { + protected source: string; + protected engine: EngineInterface; + protected schema: SchemaInterface; + + constructor(source: string, engine: EngineInterface) { + this.source = source; + this.engine = engine; + this.schema = this.engine.schema.clone(); + } + + parser() { + const conversion = this.engine.conversion.clone(); + this.engine.trigger('paste:schema', this.schema); + const parser = new Parser(this.source, this.engine, (root) => { + this.engine.trigger('paste:origin', root); + }); + return parser.toDOM(this.schema, conversion); + } + + getDefaultStyle() { + const defaultStyle = [ + { + color: tinycolor2(this.engine.container.css('color')).toHex(), + }, + { + 'background-color': tinycolor2('white').toHex(), + }, + { + 'font-size': this.engine.container.css('font-size'), + }, + ]; + return defaultStyle; + } + + elementNormalize(fragment: DocumentFragment) { + const defaultStyle = this.getDefaultStyle(); + const { inline } = this.engine; + const nodeApi = this.engine.node; + // 第一轮预处理,主要处理 span 节点 + $(fragment).traverse((node) => { + // 跳过Card + if (node.isCard() || fragment === node.fragment) { + return; + } + // 删除与默认样式一样的样式 + if (node.isElement()) { + defaultStyle.forEach((style) => { + const key = Object.keys(style)[0]; + const defaultValue = style[key]; + let value = node.get()?.style[key]; + if (value) { + if (/color$/.test(key)) { + value = tinycolor2(value).toHex(); + } + if (value === defaultValue) { + node.css(key, ''); + } + } + }); + //处理后如果不是一个有效的节点就移除包裹 + if (!this.schema.getType(node)) nodeApi.unwrap(node); + + nodeApi.removeMinusStyle(node, 'text-indent'); + if (nodeApi.isList(node)) { + node.css('padding-left', ''); + } + // 删除空 style 属性 + if (!node.attributes('style')) { + node.removeAttributes('style'); + } + // 删除空 span + let first: NodeInterface | null = null; + if ( + node.name === 'span' && + Object.keys(node.attributes()).length === 0 && + Object.keys(node.css()).length === 0 && + (node.text().trim() === '' || + (first = + node.first() && + first && + (nodeApi.isMark(first, this.schema) || + nodeApi.isBlock(first, this.schema)))) + ) { + nodeApi.unwrap(node); + return; + } + + // br 换行改成正常段落 + if (nodeApi.isBlock(node, this.schema)) { + this.engine.block.brToBlock(node); + } + } + }); + // 第二轮处理 + $(fragment).traverse((node) => { + let parent = node.parent(); + // 跳过已被删除的节点 + if (!parent || node.fragment === fragment) { + return; + } + // 删除 google docs 根节点 + // + if (/^docs-internal-guid-/.test(node.attributes('id'))) { + nodeApi.unwrap(node); + return; + } + // 跳过Card + if (node.attributes(READY_CARD_KEY)) { + return; + } + // 删除零高度的空行 + if ( + nodeApi.isBlock(node, this.schema) && + node.attributes('data-type') !== 'p' && + !nodeApi.isVoid(node, this.schema) && + !nodeApi.isBlock(parent, this.schema) && + //!node.isSolid() && + nodeApi.html(node) === '' + ) { + node.remove(); + return; + } + // 段落 + if (node.attributes('data-type') === 'p') { + node.removeAttributes('data-type'); + } + if (nodeApi.isBlock(node, this.schema) && parent?.name === 'p') { + nodeApi.unwrap(node); + parent = node.parent(); + } + // 补齐 ul 或 ol + if (node.name === 'li' && parent && !nodeApi.isList(parent)) { + const ul = $('

      '); + node.before(ul); + ul.append(node); + return; + } + // 补齐 li + if (node.name !== 'li' && parent && nodeApi.isList(parent)) { + const li = $('
    • '); + node.before(li); + li.append(node); + return; + } + //
    • two
      1. three
      four
    • + if ( + nodeApi.isList(node) && + parent?.name === 'li' && + (node.prev() || node.next()) + ) { + let li: NodeInterface | null; + const isCustomizeList = parent?.parent()?.hasClass('data-list'); + const children = parent?.children(); + children.each((child, index) => { + const node = children.eq(index); + if (!node || nodeApi.isEmptyWithTrim(node)) { + return; + } + const isList = nodeApi.isList(node); + if (!li || isList) { + li = isCustomizeList + ? $('
    • ') + : $('
    • '); + parent?.before(li); + } + li.append(child); + if (isList) { + li = null; + } + }); + parent?.remove(); + return; + } + // p 改成 li + if (node.name === 'p' && parent && nodeApi.isList(parent)) { + nodeApi.replace(node, $('
    • ')); + return; + } + // 处理空 Block + if ( + nodeApi.isBlock(node, this.schema) && + !nodeApi.isVoid(node, this.schema) && + nodeApi.html(node).trim() === '' + ) { + //

      to


      + if ( + nodeApi.isRootBlock(node, this.schema) || + node.name === 'li' + ) { + nodeApi.html(node, '
      '); + } + } + //
    • foo

    • + if (nodeApi.isBlock(node, this.schema) && parent?.name === 'li') { + //

    • + if ( + node.children().length === 1 && + node.first()?.name === 'br' + ) { + // nothing + } else { + node.after('
      '); + } + nodeApi.unwrap(node); + return; + } + if ( + nodeApi.isInline(node) && + !node.isCard() && + !nodeApi.isVoid(node, this.schema) + ) { + const isVoid = node + .allChildren() + .some((node) => nodeApi.isVoid(node, this.schema)); + if (nodeApi.isEmptyWithTrim(node) && !isVoid) node.remove(); + else inline.repairCursor(node); + } + // 移除两边的 BR + nodeApi.removeSide(node); + + // 处理嵌套 + let nodeParent = parent; + while ( + nodeParent && + !nodeParent.fragment && + nodeApi.isBlock(node, this.schema) && + !nodeApi.isBlock(nodeParent, this.schema) + ) { + const nodeClone = node.clone(); + nodeApi.unwrap(node); + nodeParent.before(nodeClone); + nodeClone.append(nodeParent); + node = nodeClone; + nodeParent = node.parent(); + } + // mark 相同的嵌套 + nodeParent = parent; + while ( + nodeParent && + nodeApi.isMark(nodeParent, this.schema) && + nodeApi.isMark(node, this.schema) + ) { + if (this.engine.mark.compare(nodeParent.clone(), node, true)) { + nodeApi.unwrap(node); + break; + } else { + nodeParent = nodeParent.parent(); + } + } + }); + } + + normalize() { + const nodeApi = this.engine.node; + let fragment = this.parser(); + this.elementNormalize(fragment); + const range = this.engine.change.range.get(); + const root = range.commonAncestorNode; + const inline = this.engine.inline.closest(root); + if ( + root.inEditor() && + !inline.isCard() && + nodeApi.isInline(inline, this.schema) + ) { + this.removeElementNodes($(fragment)); + return fragment; + } + if ( + root.inEditor() && + root.isText() && + range.startContainer === range.endContainer + ) { + const text = root[0].nodeValue; + const leftText = text?.substr(0, range.startOffset); + const rightText = text?.substr(range.endOffset); + // 光标在 [text](|) 里 + if ( + /\[.*?\]\($/.test(leftText || '') && + /^\)/.test(rightText || '') + ) { + this.removeElementNodes($(fragment)); + return fragment; + } + } + + $(fragment).traverse((node) => { + if (node.fragment === fragment) return; + if (node.length > 0 && node.parent()) + this.engine.trigger('paste:each', node); + // 删除非block节点的换行 \r\n\r\n { + if (node.fragment === fragment) return; + this.engine.trigger('paste:each-after', node); + if (node.isText()) { + const text = node.text(); + const match = /((\n)+)/.exec(text); + if (match && !text.endsWith('\n') && !text.startsWith('\n')) { + const nextReg = node.get()!.splitText(match.index); + const endReg = nextReg.splitText(match[0].length); + node.after(nextReg); + node.after(endReg); + } + } + // 删除包含Card的 pre 标签 + if ( + node.name === 'pre' && + node.find(READY_CARD_SELECTOR).length > 0 + ) { + nodeApi.unwrap(node); + } + }); + + const node = nodeApi.normalize($(fragment)); + if (node.fragment) fragment = node.fragment; + fragment.normalize(); + let fragmentNode = $(fragment); + const first = fragmentNode.first(); + //如果光标在文本节点,并且父级节点不是根节点,移除粘贴数据的第一个节点块级节点,让其内容接在光标所在行 + const { startNode } = range + .cloneRange() + .shrinkToElementNode() + .shrinkToTextNode(); + if ( + startNode.inEditor() && + first && + first.name === 'p' && + !(first.length === 1 && first.first()?.name === 'br') + ) { + nodeApi.unwrap(first); + } + fragmentNode = $(fragment); + fragmentNode.each((_, index) => { + const children = fragmentNode.eq(index); + children?.find('ul,ol').each((_, index) => { + const child = children.eq(index); + if (child && nodeApi.isList(child)) { + this.engine.list.addStart(child); + } + }); + }); + this.engine.nodeId.generateAll($(fragment), true); + return fragment; + } + + removeElementNodes(fragment: NodeInterface) { + const nodes = fragment.allChildren(); + nodes.forEach((node) => { + if (node.isElement()) { + this.engine.node.unwrap(node); + } + }); + } +} diff --git a/packages/engine/src/change/range.ts b/packages/engine/src/change/range.ts new file mode 100644 index 00000000..8a44537e --- /dev/null +++ b/packages/engine/src/change/range.ts @@ -0,0 +1,342 @@ +import { + ChangeRangeInterface, + EngineInterface, + RangeInterface, +} from '../types'; +import { $ } from '../node'; +import { CARD_ELEMENT_KEY } from '../constants'; +import Range from '../range'; + +export type ChangeRangeOptions = { + onSelect?: (range: RangeInterface) => void; +}; + +class ChangeRange implements ChangeRangeInterface { + engine: EngineInterface; + #lastBlurRange?: RangeInterface; + #otpions: ChangeRangeOptions; + + constructor(engine: EngineInterface, options: ChangeRangeOptions = {}) { + this.engine = engine; + this.#otpions = options; + + this.engine.on('foucs', () => { + this.#lastBlurRange = undefined; + }); + + this.engine.on('blur', () => { + const range = this.get(); + if (range.commonAncestorNode.inEditor()) + this.#lastBlurRange = range; + }); + } + + /** + * 获取安全可控的光标对象 + * @param range 默认当前光标 + */ + toTrusty(range: RangeInterface = this.get()) { + // 如果不在编辑器内,聚焦到编辑器 + const { commonAncestorNode } = range; + if ( + !commonAncestorNode.isEditable() && + !commonAncestorNode.inEditor() + ) { + range + .select(this.engine.container, true) + .shrinkToElementNode() + .collapse(false); + } + //卡片 + let rangeClone = range.cloneRange(); + rangeClone.collapse(true); + this.setCardRang(rangeClone); + if ( + !range.startNode.equal(rangeClone.startNode) || + range.startOffset !== rangeClone.startOffset + ) + range.setStart(rangeClone.startContainer, rangeClone.startOffset); + + rangeClone = range.cloneRange(); + rangeClone.collapse(false); + this.setCardRang(rangeClone); + if ( + !range.endNode.equal(rangeClone.endNode) || + range.endOffset !== rangeClone.endOffset + ) + range.setEnd(rangeClone.endContainer, rangeClone.endOffset); + + if (range.collapsed) { + rangeClone = range.cloneRange(); + rangeClone.enlargeFromTextNode(); + + const startNode = $(rangeClone.startContainer); + const startOffset = rangeClone.startOffset; + + if (this.engine.node.isInline(startNode) && startOffset === 0) { + range.setStartBefore(startNode[0]); + } + if ( + this.engine.node.isInline(startNode) && + startOffset === startNode[0].childNodes.length + ) { + range.setStartAfter(startNode[0]); + } + range.collapse(true); + } + return range; + } + + private setCardRang(range: RangeInterface) { + const { startNode, startOffset } = range; + const { card } = this.engine; + const component = card.find(startNode); + if (component) { + const cardCenter = component.getCenter().get(); + if ( + cardCenter && + (!startNode.isElement() || + startNode[0].parentNode !== component.root[0] || + startNode.attributes(CARD_ELEMENT_KEY)) + ) { + const comparePoint = () => { + const doc_rang = Range.create(this.engine); + doc_rang.select(cardCenter, true); + return doc_rang.comparePoint(startNode, startOffset) < 0; + }; + + if ('inline' === component.type) { + range.select(component.root); + range.collapse(comparePoint()); + return; + } + + if (comparePoint()) { + card.focusPrevBlock(component, range, true); + } else { + card.focusNextBlock(component, range, true); + } + } + } + } + + get() { + const { container } = this.engine; + const { window } = container; + let range = Range.from(this.engine, window!, false); + if (!range) { + range = Range.create(this.engine, window!.document) + .select(container, true) + .shrinkToElementNode() + .collapse(false); + } + return range; + } + + select(range: RangeInterface) { + const { container, inline, node, change } = this.engine; + const { window } = container; + const selection = window?.getSelection(); + if (change.isComposing()) return; + //折叠状态 + if (range.collapsed) { + const { startNode, startOffset } = range; + //如果节点下只要一个br标签,并且是


      ,那么选择让光标选择在


      + if ( + ((startNode.isElement() && + 1 === startOffset && + 1 === startNode.children().length) || + (2 === startOffset && + 2 === startNode.children().length && + startNode.first()?.isCard())) && + 'br' === startNode.last()?.name + ) { + range.setStart(startNode, startOffset - 1); + range.collapse(true); + } + } + //修复inline光标 + let { startNode, endNode, startOffset, endOffset } = range + .cloneRange() + .shrinkToTextNode(); + const prev = startNode.prev(); + const next = endNode.next(); + //光标上一个节点是inline节点,让其选择在inline节点后的零宽字符后面 + if ( + prev && + !prev.isCard() && + !node.isVoid(prev) && + node.isInline(prev) + ) { + const text = startNode.text(); + //前面是inline节点,后面是零宽字符 + if (/^\u200B/g.test(text) && startOffset === 0) { + range.setStart(endNode, startOffset + 1); + if (range.collapsed) range.collapse(true); + } + } + //光标下一个节点是inline节点,让其选择在inline节点前面的零宽字符前面 + if ( + next && + !next.isCard() && + !node.isVoid(next) && + node.isInline(next) + ) { + const text = endNode.text(); + if (/\u200B$/g.test(text) && endOffset === text.length) { + range.setEnd(endNode, endOffset - 1); + if (range.collapsed) range.collapse(false); + } + } + //光标内侧位置 + const inlineNode = inline.closest(startNode); + if ( + !inlineNode.isCard() && + node.isInline(inlineNode) && + !node.isVoid(inlineNode) + ) { + //左侧 + if ( + startNode.isText() && + !startNode.prev() && + startNode.parent()?.equal(inlineNode) && + startOffset === 0 + ) { + range.setStart(startNode, startOffset + 1); + if (range.collapsed) range.collapse(true); + } + //右侧 + if ( + endNode.isText() && + !endNode.next() && + endNode.parent()?.equal(inlineNode) && + endOffset === endNode.text().length + ) { + range.setEnd(endNode, endOffset - 1); + if (range.collapsed) range.collapse(false); + } + } + startNode = range.startNode; + endNode = range.endNode; + const startChildNodes = startNode.children(); + // 自定义列表节点选中卡片前面就让光标到卡片后面去 + if (node.isCustomize(startNode) && startOffset === 0) { + range.setStart(startNode, 1); + } + if (node.isCustomize(endNode) && endOffset === 0) { + range.setEnd(endNode, 1); + } + // 空节点添加br + if (startNode.name === 'p') { + if (startChildNodes.length === 0) startNode.append('
      '); + else if ( + startChildNodes.length > 1 && + startChildNodes[startChildNodes.length - 2].nodeName !== 'BR' && + startChildNodes[startChildNodes.length - 1].nodeName === 'BR' + ) { + startNode.last()?.remove(); + } + } + if ( + !range.collapsed && + endNode.name === 'p' && + endNode.children().length === 0 + ) { + endNode.append('
      '); + } + // 列表节点没有子节点 + if (node.isList(startNode) && startNode.children().length === 0) { + const newNode = $('


      '); + startNode.before(newNode); + startNode.remove(); + startNode = newNode; + } + // 空列表添加br + if (startNode.name === 'li') { + if (startChildNodes.length === 0) { + startNode.append('
      '); + } else if ( + !node.isCustomize(startNode) && + startChildNodes.length > 1 && + startChildNodes[startChildNodes.length - 2].nodeName !== 'BR' && + startChildNodes[startChildNodes.length - 1].nodeName === 'BR' + ) { + startNode.last()?.remove(); + } else if ( + node.isCustomize(startNode) && + startChildNodes.length === 1 + ) { + startNode.append('
      '); + } else if ( + node.isCustomize(startNode) && + startChildNodes.length > 2 && + startChildNodes[startChildNodes.length - 2].nodeName !== 'BR' && + startChildNodes[startChildNodes.length - 1].nodeName === 'BR' + ) { + startNode.last()?.remove(); + } + } + if (!range.collapsed && endNode.name === 'li') { + const endChildNodes = endNode.children(); + if (endChildNodes.length === 0) { + endNode.append('
      '); + } else if ( + !node.isCustomize(endNode) && + endChildNodes.length > 1 && + endChildNodes[endChildNodes.length - 2].nodeName !== 'BR' && + endChildNodes[endChildNodes.length - 1].nodeName === 'BR' + ) { + startNode.last()?.remove(); + } else if ( + node.isCustomize(endNode) && + endChildNodes.length === 1 + ) { + endNode.append('
      '); + } else if ( + node.isCustomize(endNode) && + endChildNodes.length > 2 && + endChildNodes[endChildNodes.length - 2].nodeName !== 'BR' && + endChildNodes[endChildNodes.length - 1].nodeName === 'BR' + ) { + startNode.last()?.remove(); + } + } + + if (startNode.isEditable() && startNode.children().length === 0) { + startNode.html('


      '); + } + //在非折叠,或者当前range对象和selection中的对象不一致的时候重新设置range + if ( + selection && + (range.collapsed || + (selection.rangeCount > 0 && + !range.equal(selection.getRangeAt(0)))) + ) { + selection.removeAllRanges(); + selection.addRange(range.toRange()); + } + const { onSelect } = this.#otpions; + if (onSelect) onSelect(range); + } + + /** + * 聚焦编辑器 + * @param toStart true:开始位置,false:结束位置,默认为之前操作位置 + */ + focus(toStart?: boolean) { + const range = this.#lastBlurRange || this.get(); + if (toStart !== undefined) { + range + .select(this.engine.container, true) + .shrinkToElementNode() + .collapse(toStart); + } + this.select(range); + this.engine.container.get()?.focus(); + } + + blur() { + this.engine.container.get()?.blur(); + } +} +export default ChangeRange; diff --git a/packages/engine/src/clipboard.ts b/packages/engine/src/clipboard.ts new file mode 100644 index 00000000..edb05a45 --- /dev/null +++ b/packages/engine/src/clipboard.ts @@ -0,0 +1,378 @@ +import copyTo from 'copy-to-clipboard'; +import Parser from './parser'; +import { ClipboardInterface } from './types/clipboard'; +import { EditorInterface, EngineInterface } from './types/engine'; +import { RangeInterface } from './types/range'; +import { getWindow, isEngine, isSafari } from './utils'; +import { $ } from './node'; +import Range from './range'; +import { NodeInterface } from './types'; +import { CARD_ELEMENT_KEY, CARD_KEY, DATA_ID } from './constants'; + +export const isDragEvent = ( + event: DragEvent | ClipboardEvent, +): event is DragEvent => { + return !!(event).dataTransfer; +}; + +export default class Clipboard implements ClipboardInterface { + private editor: EditorInterface; + constructor(editor: EditorInterface) { + this.editor = editor; + } + + getData(event: DragEvent | ClipboardEvent) { + const transfer = isDragEvent(event) + ? event.dataTransfer + : event.clipboardData; + let html = transfer?.getData('text/html'); + let text = transfer?.getData('text'); + let files: Array = []; + // Edge 处理 + try { + if (transfer?.items && transfer.items.length > 0) { + Array.from(transfer.items).forEach((item) => { + let file = item.kind === 'file' ? item.getAsFile() : null; + if (file !== null) { + if ( + file.type && + file.type.indexOf('image/png') > -1 && + !file.lastModified + ) { + file = new File([file], 'image.png', { + type: file.type, + }); + } + file['ext'] = text?.split('.').pop(); + } + if (file) files.push(file); + }); + } else if (transfer?.files && transfer.files.length > 0) { + files = Array.from(transfer.files); + } + } catch (err) { + if (transfer?.files && transfer.files.length > 0) { + files = Array.from(transfer.files); + } + } + + // 从 Mac OS Finder 复制文件 + if (html === '' && text && /^.+\.\w+$/.test(text) && files.length > 0) { + text = ''; // 在图片上,点击右键复制 + } else if ( + text === '' && + html && + /^()?$/.test(html) && + files.length > 0 + ) { + html = ''; // 从 Excel、Numbers 复制 + } else if ( + (html || text) && + files.length > 0 && + !html?.startsWith(' void, + ) { + if (!range) range = Range.from(this.editor); + if (!range) throw 'Range is null'; + range = range.cloneRange().shrinkToElementNode(); + let card = range.startNode.closest(`[${CARD_KEY}]`, (node) => { + return $(node).isEditable() + ? undefined + : node.parentNode || undefined; + }); + if (card.length > 0 && !range.collapsed && range.endOffset === 0) { + if (range.endContainer.previousSibling) { + range.setEndAfter(range.endContainer.previousSibling); + } + if ( + !range.collapsed && + range.endOffset > 0 && + range.endContainer.childNodes[range.endOffset - 1] === card[0] + ) { + const cardCenter = range.startNode.closest( + `[${CARD_ELEMENT_KEY}="center"]`, + (node) => { + return $(node).isEditable() + ? undefined + : node.parentNode || undefined; + }, + ); + if (cardCenter.length > 0) { + range.setEnd( + cardCenter[0], + cardCenter[0].childNodes.length, + ); + } else { + range.setEnd(card[0], card[0].childNodes.length); + } + } + } + let root = range.commonAncestorNode; + card = root.closest(`[${CARD_KEY}]`, (node) => { + return $(node).isEditable() + ? undefined + : node.parentNode || undefined; + }); + if (card.length > 0) { + const cardCenter = root.closest( + `[${CARD_ELEMENT_KEY}="center"]`, + (node) => { + return $(node).isEditable() + ? undefined + : node.parentNode || undefined; + }, + ); + if (cardCenter.length === 0) { + range.select(card); + root = range.commonAncestorNode; + } + } + const nodes: Array = + root.name === '#text' ? [document.createElement('span')] : []; + card = root.closest(`[${CARD_KEY}]`, (node) => { + if ($(node).isEditable()) return; + if (node.nodeType === getWindow().Node.ELEMENT_NODE) { + const display = window + .getComputedStyle(node as Element) + .getPropertyValue('display'); + if (display === 'inline') { + nodes.push(node.cloneNode()); + } + } + return node.parentNode || undefined; + }); + const { node, list } = this.editor; + const hasChildEngine = + root.find('.am-engine-view').length > 0 || + root.find('.am-engine').length > 0; + const hasParentEngine = + root.closest('.am-engine-view').length > 0 || + root.closest('.am-engine').length > 0; + if (card.length <= 0 && (hasChildEngine || hasParentEngine)) { + event.preventDefault(); + if (range.collapsed) { + event.clipboardData?.setData('text/html', ''); + event.clipboardData?.setData('text', ''); + } else { + // 修复自定义列表选择范围 + let customizeStartItem: NodeInterface | undefined; + if (range.startNode.isText()) { + const li = range.startNode.closest('li'); + + if (li && node.isCustomize(li)) { + const endLi = range.endNode.closest('li'); + if ( + !li.equal(endLi) || + (list.isLast(range) && list.isFirst(range)) + ) { + if (range.startOffset === 0) { + const ul = li.parent(); + const index = li.getIndex(); + if (ul) + range.setStart(ul, index < 0 ? 0 : index); + } else { + const ul = li.parent(); + // 选在列表项靠后的节点,把剩余节点拼接成完成的列表项 + const selection = range.createSelection(); + const rightNode = selection.getNode( + li, + 'center', + true, + ); + selection.anchor?.remove(); + selection.focus?.remove(); + if (isEngine(this.editor)) + this.editor.change.combinText(); + if (rightNode.length > 0) { + let isRemove = false; + rightNode.each((_, index) => { + const item = rightNode.eq(index); + if (!isRemove && item?.name === 'li') { + isRemove = true; + return; + } + if (isRemove) item?.remove(); + }); + const card = li.first(); + const component = card + ? this.editor.card.find(card) + : undefined; + if (component) { + customizeStartItem = rightNode; + this.editor.list.addCardToCustomize( + customizeStartItem, + component.name, + component.getValue(), + ); + if (ul) + node.wrap( + customizeStartItem, + ul?.clone(), + ); + } + } + } + } + } + } + const contents = range.cloneContents(); + if (customizeStartItem) { + contents.removeChild(contents.childNodes[0]); + contents.prepend(customizeStartItem[0]); + } + const listMergeBlocks: NodeInterface[] = []; + contents.childNodes.forEach((child) => { + let childElement = $(child); + if (childElement.name !== 'li') return; + + const dataId = childElement.attributes(DATA_ID); + if (!dataId) return; + const curentElement = document.querySelector( + `[${DATA_ID}=${dataId}]`, + ); + let parent: NodeInterface | Node | null | undefined = + curentElement?.parentElement; + parent = parent ? $(parent.cloneNode(false)) : null; + if (curentElement && parent && node.isList(parent)) { + if (parent.name === 'ol') { + // 设置复制位置的 start 属性,默认不设置 + // let start = parseInt(parent.attributes('start') || '0', 10) + // start = $(curentElement).index() + start + // if(start === 0) start = 1 + // parent.attributes('start', start); + parent.removeAttributes('start'); + } + node.wrap(child, parent); + listMergeBlocks.push(parent); + } + }); + const { inner, outter } = this.setNodes(nodes); + const listNodes: NodeInterface[] = []; + contents.childNodes.forEach((child) => { + const childNode = $(child); + if (node.isList(childNode) || childNode.name === 'li') { + listNodes.push(childNode); + } + }); + // 合并列表 + this.editor.list.merge(listNodes); + const parser = new Parser(contents, this.editor); + let { html, text } = parser.toHTML(inner, outter); + if (callback) { + callback({ html, text }); + } + if (html) + html = '' + html; + event.clipboardData?.setData('text/html', html); + event.clipboardData?.setData('text', text); + } + } + } + + copy(data: Node | string, trigger: boolean = false) { + if (typeof data === 'string') { + return copyTo(data); + } + const editor = this.editor; + const selection = window.getSelection(); + const range = selection + ? Range.from(editor, selection) || Range.create(editor) + : Range.create(editor); + const cloneRange = range.cloneRange(); + const block = $('
      '); + block.css({ + position: 'fixed', + top: 0, + clip: 'rect(0, 0, 0, 0)', + }); + const clera = () => { + block.remove(); + selection?.removeAllRanges(); + selection?.addRange(cloneRange.toRange()); + }; + block.on('copy', (e: ClipboardEvent) => { + e.stopPropagation(); + this.write(e, range); + clera(); + }); + $(document.body).append(block); + block.append(editor.node.clone($(data), true)); + if (trigger) { + block.traverse((child) => { + if (child.equal(block)) return; + editor.trigger('copy', child); + }); + } + block.append($('​', null)); + const first = block.first()!; + const end = block.last()!; + range.select(block, true); + range.setStartAfter(first); + range.setEndBefore(end); + selection?.removeAllRanges(); + selection?.addRange(range.toRange()); + let success = false; + + try { + success = document.execCommand('copy'); + if (!success) { + throw 'Copy failed'; + } + } catch (err) { + console.log('The copy command was not executed successfully ', err); + clera(); + } + return success; + } + + cut() { + const range = Range.from(this.editor); + if (!range) return; + const root = range.commonAncestorNode; + (this.editor as EngineInterface).change.delete(range); + const listElements = this.editor.node.isList(root) + ? root + : root.find('ul,ol'); + for (let i = 0; i < listElements.length; i++) { + const list = $(listElements[i]); + const childs = list.find('li'); + childs.each((child) => { + if ( + '' === (child as HTMLElement).innerText || + (isSafari && '\n' === (child as HTMLElement).innerText) + ) { + child.parentNode?.removeChild(child); + } + }); + if (list.children().length === 0) { + list.remove(); + } + } + } + + private setNodes(nodes: Array) { + if (0 === nodes.length) return {}; + for (let i = nodes.length - 1; i > 0; i--) { + const node = nodes[i]; + node.appendChild(nodes[i - 1]); + } + return { + inner: nodes[0], + outter: nodes[nodes.length - 1], + }; + } +} diff --git a/packages/engine/src/command.ts b/packages/engine/src/command.ts new file mode 100644 index 00000000..3b1a3874 --- /dev/null +++ b/packages/engine/src/command.ts @@ -0,0 +1,122 @@ +import { ChangeInterface } from './types'; +import { CommandInterface } from './types/command'; +import { EditorInterface } from './types/engine'; +import { isEngine } from './utils'; + +/** + * 引擎命令管理器 + */ +class Command implements CommandInterface { + private editor: EditorInterface; + constructor(editor: EditorInterface) { + this.editor = editor; + } + + /** + * 查询插件是否启用 + * @param name 插件名称 + * @returns + */ + queryEnabled(name: string) { + // 没有插件 + const plugin = this.editor.plugin.components[name]; + if (!plugin || plugin.disabled) return false; + // 只读状态下,如果插件没有指定为非禁用,一律禁用 + if ( + (!isEngine(this.editor) || + this.editor.readonly || + !this.editor.isFocus()) && + plugin.disabled !== false + ) + return false; + // 当前激活非可编辑卡片时全部禁用 + if (this.editor.card.active && !this.editor.card.active.isEditable) + return false; + // TODO:查询当前所处位置的插件 + return true; + } + + /** + * 查询插件状态 + * @param name 插件名称 + * @param args 插件 queryState 返回需要的参数 + * @returns + */ + queryState(name: string, ...args: any) { + const plugin = this.editor.plugin.components[name]; + if (plugin && plugin.queryState) { + try { + return plugin.queryState(args); + } catch (error) { + console.error(error); + } + } + } + + /** + * 执行命令前缓存当前光标位置并且光标不在编辑范围内就focus到编辑区域内 + * @returns + */ + handleExecuteBefore() { + let change: ChangeInterface | undefined; + if (isEngine(this.editor)) { + change = this.editor.change; + const range = change.range.get(); + if ( + !range.commonAncestorNode.isRoot() && + !range.commonAncestorNode.inEditor() + ) + this.editor.focus(); + change.cacheRangeBeforeCommand(); + } + return change; + } + + /** + * 执行插件命令 + * @param name 插件名称 + * @param args 插件所需要的参数 + * @returns + */ + execute(name: string, ...args: any) { + const plugin = this.editor.plugin.components[name]; + if (plugin && plugin.execute) { + const change = this.handleExecuteBefore(); + this.editor.trigger('beforeCommandExecute', name, ...args); + try { + const result = plugin.execute(...args); + change?.combinText(); + change?.onSelect(); + this.editor.trigger('afterCommandExecute', name, ...args); + return result; + } catch (error) { + console.error(error); + } + } + } + + /** + * 执行插件里面的一个返回 + * @param name 插件名称 + * @param method 插件中的方法名称 + * @param args 插件中方法所需要的参数 + * @returns + */ + executeMethod(name: string, method: string, ...args: any) { + const plugin = this.editor.plugin.components[name]; + if (plugin && plugin[method]) { + try { + const change = isEngine(this.editor) + ? this.editor.change + : null; + const result = plugin[method](...args); + change?.combinText(); + return result; + } catch (error) { + console.log(error); + } + } + } +} + +export default Command; diff --git a/packages/engine/src/constants/card.ts b/packages/engine/src/constants/card.ts new file mode 100644 index 00000000..61e7ea00 --- /dev/null +++ b/packages/engine/src/constants/card.ts @@ -0,0 +1,18 @@ +export const CARD_TAG = 'card'; +export const CARD_KEY = 'data-card-key'; +export const READY_CARD_KEY = 'data-ready-card'; +export const CARD_TYPE_KEY = 'data-card-type'; +export const CARD_VALUE_KEY = 'data-card-value'; +export const CARD_ELEMENT_KEY = 'data-card-element'; +export const CARD_SELECTOR = 'div[' + .concat(CARD_KEY, '],span[') + .concat(CARD_KEY, ']'); +export const READY_CARD_SELECTOR = 'div[' + .concat(READY_CARD_KEY, '],span[') + .concat(READY_CARD_KEY, ']'); +export const CARD_LEFT_SELECTOR = 'span['.concat(CARD_ELEMENT_KEY, '=left]'); +export const CARD_CENTER_SELECTOR = 'div[' + .concat(CARD_ELEMENT_KEY, '=center],span[') + .concat(CARD_ELEMENT_KEY, '=center]'); +export const CARD_RIGHT_SELECTOR = 'span['.concat(CARD_ELEMENT_KEY, '=right]'); +export const TRIGGER_CARD_ID = 'trigger-card-id'; diff --git a/packages/engine/src/constants/conversion.ts b/packages/engine/src/constants/conversion.ts new file mode 100644 index 00000000..91963ea7 --- /dev/null +++ b/packages/engine/src/constants/conversion.ts @@ -0,0 +1,66 @@ +import { DATA_ELEMENT, ROOT } from './root'; +import $ from '../node/query'; +import { ConversionData } from '../types'; +import { + CARD_KEY, + CARD_TYPE_KEY, + CARD_VALUE_KEY, + READY_CARD_KEY, +} from './card'; + +const defaultConversion: ConversionData = [ + { + from: (_name, _styles, attributes) => { + return !!attributes[CARD_KEY] || !!attributes[READY_CARD_KEY]; + }, + to: (_, style, attributes) => { + const value = attributes[CARD_VALUE_KEY]; + const oldAttrs = { ...attributes }; + attributes = { + type: attributes[CARD_TYPE_KEY], + name: ( + attributes[CARD_KEY] || attributes[READY_CARD_KEY] + ).toLowerCase(), + }; + //其它 data 属性 + Object.keys(oldAttrs).forEach((attrName) => { + if ( + attrName !== READY_CARD_KEY && + attrName.indexOf('data-') === 0 && + attrName.indexOf('data-card') !== 0 + ) { + attributes[attrName] = oldAttrs[attrName]; + } + }); + + if (value !== undefined) { + attributes.value = value; + } + style = {}; + const card = $(''); + Object.keys(attributes).forEach((name) => { + card.attributes(name, attributes[name]); + }); + return card; + }, + }, + { + from: (_name, _styles, attributes) => { + return ( + _name === 'div' && + (!attributes[CARD_KEY] || !attributes[READY_CARD_KEY]) && + attributes[DATA_ELEMENT] !== ROOT + ); + }, + to: (_, style, attributes) => { + const p = $('

      '); + p.css(style); + Object.keys(attributes).forEach((name) => { + p.attributes(name, attributes[name]); + }); + return p; + }, + }, +]; + +export default defaultConversion; diff --git a/packages/engine/src/constants/index.ts b/packages/engine/src/constants/index.ts new file mode 100644 index 00000000..599aca68 --- /dev/null +++ b/packages/engine/src/constants/index.ts @@ -0,0 +1,8 @@ +export * from './selection'; +export * from './card'; +export * from './root'; +export * from './ot'; +import Conversion from './conversion'; +import Schema from './schema'; + +export { Conversion, Schema }; diff --git a/packages/engine/src/constants/ot.ts b/packages/engine/src/constants/ot.ts new file mode 100644 index 00000000..3ce89310 --- /dev/null +++ b/packages/engine/src/constants/ot.ts @@ -0,0 +1,14 @@ +/** + * 描述json0协议中标签名称、属性、节点列表再结构中位置 + */ +export const JSON0_INDEX = { + // 标签名称 + TAG_NAME: 0, + // 属性 + ATTRIBUTE: 1, + // 节点列表 + ELEMENT: 2, +}; + +export const DATA_UUID = 'data-uuid'; +export const DATA_COLOR = 'data-color'; diff --git a/packages/engine/src/constants/root.ts b/packages/engine/src/constants/root.ts new file mode 100644 index 00000000..5149bf17 --- /dev/null +++ b/packages/engine/src/constants/root.ts @@ -0,0 +1,16 @@ +export const DATA_ELEMENT = 'data-element'; +export const DATA_ID = 'data-id'; + +export const ROOT = 'root'; +export const ROOT_SELECTOR = '['.concat(DATA_ELEMENT, '=').concat(ROOT, ']'); + +export const UI = 'ui'; +export const UI_SELECTOR = '['.concat(DATA_ELEMENT, '=').concat(UI, ']'); + +export const EDITABLE = 'editable'; +export const EDITABLE_SELECTOR = '[' + .concat(DATA_ELEMENT, '=') + .concat(EDITABLE, ']'); + +export const DATA_TRANSIENT_ATTRIBUTES = 'data-transient-attributes'; +export const DATA_TRANSIENT_ELEMENT = 'data-transient-element'; diff --git a/packages/engine/src/constants/schema.ts b/packages/engine/src/constants/schema.ts new file mode 100644 index 00000000..c979b3b5 --- /dev/null +++ b/packages/engine/src/constants/schema.ts @@ -0,0 +1,153 @@ +import { SchemaBlock, SchemaGlobal, SchemaRule } from '../types'; +import { + CARD_KEY, + CARD_TYPE_KEY, + CARD_VALUE_KEY, + READY_CARD_KEY, +} from './card'; +import { DATA_ID, DATA_ELEMENT } from './root'; +import { ANCHOR, CURSOR, FOCUS } from './selection'; + +const defualtSchema: Array = [ + { + type: 'block', + attributes: { + [DATA_ID]: '*', + }, + }, + { + name: 'p', + type: 'block', + allowIn: ['$root'], + }, + { + name: 'br', + type: 'inline', + isVoid: true, + }, + { + name: ANCHOR, + type: 'inline', + isVoid: true, + }, + { + name: FOCUS, + type: 'inline', + isVoid: true, + }, + { + name: CURSOR, + type: 'inline', + isVoid: true, + }, + { + name: 'span', + type: 'mark', + attributes: { + [DATA_ELEMENT]: { + required: true, + value: ['anchor', 'cursor', 'focus'], + }, + }, + }, + { + name: 'card', + type: 'inline', + attributes: { + name: { + required: true, + value: /\w+/, + }, + type: { + required: true, + value: 'inline', + }, + value: '*', + }, + }, + { + name: 'span', + type: 'inline', + attributes: { + [CARD_KEY]: { + required: true, + value: /\w+/, + }, + [CARD_TYPE_KEY]: { + required: true, + value: 'inline', + }, + [CARD_VALUE_KEY]: '*', + class: '*', + contenteditable: '*', + }, + }, + { + name: 'span', + type: 'inline', + attributes: { + [READY_CARD_KEY]: { + required: true, + value: /\w+/, + }, + [CARD_TYPE_KEY]: { + required: true, + value: 'inline', + }, + [CARD_VALUE_KEY]: '*', + class: '*', + contenteditable: '*', + }, + }, + { + name: 'card', + type: 'block', + attributes: { + name: { + required: true, + value: /\w+/, + }, + type: { + required: true, + value: 'block', + }, + value: '*', + }, + }, + { + name: 'div', + type: 'block', + attributes: { + [CARD_KEY]: { + required: true, + value: /\w+/, + }, + [CARD_TYPE_KEY]: { + required: true, + value: 'block', + }, + [CARD_VALUE_KEY]: '*', + class: '*', + contenteditable: '*', + }, + }, + { + name: 'div', + type: 'block', + attributes: { + [READY_CARD_KEY]: { + required: true, + value: /\w+/, + }, + [CARD_TYPE_KEY]: { + required: true, + value: 'block', + }, + [CARD_VALUE_KEY]: '*', + class: '*', + contenteditable: '*', + }, + }, +]; + +export default defualtSchema; diff --git a/packages/engine/src/constants/selection.ts b/packages/engine/src/constants/selection.ts new file mode 100644 index 00000000..4c3c8932 --- /dev/null +++ b/packages/engine/src/constants/selection.ts @@ -0,0 +1,14 @@ +import { DATA_ELEMENT } from './root'; + +export const ANCHOR = 'anchor'; +export const FOCUS = 'focus'; +export const CURSOR = 'cursor'; +export const ANCHOR_SELECTOR = 'span[' + .concat(DATA_ELEMENT, '=') + .concat(ANCHOR, ']'); +export const FOCUS_SELECTOR = 'span[' + .concat(DATA_ELEMENT, '=') + .concat(FOCUS, ']'); +export const CURSOR_SELECTOR = 'span[' + .concat(DATA_ELEMENT, '=') + .concat(CURSOR, ']'); diff --git a/packages/engine/src/engine/container.ts b/packages/engine/src/engine/container.ts new file mode 100644 index 00000000..92201f0c --- /dev/null +++ b/packages/engine/src/engine/container.ts @@ -0,0 +1,187 @@ +import { DATA_ELEMENT, ROOT } from '../constants'; +import { EngineInterface, NodeInterface, Selector } from '../types'; +import { $ } from '../node'; +import { isMobile } from '../utils'; + +export type Options = { + engine: EngineInterface; + lang?: string; + tabIndex?: number; + className?: string | Array; + placeholder?: string; +}; + +class Container { + private options: Options; + private node: NodeInterface; + private _focused: boolean = false; + #styleElement?: Element; + + constructor(selector: Selector, options: Options) { + this.node = $(selector); + this.options = options; + this._init(); + this._focused = + document.activeElement !== null && + this.node.equal(document.activeElement); + } + + _init() { + const { lang, tabIndex, className } = this.options; + this.node.attributes(DATA_ELEMENT, ROOT); + this.node.attributes({ + contenteditable: 'true', + role: 'textbox', + autocorrect: lang === 'en-US' ? 'on' : 'off', + autocomplete: 'off', + spellcheck: lang === 'en-US' ? 'true' : 'false', + 'data-gramm': 'false', + }); + + if (tabIndex !== undefined) { + this.node.attributes('tabindex', tabIndex); + } + + if (!this.node.hasClass('am-engine')) { + this.node.addClass('am-engine'); + } + + if (isMobile) this.node.addClass('am-engine-mobile'); + + if (className !== undefined) { + (Array.isArray(className) + ? className + : className.split(/\s+/) + ).forEach((name) => { + if (name.trim() !== '') this.node.addClass(name); + }); + } + } + + init() { + const { engine } = this.options; + this.node.on('input', (e) => { + if (engine.readonly) { + return; + } + + if (engine.card.find(e.target)) { + return; + } + const range = engine.change.range.get(); + range.handleBr(true); + }); + // 编辑器文档尾部始终保持一行 + this.node.on('click', (event: MouseEvent) => { + if (event.target && $(event.target).isEditable()) { + //获取到编辑器内最后一个子节点 + const block = this.node.last(); + if (block) { + //不是卡片不处理 + if (!block.isCard()) return; + //节点不可见不处理 + if ( + (block.get()?.offsetTop || 0) + + (block.get()?.clientHeight || 0) > + event.offsetY + ) + return; + } + const node = $('


      '); + this.node.append(node); + const range = engine.change.range.get(); + range.select(node, true).collapse(false); + engine.change.apply(range); + } + }); + let isMousedown = false; + this.node.on(isMobile ? 'touchstart' : 'mousedown', () => { + isMousedown = true; + setTimeout(() => { + if (!this._focused) { + this._focused = true; + engine.trigger('focus'); + } + isMousedown = false; + }, 10); + }); + this.node.on('focus', () => { + isMousedown = false; + this._focused = true; + engine.trigger('focus'); + }); + this.node.on('blur', () => { + if (isMousedown) return; + isMousedown = false; + this._focused = false; + engine.trigger('blur'); + }); + } + + isFocus() { + return this._focused; + } + + getNode() { + return this.node; + } + + setReadonly(readonly: boolean) { + this.node.attributes('contenteditable', readonly ? 'false' : 'true'); + } + + showPlaceholder() { + const { placeholder } = this.options; + if (placeholder) { + if (this.#styleElement && this.#styleElement.parentNode) + document.body.removeChild(this.#styleElement); + this.#styleElement = document.createElement('style'); + //const left = this.node.css('padding-left'); + //const top = this.node.css('padding-top'); + const styleText = document.createTextNode(`.am-engine:before { + content: attr(data-placeholder); + pointer-events: none; + position: absolute; + color: #bbbfc4; + height: 0; + }`); + this.#styleElement.appendChild(styleText); + document.body.appendChild(this.#styleElement); + this.node.attributes({ + 'data-placeholder': placeholder, + }); + } else if (this.#styleElement && this.#styleElement.parentNode) + document.body.removeChild(this.#styleElement); + } + + hidePlaceholder() { + this.node.removeAttributes('data-placeholder'); + } + + destroy() { + const { className, engine } = this.options; + this.node.removeAttributes(DATA_ELEMENT); + this.node.removeAttributes('contenteditable'); + this.node.removeAttributes('role'); + this.node.removeAttributes('autocorrect'); + this.node.removeAttributes('autocomplete'); + this.node.removeAttributes('spellcheck'); + this.node.removeAttributes('data-gramm'); + this.node.removeAttributes('tabindex'); + this.node.removeAttributes('data-placeholder'); + if (this.#styleElement) document.body.removeChild(this.#styleElement); + if (this.options.className) { + (Array.isArray(className) + ? className + : (className || '').split(/\s+/) + ).forEach((name) => { + if (name.trim() !== '') this.node.removeClass(name); + }); + } + + if (engine.card.closest(this.node)) this.node.removeClass('am-engine'); + this.node.removeAllEvents(); + } +} + +export default Container; diff --git a/packages/engine/src/engine/index.css b/packages/engine/src/engine/index.css new file mode 100644 index 00000000..8acfe89e --- /dev/null +++ b/packages/engine/src/engine/index.css @@ -0,0 +1,704 @@ +@font-face { + font-family: "data-icon"; /* Project id 1456030 */ + src: url('//at.alicdn.com/t/font_1456030_mvh913k905.woff2?t=1629619375484') format('woff2'), + url('//at.alicdn.com/t/font_1456030_mvh913k905.woff?t=1629619375484') format('woff'), + url('//at.alicdn.com/t/font_1456030_mvh913k905.ttf?t=1629619375484') format('truetype'); + } + + .data-icon { + font-family: "data-icon" !important; + font-size: 16px; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + .data-icon-block-image:before { + content: "\ea20"; + } + + .data-icon-inline-image:before { + content: "\e601"; + } + + .data-icon-no-border:before { + content: "\e608"; + } + + + .data-icon-line-height:before { + content: "\e697"; + } + + .data-icon-text:before { + content: "\e600"; + } + + .data-icon-comment:before { + content: "\e73e"; + } + + .data-icon-huanyuan:before { + content: "\e60a"; + } + + .data-icon-label:before { + content: "\e714"; + } + + .data-icon-video:before { + content: "\e741"; + } + + .data-icon-table:before { + content: "\e6a8"; + } + + .data-icon-h:before { + content: "\e7a0"; + } + + .data-icon-collapse-subtree:before { + content: "\e754"; + } + + .data-icon-expand-subtree:before { + content: "\e792"; + } + + .data-icon-sub-node:before { + content: "\e78e"; + } + + .data-icon-sister-node:before { + content: "\e784"; + } + + .data-icon-sup:before { + content: "\e790"; + } + + .data-icon-sub:before { + content: "\e75a"; + } + + .data-icon-maximize:before { + content: "\e752"; + } + + .data-icon-codeblock:before { + content: "\e709"; + } + + .data-icon-emoji:before { + content: "\e73a"; + } + + .data-icon-h4:before { + content: "\e759"; + } + + .data-icon-h1:before { + content: "\e75b"; + } + + .data-icon-h5:before { + content: "\e75c"; + } + + .data-icon-h2:before { + content: "\e75d"; + } + + .data-icon-h3:before { + content: "\e760"; + } + + .data-icon-h6:before { + content: "\e761"; + } + + .data-icon-liuchengtu:before { + content: "\e61c"; + } + + .data-icon-website:before { + content: "\e694"; + } + + .data-icon-preferences:before { + content: "\e788"; + } + + .data-icon-hr:before { + content: "\e76a"; + } + + .data-icon-task-list:before { + content: "\e79f"; + } + + .data-icon-unordered-list:before { + content: "\e777"; + } + + .data-icon-ordered-list:before { + content: "\e795"; + } + + .data-icon-arrow-left:before { + content: "\e748"; + } + + .data-icon-arrow-up:before { + content: "\e769"; + } + + .data-icon-arrow-right:before { + content: "\e779"; + } + + .data-icon-arrow-down:before { + content: "\e79a"; + } + + .data-icon-moremark:before { + content: "\e772"; + } + + .data-icon-clean:before { + content: "\e74d"; + } + + .data-icon-paintformat:before { + content: "\e756"; + } + + .data-icon-lock:before { + content: "\e768"; + } + + .data-icon-loading:before { + content: "\e76b"; + } + + .data-icon-unlock:before { + content: "\e796"; + } + + .data-icon-collapse:before { + content: "\e79e"; + } + + .data-icon-align-bottom:before { + content: "\e72b"; + } + + .data-icon-attachment:before { + content: "\e72c"; + } + + .data-icon-bold:before { + content: "\e72d"; + } + + .data-icon-border-color:before { + content: "\e72e"; + } + + .data-icon-border-all:before { + content: "\e72f"; + } + + .data-icon-border-inner:before { + content: "\e730"; + } + + .data-icon-border-left:before { + content: "\e731"; + } + + .data-icon-border-bottom:before { + content: "\e732"; + } + + .data-icon-border-none:before { + content: "\e733"; + } + + .data-icon-box:before { + content: "\e734"; + } + + .data-icon-border-outer:before { + content: "\e735"; + } + + .data-icon-border-right:before { + content: "\e736"; + } + + .data-icon-clear:before { + content: "\e737"; + } + + .data-icon-close:before { + content: "\e738"; + } + + .data-icon-code-example:before { + content: "\e739"; + } + + .data-icon-clip:before { + content: "\e73b"; + } + + .data-icon-border-up:before { + content: "\e73c"; + } + + .data-icon-code:before { + content: "\e73d"; + } + + .data-icon-command:before { + content: "\e73f"; + } + + .data-icon-compact-display:before { + content: "\e740"; + } + + .data-icon-copy:before { + content: "\e742"; + } + + .data-icon-download:before { + content: "\e743"; + } + + .data-icon-deletecolumn:before { + content: "\e744"; + } + + .data-icon-cut:before { + content: "\e745"; + } + + .data-icon-decreasedecimalplace:before { + content: "\e746"; + } + + .data-icon-drag:before { + content: "\e747"; + } + + .data-icon-delete:before { + content: "\e749"; + } + + .data-icon-drag-circle:before { + content: "\e74a"; + } + + .data-icon-deleterow:before { + content: "\e74b"; + } + + .data-icon-edit:before { + content: "\e74c"; + } + + .data-icon-filter:before { + content: "\e74e"; + } + + .data-icon-embedded-preview:before { + content: "\e74f"; + } + + .data-icon-error:before { + content: "\e750"; + } + + .data-icon-freezerowcoloum:before { + content: "\e751"; + } + + .data-icon-freezefirstrow:before { + content: "\e753"; + } + + .data-icon-freezzecolumn:before { + content: "\e755"; + } + + .data-icon-border-style:before { + content: "\e757"; + } + + .data-icon-gotolink:before { + content: "\e758"; + } + + .data-icon-increasedecimalplace:before { + content: "\e75e"; + } + + .data-icon-insertrowbelow:before { + content: "\e75f"; + } + + .data-icon-image:before { + content: "\e762"; + } + + .data-icon-italic:before { + content: "\e763"; + } + + .data-icon-indent:before { + content: "\e764"; + } + + .data-icon-insertrowabove:before { + content: "\e765"; + } + + .data-icon-insertrowright:before { + content: "\e766"; + } + + .data-icon-left-circle-fill:before { + content: "\e767"; + } + + .data-icon-link:before { + content: "\e76c"; + } + + .data-icon-keyboard:before { + content: "\e76d"; + } + + .data-icon-more:before { + content: "\e76e"; + } + + .data-icon-merge-cells:before { + content: "\e76f"; + } + + .data-icon-outdent:before { + content: "\e770"; + } + + .data-icon-mention:before { + content: "\e771"; + } + + .data-icon-plus:before { + content: "\e773"; + } + + .data-icon-minus-circle-o:before { + content: "\e774"; + } + + .data-icon-highlight:before { + content: "\e775"; + } + + .data-icon-paste:before { + content: "\e776"; + } + + .data-icon-insertrowleft:before { + content: "\e778"; + } + + .data-icon-quote:before { + content: "\e77a"; + } + + .data-icon-plus-circle-o:before { + content: "\e77b"; + } + + .data-icon-right-circle-fill:before { + content: "\e77c"; + } + + .data-icon-question-circle-o:before { + content: "\e77d"; + } + + .data-icon-preview:before { + content: "\e77e"; + } + + .data-icon-reload:before { + content: "\e77f"; + } + + .data-icon-rotate-left:before { + content: "\e780"; + } + + .data-icon-math:before { + content: "\e781"; + } + + .data-icon-overflow:before { + content: "\e782"; + } + + .data-icon-redo:before { + content: "\e783"; + } + + .data-icon-searchreplace:before { + content: "\e785"; + } + + .data-icon-save:before { + content: "\e786"; + } + + .data-icon-singleselect:before { + content: "\e787"; + } + + .data-icon-rotate-right:before { + content: "\e789"; + } + + .data-icon-sort-ascending:before { + content: "\e78a"; + } + + .data-icon-sort-descending:before { + content: "\e78b"; + } + + .data-icon-toc:before { + content: "\e78c"; + } + + .data-icon-solit-cells:before { + content: "\e78d"; + } + + .data-icon-translate:before { + content: "\e78f"; + } + + .data-icon-successful:before { + content: "\e791"; + } + + .data-icon-strikethrough:before { + content: "\e793"; + } + + .data-icon-undo:before { + content: "\e794"; + } + + .data-icon-underline:before { + content: "\e797"; + } + + .data-icon-unlink:before { + content: "\e798"; + } + + .data-icon-wrap:before { + content: "\e799"; + } + + .data-icon-upload:before { + content: "\e79b"; + } + + .data-icon-zoom-out:before { + content: "\e79c"; + } + + .data-icon-zoom-in:before { + content: "\e79d"; + } + + .data-icon-align-center:before { + content: "\e725"; + } + + .data-icon-align-justify:before { + content: "\e726"; + } + + .data-icon-align-left:before { + content: "\e727"; + } + + .data-icon-align-top:before { + content: "\e728"; + } + + .data-icon-align-right:before { + content: "\e729"; + } + + .data-icon-align-middle:before { + content: "\e72a"; + } + +.data-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; +} + +.data-anticon svg { + display: inline-block; +} + +@-webkit-keyframes loadingCircle { + 100% { + transform: rotate(360deg); + } +} +@keyframes loadingCircle { + 100% { + transform: rotate(360deg); + } +} + +.data-anticon .data-anticon-spin { + display: inline-block; + -webkit-animation: loadingCircle 1s infinite linear; + animation: loadingCircle 1s infinite linear; +} + +.data-anticon > * { + line-height: 1; +} + +*, *::before, *::after { + box-sizing: border-box; +} + +.am-engine { + position: relative; + background-color: #FFFFFF; +} + +.am-engine ::selection { + background: rgba(180, 213, 254, 0.5) !important; + color: inherit!important; +} + +.am-engine , .am-engine-view { + font-family: 'Chinese Quote', 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif, 'Apple Color Emoji'; + word-wrap: break-word; + outline-style: none; + white-space: pre-wrap; + user-select: auto; + font-size: 14px; + line-height: 1.74; + color: #262626; + letter-spacing: .05em; + /* pt / px 换算表:https://websemantics.uk/articles/font-size-conversion/ */ +} + +.am-engine > *:first-child,.am-engine-view > *:first-child { + margin-top: 0 !important; +} + +.am-engine p , .am-engine-view p { + white-space: normal; + margin: 0; + line-height: 1.74; +} + +.am-engine [contenteditable="true"],.am-engine-view [contenteditable="true"] { + outline-style: none; +} + +.am-engine .selection-transparent::selection,.am-engine-view .selection-transparent::selection { + background: transparent; +} +/*---------------------------卡片 begin-----------------------*/ + +.am-engine [data-card-type], .am-engine-view [data-card-type] { + white-space: normal; +} +.am-engine span[data-card-type="inline"],.am-engine-view span[data-card-type="inline"] { + display: inline-block; + text-indent: 0; + vertical-align: baseline; + white-space: initial; +} + +.am-engine span[data-card-type="inline"] span[data-card-element],.am-engine-view span[data-card-type="inline"] span[data-card-element] { + display: inline-block; +} + +.am-engine span[data-card-type="inline"] span[data-card-element="center"],.am-engine-view span[data-card-type="inline"] span[data-card-element="center"] { + vertical-align: bottom; +} + +.am-engine span[data-card-type="inline"] span[data-card-element="left"],.am-engine-view span[data-card-type="inline"] span[data-card-element="left"],.am-engine span[data-card-type="inline"] span[data-card-element="right"],.am-engine-view span[data-card-type="inline"] span[data-card-element="right"] { + min-width: 1px; + text-align: left; + -webkit-user-select: text; + user-select: text; +} + +.am-engine div[data-card-type="block"],.am-engine-view div[data-card-type="block"],.am-engine span[data-card-type="inline"].data-card-block,.am-engine-view span[data-card-type="inline"].data-card-block { + display: block; +} + +.am-engine div[data-card-type="block"] > div[data-card-element="body"] > span[data-card-element="left"],.am-engine-view div[data-card-type="block"] > div[data-card-element="body"] > span[data-card-element="left"],.am-engine span[data-card-type="inline"].data-card-block > div[data-card-element="body"] > span[data-card-element="left"],.am-engine-view span[data-card-type="inline"].data-card-block > div[data-card-element="body"] > span[data-card-element="left"],.am-engine div[data-card-type="block"] > div[data-card-element="body"] > span[data-card-element="right"],.am-engine-view div[data-card-type="block"] > div[data-card-element="body"] > span[data-card-element="right"],.am-engine span[data-card-type="inline"].data-card-block > div[data-card-element="body"] > span[data-card-element="right"],.am-engine-view span[data-card-type="inline"].data-card-block > div[data-card-element="body"] > span[data-card-element="right"] { + bottom: 0; + position: absolute; + width: 2px; + overflow: hidden; + outline: none; + text-align: left; + text-indent: 0; + -webkit-box-flex: 0; + flex: 0 0 auto; + -webkit-user-select: text; + user-select: text; +} + +.am-engine div[data-card-type="block"] > div[data-card-element="body"] > span[data-card-element="left"],.am-engine-view div[data-card-type="block"] > div[data-card-element="body"] > span[data-card-element="left"],.am-engine span[data-card-type="inline"].data-card-block > div[data-card-element="body"] > span[data-card-element="left"],.am-engine-view span[data-card-type="inline"].data-card-block > div[data-card-element="body"] > span[data-card-element="left"] { + left: -2px; + text-align: left; +} + +.am-engine div[data-card-type="block"] > div[data-card-element="body"] > span[data-card-element="right"],.am-engine-view div[data-card-type="block"] > div[data-card-element="body"] > span[data-card-element="right"],.am-engine span[data-card-type="inline"].data-card-block > div[data-card-element="body"] > span[data-card-element="right"],.am-engine-view span[data-card-type="inline"].data-card-block > div[data-card-element="body"] > span[data-card-element="right"] { + right: -2px; + text-align: right; +} + +.am-engine span[data-card-element="body"],.am-engine-view span[data-card-element="body"],.am-engine div[data-card-element="body"],.am-engine-view div[data-card-element="body"] { + position: relative; +} + +.am-engine span[data-card-element="body"] [data-card-element="center"],.am-engine-view span[data-card-element="body"] [data-card-element="center"],.am-engine div[data-card-element="body"] [data-card-element="center"],.am-engine-view div[data-card-element="body"] [data-card-element="center"] { + -webkit-user-select: text; + user-select: text; +} +.am-engine span[data-card-element="body"] [data-element="editable"],.am-engine div[data-card-element="body"] [data-element="editable"] +{ + cursor: text; +} +/*---------------------------卡片 end-----------------------*/ \ No newline at end of file diff --git a/packages/engine/src/engine/index.ts b/packages/engine/src/engine/index.ts new file mode 100644 index 00000000..b7befb43 --- /dev/null +++ b/packages/engine/src/engine/index.ts @@ -0,0 +1,453 @@ +import { merge } from 'lodash-es'; +import NodeModel, { Event, $ } from '../node'; +import language from '../locales'; +import Change from '../change'; +import { DATA_ELEMENT } from '../constants/root'; +import schemaDefaultData from '../constants/schema'; +import conversionDefault from '../constants/conversion'; +import Schema from '../schema'; +import OT from '../ot'; +import { + Selector, + NodeInterface, + EventInterface, + EventListener, + NodeModelInterface, + NodeIdInterface, +} from '../types/node'; +import { ChangeInterface } from '../types/change'; +import { + ContainerInterface, + EngineInterface, + EngineOptions, +} from '../types/engine'; +import { HistoryInterface } from '../types/history'; +import { OTInterface } from '../types/ot'; +import { SchemaInterface } from '../types/schema'; +import { ConversionInterface } from '../types/conversion'; +import { CommandInterface } from '../types/command'; +import { PluginModelInterface } from '../types/plugin'; +import { HotkeyInterface } from '../types/hotkey'; +import { CardInterface, CardModelInterface } from '../types/card'; +import { ClipboardInterface } from '../types/clipboard'; +import { LanguageInterface } from '../types/language'; +import { MarkModelInterface } from '../types/mark'; +import { ListModelInterface } from '../types/list'; +import { InlineModelInterface } from '../types/inline'; +import { BlockModelInterface } from '../types/block'; +import { RequestInterface } from '../types/request'; +import Conversion from '../parser/conversion'; +import History from '../history'; +import Command from '../command'; +import Hotkey from '../hotkey'; +import Plugin from '../plugin'; +import CardModel from '../card'; +import { getDocument } from '../utils'; +import { ANCHOR, CURSOR, FOCUS } from '../constants/selection'; +import { toJSON0, toDOM } from '../ot/utils'; +import Clipboard from '../clipboard'; +import Parser from '../parser'; +import Language from '../language'; +import Mark from '../mark'; +import List from '../list'; +import { EditorInterface, TypingInterface } from '../types'; +import Typing from '../typing'; +import Container from './container'; +import Inline from '../inline'; +import Block from '../block'; +import Selection from '../selection'; +import Request from '../request'; +import NodeId from '../node/id'; +import './index.css'; + +class Engine implements EngineInterface { + private _readonly: boolean = false; + private _container: ContainerInterface; + readonly kind = 'engine'; + options: EngineOptions = { + lang: 'zh-CN', + locale: {}, + plugins: [], + cards: [], + config: {}, + }; + language: LanguageInterface; + root: NodeInterface; + change: ChangeInterface; + card: CardModelInterface; + plugin: PluginModelInterface; + node: NodeModelInterface; + nodeId: NodeIdInterface; + list: ListModelInterface; + mark: MarkModelInterface; + inline: InlineModelInterface; + block: BlockModelInterface; + event: EventInterface; + typing: TypingInterface; + ot: OTInterface; + schema: SchemaInterface; + conversion: ConversionInterface; + history: HistoryInterface; + command: CommandInterface; + hotkey: HotkeyInterface; + clipboard: ClipboardInterface; + request: RequestInterface; + #_scrollNode: NodeInterface | null = null; + + get container(): NodeInterface { + return this._container.getNode(); + } + + get readonly(): boolean { + return this._readonly; + } + + get scrollNode(): NodeInterface | null { + if (this.#_scrollNode) return this.#_scrollNode; + const { scrollNode } = this.options; + let sn = scrollNode + ? typeof scrollNode === 'function' + ? scrollNode() + : scrollNode + : null; + // 查找父级样式 overflow 或者 overflow-y 为 auto 或者 scroll 的节点 + const targetValues = ['auto', 'scroll']; + let parent = this.container.parent(); + while (parent && parent.length > 0 && parent.name !== 'body') { + if ( + targetValues.includes(parent.css('overflow')) || + targetValues.includes(parent.css('overflow-y')) + ) { + sn = parent.get(); + break; + } else { + parent = parent.parent(); + } + } + if (sn === null) sn = document.documentElement; + this.#_scrollNode = sn ? $(sn) : null; + return this.#_scrollNode; + } + + set readonly(readonly: boolean) { + if (this.readonly === readonly) return; + if (readonly) { + this.hotkey.disable(); + this._container.setReadonly(true); + } else { + this.hotkey.enable(); + this._container.setReadonly(false); + } + this._readonly = readonly; + this.card.reRender(); + //广播readonly事件 + this.trigger('readonly', readonly); + } + + constructor(selector: Selector, options?: EngineOptions) { + this.options = { ...this.options, ...options }; + // 多语言 + this.language = new Language( + this.options.lang || 'zh-CN', + merge(language, options?.locale), + ); + // 事件管理 + this.event = new Event(); + // 命令 + this.command = new Command(this); + // 节点规则 + this.schema = new Schema(); + this.schema.add(schemaDefaultData); + // 节点转换规则 + this.conversion = new Conversion(this); + conversionDefault.forEach((rule) => + this.conversion.add(rule.from, rule.to), + ); + // 历史 + this.history = new History(this); + // 卡片 + this.card = new CardModel(this); + // 剪贴板 + this.clipboard = new Clipboard(this); + // http请求 + this.request = new Request(); + // 插件 + this.plugin = new Plugin(this); + // 节点管理 + this.node = new NodeModel(this); + this.nodeId = new NodeId(this); + // 列表 + this.list = new List(this); + // 样式标记 + this.mark = new Mark(this); + // 行内样式 + this.inline = new Inline(this); + // 块级节点 + this.block = new Block(this); + // 编辑器容器 + this._container = new Container(selector, { + engine: this, + lang: this.options.lang, + className: this.options.className, + tabIndex: this.options.tabIndex, + placeholder: this.options.placeholder, + }); + // 编辑器父节点 + this.root = $( + this.options.root || this.container.parent() || getDocument().body, + ); + const rootPosition = this.root.css('position'); + if (!rootPosition || rootPosition === 'static') + this.root.css('position', 'relative'); + // 实例化容器 + this._container.init(); + // 编辑器改变时 + this.change = new Change(this, { + onChange: (value, trigger) => + this.trigger('change', value, trigger), + onSelect: () => this.trigger('select'), + onRealtimeChange: (trigger) => { + if (this.isEmpty()) { + this._container.showPlaceholder(); + } else { + this._container.hidePlaceholder(); + } + this.trigger('realtimeChange', trigger); + }, + onSetValue: () => this.trigger('afterSetValue'), + }); + this.change.init(); + // 事件处理 + this.typing = new Typing(this); + // 只读 + this._readonly = + this.options.readonly === undefined ? false : this.options.readonly; + this._container.setReadonly(this._readonly); + // 实例化插件 + this.mark.init(); + this.inline.init(); + this.block.init(); + this.list.init(); + // 快捷键 + this.hotkey = new Hotkey(this); + this.card.init(this.options.cards || []); + this.plugin.init(this.options.plugins || [], this.options.config || {}); + this.nodeId.init(); + // 协同 + this.ot = new OT(this); + + if (this.isEmpty()) { + this._container.showPlaceholder(); + } + this.ot.initLocal(); + } + + setScrollNode(node?: HTMLElement) { + this.#_scrollNode = node ? $(node) : null; + } + + isFocus() { + return this._container.isFocus(); + } + + isEmpty() { + return this.change.isEmpty(); + } + + focus(toStart?: boolean) { + this.change.range.focus(toStart); + } + + blur() { + this.change.range.blur(); + } + + on(eventType: string, listener: EventListener, rewrite?: boolean) { + this.event.on(eventType, listener, rewrite); + return this; + } + + off(eventType: string, listener: EventListener) { + this.event.off(eventType, listener); + return this; + } + + trigger(eventType: string, ...args: any) { + return this.event.trigger(eventType, ...args); + } + + getValue(ignoreCursor: boolean = false) { + const value = this.change.getValue({}); + return ignoreCursor ? Selection.removeTags(value) : value; + } + + async getValueAsync( + ignoreCursor: boolean = false, + callback?: ( + name: string, + card?: CardInterface, + ...args: any + ) => boolean | number | void, + ): Promise { + return new Promise(async (resolve, reject) => { + const pluginNames = Object.keys(this.plugin.components); + for (let i = 0; i < pluginNames.length; i++) { + const plugin = this.plugin.components[pluginNames[i]]; + const result = await new Promise((resolve) => { + if (plugin.waiting) { + plugin + .waiting(callback) + .then(() => resolve(true)) + .catch(resolve); + } else resolve(true); + }); + if (typeof result === 'object') { + reject(result); + return; + } + } + resolve(this.getValue(ignoreCursor)); + }); + } + + getHtml(): string { + const node = $(this.container[0].cloneNode(true)); + node.removeAttributes('contenteditable'); + node.removeAttributes('tabindex'); + node.removeAttributes('autocorrect'); + node.removeAttributes('autocomplete'); + node.removeAttributes('spellcheck'); + node.removeAttributes('data-gramm'); + node.removeAttributes('role'); + return new Parser(node, this).toHTML().html; + } + + setValue(value: string, callback?: (count: number) => void) { + value = this.trigger('beforeSetValue', value) || value; + this.change.setValue(value, undefined, callback); + this.normalize(); + this.nodeId.generateAll(this.container); + return this; + } + + setHtml(html: string, callback?: (count: number) => void) { + this.change.setHtml(html, (count) => { + this.container.allChildren(true).forEach((child) => { + if (this.node.isInline(child)) { + this.inline.repairCursor(child); + } else if (this.node.isMark(child)) { + this.mark.repairCursor(child); + } + if (callback) callback(count); + }); + }); + this.nodeId.generateAll(this.container); + return this; + } + + setJsonValue(value: Array, callback?: (count: number) => void) { + const dom = $(toDOM(value)); + const attributes = dom.get()?.attributes; + for (let i = 0; attributes && i < attributes.length; i++) { + const { nodeName, nodeValue } = attributes.item(i) || {}; + if ( + /^data-selection-/.test(nodeName || '') && + nodeValue !== 'null' + ) { + this.container.attributes(nodeName, nodeValue!); + } + } + const html = this.node.html(dom); + this.change.setValue(html, undefined, callback); + // const range = this.change.range.get(); + // range.shrinkToElementNode(); + // this.change.range.select(range); + this.normalize(); + this.nodeId.generateAll(this.container); + return this; + } + + getJsonValue() { + return toJSON0(this.container); + } + + private normalize() { + let block = $('

      '); + // 保证所有行内元素都在段落内 + let childNodes = this.container.children(); + childNodes.each((_, index) => { + const node = childNodes.eq(index); + if (!node) return; + if (this.node.isBlock(node)) { + if (block.get()!.childNodes.length > 0) { + node.before(block); + } + block = $('

      '); + } else if (!node.isCursor()) { + block.append(node); + } + }); + + if (block.get()!.childNodes.length > 0) { + this.container.append(block); + } + // 处理空段落 + childNodes = this.container.children(); + childNodes.each((_, index) => { + const node = childNodes.eq(index); + if (!node) return; + this.node.removeMinusStyle(node, 'text-indent'); + if (this.node.isRootBlock(node)) { + const childrenLength = + node.get()!.childNodes.length; + if (childrenLength === 0) { + node.append($('
      ')); + } else { + const child = node.first(); + if ( + childrenLength === 1 && + child?.name === 'span' && + [CURSOR, ANCHOR, FOCUS].indexOf( + child.attributes(DATA_ELEMENT), + ) >= 0 + ) { + node.prepend($('
      ')); + } + } + } + }); + } + + messageSuccess(message: string) { + console.log(`success:${message}`); + } + + messageError(error: string) { + console.log(`error:${error}`); + } + + messageConfirm(message: string): Promise { + console.log(`confirm:${message}`); + return Promise.reject(false); + } + + showPlaceholder() { + this._container.showPlaceholder(); + } + + hidePlaceholder() { + this._container.hidePlaceholder(); + } + + destroy() { + this._container.destroy(); + this.change.destroy(); + this.hotkey.destroy(); + this.card.gc(); + if (this.ot) { + this.ot.destroy(); + } + } +} + +export default Engine; diff --git a/packages/engine/src/history.ts b/packages/engine/src/history.ts new file mode 100644 index 00000000..322c8ac8 --- /dev/null +++ b/packages/engine/src/history.ts @@ -0,0 +1,466 @@ +import { cloneDeep, debounce } from 'lodash-es'; +import { Op } from 'sharedb'; +import OTJSON from 'ot-json0'; +import { Operation, TargetOp } from './types/ot'; +import { random } from './utils'; +import { isCursorOp, isReverseOp, isTransientElement } from './ot/utils'; +import { EngineInterface } from './types/engine'; +import { HistoryInterface } from './types/history'; +import { $ } from './node'; +import { DATA_ID } from './constants'; + +/** + * 历史记录管理器 + */ +class HistoryModel implements HistoryInterface { + // 所有操作片段 + private actionOps: Array = []; + // 引擎实例 + private engine: EngineInterface; + // 当前还未保存的所有操作 + private currentAction: Operation = {}; + // 当前操作的索引 + private currentActionIndex: number = 0; + private remoteOps: TargetOp[] = []; + // 监听的所有过滤事件 + private filterEvents: ((op: Op) => boolean)[] = []; + // 监听的所有收集本地操作的事件 + private selfEvents: (( + ops: Op[], + ) => Promise | boolean | undefined)[] = []; + // 等待监听收集本地操作的回调 + #selfWaiting?: Promise; + + constructor(engine: EngineInterface) { + this.engine = engine; + } + + /** + * 懒保存当前操作 + */ + lazySave = debounce(() => { + this.saveOp(); + }, 200); + + /** + * 重置所有操作 + */ + reset() { + this.actionOps = []; + this.currentAction = {}; + this.currentActionIndex = 0; + } + + /** + * 监听过滤事件 + * @param filter 事件 + */ + onFilter(filter: (op: Op) => boolean) { + this.filterEvents.push(filter); + } + + /** + * 监听收集本地操作事件 + * @param event 事件 + */ + onSelf(event: (ops: Op[]) => Promise | boolean | undefined) { + this.selfEvents.push(event); + } + + /** + * 是否有撤销操作 + * @returns boolean + */ + hasUndo() { + return !!this.getUndoOp(); + } + + /** + * 是否有重做操作 + * @returns boolean + */ + hasRedo() { + return !!this.getRedoOp(); + } + + /** + * 执行撤销操作 + */ + undo() { + this.saveOp(); + const undoOp = this.getUndoOp(); + if (undoOp) { + let isUndo = false; + this.engine.ot.stopMutation(); + try { + const { ot } = this.engine; + ot.submitOps(undoOp.ops || []); + const applyNodes = ot.consumer.handleSelfOperations( + undoOp.ops!, + ); + ot.consumer.handleIndex(undoOp.ops || [], applyNodes); + this.currentActionIndex--; + isUndo = true; + } catch (error) { + this.reset(); + console.error(error); + } + if (this.engine.isEmpty()) this.engine.change.initValue(); + this.engine.ot.startMutation(); + if (isUndo) { + //清除操作前记录的range + this.engine.change.getRangePathBeforeCommand(); + this.engine.ot.consumer.setRangeByPath(undoOp.startRangePath!); + this.engine.change.change(); + this.engine.trigger('undo'); + } + } + } + + /** + * 执行重做操作 + */ + redo() { + this.saveOp(); + const redoOp = this.getRedoOp(); + if (redoOp) { + let isRedo = false; + this.engine.ot.stopMutation(); + try { + const { ot } = this.engine; + ot.submitOps(redoOp.ops || []); + const applyNodes = ot.consumer.handleSelfOperations( + redoOp.ops!, + ); + ot.consumer.handleIndex(redoOp.ops || [], applyNodes); + this.currentActionIndex++; + isRedo = true; + } catch (error) { + this.reset(); + console.error(error); + } + this.engine.ot.startMutation(); + if (isRedo) { + // 清除操作前记录的range + this.engine.change.getRangePathBeforeCommand(); + this.engine.ot.consumer.setRangeByPath(redoOp.rangePath!); + this.engine.change.change(); + this.engine.trigger('redo'); + } + } + } + + /** + * 清空所有历史操作 + */ + clear() { + setTimeout(() => { + this.reset(); + }, 10); + } + + saveOp() { + if ( + this.currentAction && + this.currentAction.ops && + this.currentAction.ops.length > 0 + ) { + if (this.currentAction.self) { + this.currentAction.rangePath = this.getCurrentRangePath(); + this.currentAction.id = random(8); + this.currentAction.remoteOps = this.remoteOps; + this.actionOps.splice(this.currentActionIndex); + this.actionOps.push(this.currentAction); + this.remoteOps = []; + this.currentActionIndex = this.actionOps.length; + this.engine.change.change(); + } + this.currentAction = {}; + // 保存成功后清除操作前记录的range + this.engine.change.getRangePathBeforeCommand(); + } + } + + handleSelfOps(ops: Op[]) { + if (!this.currentAction?.self && ops.some((op) => !isCursorOp(op))) + this.saveOp(); + let isSave = false; + ops.forEach((op) => { + if (!isCursorOp(op)) { + isSave = true; + if (this.filterEvents.some((filter) => filter(op))) { + if (this.actionOps.length > 0) + this.actionOps[this.actionOps.length - 1].ops?.push(op); + } else { + this.currentAction.self = true; + if (!this.currentAction.ops) this.currentAction.ops = []; + + if (!this.currentAction.startRangePath) { + this.currentAction.startRangePath = + this.getRangePathBeforeCommand(); + } + const lastOp = + this.currentAction.ops[ + this.currentAction.ops.length - 1 + ]; + if (lastOp && isReverseOp(op, lastOp)) { + this.currentAction.ops.pop(); + } else { + this.currentAction.ops.push(op); + } + } + } + }); + // 监听收集 + if (isSave) { + let callback: undefined | Promise | boolean = undefined; + this.selfEvents.some((event) => { + callback = event(ops); + return callback !== undefined; + }); + // 还有等待处理的 + if (this.#selfWaiting) return; + if (typeof callback === 'boolean') { + if (callback) this.saveOp(); + else { + this.currentAction = {}; + } + } else if (typeof callback === 'object') { + this.#selfWaiting = callback; + (callback as Promise) + .then((s) => { + if (s) { + this.saveOp(); + } else { + this.currentAction = {}; + } + }) + .finally(() => (this.#selfWaiting = undefined)); + } else if (callback === undefined) { + this.lazySave(); + } + } + } + + handlePath = | ReadonlyArray>( + path: T, + id: string, + bi: number, + isOp: boolean = true, + filter: (node: Node) => boolean = (node: Node) => + !isTransientElement($(node)), + ) => { + const targetElement = this.engine.container.find( + `[${DATA_ID}="${id}"]`, + ); + if (targetElement.length > 0 && targetElement.inEditor()) { + const newPath = targetElement.getPath( + this.engine.container, + targetElement.parent()?.isRoot() ? undefined : filter, + ); + return (isOp ? newPath.map((p) => p + 2) : newPath).concat( + path.slice(bi), + ) as T; + } + return path; + }; + + handleRemoteOps(ops: TargetOp[]) { + if (this.currentAction.self && !this.#selfWaiting) this.saveOp(); + const range = this.engine.change.range.get(); + this.actionOps.forEach((action) => { + action.ops?.forEach((op) => { + if (op.id && op.bi !== undefined) { + op.p = this.handlePath(op.p, op.id, op.bi); + } + }); + if (action.rangePath) { + const { start, end } = action.rangePath; + if (start.id && start.bi !== undefined) { + start.path = this.handlePath( + start.path, + start.id, + start.bi, + false, + range.filterPath(true), + ); + } + if (end.id && end.bi !== undefined) { + end.path = this.handlePath( + end.path, + end.id, + end.bi, + false, + range.filterPath(true), + ); + } + } + if (action.startRangePath) { + const { start, end } = action.startRangePath; + if (start.id && start.bi !== undefined) { + start.path = this.handlePath( + start.path, + start.id, + start.bi, + false, + range.filterPath(true), + ); + } + if (end.id && end.bi !== undefined) { + end.path = this.handlePath( + end.path, + end.id, + end.bi, + false, + range.filterPath(true), + ); + } + } + }); + const redoOps = this.actionOps[this.currentActionIndex] + ? this.actionOps.slice(this.currentActionIndex) + : []; + const undoOps = this.actionOps[this.currentActionIndex - 1] + ? this.actionOps.slice(0, this.currentActionIndex) + : []; + ops = ops.filter((o) => !isCursorOp(o)); + ops.forEach((op) => { + if (isCursorOp(op)) return; + this.remoteOps.push(op); + if (this.actionOps.length > 0) { + // 在一个block中有操作,远程的操作索引小于撤销中的索引或者深度大于撤销中的索引,那就移除所有的撤销操作, + const isRemove = (historyOps: Operation[]) => { + return historyOps.some((uAction) => { + const actionRemove = uAction.ops?.some((uOp) => { + // 操作目标的block节点一致 + if (uOp.id === op.id) { + const uPath = uOp.p.slice(uOp.bi); + const path = op.p.slice(op.bi); + // 远程操作比undo操作目标索引小或者一致 + if ( + uPath.some((p, index) => path[index] < p) || + isReverseOp(op, uOp) + ) { + return true; + } + } + // 里面有删除,反转后执行就是插入操作,插入需要索引,如果远程操作的 ld 或者 li 的path长度与 uOp的长度相等并且小于等于就要删除撤销 + else if ('ld' in uOp) { + if ( + op.p[0] < uOp.p[0] && + op.p.length === 1 && + uOp.p.length === 1 + ) { + return true; + } + if ( + (op.p[0] === uOp.p[0] && + op.p.length > 1 && + uOp.p.some( + (p, index) => + op.p[index] === undefined || + op.p[index] < p, + )) || + isReverseOp(op, uOp) + ) + return true; + } + return false; + }); + + return actionRemove; + }); + }; + if ('li' in op || 'ld' in op || 'sd' in op || 'si' in op) { + if (isRemove(undoOps)) { + this.actionOps.splice( + this.currentActionIndex - 1, + undoOps.length, + ); + this.currentActionIndex -= undoOps.length; + } + if (isRemove(redoOps)) { + this.actionOps.splice(this.currentActionIndex); + this.currentActionIndex--; + } + } + } + }); + } + + getUndoOp(): Operation | undefined { + const prevIndex = this.currentActionIndex - 1; + if (this.actionOps[prevIndex]) { + const prevOp = cloneDeep(this.actionOps[prevIndex]); + const invertOps = OTJSON.type.invert(prevOp.ops || []); + invertOps.forEach((op, index) => { + const pOp = (prevOp.ops || [])[invertOps.length - index - 1]; + op['id'] = pOp.id; + op['bi'] = pOp.bi; + }); + try { + return { + self: true, + ops: invertOps, + id: prevOp.id, + type: 'undo', + rangePath: prevOp.rangePath, + startRangePath: prevOp.startRangePath, + }; + } catch (error) { + console.error(error); + } + } + return; + } + + getRedoOp(): Operation | undefined { + const currentIndex = this.currentActionIndex; + if (this.actionOps[currentIndex]) { + let currentOp = cloneDeep(this.actionOps[currentIndex]); + let invertOps: Op[] | undefined = []; + if (currentOp.type === 'undo') { + // ops 倒置会丢失 id 和 bi + invertOps = OTJSON.type.invert(currentOp.ops || []); + // 重新赋值 id 和 bi + invertOps.forEach((op, index) => { + const pOp = (currentOp.ops || [])[ + invertOps!.length - index - 1 + ]; + op['id'] = pOp.id; + op['bi'] = pOp.bi; + }); + } else { + invertOps = currentOp.ops; + } + try { + return { + self: true, + ops: invertOps, + id: currentOp.id, + type: 'redo', + rangePath: currentOp.rangePath, + startRangePath: currentOp.startRangePath, + }; + } catch (error) { + console.error(error); + } + } + return; + } + + getCurrentRangePath() { + const { ot, change } = this.engine; + const currentPath = ot.selection.currentRangePath; + return currentPath ? currentPath : change.range.get().toPath(); + } + + getRangePathBeforeCommand() { + return ( + this.engine.change.getRangePathBeforeCommand() || + this.getCurrentRangePath() + ); + } +} + +export default HistoryModel; diff --git a/packages/engine/src/hotkey.ts b/packages/engine/src/hotkey.ts new file mode 100644 index 00000000..6cb8d547 --- /dev/null +++ b/packages/engine/src/hotkey.ts @@ -0,0 +1,105 @@ +import isHotkey from 'is-hotkey'; +import { EngineInterface } from './types/engine'; +import { HotkeyInterface } from './types/hotkey'; + +/** + * 快捷键管理器 + * @class Hotkey + */ +class Hotkey implements HotkeyInterface { + private engine: EngineInterface; + private disabled: boolean = false; + constructor(engine: EngineInterface) { + this.engine = engine; + //绑定事件 + this.engine.container.on('keydown', (event) => this.trigger(event)); + } + + /** + * 处理按键按下事件 + * @param e 事件 + */ + trigger(e: KeyboardEvent) { + //禁用快捷键不处理 + if (this.disabled) { + return; + } + //遍历插件 + Object.keys(this.engine.plugin.components).every((name) => { + const plugin = this.engine.plugin.components[name]; + //插件实现了热键方法 + if (plugin.hotkey) { + const result = plugin.hotkey(e); + let isCommand = false; + let commandArgs: any = []; + //返回热键字符串,并且匹配当前按下的键 + if (typeof result === 'string' && isHotkey(result, e)) { + isCommand = true; + } + //返回多个热键 + else if (Array.isArray(result)) { + //遍历热键 + result.some((item: { key: string; args: any } | string) => { + if (typeof item === 'string') { + if (isHotkey(item, e)) { + isCommand = true; + commandArgs = []; + return true; + } + } else { + const { key, args } = item; + if (isHotkey(key, e)) { + isCommand = true; + commandArgs = Array.isArray(args) + ? args + : [args]; + return true; + } + } + return false; + }); + } + //返回类型是对象,带执行命令参数 + else if ( + typeof result === 'object' && + isHotkey(result.key, e) + ) { + isCommand = true; + //参数以数组传递 + commandArgs = Array.isArray(result.args) + ? result.args + : [result.args]; + } + //有匹配到热键,执行命令 + if (isCommand) { + e.preventDefault(); + this.engine.command.execute(name, ...commandArgs); + return false; + } + } + return true; + }); + } + + /** + * 启用快捷键 + */ + enable() { + this.disabled = false; + } + + /** + * 禁用快捷键 + */ + disable() { + this.disabled = true; + } + + /** + * 销毁快捷键 + */ + destroy() { + this.engine.container.off('keydown', this.trigger); + } +} +export default Hotkey; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts new file mode 100644 index 00000000..ee90ad4a --- /dev/null +++ b/packages/engine/src/index.ts @@ -0,0 +1,65 @@ +import isHotkey from 'is-hotkey'; +import { Path } from 'sharedb'; +import Engine from './engine'; +import { + Plugin, + ElementPlugin, + BlockPlugin, + MarkPlugin, + InlinePlugin, + ListPlugin, +} from './plugin'; +import Card from './card/entry'; +import View from './view'; +import Toolbar, { Tooltip } from './toolbar'; +import Range, { isRangeInterface, isRange, isSelection } from './range'; +import Selection from './selection'; +import Parser from './parser'; +import Request, { + Ajax, + Uploader, + getExtensionName, + getFileSize, +} from './request'; +import Scrollbar from './scrollbar'; +import Position from './position'; +import { $, getHashId } from './node'; + +export * from './types'; +export * from './utils'; +export * from './ot/utils'; +export * from './constants'; +export * from './card/enum'; +export * from './node/utils'; + +export default Engine; + +export { + $, + getHashId, + Selection, + Range, + View, + Plugin, + ElementPlugin, + BlockPlugin, + MarkPlugin, + InlinePlugin, + ListPlugin, + Card, + Toolbar, + Tooltip, + Parser, + isHotkey, + Request, + Uploader, + Ajax, + getExtensionName, + getFileSize, + Scrollbar, + Position, + Path, + isRangeInterface, + isRange, + isSelection, +}; diff --git a/packages/engine/src/inline/index.ts b/packages/engine/src/inline/index.ts new file mode 100644 index 00000000..a45d313b --- /dev/null +++ b/packages/engine/src/inline/index.ts @@ -0,0 +1,1054 @@ +import { + CARD_ELEMENT_KEY, + CARD_KEY, + CARD_SELECTOR, + CARD_TYPE_KEY, +} from '../constants'; +import { EditorInterface, EngineInterface } from '../types/engine'; +import { InlineModelInterface } from '../types/inline'; +import { NodeInterface } from '../types/node'; +import { RangeInterface } from '../types/range'; +import { getDocument, getWindow, isEngine } from '../utils'; +import { Backspace, Left, Right } from './typing'; +import { $ } from '../node'; +import { isNode } from '../node/utils'; +import { isInlinePlugin } from '../plugin/inline'; +import { isRangeInterface } from '../range'; + +class Inline implements InlineModelInterface { + private editor: EditorInterface; + + constructor(editor: EditorInterface) { + this.editor = editor; + } + + init() { + const editor = this.editor; + if (isEngine(editor)) { + const { typing, event } = editor; + //删除事件 + const backspace = new Backspace(editor); + typing + .getHandleListener('backspace', 'keydown') + ?.on((event) => backspace.trigger(event)); + //左方向键 + const left = new Left(editor); + typing + .getHandleListener('left', 'keydown') + ?.on((event) => left.trigger(event)); + //右方向键 + const right = new Right(editor); + typing + .getHandleListener('right', 'keydown') + ?.on((event) => right.trigger(event)); + //markdown + event.on('keydown:space', (event) => this.triggerMarkdown(event)); + } + } + + /** + * 修复光标选区位置,​acde​ -> ​acde​ + * 否则在ot中,可能无法正确的应用inline节点两边​的更改 + */ + repairRange(range?: RangeInterface) { + const { change, node } = this.editor as EngineInterface; + range = range || change.range.get(); + const { startNode, startOffset, endNode, endOffset, collapsed } = range; + if (collapsed) return range; + const startInline = this.closest(startNode); + //让其选中节点外的 \u200b 零宽字符 + if (startInline && node.isInline(startInline) && startOffset <= 1) { + //检测是否处于inline标签内部左侧 + let atBefore = true; + let childNode: NodeInterface | undefined = startNode; + while (childNode && !childNode.equal(startInline)) { + if (childNode.prev()) { + atBefore = false; + break; + } + childNode = childNode.parent(); + } + if (atBefore) { + const prev = startInline.prev(); + const text = prev?.text() || ''; + if (prev && prev.isText() && /\u200B$/g.test(text)) { + range.setStart(prev, text.length - 1); + } + } + } + + const endInline = this.closest(endNode); + const last = endInline.last(); + if ( + endInline && + node.isInline(endInline) && + last && + endNode.equal(last) && + endOffset >= last.text().length - 1 + ) { + //检测是否处于inline标签内部右侧 + let atAfter = true; + let childNode: NodeInterface | undefined = startNode; + while (childNode && !childNode.equal(endInline)) { + if (childNode.next()) { + atAfter = false; + break; + } + childNode = childNode.parent(); + } + if (atAfter) { + const next = endInline.next(); + const text = next?.text() || ''; + if (next && next.isText() && /^\u200B/g.test(text)) { + range.setEnd(next, 1); + } + } + } + return range; + } + + /** + * 解析markdown + * @param event 事件 + */ + triggerMarkdown(event: KeyboardEvent) { + const editor = this.editor; + if (!isEngine(editor)) return; + const { change } = editor; + let range = change.range.get(); + if (!range.collapsed || change.isComposing()) return; + const { startNode, startOffset } = range; + const node = + startNode.type === Node.TEXT_NODE + ? startNode + : startNode.children().eq(startOffset - 1); + if (!node) return; + const cacheRange = range.toPath(); + const text = + node.type === Node.TEXT_NODE + ? node.text().substr(0, startOffset) + : node.text(); + const result = !Object.keys(editor.plugin.components).some( + (pluginName) => { + const plugin = editor.plugin.components[pluginName]; + if (isInlinePlugin(plugin) && !!plugin.markdown) { + const reuslt = plugin.triggerMarkdown(event, text, node); + if (reuslt === false) return true; + } + return; + }, + ); + if (!result) change.rangePathBeforeCommand = cacheRange; + return result; + } + + /** + * 获取最近的 Inline 节点,找不到返回 node + */ + closest(source: NodeInterface) { + const nodeApi = this.editor.node; + let node = source.parent(); + while (node && !node.isEditable() && !nodeApi.isBlock(node)) { + if (nodeApi.isInline(node)) return node; + const parentNode = node.parent(); + if (!parentNode) break; + node = parentNode; + } + return source; + } + /** + * 获取向上第一个非 Inline 节点 + */ + closestNotInline(node: NodeInterface) { + const nodeApi = this.editor.node; + while ( + nodeApi.isInline(node) || + nodeApi.isMark(node) || + node.isText() + ) { + if (node.isEditable()) break; + const parent = node.parent(); + if (!parent) break; + node = parent; + } + return node; + } + /** + * 给当前光标节点添加inline包裹 + * @param inline inline标签 + * @param range 光标,默认获取当前光标 + */ + wrap(inline: NodeInterface | Node | string, range?: RangeInterface) { + if (!isEngine(this.editor)) return; + const { change, mark, node } = this.editor; + const safeRange = range || change.range.toTrusty(); + const doc = getDocument(safeRange.startContainer); + if (typeof inline === 'string' || isNode(inline)) { + inline = $(inline, doc); + } else inline = inline; + if (!node.isInline(inline)) return; + + if (safeRange.collapsed) { + this.insert(inline, safeRange); + if (!range) change.apply(safeRange); + return; + } + mark.split(safeRange); + this.split(safeRange); + let { commonAncestorNode } = safeRange; + if ( + commonAncestorNode.type === getWindow().Node.TEXT_NODE || + node.isMark(commonAncestorNode) + ) { + commonAncestorNode = commonAncestorNode.parent()!; + while (node.isMark(commonAncestorNode)) { + commonAncestorNode = commonAncestorNode.parent()!; + } + } + + // 插入范围的开始和结束标记 + const selection = safeRange.enlargeToElementNode().createSelection(); + if (!selection.has()) { + if (!range) change.apply(safeRange); + return; + } + // 遍历范围内的节点,添加 Inline + let started = false; + let inlineClone = node.clone(inline, false); + const inlnes: NodeInterface[] = []; + commonAncestorNode.traverse((child) => { + if (!child.equal(selection.anchor!)) { + if (started) { + if (child.equal(selection.focus!)) { + started = false; + return false; + } + if (node.isInline(child)) { + const children = child.children(); + node.unwrap(child); + child = children; + } + if ( + (node.isMark(child) && !child.isCard()) || + child.isText() + ) { + if (node.isEmpty(child)) { + child.remove(); + return true; + } + if (!inlineClone.parent()) { + child.before(inlineClone); + } + inlineClone.append(child); + this.repairCursor(inlineClone); + inlnes.push(inlineClone); + return true; + } + if ( + inlineClone[0].childNodes.length !== 0 && + !!inlineClone.parent() + ) { + inlineClone = node.clone(inlineClone, false); + } + } + return; + } else { + started = true; + return; + } + }); + + const { anchor, focus } = selection; + const anchorParent = anchor?.parent(); + + if ( + anchorParent && + node.isRootBlock(anchorParent) && + !anchor!.prev() && + !anchor!.next() + ) { + anchor!.after('
      '); + } + + if (!anchor!.equal(focus!)) { + const focusParent = focus?.parent(); + if ( + focusParent && + node.isRootBlock(focusParent) && + !focus!.prev() && + !focus!.next() + ) { + focus!.before('
      '); + } + } + selection.move(); + if (inlnes.length > 0) { + const startNode = inlnes[0].first()!; + const lastNode = inlnes[inlnes.length - 1].last()!; + safeRange.setStart(startNode, 1); + safeRange.setEnd(lastNode, lastNode.text().length - 1); + } + + if (!range) change.apply(safeRange); + } + /** + * 移除inline包裹 + * @param range 光标,默认当前编辑器光标,或者需要移除的inline节点 + */ + unwrap(range?: RangeInterface | NodeInterface) { + const editor = this.editor; + if (!isEngine(editor)) return; + const { change, mark } = editor; + const safeRange = + !range || !isRangeInterface(range) + ? change.range.toTrusty() + : range; + this.repairRange(safeRange); + mark.split(safeRange); + const inlineNodes = + range && !isRangeInterface(range) + ? [range] + : this.findInlines(safeRange); + // 清除 Inline + const selection = safeRange.createSelection(); + inlineNodes.forEach((node) => { + let prev = node.prev(); + if (prev && prev.isCursor()) prev = prev.prev(); + let next = node.next(); + if (next && next.isCursor()) next = next.prev(); + let first = node.first(); + if (first && first.isCursor()) first = first.next(); + + const prevText = prev?.text() || ''; + const nextText = next?.text() || ''; + const firstText = first?.text() || ''; + + if (prev && prev.isText() && /\u200B$/g.test(prevText)) { + if (/^\u200B$/g.test(prevText)) prev.remove(); + else prev.text(prevText.substr(0, prevText.length - 1)); + } + if (next && next.isText() && /^\u200B/g.test(nextText)) { + if (/^\u200B$/g.test(nextText)) next.remove(); + else next.text(nextText.substr(1)); + } + if (first && first.isText() && /^\u200B/g.test(firstText)) { + if (/^\u200B$/g.test(firstText)) first.remove(); + else { + first.get()!.splitText(1); + first.remove(); + } + } + let last = node.last(); + if (last && last.isCursor()) last = last.prev(); + const lastText = last?.text() || ''; + if (last && last.isText() && /\u200B$/g.test(lastText)) { + if (/^\u200B$/g.test(lastText)) last.remove(); + else + last.get()! + .splitText(lastText.length - 1) + .remove(); + } + editor.node.unwrap(node); + }); + + selection.move(); + mark.merge(safeRange); + if (!range) change.apply(safeRange); + } + + /** + * 插入inline标签 + * @param inline inline标签 + * @param range 光标 + */ + insert(inline: NodeInterface | Node | string, range?: RangeInterface) { + if (!isEngine(this.editor)) return; + const { change, node, mark } = this.editor; + const safeRange = range || change.range.toTrusty(); + const doc = getDocument(safeRange.startContainer); + if (typeof inline === 'string' || isNode(inline)) { + inline = $(inline, doc); + } + if (!node.isInline(inline)) return; + // 范围为折叠状态时先删除内容 + if (!safeRange.collapsed) { + change.delete(safeRange); + } + mark.split(safeRange); + // 插入新 Inline + node.insert(inline, safeRange)?.select(inline).collapse(false); + + if (inline.name !== 'br') { + safeRange.handleBr(); + } + const hasChild = inline.children().length !== 0; + this.repairCursor(inline); + //如果有内容,就让光标选择在节点外的零宽字符前 + if (!inline.isCard() && !node.isVoid(inline)) { + if (hasChild) { + const next = inline.next()!; + safeRange.setStart(next, 1); + safeRange.setEnd(next, 1); + } else { + //如果没有子节点,就让光标选择在最后的零宽字符前面 + const last = inline.last()!; + const text = last.text(); + safeRange.setStart(last, text.length - 1); + safeRange.setEnd(last, text.length - 1); + } + } + + if (!range) change.apply(safeRange); + } + + /** + * 去除一个节点下的所有空 Inline callback 可以设置其它条件 + * @param root 节点 + * @param callback 回调 + */ + unwrapEmptyInlines( + root: NodeInterface, + callback?: (node: NodeInterface) => boolean, + ) { + const { node } = this.editor; + const children = root.allChildren(); + children.forEach((child) => { + if ( + node.isEmpty(child) && + node.isInline(child) && + (!callback || callback(child)) + ) { + node.unwrap(child); + } + }); + } + + /** + * 在光标重叠位置时分割 + * @param range 光标 + */ + splitOnCollapsed(range: RangeInterface, keelpNode?: NodeInterface | Node) { + if (!range.collapsed) return; + //扩大光标选区 + range.enlargeFromTextNode(); + range.shrinkToElementNode(); + const { startNode } = range; + const startParent = startNode.parent(); + //获取卡片 + const { node } = this.editor; + const card = startNode.isCard() + ? startNode + : startNode.closest(CARD_SELECTOR); + if ( + (card.length === 0 || + card.attributes(CARD_TYPE_KEY) !== 'inline') && + (node.isInline(startNode) || + (startParent && node.isInline(startParent))) + ) { + // 获取上面第一个非inline标签 + const parent = this.closestNotInline(startNode); + // 插入范围的开始和结束标记 + const selection = range.createSelection(); + // 获取标记左右两侧节点 + const left = selection.getNode(parent, 'left'); + let right: NodeInterface | undefined = undefined; + let keelpRoot: NodeInterface | undefined = undefined; + let keelpPath: Array = []; + if (keelpNode) { + if (isNode(keelpNode)) keelpNode = $(keelpNode); + // 获取需要跟踪节点的路径 + const path = keelpNode.getPath(parent.get()!); + const cloneParent = parent.clone(true); + keelpPath = path.slice(1); + // 获取需要跟踪节点的root节点 + keelpRoot = $(cloneParent.getChildByPath(path.slice(0, 1))); + right = selection.getNode(cloneParent, 'right', false); + } else right = selection.getNode(parent, 'right'); + // 删除空标签 + this.unwrapEmptyInlines(left); + this.unwrapEmptyInlines(right); + // 清空原父容器,用新的内容代替 + const children = parent.children(); + children.each((_, index) => { + if (!children.eq(index)?.isCard()) { + children.eq(index)?.remove(); + } + }); + let appendChild: NodeInterface | undefined | null = undefined; + const appendToParent = (childrenNodes: NodeInterface) => { + childrenNodes.each((child, index) => { + if (childrenNodes.eq(index)?.isCard()) { + appendChild = appendChild + ? appendChild.next() + : parent.first(); + if (appendChild) childrenNodes[index] = appendChild[0]; + return; + } + if (appendChild) { + appendChild.after(child); + appendChild = childrenNodes.eq(index); + } else { + appendChild = childrenNodes.eq(index); + parent.prepend(child); + } + }); + }; + const leftChildren = left.children(); + const leftNodes = leftChildren.toArray(); + appendToParent(leftChildren); + const rightChildren = right.children(); + const rightNodes = rightChildren.toArray(); + // 根据跟踪节点的root节点和path获取其在rightNodes中的新节点 + if (keelpRoot) + keelpNode = rightNodes + .find((node) => node.equal(keelpRoot!)) + ?.getChildByPath(keelpPath); + appendToParent(rightChildren); + // 重新设置范围 + //移除左右两边的 br 标签 + if (leftNodes.length === 1 && leftNodes[0].name === 'br') { + leftNodes[0].remove(); + leftNodes.splice(0, 1); + } + if (rightNodes.length === 1 && rightNodes[0].name === 'br') { + rightNodes[0].remove(); + rightNodes.splice(0, 1); + } + parent.traverse((child) => { + if (node.isInline(child)) { + this.repairCursor(child); + } + }); + } + return keelpNode; + } + /** + * 在光标位置不重合时分割 + * @param range 光标 + * @param removeMark 要移除的空mark节点 + */ + splitOnExpanded(range: RangeInterface) { + if (range.collapsed) return; + range.enlargeToElementNode(); + range.shrinkToElementNode(); + const { startNode, endNode } = range; + const cardStart = startNode.isCard() + ? startNode + : startNode.closest(CARD_SELECTOR); + const cardEnd = endNode.isCard() + ? endNode + : endNode.closest(CARD_SELECTOR); + if ( + !( + (cardStart.length > 0 && + 'inline' === cardStart.attributes(CARD_TYPE_KEY)) || + (cardEnd.length > 0 && + 'inline' === cardEnd.attributes(CARD_TYPE_KEY)) + ) + ) { + //开始非inline标签父节点 + const startNotInlineParent = this.closestNotInline(startNode); + //结束非inine标签父节点 + const endNotInlineParent = this.closestNotInline(endNode); + if (!startNotInlineParent.equal(endNotInlineParent)) { + //开始位置 + const startRange = range.cloneRange(); + startRange.collapse(true); + //结束位置 + const endRange = range.cloneRange(); + endRange.collapse(false); + + //如果开始非inline标签父节点包含结束非inline标签父节点,那么分割的时候会清空 结束非inline标签父节点的内容进行重组。结束非inline标签父节点 将无非找到 + //所以需要从被包含的节点开始分割 + let keelpNode: NodeInterface | Node | undefined = undefined; + let startOffset = startRange.startOffset; + let endOffset = endRange.endOffset; + //如果开始节点的父节点包含结尾父节点,会将结尾父节点删除重组,导致光标失效,需要先执行开始节点分割,并跟踪结尾节点 + if (startNotInlineParent.contains(endNotInlineParent)) { + //先分割开始节点,并跟踪结尾节点 + keelpNode = this.splitOnCollapsed( + startRange, + endRange.endNode, + ); + range.setStart( + startRange.startContainer, + startRange.startOffset, + ); + //如果有跟踪到,重新设置结尾节点 + if (keelpNode) { + endRange.setOffset(keelpNode, endOffset, endOffset); + } + //分割结尾节点 + this.splitOnCollapsed(endRange); + range.setEnd(endRange.startContainer, endRange.startOffset); + } else { + //结尾父节点包含开始节点父节点 + //先分割结尾节点,并跟踪开始节点 + keelpNode = this.splitOnCollapsed( + endRange, + startRange.startNode, + ); + range.setEnd(endRange.startContainer, endRange.startOffset); + //如果有跟踪到,重新设置开始节点 + if (keelpNode) { + startRange.setOffset( + keelpNode, + startOffset, + startOffset, + ); + } + //分割开始节点 + this.splitOnCollapsed(startRange); + range.setStart( + startRange.startContainer, + startRange.startOffset, + ); + } + return; + } + const { node } = this.editor; + // 节点不是Inline,文本节点时判断父节点 + const startParent = startNode.parent(); + const startIsInline = + node.isInline(startNode) || + (startParent && node.isInline(startParent)); + const endParent = endNode.parent(); + const endIsInline = + node.isInline(endNode) || + (endParent && node.isInline(endParent)); + // 开始节点和结束节点都不是Inline,无需分割 + if (!startIsInline && !endIsInline) { + return; + } + let { commonAncestorNode } = range; + if (commonAncestorNode.isText()) { + commonAncestorNode = commonAncestorNode.parent()!; + } + // 获取上面第一个非样式标签 + const parent = this.closestNotInline(commonAncestorNode); + // 插入范围的开始和结束标记 + const selection = range.createSelection(); + // 标记的左边 + const left = selection.getNode(parent, 'left'); + // 标记的节点 + const center = selection.getNode(parent); + // 标记的右边 + const right = selection.getNode(parent, 'right'); + // 删除空标签 + this.unwrapEmptyInlines(left); + this.unwrapEmptyInlines(right); + // 清空原父容器,用新的内容代替 + const children = parent.children(); + children.each((_, index) => { + if (!children.eq(index)?.isCard()) { + children.eq(index)?.remove(); + } + }); + let appendChild: NodeInterface | undefined | null = undefined; + const appendToParent = (childrenNodes: NodeInterface) => { + childrenNodes.each((child, index) => { + if (childrenNodes.eq(index)?.isCard()) { + appendChild = appendChild + ? appendChild.next() + : parent.first(); + if (appendChild) childrenNodes[index] = appendChild[0]; + return; + } + if (appendChild) { + appendChild.after(child); + appendChild = childrenNodes.eq(index); + } else { + appendChild = childrenNodes.eq(index); + parent.prepend(child); + } + }); + }; + appendToParent(left.children()); + const centerChildren = center.children(); + const centerNodes = centerChildren.toArray(); + appendToParent(centerChildren); + appendToParent(right.children()); + parent.traverse((child) => { + if (node.isInline(child)) { + this.repairCursor(child); + } + }); + // 重新设置范围 + range.setStartBefore(centerNodes[0][0]); + range.setEndAfter(centerNodes[centerNodes.length - 1][0]); + } + } + + /** + * 分割inline标签 + */ + split(range?: RangeInterface) { + if (!isEngine(this.editor)) return; + const { change } = this.editor; + const safeRange = range || change.range.toTrusty(); + //const selection = safeRange.createSelection('inline-split'); + if (safeRange.collapsed) { + this.splitOnCollapsed(safeRange); + } else { + this.splitOnExpanded(safeRange); + } + //selection.move() + if (!range) change.apply(safeRange); + } + + /** + * 获取光标范围内的所有 inline 标签 + * @param range 光标 + */ + findInlines(range: RangeInterface) { + const cloneRange = range.cloneRange(); + if (cloneRange.startNode.isRoot()) cloneRange.shrinkToElementNode(); + if (!cloneRange.startNode.inEditor()) return []; + const nodeApi = this.editor.node; + const handleRange = ( + allowBlock: boolean, + range: RangeInterface, + toStart: boolean = false, + ) => { + if (!range.collapsed) return; + const { startNode, startOffset } = range; + //没有父节点 + const startParent = startNode.findParent(); + if (!startParent) return; + //选择父节点内容 + const cloneRange = range.cloneRange(); + cloneRange.select(startParent, true); + //开始位置 + if (toStart) { + cloneRange.setEnd(startNode, startOffset); + cloneRange.enlargeFromTextNode(); + cloneRange.enlargeToElementNode(true); + const startChildren = startNode.children(); + const { endNode, endOffset } = cloneRange; + const endChildren = endNode.children(); + const endOffsetNode = endChildren.eq(endOffset); + const startOffsetNode = + startChildren.eq(startOffset) || + startChildren.eq(startOffset - 1); + if ( + !allowBlock && + endNode.type === Node.ELEMENT_NODE && + endOffsetNode && + nodeApi.isBlock(endOffsetNode) && + (startNode.type !== Node.ELEMENT_NODE || + (!!startOffsetNode && + !nodeApi.isBlock(startOffsetNode))) + ) + return; + cloneRange.select(startParent, true); + cloneRange.setStart(endNode, endOffset); + cloneRange.shrinkToElementNode(); + cloneRange.shrinkToTextNode(); + range.setStart( + cloneRange.startContainer, + cloneRange.startOffset, + ); + range.collapse(true); + } else { + cloneRange.setStart(startNode, startOffset); + cloneRange.enlargeFromTextNode(); + cloneRange.enlargeToElementNode(true); + const startChildren = startNode.children(); + const startNodeClone = cloneRange.startNode; + const startOffsetClone = cloneRange.startOffset; + const startNodeCloneChildren = startNodeClone.children(); + const startOffsetNode = + startNodeCloneChildren.eq(startOffsetClone); + const startChildrenOffsetNode = + startChildren.eq(startOffset) || + startChildren.eq(startOffset - 1); + if ( + !allowBlock && + startNodeClone.type === Node.ELEMENT_NODE && + startOffsetNode && + nodeApi.isBlock(startOffsetNode) && + (startNode.type !== Node.ELEMENT_NODE || + (startChildrenOffsetNode && + !nodeApi.isBlock(startChildrenOffsetNode))) + ) + return; + cloneRange.select(startParent, true); + cloneRange.setEnd(startNodeClone, startOffsetClone); + cloneRange.shrinkToElementNode(); + cloneRange.shrinkToTextNode(); + range.setEnd(cloneRange.endContainer, cloneRange.endOffset); + range.collapse(false); + } + }; + // 左侧不动,只缩小右侧边界 + // foobar + // 改成 + // foobar + if (!cloneRange.collapsed) { + const leftRange = cloneRange.cloneRange(); + const rightRange = cloneRange.cloneRange(); + leftRange.collapse(true); + rightRange.collapse(false); + handleRange(true, leftRange, true); + handleRange(true, rightRange); + cloneRange.setStart( + leftRange.startContainer, + leftRange.startOffset, + ), + cloneRange.setEnd( + rightRange.startContainer, + rightRange.startOffset, + ); + } + handleRange(false, cloneRange); + const sc = cloneRange.startContainer; + const so = cloneRange.startOffset; + const ec = cloneRange.endContainer; + const eo = cloneRange.endOffset; + let startNode = sc; + let endNode = ec; + + if (sc.nodeType === getWindow().Node.ELEMENT_NODE) { + if (sc.childNodes[so]) { + startNode = sc.childNodes[so] || sc; + } + } + + if (ec.nodeType === getWindow().Node.ELEMENT_NODE) { + if (eo > 0 && ec.childNodes[eo - 1]) { + endNode = ec.childNodes[eo - 1] || sc; + } + } + // 折叠状态时,按右侧位置的方式处理 + if (cloneRange.collapsed) { + startNode = endNode; + } + // 不存在时添加 + const addNode = (nodes: Array, nodeB: NodeInterface) => { + if (!nodes.some((nodeA) => nodeA[0] === nodeB[0])) { + nodes.push(nodeB); + } + }; + // 向上寻找 + const findNodes = (node: NodeInterface) => { + const nodes = []; + while (node) { + if (node.isEditable()) break; + if (nodeApi.isInline(node)) nodes.push(node); + const parent = node.parent(); + if (!parent) break; + node = parent; + } + return nodes; + }; + + const nodes = findNodes($(startNode)); + const { commonAncestorNode } = cloneRange; + const card = this.editor.card.find(commonAncestorNode, true); + let isEditable = card?.isEditable; + const selectionNodes = isEditable + ? card?.getSelectionNodes + ? card.getSelectionNodes() + : [] + : [commonAncestorNode]; + if (selectionNodes.length === 0) { + isEditable = false; + selectionNodes.push(commonAncestorNode); + } + if (!cloneRange.collapsed || isEditable) { + findNodes($(endNode)).forEach((nodeB) => { + return addNode(nodes, nodeB); + }); + if (sc !== ec || isEditable) { + let isBegin = false; + let isEnd = false; + selectionNodes.forEach((commonAncestorNode) => { + commonAncestorNode.traverse((child) => { + if (isEnd) return false; + //节点不是开始节点 + if (!child.equal(sc)) { + if (isBegin) { + //节点是结束节点,标记为结束 + if (child.equal(ec)) { + isEnd = true; + return false; + } + if ( + nodeApi.isInline(child) && + !child.attributes(CARD_KEY) && + !child.attributes(CARD_ELEMENT_KEY) + ) { + addNode(nodes, child); + } + } + } else { + //如果是开始节点,标记为开始 + isBegin = true; + } + return; + }); + }); + } + } + return nodes; + } + /** + * 修复inline节点光标占位符 + * @param node inlne 节点 + */ + repairCursor(node: NodeInterface | Node) { + const nodeApi = this.editor.node; + if (isNode(node)) node = $(node); + if (!nodeApi.isInline(node) || nodeApi.isVoid(node) || node.isCard()) + return; + const childrenNodes = node.children(); + childrenNodes.each((_, index) => { + const child = childrenNodes.eq(index); + if (child?.isText()) { + const text = child.text(); + if (text.length === 1 && /\u200b/.test(text)) { + child.remove(); + return; + } + child.text(text.replace(/\u200b/g, '')); + } + }); + this.repairBoth(node); + let firstChild = node.first(); + if (firstChild?.isCursor()) firstChild = firstChild.next(); + if ( + !firstChild || + firstChild.type !== Node.TEXT_NODE || + !/^\u200B/g.test(firstChild.text()) + ) { + if (!firstChild) node.append($('\u200b', null)); + else if (firstChild.isText()) { + firstChild.text('\u200B' + firstChild.text()); + } else firstChild.before($('\u200b', null)); + } + + let last = node.last(); + if (last?.isCursor()) last = last.prev(); + if ( + last && + (/^\u200B$/g.test(node.text()) || + last.type !== Node.TEXT_NODE || + !/\u200B$/g.test(last.text())) + ) { + if (last.isText()) { + last.text(last.text() + '\u200B'); + } else last.after($('\u200b', null)); + } + } + + /** + * 修复节点两侧零宽字符占位 + * @param node 节点 + */ + repairBoth(node: NodeInterface | Node) { + const nodeApi = this.editor.node; + if (isNode(node)) node = $(node); + if (node.parent() && !nodeApi.isVoid(node)) { + const zeroNode = $('\u200b', null); + const prev = node.prev(); + const prevPrev = prev?.prev(); + const prevText = prev?.text() || ''; + if ( + !prev || + !prev.isText() || + !/\u200B$/g.test(prevText) || + (prevPrev && + nodeApi.isInline(prevPrev) && + !/\u200B.*\u200B$/g.test(prevText)) + ) { + if (prev && prev.isText()) { + prev.text(prevText + '\u200b'); + } else { + node.before(nodeApi.clone(zeroNode, true)); + } + } else if ( + prev && + prev.isText() && + /\u200B\u200B$/g.test(prevText) && + prevPrev && + !nodeApi.isInline(prevPrev) + ) { + prev.text(prevText.substr(0, prevText.length - 1)); + } + + const next = node.next(); + const nextText = next?.text() || ''; + const nextNext = next?.next(); + if ( + !next || + !next.isText() || + !/^\u200B/g.test(nextText) || + (nextNext && + nodeApi.isInline(nextNext) && + !/^\u200B\u200B/g.test(nextText)) + ) { + if (next && next.isText()) { + next.text('\u200b' + next.text()); + } else { + node.after(this.editor.node.clone(zeroNode, true)); + if (next?.name === 'br') { + next.remove(); + } + } + } + } + } + + flat(node: NodeInterface | RangeInterface) { + if (isRangeInterface(node)) { + const selection = node.shrinkToElementNode().createSelection(); + const inlines = this.findInlines(node); + inlines.forEach((inline) => { + if (inline.isCard()) return; + this.flat(inline); + }); + selection.move(); + return; + } + if (node.isCard()) return; + const nodeApi = this.editor.node; + const markApi = this.editor.mark; + //当前节点是 inline 节点,inline 节点不允许嵌套、不允许放入mark节点 + if (nodeApi.isInline(node) && node.name !== 'br') { + const parentInline = this.closest(node); + //不允许嵌套 + if (!parentInline.equal(node) && nodeApi.isInline(parentInline)) { + nodeApi.unwrap(node); + } + //不允许放入mark + else { + const parentMark = markApi.closest(node); + if (!parentMark.equal(node) && nodeApi.isMark(parentMark)) { + const cloneMark = parentMark.clone(); + const inlineMark = node.clone(); + parentMark.children().each((markChild) => { + // 零宽字符的文本跳过 + if ( + markChild.nodeType === 3 && + /^\u200b$/.test(markChild.textContent || '') + ) { + return; + } + if (node.equal(markChild)) { + nodeApi.wrap( + nodeApi.replace(node, cloneMark), + inlineMark, + ); + this.repairBoth(inlineMark); + } else { + nodeApi.wrap(markChild, cloneMark); + } + }); + nodeApi.unwrap(parentMark); + } + } + } + } +} + +export default Inline; diff --git a/packages/engine/src/inline/typing/backspace.ts b/packages/engine/src/inline/typing/backspace.ts new file mode 100644 index 00000000..38f417d2 --- /dev/null +++ b/packages/engine/src/inline/typing/backspace.ts @@ -0,0 +1,176 @@ +import { EngineInterface, NodeInterface } from '../../types'; + +class Backspace { + private engine: EngineInterface; + constructor(engine: EngineInterface) { + this.engine = engine; + } + /** + * 在inline节点处按下backspace键 + */ + trigger(event: KeyboardEvent) { + const { change, mark, inline, node } = this.engine; + const range = change.range.get(); + const { collapsed, endNode, startNode, startOffset } = range + .cloneRange() + .shrinkToTextNode(); + if ( + endNode.type === Node.TEXT_NODE || + startNode.type === Node.TEXT_NODE + ) { + //光标展开的情况下,判断光标结束位置是否在inline节点内左侧零宽字符后面,并且inline节点为空 + if (!collapsed) { + const inlineNode = inline.closest(endNode); + if ( + node.isInline(inlineNode) && + !inlineNode.isCard() && + node.isEmpty(inlineNode) + ) { + //offset 大于 1 说明至少不在左侧,不处理 + if (startOffset > 1) return true; + let prev = endNode.prev(); + let parent = endNode.parent(); + //位于inline 1级子节点下 + if (parent && node.isInline(parent)) { + //前面还有节点,不处理 + if (prev) return true; + const text = endNode.text(); + const leftText = text.substr(0, startOffset); + //不位于零宽字符后,不处理 + if (!/^\u200b$/.test(leftText)) return true; + //光标结束位置选中inline节点零宽字符后 + const inlineNext = inlineNode.next(); + const nextText = inlineNext?.text(); + if ( + inlineNext && + inlineNext.isText() && + nextText && + /^\u200b/.test(nextText) + ) { + range.setEnd(inlineNext, 1); + return false; + } + } + } + if (node.isInline(inlineNode) && !inlineNode.isCard()) { + setTimeout(() => { + inline.repairCursor(inlineNode); + }, 100); + } + return true; + } + let inlineNode = inline.closest(startNode); + //开始节点在inline标签内 + if (node.isInline(inlineNode)) { + if (inlineNode.isCard()) return true; + //offset 大于 1 说明至少不在左侧,不处理 + if (startOffset > 1) return true; + let prev = startNode.prev(); + let parent = startNode.parent(); + //位于inline 1级子节点下 + if (parent && node.isInline(parent)) { + //前面还有节点,不处理 + if (prev) return true; + const text = startNode.text(); + const leftText = text.substr(0, startOffset); + //不位于零宽字符后,不处理 + if (!/\u200b$/.test(leftText)) return true; + } + // 其它内嵌节点内 + else if (startOffset === 0) { + //循环判断是否处于inline节点内的第一个零宽字符后面,可能inline节点内包含多个mark或其它标签 + while (!prev && parent && !node.isInline(parent)) { + prev = parent.prev(); + parent = parent?.parent(); + } + //前面有节点,并且不是 text 节点,不处理 + if (prev && !prev.isText()) return true; + //前面有text节点 + if (prev) { + //不位于零宽字符后,不处理 + if (!/\u200b$/.test(prev.text())) return true; + } + } else return true; + //让光标选择在inline节点前面零宽字符节点前面 + const inlinePrev = inlineNode.prev(); + const prevText = inlinePrev?.text(); + if ( + inlinePrev && + inlinePrev.isText() && + prevText && + /\u200b$/.test(prevText) + ) { + range.setStart(inlinePrev, prevText.length - 1); + //如果inlne节点中没有内容了,选择在inline标签前后零宽字符两侧 + if (node.isEmpty(inlineNode)) { + const inlineNext = inlineNode.next(); + const nextText = inlineNext?.text(); + if ( + inlineNext && + inlineNext.isText() && + nextText && + /^\u200b/.test(nextText) + ) { + range.setEnd(inlineNext, 1); + } + } else { + range.collapse(true); + } + change.range.select(range); + return false; + } + return true; + } + // 在inline节点外 + else { + let prev = startNode.prev(); + let parent = startNode.parent(); + let inlineNode: NodeInterface | undefined = undefined; + //在零宽字符后面,并且前面有inline节点 + if (prev) { + if (node.isInline(prev) && !prev.isCard()) { + const text = startNode.text(); + const leftText = text.substr(0, startOffset); + //不位于零宽字符后,不处理 + if (!/^\u200b$/.test(leftText)) return true; + inlineNode = prev; + } + } + //前方没有节点,考虑是否在mark节点或其它节点内 + else if (startOffset === 0) { + //循环判断是否处于inline节点内的第一个零宽字符后面,可能inline节点内包含多个mark或其它标签 + while (!prev && parent && !node.isBlock(parent)) { + prev = parent.prev(); + parent = parent?.parent(); + } + //前面有节点,并且不是 text 节点,不处理 + if (prev && !prev.isText()) return true; + //前面有text节点 + if (prev) { + //不位于零宽字符后,不处理 + if (!/^\u200b$/.test(prev.text())) return true; + //位于零宽字符后,并且零宽字符前面是inline节点 + prev = prev.prev(); + if (prev && node.isInline(prev)) { + inlineNode = prev; + } else return true; + } + } else return true; + //让光标选择inline内部最后一个零宽字符前 + if (inlineNode) { + const last = inlineNode.last(); + const text = last?.text(); + if (last && last.isText() && text && /\u200b$/.test(text)) { + event.preventDefault(); + range.setStart(last, text.length - 1); + range.collapse(true); + change.range.select(range); + return false; + } + } + } + } + return true; + } +} +export default Backspace; diff --git a/packages/engine/src/inline/typing/index.ts b/packages/engine/src/inline/typing/index.ts new file mode 100644 index 00000000..03eb4ba4 --- /dev/null +++ b/packages/engine/src/inline/typing/index.ts @@ -0,0 +1,5 @@ +import Backspace from './backspace'; +import Left from './left'; +import Right from './right'; + +export { Backspace, Left, Right }; diff --git a/packages/engine/src/inline/typing/left.ts b/packages/engine/src/inline/typing/left.ts new file mode 100644 index 00000000..f8bb1db1 --- /dev/null +++ b/packages/engine/src/inline/typing/left.ts @@ -0,0 +1,122 @@ +import isHotkey from 'is-hotkey'; +import { EngineInterface, NodeInterface } from '../../types'; + +class Left { + private engine: EngineInterface; + constructor(engine: EngineInterface) { + this.engine = engine; + } + + trigger(event: KeyboardEvent) { + const { change, inline, node } = this.engine; + const range = change.range.get().cloneRange().shrinkToTextNode(); + const { startNode, startOffset } = range; + const card = this.engine.card.getSingleCard(range); + if (!card && startNode.type === Node.TEXT_NODE) { + //​​ -> ​​​ + const inlineNode = inline.closest(startNode); + //在inline节点内,靠近左侧位置 + if (node.isInline(inlineNode)) { + if (inlineNode.isCard()) return; + //offset 大于 1 说明至少不在左侧,不处理 + if (startOffset > 1) return true; + let prev = startNode.prev(); + let parent = startNode.parent(); + //位于inline 1级子节点下 + if (parent && node.isInline(parent)) { + //前面还有节点,不处理 + if (prev) return true; + const text = startNode.text(); + const leftText = text.substr(0, startOffset); + //不位于零宽字符后,不处理 + if (!/^\u200b$/.test(leftText)) return true; + } + // 其它内嵌节点内 + else if (startOffset === 0) { + //循环判断是否处于inline节点内的第一个零宽字符后面,可能inline节点内包含多个mark或其它标签 + while (!prev && parent && !node.isInline(parent)) { + prev = parent.prev(); + parent = parent?.parent(); + } + //前面有节点,并且不是 text 节点,不处理 + if (prev && !prev.isText()) return true; + //前面有text节点 + if (prev) { + //不位于零宽字符后,不处理 + if (!/^\u200b$/.test(prev.text())) return true; + } + } else return true; + //让光标选择在inline节点前面节点 + const inlinePrev = inlineNode.prev(); + const prevText = inlinePrev?.text(); + if ( + inlinePrev && + inlinePrev.isText() && + prevText && + /\u200b$/.test(prevText) + ) { + event.preventDefault(); + const { collapsed } = range.cloneRange(); + range.setStart(inlinePrev, prevText.length - 1); + if (collapsed) range.collapse(true); + change.range.select(range); + return false; + } + return true; + } + // 在inline节点外 + else { + let prev = startNode.prev(); + let parent = startNode.parent(); + let inlineNode: NodeInterface | undefined = undefined; + //在零宽字符后面,并且前面有inline节点 + if (prev) { + if (node.isInline(prev) && !prev.isCard()) { + const text = startNode.text(); + const leftText = text.substr(0, startOffset); + //不位于零宽字符后,不处理 + if (!/^\u200b$/.test(leftText)) return true; + inlineNode = prev; + } + } + //前方没有节点,考虑是否在mark节点或其它节点内 + else if (startOffset === 0) { + //循环判断是否处于inline节点内的第一个零宽字符后面,可能inline节点内包含多个mark或其它标签 + while (!prev && parent && !node.isBlock(parent)) { + prev = parent.prev(); + parent = parent?.parent(); + } + //前面有节点,并且不是 text 节点,不处理 + if (prev && !prev.isText()) return true; + //前面有text节点 + if (prev) { + //不位于零宽字符后,不处理 + if (!/^\u200b$/.test(prev.text())) return true; + //位于零宽字符后,并且零宽字符前面是inline节点 + prev = prev.prev(); + if (prev && node.isInline(prev)) { + inlineNode = prev; + } else return true; + } + } else return true; + //让光标选择inline内部最后一个零宽字符前 + if (inlineNode) { + event.preventDefault(); + const last = inlineNode.last(); + const text = last?.text(); + if (last && last.isText() && text && /\u200b$/.test(text)) { + const { collapsed } = range.cloneRange(); + range.setStart(last, text.length - 1); + if (collapsed && !isHotkey('shift+left', event)) { + range.collapse(true); + } + change.range.select(range); + return false; + } + } + } + } + return true; + } +} +export default Left; diff --git a/packages/engine/src/inline/typing/right.ts b/packages/engine/src/inline/typing/right.ts new file mode 100644 index 00000000..92e718a7 --- /dev/null +++ b/packages/engine/src/inline/typing/right.ts @@ -0,0 +1,138 @@ +import isHotkey from 'is-hotkey'; +import { EngineInterface, NodeInterface } from '../../types'; + +class Right { + private engine: EngineInterface; + constructor(engine: EngineInterface) { + this.engine = engine; + } + + trigger(event: KeyboardEvent) { + const { change, inline, node } = this.engine; + const range = change.range.get().cloneRange().shrinkToTextNode(); + const { endNode, endOffset } = range; + const card = this.engine.card.getSingleCard(range); + if (!card && endNode.type === Node.TEXT_NODE) { + //​​ -> ​​​ + const inlineNode = inline.closest(endNode); + const text = endNode.text(); + //在inline节点内,靠近右侧位置 + if (node.isInline(inlineNode)) { + if (inlineNode.isCard()) return; + //offset 小于 文本长度 说明至少不在右侧,不处理 + if (endOffset < text.length - 1) return true; + let next = endNode.next(); + let parent = endNode.parent(); + //位于inline 1级子节点下 + if (parent && node.isInline(parent)) { + //后面还有节点,不处理 + if (next) return true; + const rightText = text.substr(endOffset); + //不位于零宽字符前,不处理 + if (!/^\u200b$/.test(rightText)) return true; + } + // 其它内嵌节点内,并且开始offset在最后位置,或者在倒数第一前面的位置,浏览器默认会跳出inline节点 + else if ( + endOffset === text.length || + endOffset === text.length - 1 + ) { + //循环判断是否处于inline节点内的第一个零宽字符前面,可能inline节点内包含多个mark或其它标签 + while (!next && parent && !node.isInline(parent)) { + next = parent.next(); + parent = parent?.parent(); + } + //后面有节点,并且不是 text 节点,不处理 + if (next && !next.isText()) return true; + //后面有text节点 + if (next) { + //不位于零宽字符前,不处理 + if (!/^\u200b$/.test(next.text())) return true; + //选中零宽字符前面 + if (endOffset === text.length - 1) { + event.preventDefault(); + const { collapsed } = range.cloneRange(); + range.setEnd(next, 0); + if (collapsed) range.collapse(false); + change.range.select(range); + return false; + } + } + } else return true; + //让光标选择在inline节点的下一个零宽字符后面 + const inlineNext = inlineNode.next(); + const nextText = inlineNext?.text(); + if ( + inlineNext && + inlineNext.isText() && + nextText && + /^\u200b/.test(nextText) + ) { + event.preventDefault(); + const { collapsed } = range.cloneRange(); + range.setEnd(inlineNext, 1); + if (collapsed) range.collapse(false); + change.range.select(range); + return false; + } + return true; + } + // 在inline节点外 + else { + let next = endNode.next(); + let parent = endNode.parent(); + let inlineNode: NodeInterface | undefined = undefined; + //在零宽字符后面,并且后面有inline节点 + if (next) { + if (node.isInline(next) && !next.isCard()) { + const rightText = text.substr(endOffset); + //不位于零宽字符前,不处理 + if (!/^\u200b$/.test(rightText)) return true; + inlineNode = next; + } + } + //后方没有节点,考虑是否在mark节点或其它节点内 + else if (endOffset === text.length) { + //循环判断是否处于inline节点内的最后一个零宽字符前面,可能inline节点内包含多个mark或其它标签 + while (!next && parent && !node.isBlock(parent)) { + next = parent.next(); + parent = parent?.parent(); + } + //后面有节点,并且不是 text 节点,不处理 + if (next && !next.isText()) return true; + //后面有text节点 + if (next) { + //不位于零宽字符前,不处理 + if (!/^\u200b$/.test(next.text())) return true; + //位于零宽字符前,并且零宽字符后面是inline节点 + next = next.next(); + if (next && node.isInline(next)) { + inlineNode = next; + } else return true; + } + } else return true; + //让光标选择inline内部最第一个零宽字符后 + if (inlineNode) { + event.preventDefault(); + const first = inlineNode.first(); + const text = first?.text(); + if ( + first && + first.isText() && + text && + /^\u200b/.test(text) + ) { + const { collapsed } = range.cloneRange(); + range.setEnd(first, 1); + if (collapsed && !isHotkey('shift+right', event)) { + range.collapse(false); + } + change.range.select(range); + return false; + } + } + } + } + return true; + } +} +export default Right; diff --git a/packages/engine/src/language.ts b/packages/engine/src/language.ts new file mode 100644 index 00000000..5f022226 --- /dev/null +++ b/packages/engine/src/language.ts @@ -0,0 +1,35 @@ +import { merge } from 'lodash-es'; +import { LanguageInterface } from './types/language'; + +/** + * 语言包管理器 + */ +class Language implements LanguageInterface { + private data: {} = {}; + private locale: string = 'zh-CN'; + + constructor(locale: string, data: {} = {}) { + this.locale = locale; + this.data = data; + } + + add(data: {}) { + this.data = merge(this.data, data); + } + + get(...keys: Array): T { + const get = (start: number = 0, language: {}): T => { + for (let i = start; i < keys.length; i++) { + const value = language[keys[i]]; + if (typeof value === 'object') { + return get(i + 1, value); + } + return value || ''; + } + return language as T; + }; + return get(0, this.data[this.locale]); + } +} + +export default Language; diff --git a/packages/engine/src/list/index.ts b/packages/engine/src/list/index.ts new file mode 100644 index 00000000..e9228c8b --- /dev/null +++ b/packages/engine/src/list/index.ts @@ -0,0 +1,1278 @@ +import { CARD_KEY, DATA_ID } from '../constants'; +import Range from '../range'; +import { + EditorInterface, + NodeInterface, + PluginEntry, + RangeInterface, +} from '../types'; +import { ListInterface, ListModelInterface } from '../types/list'; +import { getDocument, getWindow, isEngine, removeUnit } from '../utils'; +import { Enter, Backspace } from './typing'; +import { $ } from '../node'; +import { isNode } from '../node/utils'; + +class List implements ListModelInterface { + private editor: EditorInterface; + /** + * 自定义列表样式 + */ + readonly CUSTOMZIE_UL_CLASS = 'data-list'; + /** + * 自定义列表样式 + */ + readonly CUSTOMZIE_LI_CLASS = 'data-list-item'; + /** + * 列表缩进key + */ + readonly INDENT_KEY = 'data-indent'; + /** + * 列表项point位置 + */ + readonly STYLE_POSITION_NAME = 'list-style-position'; + readonly STYLE_POSITION_VALUE = 'inside'; + + backspaceEvent?: Backspace; + + constructor(editor: EditorInterface) { + this.editor = editor; + } + + init() { + if (isEngine(this.editor)) { + //绑定回车事件 + const enter = new Enter(this.editor); + this.editor.typing + .getHandleListener('enter', 'keydown') + ?.on((event) => enter.trigger(event)); + //删除事件 + this.backspaceEvent = new Backspace(this.editor); + this.editor.typing + .getHandleListener('backspace', 'keydown') + ?.on((event) => this.backspaceEvent?.trigger(event)); + } + } + + /** + * 判断列表项节点是否为空 + * @param node 节点 + */ + isEmptyItem(node: NodeInterface): boolean { + return ( + //节点名称必须为li + 'li' === node.name && + //空节点 + (this.editor.node.isEmpty(node) || + //子节点只有一个,如果是自定义列表并且第一个是卡片 或者第一个节点是 br标签,就是空节点 + (1 === node.children().length + ? (node.hasClass(this.CUSTOMZIE_LI_CLASS) && + node.first()?.isCard()) || + 'br' === node.first()?.name + : //子节点有两个,并且是自定义列表而且第一个是卡片,并且第二个节点是br标签 + 2 === node.children().length && + node.hasClass(this.CUSTOMZIE_LI_CLASS) && + !!node.first()?.isCard() && + 'br' === node.last()?.name)) + ); + } + + /** + * 判断两个节点是否是一样的List节点 + * @param sourceNode 源节点 + * @param targetNode 目标节点 + */ + isSame(sourceNode: NodeInterface, targetNode: NodeInterface) { + //节点名称不一样 + if (sourceNode.name !== targetNode.name) return false; + const { node } = this.editor; + //自定义列表类型不一致,要么都是,要么都不是 + if (node.isCustomize(sourceNode) !== node.isCustomize(targetNode)) + return false; + //判断缩进是否一致 + const sourceIndent = + parseInt(sourceNode.attributes(this.INDENT_KEY), 10) || 0; + const targetIndent = + parseInt(targetNode.attributes(this.INDENT_KEY), 10) || 0; + return sourceIndent === targetIndent; + } + + /** + * 判断节点集合是否是指定类型的List列表 + * @param blocks 节点集合 + * @param name 节点标签类型 + * @param card 是否是指定的自定义列表项的卡片名称 + */ + isSpecifiedType( + blocks: Array, + name: 'ul' | 'ol' = 'ul', + card?: string, + ) { + const { node } = this.editor; + let isSame = true; + blocks.forEach((block) => { + //如果节点内包含了列表节点,则跳过此节点 + if ( + ['li', 'p'].indexOf(block.name) === -1 && + (block.name === name || block.find(name).length > 0) + ) + return; + + switch (block.name) { + case 'li': + //有指定卡片,判断是否是自定义列表项的卡片相同 + if (card) { + let firstChild = block.first(); + if (firstChild?.isCursor()) + firstChild = firstChild.next(); + isSame = + isSame && + node.isCustomize(block) && + (firstChild?.attributes(CARD_KEY) || '') === card; + } else { + isSame = isSame && !node.isCustomize(block); + } + break; + case 'p': + if (block.parent() && block.parent()?.name !== 'li') { + isSame = false; + } + break; + default: + isSame = false; + break; + } + }); + return isSame; + } + + getPlugins() { + const plugins: Array = []; + Object.keys(this.editor.plugin.components).forEach((name) => { + const plugin = this.editor.plugin.components[name]; + if (!!(plugin as ListInterface).isCurrent) { + plugins.push(plugin as ListInterface); + } + }); + return plugins; + } + + /** + * 根据节点获取列表插件名称 + * @param block 节点 + */ + getPluginNameByNode(block: NodeInterface) { + let name = block.name; + const getName = (node: NodeInterface) => { + let name = ''; + this.getPlugins().some((plugin) => { + if (plugin.isCurrent(node)) { + name = (plugin.constructor as PluginEntry).pluginName; + return true; + } + return; + }); + return name; + }; + //如果是自定义列表 + if (this.editor.node.isCustomize(block)) return getName(block); + //如果是li标签 + if ('li' === name && block.parent()) return getName(block.parent()!); + return ''; + } + + /** + * 获取列表插件名称 + * @param blocks 节点集合 + */ + getPluginNameByNodes(blocks: Array) { + let listType = ''; + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + //节点父级 + const parent = block.parent(); + let type = ''; + switch (block.name) { + case 'li': + case 'ul': + case 'ol': + type = this.getPluginNameByNode(blocks[i]); + break; + case 'p': + if (parent && parent.name === 'li') { + type = this.getPluginNameByNode(parent); + } else { + return ''; + } + break; + default: + //如果节点内包含了列表节点,则跳过此节点 + if ( + this.editor.node.isBlock(block) && + block.find('ul,ol').length > 0 + ) + break; + else return ''; + } + if (listType && type && listType !== type) { + return ''; + } + listType = type; + } + return listType; + } + + /** + * 清除自定义列表节点相关属性 + * @param node 节点 + */ + unwrapCustomize(node: NodeInterface) { + if (this.editor.node.isCustomize(node)) { + switch (node.name) { + case 'li': + if (this.editor.node.isCustomize(node)) { + const first = node.first(); + if (first?.isCard()) first.remove(); + } + node.removeAttributes('class'); + return node; + case 'ul': + node.removeAttributes('class'); + return node; + default: + return node; + } + } + return node; + } + + /** + * 取消节点的列表 + * @param blocks 节点集合 + * @param normalBlock 要转换的block默认为

      + */ + unwrap( + blocks: Array, + normalBlock: NodeInterface = $('

      '), + ) { + let indent = 0; + const { node, schema } = this.editor; + const globals = schema.data.globals['block'] || {}; + const globalStyles = globals.style || {}; + blocks.forEach((block) => { + this.unwrapCustomize(block); + if (node.isList(block)) { + indent = parseInt(block.attributes(this.INDENT_KEY), 10) || 0; + node.unwrap(block); + } + + if (block.name === 'li') { + const toBlock = node.clone(normalBlock, false); + if (indent !== 0) { + toBlock.css('text-indent', indent * 2 + 'em'); + } + block.removeAttributes(this.INDENT_KEY); + const attributes = block.attributes(); + Object.keys(attributes).forEach((name) => { + if (name !== DATA_ID && name !== 'id' && globals[name]) { + toBlock.attributes(name, attributes[name]); + } + }); + const styles = block.css(); + if (styles['text-align']) + this.addAlign(toBlock, styles['text-align'] as any); + delete styles['text-align']; + // 移除align样式 + styles[this.STYLE_POSITION_NAME] = ''; + // 移除不符合全局条件的样式 + Object.keys(styles).forEach((name) => { + if (!globalStyles[name]) styles[name] = ''; + }); + toBlock.css(styles); + node.replace(block, toBlock); + } + }); + } + + /** + * 获取当前选区的修复列表后的节点集合 + */ + normalize(range?: RangeInterface) { + if (!isEngine(this.editor)) return []; + const { change, block, node } = this.editor; + range = range || change.range.get(); + const blocks = block.getBlocks(range); + const listNodes: Array = []; + blocks.forEach((block, i) => { + const parent = block.parent(); + //节点是p标签 + if (block.name === 'p') { + //p标签被li节点包裹时,去除p标签,并保留子节点和内容 + if (parent?.name === 'li') { + if (i === 0) { + listNodes.push(parent); + } + //去除包裹 + node.unwrap(block); + return; + } + //

        a

      =>
      • a
      + if (parent && ['ul', 'ol'].indexOf(parent.name) > -1) { + block = node.replace(block, $('
    • ')); + listNodes.push(block); + return; + } + } + + if (block.name === 'li' && parent?.name === 'li') { + //
    • a
    • =>
    • a
    • + if (i === 0) { + listNodes.push(parent); + } + + node.unwrap(block); + return; + } + + if (['ul', 'ol'].indexOf(block.name) > -1) { + //
      • ...
    • =>
    • ...
    • + if (parent?.name === 'li') { + node.unwrap(block); + return; + } + } + + listNodes.push(block); + }); + // 最后一个 li 如果没选中内容,会在 getBlocks 时抛弃掉,这里需要补回来 + const lastBlock = range.endNode.closest('li'); + + if ( + !listNodes.some((block) => { + return block[0] === lastBlock[0]; + }) + ) { + listNodes.push(lastBlock); + } + return listNodes; + } + + /** + * 将选中列表项列表分割出来单独作为一个列表 + */ + split(range?: RangeInterface) { + if (!isEngine(this.editor)) return; + const { change, node } = this.editor; + const safeRange = range || change.range.toTrusty(); + const blocks = this.normalize(range); + if ( + blocks.length > 0 && + (blocks[0].name === 'li' || blocks[blocks.length - 1].name === 'li') + ) { + const selection = safeRange.createSelection(); + const firstBlock = blocks[0]; + const lastBlock = blocks[blocks.length - 1]; + const middleList = []; + const rightList = []; + let beforeListElement: NodeInterface | undefined; + //选区中最后的li节点的列表节点 + let afterListElement: NodeInterface | undefined; + // 当前选中的第一个节点是li,并且这个节点前面还有节点,那就获取选中的节点所在列表的后续节点然后放入middleList + if (firstBlock.prev()) { + beforeListElement = firstBlock.parent(); + let indexInRange = 0; + + while ( + blocks[indexInRange] && + blocks[indexInRange].name === 'li' + ) { + middleList.push(blocks[indexInRange]); + indexInRange += 1; + } + } + // 当前选中的最后一个节点是li,那么获取到这个列表的最后所有的li节点放入rightList + if (lastBlock.next()) { + afterListElement = lastBlock.parent(); + let nextBlock = lastBlock.next(); + + while (nextBlock && nextBlock.name === 'li') { + rightList.push(nextBlock); + nextBlock = nextBlock.next(); + } + } + + //将 rightList 集合添加到最后的列表节点内 + let afterListElementClone: NodeInterface | undefined; + + if (rightList.length > 0 && afterListElement) { + afterListElementClone = node.clone(afterListElement, false); + rightList.forEach((li) => { + afterListElementClone?.append(li[0]); + }); + afterListElement.after(afterListElementClone); + } + + let beforeListElementClone: NodeInterface | undefined; + //将 middleList 集合添加到前方列表节点内 + if (middleList.length > 0 && beforeListElement) { + beforeListElementClone = node.clone(beforeListElement, false); + middleList.forEach((li) => { + beforeListElementClone?.append(li[0]); + }); + beforeListElement.after(beforeListElementClone); + } + //有序列表设置start属性 + if ( + beforeListElement && + afterListElement && + afterListElement.equal(beforeListElement) && + beforeListElement.name === 'ol' + ) { + const newStart = + (parseInt(beforeListElement.attributes('start'), 10) || 1) + + beforeListElement.find('li').length; + afterListElementClone!.attributes('start', newStart); + } + selection.move(); + } + if (!range) change.apply(safeRange); + } + + merge(blocks?: Array, range?: RangeInterface) { + if (!isEngine(this.editor)) return; + const { change, block, node } = this.editor; + const safeRange = range || change.range.toTrusty(); + const selection = blocks + ? undefined + : safeRange.shrinkToElementNode().createSelection(); + blocks = blocks || block.getBlocks(safeRange); + blocks.forEach((block) => { + block = block.closest('ul,ol'); + if (!node.isList(block)) { + return; + } + const prevBlock = block.prev(); + const nextBlock = block.next(); + + if (prevBlock && this.isSame(prevBlock, block)) { + node.merge(prevBlock, block); + // 原来 block 已经被移除,重新指向 + block = prevBlock; + } + + if (nextBlock && this.isSame(nextBlock, block)) { + node.merge(block, nextBlock); + } + }); + blocks = block.getBlocks(safeRange); + if (blocks.length > 0) { + const block = blocks[0].closest('ul,ol'); + this.addStart(block); + } + selection?.move(); + if (!range && selection !== undefined) change.apply(safeRange); + } + /** + * 给列表添加start序号 + * @param block 列表节点 + */ + addStart(block?: NodeInterface) { + if (!isEngine(this.editor)) return; + const { change, node } = this.editor; + if (!block) { + const blocks = this.editor.block.getBlocks(change.range.get()); + if (blocks.length === 0) return; + block = blocks[0].closest('ul,ol'); + } + if (!block || !node.isList(block)) return; + const startIndent = + parseInt(block.attributes(this.INDENT_KEY), 10) || 0; + // 当前选区起始位置如果不是第一层级,需要向前遍历,找到各层级的前序序号 + // 直到遇到一个非列表截止,比如 p + + let startCache: Array = []; + let cacheIndent = startIndent; + let prevNode = block.prev(); + + while (prevNode && node.isList(prevNode)) { + if (prevNode.name === 'ol') { + const prevIndent = + parseInt(prevNode.attributes(this.INDENT_KEY), 10) || 0; + const prevStart = + parseInt(prevNode.attributes('start'), 10) || 1; + const len = prevNode.find('li').length; + + if (prevIndent === 0) { + startCache[prevIndent] = prevStart + len; + break; + } + if (prevIndent <= cacheIndent) { + cacheIndent = prevIndent; + startCache[prevIndent] = + startCache[prevIndent] || prevStart + len; + } + } else + cacheIndent = + parseInt(prevNode.attributes(this.INDENT_KEY), 10) || 0; + prevNode = prevNode.prev(); + } + + let nextNode = block; + while (nextNode) { + if (node.isList(nextNode)) { + const nextIndent = + parseInt(nextNode.attributes(this.INDENT_KEY), 10) || 0; + const nextStart = parseInt(nextNode.attributes('start'), 10); + const _len = nextNode.find('li').length; + + if (nextNode.name === 'ol') { + let currentStart = startCache[nextIndent]; + if (nextIndent > 0) { + currentStart = currentStart || 1; + if (currentStart > 1) + nextNode.attributes('start', currentStart); + else nextNode.removeAttributes('start'); + startCache[nextIndent] = currentStart + _len; + } else { + if (currentStart && currentStart !== nextStart) { + if (currentStart > 1) + nextNode.attributes('start', currentStart); + else nextNode.removeAttributes('start'); + startCache[nextIndent] = currentStart + _len; + } else { + startCache[nextIndent] = (nextStart || 1) + _len; + startCache = startCache.slice(0, nextIndent + 1); + } + } + } + } else startCache = []; + const next = nextNode.next(); + if (!next) break; + nextNode = next; + } + } + + /** + * 给列表节点增加缩进 + * @param block 列表节点 + * @param value 缩进值 + */ + addIndent(block: NodeInterface, value: number, maxValue?: number) { + if (this.editor.node.isList(block)) { + const indentValue = this.getIndent(block); + value = indentValue + (value < 0 ? -1 : 1); + if (maxValue && value > maxValue) value = maxValue; + if (value < 1) { + block.removeAttributes(this.INDENT_KEY); + } else { + block.attributes(this.INDENT_KEY, value); + } + } + } + /** + * 获取列表节点 indent 值 + * @param block 列表节点 + * @returns + */ + getIndent(block: NodeInterface) { + if (this.editor.node.isList(block)) { + return parseInt(block.attributes(this.INDENT_KEY), 10) || 0; + } + return 0; + } + /** + * 给列表节点增加文字方向 + * @param block 列表项节点 + * @param align 方向 + * @returns + */ + addAlign( + block: NodeInterface, + align?: 'left' | 'center' | 'right' | 'justify', + ) { + if (block.name !== 'li') return; + if (align && align !== 'left') { + if (['center', 'right'].indexOf(align) > -1) { + block.css({ + [this.STYLE_POSITION_NAME]: this.STYLE_POSITION_VALUE, + }); + } + block.css({ 'text-align': align }); + } else { + block.css({ [this.STYLE_POSITION_NAME]: '', 'text-align': '' }); + } + } + /** + * 为自定义列表项添加卡片节点 + * @param node 列表节点项 + * @param cardName 卡片名称,必须是支持inline卡片类型 + * @param value 卡片值 + */ + addCardToCustomize( + node: NodeInterface | Node, + cardName: string, + value?: any, + ) { + if (isNode(node)) node = $(node); + //必须是li标签 + if (node.name !== 'li') return; + //第一个子节点必须不是相同卡片 + const first = node.first(); + if ( + first?.isBlockCard() || + (first?.isCard() && first.attributes(CARD_KEY) === cardName) + ) + return; + //创建卡片 + const { card } = this.editor; + const component = card.create(cardName, { + value, + }); + const range = Range.create(this.editor); + //设置光标选中空的标签,在这个位置插入卡片 + const br = $('
      '); + if (node.children().length > 0) { + node.first()?.before(br); + } else { + node.append(br); + } + range.select(br, true); + //插入卡片 + card.insertNode(range, component); + const lastNode = node.last(); + if (lastNode?.name === 'br') { + lastNode.remove(); + } + return component; + } + /** + * 为自定义列表项添加待渲染卡片节点 + * @param node 列表节点项 + * @param cardName 卡片名称,必须是支持inline卡片类型 + * @param value 卡片值 + */ + addReadyCardToCustomize( + node: NodeInterface | Node, + cardName: string, + value?: any, + ) { + if (isNode(node)) node = $(node); + //必须是li标签 + if (node.name !== 'li') return; + //第一个子节点必须不是卡片 + const first = node.first(); + if ( + first?.isBlockCard() || + (first?.isCard() && first.attributes(CARD_KEY) === cardName) + ) + return; + const cardRoot = $(''); + node.prepend(cardRoot); + this.editor.card.replaceNode(cardRoot, cardName, value); + return cardRoot; + } + + /** + * 给列表添加BR标签 + * @param node 列表节点项 + */ + addBr(node: NodeInterface) { + const nodeApi = this.editor.node; + if (nodeApi.isList(node)) { + node.find('li').each((node) => { + this.addBr($(node)); + }); + } else if (nodeApi.isCustomize(node)) { + let child = node.last(); + while (child?.isCursor()) child = child.prev(); + if (child) { + //自定义节点,并且最后一个是卡片 + const children = node.children(); + if (children.length === 1 && child.isCard()) { + node.append($('
      ')); + return; + } + if (children.length > 2 && child.name === 'br') { + if (child.prev()?.name !== 'br') child.remove(); + return; + } + while (child) { + if ( + child.equal(node.first()!) && + (child.isCard() || child.text() === '') + ) { + node.append($('
      ')); + return; + } + //文本 + if (child.type === getWindow().Node.TEXT_NODE) { + if (child.text() !== '') return; + child = child.prev(); + } + //节点 + else if (child.type === getWindow().Node.ELEMENT_NODE) { + if (!nodeApi.isMark(child) || child.text() !== '') + return; + child = node.prev(); + } + //其它 + else child = child.prev(); + } + } else node.append($('
      ')); + } + } + + insert(fragment: DocumentFragment, range?: RangeInterface) { + if (!isEngine(this.editor) || fragment.childNodes.length === 0) return; + const { change, node, block } = this.editor; + const safeRange = range || change.range.toTrusty(); + // 光标展开,先删除内容 + if (!safeRange.collapsed) change.delete(safeRange, true, true); + const cloneRange = safeRange.cloneRange().shrinkToElementNode(); + let { startNode, startOffset } = cloneRange; + let startElement: NodeInterface | undefined = startNode; + // 如果是列表,取 offset 的li + if (node.isList(startNode)) { + startElement = startNode.children().eq(startOffset); + } + startElement = startElement?.closest('li', (n) => + node.isBlock(n) && n.nodeName !== 'LI' + ? undefined + : n.parentElement || undefined, + ); + // 非li不操作 + if (startElement?.length === 0 || startElement?.name !== 'li') return; + + // 缩小范围到文本节点 + safeRange.shrinkToElementNode().shrinkToTextNode(); + + // 把列表分割扣出来 + //this.split(safeRange); + // 从光标处分割 + block.split(safeRange); + // 把列表分割扣出来 + const selection = safeRange.createSelection('list-insert'); + this.split(safeRange); + const apply = (newRange: RangeInterface) => { + block.merge(newRange); + this.merge(undefined, newRange); + selection?.move(); + if (!range) { + change.apply(newRange); + } + }; + // 第一个节点嵌入到分割节点位置 + let beginNode = $(fragment) + .toArray() + .some((child) => node.isBlock(child)) + ? $(fragment.childNodes[0]) + : $('

      ').append($(fragment)); + // 要插入的是列表 + const listElement = safeRange.startNode.closest('ul,ol'); + if (!listElement || !node.isList(listElement)) { + apply(safeRange); + return; + } + const startLi = safeRange.startNode.closest('li'); + // 自定义列表节点,补充被切割后的li里面的卡片 + if (node.isCustomize(listElement)) { + const cardElement = listElement.prev()?.first()?.first(); + if (cardElement) { + const cardComponent = this.editor.card.find(cardElement); + if (cardComponent) + this.addCardToCustomize( + startLi, + cardComponent.name, + cardComponent.getValue(), + ); + } + } + // 要插入的不是一个列表,或者是相同的列表,就把第一个节点内容追加到分割后的li后面 + const startIsMerge = + !node.isList(beginNode) || this.isSame(listElement, beginNode); + if (startIsMerge) { + while (node.isBlock(beginNode)) { + // 如果第一个子节点还是block节点就取这个节点作为第一个节点 + const first = beginNode.first(); + if (first && node.isBlock(first)) { + beginNode = first; + continue; + } + // 如果不是就跳出 + break; + } + // 删除多余的br标签 + const beforeELement = startLi.parent()?.prev()?.last(); + if (beforeELement?.name === 'li') { + const beforeChildren = beforeELement?.children(); + beforeChildren?.each((child, index) => { + if (child.nodeName === 'BR') + beforeChildren.eq(index)?.remove(); + }); + // 自定义列表,删除第一个卡片 + if (node.isCustomize(beginNode)) { + beginNode.first()?.remove(); + } + if (beginNode.isBlockCard()) { + beforeELement.parent()?.after(beginNode); + } else { + beforeELement?.append( + node.isBlock(beginNode) + ? beginNode.children() + : beginNode, + ); + if (beforeELement) this.addBr(beforeELement); + if (node.isBlock(beginNode)) { + beginNode.remove(); + } + } + } + } else { + // 如果开头位置不用拼接,判断是否是空节点,空节点就移除 + const beforeELement = startLi.parent()?.prev()?.last(); + if ( + beforeELement && + (node.isEmpty(beforeELement) || this.isEmptyItem(beforeELement)) + ) { + beforeELement.remove(); + if (beforeELement.parent()?.children().length === 0) + beforeELement.parent()?.remove(); + } + } + // 只有一行 + if (fragment.childNodes.length === 0) { + const parent = startLi.parent(); + if (!beginNode.isBlockCard()) { + if (node.isCustomize(startLi)) { + startLi.first()?.remove(); + } + startLi.find('br').remove(); + parent?.prev()?.last()?.append(startLi.children()); + parent?.remove(); + } else if (parent) { + const mergeNodes = [parent]; + const prev = beginNode.prev(); + if (prev && node.isList(prev)) mergeNodes.push(prev); + this.merge(mergeNodes); + } + apply(safeRange); + return; + } + + let startListElment = safeRange.startNode.closest('li').parent(); + if (!startListElment) { + apply(safeRange); + return; + } + const fragmentLength = fragment.childNodes.length; + let endNode = $(fragment.childNodes[fragmentLength - 1]); + const endIsMerge = + (!node.isList(endNode) || this.isSame(listElement, endNode)) && + !endNode.isBlockCard(); + // 如果集合中有列表或者block card,使用原节点,如果没有,其它节点都转换为列表 + let hasList = false; + for (let i = 0; i < fragment.childNodes.length; i++) { + const childnode = $(fragment.childNodes[i]); + if ( + (node.isList(fragment.childNodes[i]) && + !this.isSame(listElement, childnode)) || + childnode.isBlockCard() + ) { + hasList = true; + break; + } + } + const prevListElement = startListElment.prev(); + const mergeLists: NodeInterface[] = prevListElement + ? [prevListElement] + : []; + // 需要判断最后一个是否交给最后一个节点做合并,如果集合总含有列表就不合并 + for ( + let i = 0; + i < (endIsMerge && !hasList ? fragmentLength - 1 : fragmentLength); + i++ + ) { + // 每处理一个fragment中的集合就少一个,所以这里始终使用0做为索引 + const childElement = $(fragment.childNodes[0]); + // 如果是列表 + if (node.isList(childElement)) { + if (childElement.children().length === 0) { + childElement.remove(); + continue; + } + startListElment.before(childElement); + mergeLists.push(childElement); + } else { + // 追加为普通节点 + if (hasList) { + startListElment?.before(childElement); + continue; + } + // 如果是block节点,要把它的所有子block节点都unwrap + if (node.isBlock(childElement)) { + childElement.allChildren().forEach((child) => { + if (child.type === getDocument().TEXT_NODE) return; + if (node.isBlock(child)) node.unwrap(child); + }); + } + // 自定义列表 + if (node.isCustomize(startListElment)) { + const firstCard = startListElment.first()?.first(); + if (firstCard && firstCard.isCard()) { + const cardName = + firstCard.attributes(CARD_KEY) || + firstCard.attributes('name'); + const customizeList = this.toCustomize( + childElement, + cardName, + ); + if (customizeList) { + (Array.isArray(customizeList) + ? customizeList + : [customizeList] + ).forEach((child) => { + startListElment?.before(child); + }); + } + } + } else { + // 非自定义列表 + const listElements = this.toNormal( + childElement, + startListElment.name as any, + ); + (Array.isArray(listElements) + ? listElements + : [listElements] + ).forEach((child) => { + startListElment?.before(child); + }); + } + } + } + if (mergeLists.length > 0) this.merge(mergeLists); + if (!startIsMerge && node.isEmptyWidthChild(startListElment)) + startListElment.remove(); + // 后续不需要拼接到最后节点 + if (fragment.childNodes.length === 0) { + // 删除由于分割造成的空行 + if (node.isEmpty(startLi) || this.isEmptyItem(startLi)) { + const prevElement = startLi.parent()?.prev(); + startLi.find('br').remove(); + if (node.isCustomize(startLi)) startLi.first()?.remove(); + if (prevElement && node.isList(prevElement)) + prevElement.last()?.append(startLi.children()); + // 把光标位置放到前面的li里面 + else if (prevElement) prevElement.append(startLi.children()); + startLi.parent()?.remove(); + } + apply(safeRange); + return; + } + // 最后一个节点嵌入到分割后的最后一个节点内容前面 + while (node.isBlock(endNode)) { + // 如果最后一个子节点还是block节点就取这个节点作为最后一个节点 + const last = endNode.last(); + if (last && node.isBlock(last)) { + endNode = last; + continue; + } + // 如果不是就跳出 + break; + } + const lasetELement = startLi; + if (lasetELement) { + // 删除多余的br标签 + if (endNode.name === 'br') endNode.remove(); + else { + const endChildren = endNode.children(); + endChildren.each((child, index) => { + if (child.nodeName === 'BR') + endChildren.eq(index)?.remove(); + }); + if (node.isCustomize(lasetELement)) { + if (node.isCustomize(endNode)) { + const endNodeCard = endNode.first(); + if (endNodeCard?.isCard()) endNodeCard.remove(); + } + lasetELement + .first() + ?.after( + node.isBlock(endNode) + ? endNode.children() + : endNode, + ); + } else { + lasetELement.prepend( + node.isBlock(endNode) ? endNode.children() : endNode, + ); + } + this.addBr(lasetELement); + if (node.isBlock(endNode)) endNode.remove(); + } + } + apply(safeRange); + } + + /** + * block 节点转换为列表项节点 + * @param block block 节点 + * @param root 列表根节点 + * @param cardName 可选,自定义列表项卡片名称 + * @param value 可选,自定义列表项卡片值 + * @returns + */ + blockToItem( + block: NodeInterface, + root: NodeInterface, + cardName?: string, + value?: string, + ) { + const item = $('
    • '); + const { node, schema } = this.editor; + if (!node.isList(root)) return root; + // 获取缩进 + const indent = removeUnit(block.css('text-indent')) / 2; + // 复制全局属性 + const globals = schema.data.globals['block'] || {}; + const attributes = block.attributes(); + Object.keys(attributes).forEach((name) => { + if (name !== DATA_ID && name !== 'id' && globals['name']) { + item.attributes(name, attributes[name]); + } + }); + // 复制全局样式,及生成 text-align + const globalStyles = globals.style || {}; + const styles = block.css(); + if (styles['text-align']) + this.addAlign(item, styles['text-align'] as any); + delete styles['text-align']; + delete styles[this.STYLE_POSITION_NAME]; + Object.keys(styles).forEach((name) => { + if (!globalStyles[name]) delete styles[name]; + }); + item.css(styles); + // 替换 + block = node.replace(block, item); + // 如果是自定义列表,增加卡片 + if (cardName) { + block.addClass(this.CUSTOMZIE_LI_CLASS); + this.addCardToCustomize(block, cardName, value); + } + // 如果有设置缩进,就设置缩进属性 + if (indent) { + root.attributes(this.INDENT_KEY, indent); + } + + return node.wrap(block, root); + } + + /** + * 将节点转换为自定义节点 + * @param blocks 节点 + * @param cardName 卡片名称 + * @param value 卡片值 + */ + toCustomize( + blocks: Array | NodeInterface, + cardName: string, + value?: any, + tagName: 'ol' | 'ul' = 'ul', + ) { + const { node } = this.editor; + if (Array.isArray(blocks)) { + let nodes: Array = []; + blocks.forEach((block) => { + if (node.isCustomize(block)) { + this.unwrapCustomize(block); + } + nodes = nodes.concat( + this.toCustomize(block, cardName, value, tagName), + ); + }); + return nodes; + } else { + const customizeRoot = $( + `<${tagName} class="${this.CUSTOMZIE_UL_CLASS}"/>`, + ); + switch (blocks.name) { + case 'li': + blocks.addClass(this.CUSTOMZIE_LI_CLASS); + this.addCardToCustomize(blocks, cardName, value); + return blocks; + + case 'ul': + case 'ol': + customizeRoot.attributes(blocks.attributes()); + blocks = node.replace(blocks, customizeRoot); + return blocks; + default: + if ( + blocks.name === 'p' || + (node.isNestedBlock(blocks) && !blocks.isBlockCard()) + ) { + if (blocks.parent()?.name === 'li') { + node.unwrap(blocks); + return blocks; + } + blocks = this.blockToItem( + blocks, + customizeRoot, + cardName, + value, + ); + } + return blocks; + } + } + } + /** + * 将节点转换为列表节点 + * @param blocks 节点 + * @param tagName 列表节点名称,ul 或者 ol + * @param start 有序列表开始序号 + */ + toNormal( + blocks: Array | NodeInterface, + tagName: 'ul' | 'ol' = 'ul', + start?: number, + ) { + const { node } = this.editor; + if (Array.isArray(blocks)) { + let nodes: Array = []; + blocks.forEach((block) => { + const node = this.toNormal(block, tagName, start); + nodes = nodes.concat(node); + }); + return nodes; + } else { + this.unwrapCustomize(blocks); + const targetNode = $('<'.concat(tagName, ' />')); + + switch (blocks.name) { + case 'li': + case tagName: + return blocks; + + case 'ol': + case 'ul': + targetNode.attributes(blocks.attributes()); + if (targetNode.name === 'ul') + targetNode.removeAttributes('start'); + blocks = node.replace(blocks, targetNode); + return blocks; + default: + if ( + blocks.name === 'p' || + (node.isNestedBlock(blocks) && !blocks.isBlockCard()) + ) { + if (blocks.parent()?.name === 'li') { + node.unwrap(blocks); + return blocks; + } + blocks = this.blockToItem(blocks, targetNode); + if (start) { + blocks.attributes('start', start); + } + } + return blocks; + } + } + } + + /** + * 判断选中的区域是否在List列表的开始 + */ + isFirst(range: RangeInterface) { + //获取选区开始节点和位置偏移值 + const { startNode, startOffset } = range; + //复制选区 + const cloneRange = range.cloneRange(); + //找到li节点 + const node = + 'li' === startNode.name ? startNode : startNode.closest('li'); + //如果没有li节点 + if (!node[0]) return false; + //让选区选择li节点 + cloneRange.select(node, true); + //设置选区结束位置偏移值 + cloneRange.setEnd(startNode[0], startOffset); + //复制选区内容 + const contents = cloneRange.cloneContents(); + //如果选区中没有节点 + if (!contents.firstChild) return true; + const firstChild = $(contents.firstChild); + const lastChild = $(contents.lastChild || []); + //如果选区中只有一个节点,并且是br标签 + if (1 === contents.childNodes.length && 'br' === firstChild.name) + return true; + //如果选区中只有一个节点,并且是自定义列表并且第一个是Card + if ( + 1 === contents.childNodes.length && + node.hasClass('data-list-item') && + firstChild.isCard() + ) + return true; + const nodeApi = this.editor.node; + //如果选区中只有两个节点,并且是自定义列表并且第一个是Card,最后一个为空节点 + if ( + 2 === contents.childNodes.length && + node.hasClass('data-list-item') && + firstChild.isCard() && + nodeApi.isEmpty(lastChild) + ) + return true; + //判断选区内容是否是空节点 + const block = $('
      '); + block.append(contents); + return nodeApi.isEmpty(block); + } + + /** + * 判断选中的区域是否在List列表的末尾 + */ + isLast(range: RangeInterface) { + //获取选区范围结束节点和结束位置偏移值 + const { endNode, endOffset } = range; + //复制选区 + const cloneRange = range.cloneRange(); + //找到li节点 + const node = 'li' === endNode.name ? endNode : endNode.closest('li'); + //如果没有li节点 + if (!node[0]) return false; + //让选区选择li节点 + cloneRange.select(node, true); + //设置选区开始位置偏移值 + cloneRange.setStart(endNode, endOffset); + //复制选区内容 + const contents = cloneRange.cloneContents(); + //如果选区中没有节点 + if (!contents.firstChild) return true; + const firstChild = $(contents.firstChild); + const lastChild = $(contents.lastChild || []); + //如果选区中只有一个节点,并且是br标签 + if (1 === contents.childNodes.length && 'br' === firstChild.name) + return true; + //如果选区中只有一个节点,并且是自定义列表并且第一个是Card + if ( + 1 === contents.childNodes.length && + node.hasClass('data-list-item') && + firstChild.isCard() + ) + return true; + const nodeApi = this.editor.node; + //如果选区中只有两个节点,并且是自定义列表并且第一个是Card,最后一个为空节点 + if ( + 2 === contents.childNodes.length && + node.hasClass('data-list-item') && + firstChild.isCard() && + nodeApi.isEmpty(lastChild) + ) + return true; + //判断选区内容是否是空节点 + const block = $('
      '); + block.append(contents); + return this.editor.node.isEmpty(block); + } +} + +export default List; diff --git a/packages/engine/src/list/typing/backspace.ts b/packages/engine/src/list/typing/backspace.ts new file mode 100644 index 00000000..e8663bc7 --- /dev/null +++ b/packages/engine/src/list/typing/backspace.ts @@ -0,0 +1,151 @@ +import { EngineInterface } from '../../types'; + +class Backspace { + private engine: EngineInterface; + constructor(engine: EngineInterface) { + this.engine = engine; + } + /** + * 列表删除事件 + * @param e 事件 + * @param isDeepMerge 是否深度合并 + */ + trigger(event: KeyboardEvent, isDeepMerge?: boolean) { + const { change, command, list, node } = this.engine; + let range = change.range.get(); + const blockApi = this.engine.block; + if (range.collapsed) { + const block = blockApi.closest(range.startNode); + if ('li' === block.name && list.isFirst(range)) { + // 内容已经删除过了 + if (event['isDelete']) return false; + event.preventDefault(); + command.execute(list.getPluginNameByNode(block)); + return false; + } + } else { + const { startNode, endNode } = range; + const startBlock = blockApi.closest(startNode); + const endBlock = blockApi.closest(endNode); + if ('li' === startBlock.name || 'li' === endBlock.name) { + event.preventDefault(); + + const cloneRange = range.cloneRange(); + // 自定义任务列表,开头位置的卡片让其不选中 + const firstChilde = startNode.first(); + const customeCard = startNode.isCard() + ? startNode + : firstChilde; + if (customeCard?.isCard()) { + if (list.isEmptyItem(startBlock)) { + const parent = startBlock.parent(); + startBlock.remove(); + if ( + parent && + node.isCustomize(parent) && + parent.children().length === 0 + ) { + parent.remove(); + } + } + } + change.delete(cloneRange, isDeepMerge); + // 光标在列表的最后一行,并且开始光标不在最后一行 + if ( + startBlock.inEditor() && + !startBlock.equal(endBlock) && + endBlock.inEditor() && + 'li' === endBlock.name + ) { + cloneRange.shrinkToElementNode().shrinkToTextNode(); + const selection = cloneRange.createSelection(); + startBlock.append(endBlock.children()); + const parent = endBlock.parent(); + endBlock.remove(); + if ( + parent && + node.isList(parent) && + parent.children().length === 0 + ) { + parent.remove(); + } + selection.move(); + } + if ('li' === startBlock.name) { + const parent = startBlock.parent(); + if ( + node.isCustomize(startBlock) && + startBlock.children().length === 0 + ) + startBlock.remove(); + if ( + parent && + node.isList(parent) && + parent.children().length === 0 + ) { + parent.remove(); + } + } + list.addBr(startBlock); + if (!startBlock.equal(endBlock)) list.addBr(endBlock); + range.setStart( + cloneRange.startContainer, + cloneRange.startOffset, + ); + range.collapse(true); + list.merge(); + if (change.isEmpty()) { + change.initValue(range); + } + change.apply(range); + return false; + } + } + if (!blockApi.isFirstOffset(range, 'start')) return; + let block = blockApi.closest(range.startNode); + // 在列表里 + if (node.isList(block)) { + // 矫正这种情况,
      • foo
      + const li = block.first(); + + if (!li || li.isText()) { + //
        foo
      + event.preventDefault(); + change.mergeAfterDelete(block); + return false; + } else { + block = li; + range.setStart(block[0], 0); + range.collapse(true); + change.range.select(range); + } + } + + if (block.name === 'li') { + if (node.isCustomize(block)) { + return; + } + + event.preventDefault(); + const listRoot = block.closest('ul'); + + if (block.parent()?.isEditable()) { + //

      foo

    • bar
    • + change.mergeAfterDelete(block); + return false; + } + + if (listRoot.length > 0) { + command.execute(list.getPluginNameByNode(listRoot)); + } else { + //

    • foo
    • + change.unwrap(block); + } + + return false; + } + return true; + } +} + +export default Backspace; diff --git a/packages/engine/src/list/typing/enter.ts b/packages/engine/src/list/typing/enter.ts new file mode 100644 index 00000000..8c255d0e --- /dev/null +++ b/packages/engine/src/list/typing/enter.ts @@ -0,0 +1,76 @@ +import { EngineInterface, PluginEntry } from '../../types'; +import Backspace from './backspace'; + +class Enter { + private engine: EngineInterface; + private backspace: Backspace; + constructor(engine: EngineInterface) { + this.engine = engine; + this.backspace = new Backspace(engine); + } + + trigger(event: KeyboardEvent) { + const { change, command, list } = this.engine; + let range = change.range.get(); + range.shrinkToElementNode(); + const startBlock = this.engine.block.closest(range.startNode); + const endBlock = this.engine.block.closest(range.endNode); + //选区开始或结束位置为li + if ('li' === startBlock.name || 'li' === endBlock.name) { + //选区为展开状态,先删除 + if (!range.collapsed) { + this.backspace.trigger( + event, + startBlock.name !== endBlock.name, + ); + range = change.range.get(); + } + event.preventDefault(); + //如果光标在列表结尾或者开始位置 + const pluginName = list.getPluginNameByNode(startBlock); + if (list.isLast(range) && list.isFirst(range)) { + command.execute(pluginName); + } else { + this.engine.block.split(); + range = change.range.get(); + //const selection = range.createSelection(); + const block = this.engine.block.closest(range.endNode); + const plugin = list + .getPlugins() + .find( + (plugin) => + pluginName === + (plugin.constructor as PluginEntry).pluginName, + ); + if (!plugin) return; + if (plugin.cardName) { + const prev = block.prev(); + if (prev) { + list.addCardToCustomize(prev, plugin.cardName); + list.addBr(prev); + } + list.addCardToCustomize(block, plugin.cardName); + list.addBr(block); + const next = block.next(); + if (next) { + list.addCardToCustomize(next, plugin.cardName); + list.addBr(next); + } + } + list.merge(undefined, range); + list.addBr(range.startNode.closest('ul')); + range.setStart( + block, + this.engine.node.isCustomize(block) ? 1 : 0, + ); + range.collapse(true).shrinkToTextNode(); + change.apply(range); + } + range.scrollIntoView(); + return false; + } + return true; + } +} + +export default Enter; diff --git a/packages/engine/src/list/typing/index.ts b/packages/engine/src/list/typing/index.ts new file mode 100644 index 00000000..69d40ccd --- /dev/null +++ b/packages/engine/src/list/typing/index.ts @@ -0,0 +1,4 @@ +import Enter from './enter'; +import Backspace from './backspace'; + +export { Enter, Backspace }; diff --git a/packages/engine/src/locales/en-US.ts b/packages/engine/src/locales/en-US.ts new file mode 100644 index 00000000..fe213b73 --- /dev/null +++ b/packages/engine/src/locales/en-US.ts @@ -0,0 +1,55 @@ +export default { + dnd: { + title: 'Drag to reposition', + }, + copy: { + title: 'Copy', + success: 'Copied successfully', + error: 'Copy error', + }, + delete: { + title: 'Delete', + }, + copyAnchor: { + title: 'Copy anchor link', + }, + link: { + placeholder: 'Please enter a link or anchor and press Enter to confirm', + save: 'Apply', + edit: 'Change', + delete: 'Remove link', + open: 'Open link', + text: 'link', + }, + copyContent: { + title: 'copy content', + }, + maximize: { + title: 'Maximize', + back: 'Back to document', + }, + expand: { + title: 'Embedded preview', + }, + collapse: { + title: 'Compact display', + }, + card: { + lockAlert: 'Please wait for the other user to finish editing', + }, + preferences: { + title: 'Preferences', + }, + download: { + title: 'Download', + }, + more: { + title: 'More', + }, + checkMarkdown: { + title: 'It is detected that the paste content conforms to the Markdown syntax. Do you need to do style conversion?', + }, + searchEmtpy: { + title: 'No matching card', + }, +}; diff --git a/packages/engine/src/locales/index.ts b/packages/engine/src/locales/index.ts new file mode 100644 index 00000000..6266072c --- /dev/null +++ b/packages/engine/src/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/packages/engine/src/locales/zh-cn.ts b/packages/engine/src/locales/zh-cn.ts new file mode 100644 index 00000000..b3691e77 --- /dev/null +++ b/packages/engine/src/locales/zh-cn.ts @@ -0,0 +1,55 @@ +export default { + dnd: { + title: '拖动调整位置', + }, + copy: { + title: '复制', + success: '复制成功', + error: '复制失败', + }, + delete: { + title: '删除', + }, + copyAnchor: { + title: '复制锚点链接', + }, + link: { + placeholder: '请输入链接或锚点,回车确认', + save: '保存', + edit: '编辑', + delete: '取消链接', + open: '打开链接', + text: '链接', + }, + copyContent: { + title: '复制内容', + }, + maximize: { + title: '最大化', + back: '返回文档', + }, + expand: { + title: '嵌入预览', + }, + collapse: { + title: '紧凑展示', + }, + card: { + lockAlert: '请等待对方编辑完毕后,再进入编辑', + }, + preferences: { + title: '设置', + }, + download: { + title: '下载', + }, + more: { + title: '更多', + }, + checkMarkdown: { + title: '检测到粘贴内容符合 Markdown 语法,是否需要转换?', + }, + searchEmtpy: { + title: '无匹配卡片', + }, +}; diff --git a/packages/engine/src/mark/index.ts b/packages/engine/src/mark/index.ts new file mode 100644 index 00000000..569821a0 --- /dev/null +++ b/packages/engine/src/mark/index.ts @@ -0,0 +1,1604 @@ +import { + CARD_ELEMENT_KEY, + CARD_KEY, + CARD_SELECTOR, + CARD_TYPE_KEY, + DATA_ELEMENT, +} from '../constants'; +import { EditorInterface, NodeInterface, RangeInterface } from '../types'; +import { MarkInterface, MarkModelInterface } from '../types/mark'; +import { getDocument, getWindow, isEngine } from '../utils'; +import { Backspace } from './typing'; +import { $ } from '../node'; +import { isNode } from '../node/utils'; +import { isMarkPlugin } from '../plugin/mark'; + +class Mark implements MarkModelInterface { + private editor: EditorInterface; + + constructor(editor: EditorInterface) { + this.editor = editor; + } + + init() { + const editor = this.editor; + if (isEngine(editor)) { + //删除事件 + const backspace = new Backspace(editor); + editor.typing + .getHandleListener('backspace', 'keydown') + ?.on((event) => backspace.trigger(event)); + + editor.on('keydown:space', (event) => this.triggerMarkdown(event)); + } + } + + /** + * 解析markdown + * @param event 事件 + */ + triggerMarkdown(event: KeyboardEvent) { + const editor = this.editor; + if (!isEngine(editor)) return; + const { change } = editor; + let range = change.range.get(); + if (!range.collapsed || change.isComposing()) return; + const { startNode, startOffset } = range; + const node = + startNode.type === Node.TEXT_NODE + ? startNode + : startNode.children().eq(startOffset - 1); + if (!node) return; + const cacheRange = range.toPath(); + const text = + node.type === Node.TEXT_NODE + ? node.text().substr(0, startOffset) + : node.text(); + const result = !Object.keys(editor.plugin.components).some( + (pluginName) => { + const plugin = editor.plugin.components[pluginName]; + if (isMarkPlugin(plugin) && !!plugin.markdown) { + const reuslt = plugin.triggerMarkdown(event, text, node); + if (reuslt === false) return true; + } + return; + }, + ); + if (!result) change.rangePathBeforeCommand = cacheRange; + return result; + } + + /** + * 根据节点查找mark插件实例 + * @param node 节点 + */ + findPlugin(mark: NodeInterface): MarkInterface | undefined { + const { node, plugin, schema } = this.editor; + if (!node.isMark(mark)) return; + let result: MarkInterface | undefined = undefined; + Object.keys(plugin.components).some((pluginName) => { + const markPlugin = plugin.components[pluginName]; + if (isMarkPlugin(markPlugin) && mark.name === markPlugin.tagName) { + const schemaRule = markPlugin.schema(); + if ( + !(Array.isArray(schemaRule) + ? schemaRule.find((rule) => + schema.checkNode(mark, rule.attributes), + ) + : schema.checkNode(mark, schemaRule.attributes)) + ) + return; + result = markPlugin; + return true; + } + return; + }); + return result; + } + /** + * 获取最近的 Mark 节点,找不到返回 node + */ + closest(source: NodeInterface) { + const nodeApi = this.editor.node; + let node = source.parent(); + while (node && !node.isEditable() && !nodeApi.isBlock(node)) { + if (nodeApi.isMark(node)) return node; + const parentNode = node.parent(); + if (!parentNode) break; + node = parentNode; + } + return source; + } + /** + * 获取向上第一个非 Mark 节点 + */ + closestNotMark(node: NodeInterface) { + while (this.editor.node.isMark(node) || node.isText()) { + if (node.isEditable()) break; + const parent = node.parent(); + if (!parent) break; + node = parent; + } + return node; + } + /** + * 比较两个节点是否相同,包括attributes、style、class + * @param source 源节点 + * @param target 目标节点 + * @param isCompareValue 是否比较每项属性的值 + */ + compare( + source: NodeInterface, + target: NodeInterface, + isCompareValue?: boolean, + ) { + //节点名称不一致 + if (source.name !== target.name) return false; + //获取节点属性 + const sourceAttributes = source.attributes(); + delete sourceAttributes['style']; + + const targetAttributes = target.attributes(); + delete targetAttributes['style']; + + //获取节点样式属性 + const sourceStyles = source.css(); + const targetStyles = target.css(); + delete sourceAttributes['class']; + delete targetAttributes['class']; + + //获取节点样式名称 + const sourceClassName = source.get()!.className.trim(); + const targetClassName = target.get()!.className.trim(); + let sourceClasses = + sourceClassName !== '' ? sourceClassName.split(/\s+/) : []; + let targetClasses = + targetClassName !== '' ? targetClassName.split(/\s+/) : []; + + //样式名称可能是可变的,如data-fontsize-12和data-fontsize-14代表的是不同的值,如果不需要比较值,直接获取标签样式规则比较 + const { schema } = this.editor; + const schemas = schema.find((rule) => rule.name === source.name); + const compareClass = (classNames: Array): string => { + for (let i = 0; i < schemas.length; i++) { + const schemaRule = schemas[i]; + if ( + schemaRule.attributes && + schema.checkValue( + schemaRule.attributes, + 'class', + classNames.join(' ').trim(), + ) + ) { + return schemaRule.attributes['class'].toString(); + } + } + return classNames.join(' ').trim(); + }; + if (!isCompareValue) { + sourceClasses = + sourceClasses.length > 0 ? [compareClass(sourceClasses)] : []; + targetClasses = + targetClasses.length > 0 ? [compareClass(targetClasses)] : []; + } + + //属性长度不一致 + if ( + Object.keys(sourceAttributes).length !== + Object.keys(targetAttributes).length + ) + return false; + //属性名称或值不一致 + if ( + !Object.keys(sourceAttributes).every((attributesName) => + isCompareValue + ? sourceAttributes[attributesName] === + targetAttributes[attributesName] + : !!targetAttributes[attributesName], + ) + ) + return false; + //样式属性长度不一致 + if ( + Object.keys(sourceStyles).length !== + Object.keys(targetStyles).length + ) + return false; + //样式属性名称或值不一致 + if ( + !Object.keys(sourceStyles).every((styleName) => + isCompareValue + ? sourceStyles[styleName] === targetStyles[styleName] + : !!targetStyles[styleName], + ) + ) + return false; + //样式名称长度不一致 + if (sourceClasses.length !== targetClasses.length) return false; + //样式名称不一致 + if ( + !sourceClasses.every( + (sourceClass) => targetClasses.indexOf(sourceClass) !== -1, + ) + ) + return false; + return true; + } + + /** + * 判断源节点是否包含目标节点的所有属性名称和样式名称 + * @param source 源节点 + * @param target 目标节点 + */ + contain(source: NodeInterface, target: NodeInterface) { + const attributes = target.attributes(); + const styles = attributes['style'] || {}; + delete attributes['style']; + + const sourceAttributes = source.attributes(); + const sourceStyles = sourceAttributes['style'] || {}; + delete sourceAttributes['style']; + + return ( + Object.keys(attributes).every((key) => !!sourceAttributes[key]) && + Object.keys(styles).every((key) => !sourceStyles[key]) + ); + } + + /** + * 去除一个节点下的所有空 Mark,通过 callback 可以设置其它条件 + * @param root 节点 + * @param callback 回调 + */ + unwrapEmptyMarks( + root: NodeInterface, + callback?: (node: NodeInterface) => boolean, + ) { + const { node } = this.editor; + const children = root.allChildren(); + children.forEach((child) => { + if ( + node.isEmpty(child) && + node.isMark(child) && + (!callback || callback(child)) + ) { + node.unwrap(child); + } + }); + } + /** + * 在光标重叠位置时分割,在分割时会清空父节点内容再重组,如果需要保持光标右边某节点的追踪,请传入该节点 + * @param range 光标 + * @param removeMark 要移除的mark空节点 + * @param keelpNode 分割光标右侧需要保持追踪的节点 + */ + splitOnCollapsed( + range: RangeInterface, + removeMark?: NodeInterface | Array, + keelpNode?: NodeInterface | Node, + ) { + if (!range.collapsed) return; + //扩大光标选区 + range.enlargeFromTextNode(); + range.shrinkToElementNode(); + const { startNode } = range; + const startParent = startNode.parent(); + //获取卡片 + const card = startNode.isCard() + ? startNode + : startNode.closest(CARD_SELECTOR); + const { node } = this.editor; + if ( + (card.length === 0 || + card.attributes(CARD_TYPE_KEY) !== 'inline') && + (node.isMark(startNode) || + (startParent && node.isMark(startParent))) + ) { + // 获取上面第一个非mark标签 + const parent = this.closestNotMark(startNode); + // 插入范围的开始和结束标记 + const selection = range.createSelection(); + // 获取标记左侧节点 + const left = selection.getNode(parent, 'left'); + // 获取标记右侧节点 + let right: NodeInterface | undefined = undefined; + let keelpRoot: NodeInterface | undefined = undefined; + let keelpPath: Array = []; + if (keelpNode) { + if (isNode(keelpNode)) keelpNode = $(keelpNode); + // 获取需要跟踪节点的路径 + const path = keelpNode.getPath(parent.get()!); + const cloneParent = parent.clone(true); + keelpPath = path.slice(1); + // 获取需要跟踪节点的root节点 + keelpRoot = $(cloneParent.getChildByPath(path.slice(0, 1))); + right = selection.getNode(cloneParent, 'right', false); + } else right = selection.getNode(parent, 'right'); + // 删除空标签 + this.unwrapEmptyMarks(left, (node) => !node.isCursor()); + this.unwrapEmptyMarks(right, (node) => { + if (removeMark && !Array.isArray(removeMark)) + removeMark = [removeMark]; + //没有传指定的mark,那就都移除。否则比较后一致就移除 + const isUnwrap = + !removeMark || + removeMark.length === 0 || + (!node.isCard() && + removeMark.some((mark) => this.compare(node, mark))); + return isUnwrap; + }); + // 清空原父容器,用新的内容代替 + const children = parent.children(); + children.each((_, index) => { + const child = children.eq(index); + if (!child?.isCard()) { + child?.remove(); + } + }); + let appendChild: NodeInterface | undefined | null = undefined; + const appendToParent = (childrenNodes: NodeInterface) => { + childrenNodes.each((child, index) => { + const childNode = childrenNodes.eq(index); + if (childNode?.isCard()) { + appendChild = appendChild + ? appendChild.next() + : parent.first(); + if (appendChild) childrenNodes[index] = appendChild[0]; + return; + } + if (appendChild) { + appendChild.after(child); + appendChild = childNode; + } else { + appendChild = childNode; + parent.prepend(child); + } + }); + }; + const leftChildren = left.children(); + const leftNodes = leftChildren.toArray(); + appendToParent(leftChildren); + const rightChildren = right.children(); + const rightNodes = rightChildren.toArray(); + // 根据跟踪节点的root节点和path获取其在rightNodes中的新节点 + if (keelpRoot) + keelpNode = rightNodes + .find((node) => node.equal(keelpRoot!)) + ?.getChildByPath(keelpPath); + appendToParent(rightChildren); + + let zeroWidthNode = $('\u200b', null); + + // 重新设置范围 + if (leftNodes.length === 1 && leftNodes[0].name === 'br') { + leftNodes[0].remove(); + leftNodes.splice(0, 1); + } + if (rightNodes.length === 1 && rightNodes[0].name === 'br') { + rightNodes[0].remove(); + rightNodes.splice(0, 1); + } + if (rightNodes.filter((child) => !child.isCursor()).length > 0) { + let rightContainer = rightNodes[0]; + for (let i = 0; i < rightNodes.length - 1; i++) { + rightContainer = rightNodes[i]; + if (!rightContainer.isCursor()) break; + } + // 右侧没文本 + if (node.isEmpty(rightContainer)) { + let firstChild: NodeInterface | null = + rightContainer.first(); + while (firstChild && !firstChild.isText()) { + rightContainer = firstChild; + firstChild = firstChild.first(); + } + + if (rightContainer.isText()) { + rightContainer.before(zeroWidthNode); + } else { + rightContainer.prepend(zeroWidthNode); + } + } + // 右侧有文本 + else { + //在多层mark嵌套的情况下,要移除的mark里面还有其它mark节点,还有要移除的mark外面还有其它mark,需要将其组合起来 + let markParent = node.isMark(startNode) + ? startNode + : startNode.parent(); + let wrapZeroNode = zeroWidthNode; + if (removeMark && !Array.isArray(removeMark)) + removeMark = [removeMark]; + while ( + removeMark && + removeMark.length > 0 && + markParent && + node.isMark(markParent) + ) { + const markClone = markParent.clone(); + //不是要移除的mark + if ( + !removeMark.some((mark) => + this.compare(markClone, mark), + ) + ) { + const isZero = zeroWidthNode.equal(wrapZeroNode); + wrapZeroNode = node.wrap(wrapZeroNode, markClone); + if (isZero) zeroWidthNode = wrapZeroNode.first()!; + } + markParent = markParent.parent(); + } + rightContainer.before(wrapZeroNode); + } + range.select(zeroWidthNode).collapse(false); + } else if ( + leftNodes.filter((childNode) => !childNode.isCursor()).length > + 0 + ) { + const leftContainer = leftNodes[leftNodes.length - 1]; + leftContainer.after(zeroWidthNode); + range.select(zeroWidthNode).collapse(false); + } else { + range.select(parent, true).collapse(true); + } + //替换多个零宽字符为一个零宽字符 + let textWithEmpty = false; + parent.children().each((child) => { + const childNode = $(child); + if (childNode.isText()) { + const { textContent } = child; + let text = textContent?.replace(/\u200b+/g, '\u200b') || ''; + if (textContent !== text) { + child.textContent = text; + } + if (textWithEmpty) { + //当前第二个连续零宽字符的下一个节点不能是inline节点 + const next = childNode.next(); + //当前第二个连续零宽字符没有下一个节点,并且父级节点不能为inline节点 + const parent = childNode.parent(); + if ( + ((!next && parent && !node.isInline(parent)) || + (next && !node.isInline(next))) && + text.startsWith('\u200b') + ) { + text = text.substring(1); + if (text) child.textContent = text; + else childNode.remove(); + } else textWithEmpty = false; + } + if (text.endsWith('\u200b')) textWithEmpty = true; + } else textWithEmpty = false; + }); + const nodeApi = node; + //移除多余的零宽字符 + if (zeroWidthNode[0].parentNode) { + const at = zeroWidthNode[0]; + let atText: string | null = null; + let atTextLen: number = 0; + const handleAt = (node: Node | null, findPrev: boolean) => { + const getAlignNode = (node: Node) => { + return findPrev + ? node.previousSibling + : node.nextSibling; + }; + while (node) { + if (node.nodeType !== at.nodeType) return; + const alignNode = getAlignNode(node); + if (node.textContent === atText) { + //inline 节点位置的零宽字符跳过 + if ( + (alignNode && nodeApi.isInline(alignNode)) || + (!alignNode && + node.parentNode && + nodeApi.isInline(node.parentNode)) + ) + break; + node.parentNode?.removeChild(node); + node = alignNode; + } else { + if (findPrev) { + while ( + atText && + node.textContent?.endsWith(atText) + ) { + //inline 节点位置的零宽字符跳过 + if ( + (alignNode && + nodeApi.isInline(alignNode)) || + (!alignNode && + node.parentNode && + nodeApi.isInline(node.parentNode)) + ) + break; + node.textContent = + node.textContent.substring( + 0, + node.textContent.length - atTextLen, + ); + } + } else { + while ( + atText && + node.textContent?.startsWith(atText) + ) { + //inline 节点位置的零宽字符跳过 + if ( + (alignNode && + nodeApi.isInline(alignNode)) || + (!alignNode && + node.parentNode && + nodeApi.isInline(node.parentNode)) + ) + break; + + node.textContent = + node.textContent.substring( + atText.length, + ); + } + } + if (node.textContent?.length !== 0) return; + node.parentNode?.removeChild(node); + node = alignNode; + } + } + }; + if (at.nodeType === getWindow().Node.TEXT_NODE) { + const { textContent } = at; + atText = textContent!; + atTextLen = atText.length; + handleAt(at.previousSibling, true); + handleAt(at.nextSibling, false); + } + } + } + return keelpNode; + } + /** + * 在光标位置不重合时分割 + * @param range 光标 + * @param removeMark 要移除的空mark节点 + */ + splitOnExpanded( + range: RangeInterface, + removeMark?: NodeInterface | Array, + ) { + if (range.collapsed) return; + range.enlargeToElementNode(); + range.shrinkToElementNode(); + const { startNode, endNode } = range; + const cardStart = startNode.isCard() + ? startNode + : startNode.closest(CARD_SELECTOR); + const cardEnd = endNode.isCard() + ? endNode + : endNode.closest(CARD_SELECTOR); + if ( + !( + (cardStart.length > 0 && + 'inline' === cardStart.attributes(CARD_TYPE_KEY)) || + (cardEnd.length > 0 && + 'inline' === cardEnd.attributes(CARD_TYPE_KEY)) + ) + ) { + //开始非mark标签父节点 + const startNotMarkParent = this.closestNotMark(startNode); + //结束非mark标签父节点 + const endNotMarkParent = this.closestNotMark(endNode); + if (!startNotMarkParent.equal(endNotMarkParent)) { + const startRange = range.cloneRange(); + startRange.collapse(true); + + const endRange = range.cloneRange(); + endRange.collapse(false); + + //如果开始非mark标签父节点包含结束非mark标签父节点,那么分割的时候会清空 结束非mark标签父节点的内容进行重组。结束非mark标签父节点 将无非找到 + //所以需要从被包含的节点开始分割 + let keelpNode: NodeInterface | Node | undefined = undefined; + let startOffset = startRange.startOffset; + let endOffset = endRange.endOffset; + //如果开始节点的父节点包含结尾父节点,会将结尾父节点删除重组,导致光标失效,需要先执行开始节点分割,并跟踪结尾节点 + if (startNotMarkParent.contains(endNotMarkParent)) { + //先分割开始节点,并跟踪结尾节点 + keelpNode = this.splitOnCollapsed( + startRange, + removeMark, + endRange.endNode, + ); + range.setStart( + startRange.startContainer, + startRange.startOffset, + ); + //如果有跟踪到,重新设置结尾节点 + if (keelpNode) { + endRange.setOffset(keelpNode, endOffset, endOffset); + } + //分割结尾节点 + this.splitOnCollapsed(endRange, removeMark); + range.setEnd(endRange.startContainer, endRange.startOffset); + } else { + //结尾父节点包含开始节点父节点 + //先分割结尾节点,并跟踪开始节点 + keelpNode = this.splitOnCollapsed( + endRange, + removeMark, + startRange.startNode, + ); + range.setEnd(endRange.startContainer, endRange.startOffset); + //如果有跟踪到,重新设置开始节点 + if (keelpNode) { + startRange.setOffset( + keelpNode, + startOffset, + startOffset, + ); + } + //分割开始节点 + this.splitOnCollapsed(startRange, removeMark); + range.setStart( + startRange.startContainer, + startRange.startOffset, + ); + } + return; + } + const { node } = this.editor; + // 节点不是样式标签,文本节点时判断父节点 + const startParent = startNode.parent(); + const startIsMark = + node.isMark(startNode) || + (startParent && node.isMark(startParent)); + const endParent = endNode.parent(); + const endIsMark = + node.isMark(endNode) || (endParent && node.isMark(endParent)); + // 不是样式标签,无需分割 + if (!startIsMark && !endIsMark) { + return; + } + // 获取上面第一个非样式标签 + let { commonAncestorNode } = range; + if (commonAncestorNode.isText()) { + commonAncestorNode = commonAncestorNode.parent()!; + } + + const parent = this.closestNotMark(commonAncestorNode); + // 插入范围的开始和结束标记 + const selection = range.createSelection(); + // 子节点分别保存在两个变量 + + const left = selection.getNode(parent, 'left'); + const center = selection.getNode(parent); + const right = selection.getNode(parent, 'right'); + // 删除空标签 + this.unwrapEmptyMarks(left); + this.unwrapEmptyMarks(right); + // 清空原父容器,用新的内容代替 + const children = parent.children(); + children.each((_, index) => { + const child = children.eq(index); + if (!child?.isCard()) { + child?.remove(); + } + }); + let appendChild: NodeInterface | undefined | null = undefined; + const appendToParent = (childrenNodes: NodeInterface) => { + childrenNodes.each((child, index) => { + const childNode = childrenNodes.eq(index); + if (childNode?.isCard()) { + appendChild = appendChild + ? appendChild.next() + : parent.first(); + if (appendChild) childrenNodes[index] = appendChild[0]; + return; + } + if (appendChild) { + appendChild.after(child); + appendChild = childNode; + } else { + appendChild = childNode; + parent.prepend(child); + } + }); + }; + const leftChildren = left.children(); + appendToParent(leftChildren); + const centerChildren = center.children(); + const centerNodes = centerChildren.toArray(); + appendToParent(centerChildren); + const rightChildren = right.children(); + appendToParent(rightChildren); + // 重新设置范围 + range.setStartBefore(centerNodes[0][0]); + range.setEndAfter(centerNodes[centerNodes.length - 1][0]); + } + } + + /** + * 分割mark标签 + * @param removeMark 需要移除的mark标签 + */ + split( + range?: RangeInterface, + removeMark?: NodeInterface | Node | string | Array, + ) { + if (!isEngine(this.editor)) return; + const { change } = this.editor; + const safeRange = range || change.range.toTrusty(); + const doc = getDocument(safeRange.startContainer); + const collapsed = safeRange.collapsed; + + if ( + typeof removeMark === 'string' || + (!Array.isArray(removeMark) && removeMark && isNode(removeMark)) + ) { + removeMark = $(removeMark, doc); + } + if (collapsed) { + this.splitOnCollapsed(safeRange, removeMark); + } else { + const selection = safeRange.createSelection('mark-split'); + this.splitOnExpanded(safeRange, removeMark); + selection.move(); + } + if (!range) change.apply(safeRange); + } + + /** + * 在当前光标选区包裹mark标签 + * @param mark mark标签 + * @param both mark标签两侧节点 + */ + wrap(mark: NodeInterface | Node | string, range?: RangeInterface) { + const change = isEngine(this.editor) ? this.editor.change : undefined; + if (!range && !change) return; + const { node } = this.editor; + const safeRange = range || change!.range.toTrusty(); + const doc = getDocument(safeRange.startContainer); + if (typeof mark === 'string' || isNode(mark)) { + mark = $(mark, doc); + } else mark = mark; + if (!node.isMark(mark)) return; + let { commonAncestorNode } = safeRange; + + if (commonAncestorNode.type === getWindow().Node.TEXT_NODE) { + commonAncestorNode = commonAncestorNode.parent()!; + } + const card = this.editor.card.find(commonAncestorNode, true); + let isEditable = card?.isEditable; + const nodes = isEditable + ? card?.getSelectionNodes + ? card.getSelectionNodes() + : [] + : [commonAncestorNode]; + if (nodes.length === 0) { + isEditable = false; + nodes.push(commonAncestorNode); + } + const nodeApi = node; + const plugin = this.findPlugin(mark); + if ( + safeRange.collapsed && + (!isEditable || !card?.getSelectionNodes || nodes.length === 1) + ) { + if ( + mark + .children() + .toArray() + .filter((node) => !node.isCursor()).length === 0 + ) + mark.append(doc.createTextNode('\u200b')); + //在相通插件下,值不同,插入到同级,不做嵌套 + const { startNode } = safeRange.shrinkToTextNode(); + let parent = startNode.parent(); + const levalNodes: NodeInterface[] = []; + let isPushMark = false; + if (startNode.isText()) { + let result = false; + while (parent && nodeApi.isMark(parent)) { + if (this.compare(parent.clone(), mark, true)) { + result = true; + break; + } + const curPlugin = this.findPlugin(parent); + if (parent.children().length === 1) { + //插件一样,并且并表明要合并值 + if ( + plugin && + plugin === curPlugin && + plugin.combineValueByWrap === true + ) { + nodeApi.wrap(parent, mark, true); + result = true; + break; + } + //插件一样,不合并,分割后插入 + else if (plugin && plugin === curPlugin) { + this.split(safeRange); + result = false; + break; + } + } + // 要插入的mark节点大于当前节点 + if ( + plugin && + (!curPlugin || plugin.mergeLeval > curPlugin.mergeLeval) + ) { + levalNodes.push(parent.clone(false)); + } else if (!isPushMark && levalNodes.length > 0) { + levalNodes.push(mark.clone(false)); + isPushMark = true; + } + parent = parent.parent(); + } + if (result) return; + } + if (levalNodes.length > 0) { + let newMark = levalNodes[0]; + newMark.append(mark.children()); + for (let i = 1; i < levalNodes.length; i++) { + newMark = nodeApi.wrap(newMark, levalNodes[i]); + } + mark = isPushMark ? newMark : nodeApi.wrap(newMark, mark); + this.split(safeRange); + } + nodeApi.insert(mark, safeRange); + this.merge(safeRange); + safeRange.handleBr(); + if (!range) change?.apply(safeRange); + return; + } + // 不是选中可编辑器卡片内部就分割 + if (!isEditable) { + this.split(safeRange); + commonAncestorNode = safeRange.commonAncestorNode; + if (commonAncestorNode.type === getWindow().Node.TEXT_NODE) { + commonAncestorNode = commonAncestorNode.parent()!; + } + nodes[0] = commonAncestorNode; + } + + // 插入范围的开始和结束标记 + const selection = !isEditable ? safeRange.createSelection() : undefined; + + if (selection && !selection.anchor && !selection.focus) { + if (!range) change?.apply(safeRange); + return; + } + // 遍历范围内的节点,添加 Mark + let started = isEditable ? true : false; + + nodes.forEach((commonAncestorNode) => { + commonAncestorNode.traverse( + (child) => { + mark = mark as NodeInterface; + if (isEditable || !child.equal(selection?.anchor!)) { + if (started) { + if (!isEditable && child.equal(selection?.focus!)) { + started = false; + return false; + } + // 要包裹的节点是mark + if (nodeApi.isMark(child)) { + if (!nodeApi.isEmpty(child)) { + //找到最底层mark标签添加包裹,abc ,在 span 节点中的text再添加包裹,不在strong外添加包裹 + let targetNode = child; + let targetChildrens = targetNode.children(); + const curPlugin = + this.findPlugin(targetNode); + while ( + nodeApi.isMark(targetNode) && + targetChildrens.length === 1 && + plugin && + curPlugin && + plugin.mergeLeval <= + curPlugin.mergeLeval + ) { + const targetChild = + targetChildrens.eq(0)!; + if (nodeApi.isMark(targetChild)) { + targetNode = targetChild; + targetChildrens = + targetNode.children(); + } else if (targetChild.isText()) { + targetNode = targetChild; + } else break; + } + + nodeApi.removeZeroWidthSpace(targetNode); + let parent = targetNode.parent(); + //父级和当前要包裹的节点,属性和值都相同,那就不包裹。只有属性一样,并且父节点只有一个节点那就移除父节点包裹,然后按插件情况合并值 + if (targetNode.isText()) { + let result = false; + while ( + parent && + nodeApi.isMark(parent) + ) { + if ( + this.compare( + parent.clone(), + mark, + true, + ) + ) { + result = true; + break; + } else if ( + parent + .children() + .toArray() + .filter( + (node) => + !node.isCursor(), + ).length === 1 + ) { + const curPlugin = + this.findPlugin(parent); + //插件一样,并且并表明要合并值 + if ( + plugin && + plugin === curPlugin && + plugin.combineValueByWrap === + true + ) { + nodeApi.wrap( + parent, + mark, + true, + ); + result = true; + break; + } + //插件一样,不合并,直接移除 + else if ( + plugin && + plugin === curPlugin + ) { + nodeApi.unwrap(parent); + result = false; + break; + } + } + parent = parent.parent(); + } + if (result) return true; + } + // 移除目标子级内相同的插件 + const allChildren = + targetNode.allChildren(); + allChildren.forEach((children) => { + if ( + children.type === + getDocument().TEXT_NODE + ) + return; + if (nodeApi.isMark(children)) { + const childPlugin = + this.findPlugin(children); + if ( + childPlugin === plugin && + !plugin?.combineValueByWrap + ) + nodeApi.unwrap(children); + } + }); + nodeApi.wrap(targetNode, mark); + if ( + !isEditable && + selection?.focus && + targetNode + .find( + `[data-element="${selection.focus.attributes( + DATA_ELEMENT, + )}"]`, + ) + .equal(selection.focus) + ) { + started = false; + return false; + } + return true; + } else if (child.name !== mark.name) { + child.remove(); + } + } + + if (child.isText() && !nodeApi.isEmpty(child)) { + nodeApi.removeZeroWidthSpace(child); + const parent = child.parent(); + //父级和当前要包裹的节点,属性和值都相同,那就不包裹。只有属性一样,并且父节点只有一个节点那就移除父节点包裹,然后按插件情况合并值 + if (parent && nodeApi.isMark(parent)) { + if ( + this.compare(parent.clone(), mark, true) + ) + return true; + if (parent.children().length === 1) { + const plugin = this.findPlugin(mark); + const curPlugin = + this.findPlugin(parent); + //插件一样,并且并表明要合并值 + if ( + plugin && + plugin === curPlugin && + plugin.combineValueByWrap === true + ) { + nodeApi.wrap(parent, mark, true); + return true; + } + //插件一样,不合并,直接移除 + else if (plugin && plugin === curPlugin) + nodeApi.unwrap(parent); + } + } + nodeApi.wrap(child, mark); + } + } + } else { + started = true; + } + return; + }, + true, + true, + ); + }); + + selection?.move(); + this.merge(safeRange); + if (!range) change?.apply(safeRange); + } + + /** + * 合并当前选区的mark节点 + * @param range 光标,默认当前选区光标 + */ + merge(range?: RangeInterface): void { + if (!isEngine(this.editor)) return; + const { change, node } = this.editor; + const safeRange = range || change.range.toTrusty(); + const marks = this.findMarks(safeRange); + if (marks.length === 0) { + return; + } + const selection = safeRange.shrinkToElementNode().createSelection(); + const mergeMarks = (marks: Array) => { + marks.forEach((mark) => { + const prevMark = mark.prev(); + const nextMark = mark.next(); + //查找是否有一样的父级mark + const parentMark = mark.parent(); + const findSameParent = ( + parentMark: NodeInterface, + sourceMark: NodeInterface, + ): boolean => { + if (node.isMark(parentMark)) { + let parent: NodeInterface | undefined = undefined; + if (this.compare(parentMark, sourceMark, true)) { + return true; + } else if ((parent = parentMark.parent())) { + return findSameParent(parent, sourceMark); + } + } + return false; + }; + //如果有一样的父级mark,则去除包裹 + if (parentMark && findSameParent(parentMark, mark)) { + node.unwrap(mark); + return; + } + + if (prevMark && this.compare(prevMark, mark, true)) { + node.merge(prevMark, mark); + // 原来 mark 已经被移除,重新指向 + mark = prevMark; + } + + if (nextMark && this.compare(nextMark, mark, true)) { + node.merge(mark, nextMark); + } + //合并子级mark + const childMarks: Array = []; + const children = mark.children(); + children.each((_, index) => { + const child = children.eq(index); + if (child && !child.isCursor() && node.isMark(child)) { + childMarks.push(child); + } + }); + if (childMarks.length > 0) { + mergeMarks(childMarks); + } + }); + }; + mergeMarks(marks); + selection.move(); + safeRange.handleBr(); + if (!range) change.apply(safeRange); + } + /** + * 去掉mark包裹 + * @param range 光标 + * @param removeMark 要移除的mark标签 + */ + unwrap( + removeMark?: NodeInterface | Node | string | Array, + range?: RangeInterface, + ) { + if (!isEngine(this.editor)) return; + const { change, node } = this.editor; + const safeRange = range || change.range.toTrusty(); + const doc = getDocument(safeRange.startContainer) || document; + + if ( + removeMark !== undefined && + !Array.isArray(removeMark) && + (typeof removeMark === 'string' || isNode(removeMark)) + ) { + removeMark = $(removeMark, doc); + } + let { commonAncestorNode } = safeRange; + + if (commonAncestorNode.type === getWindow().Node.TEXT_NODE) { + commonAncestorNode = commonAncestorNode.parent()!; + } + const card = this.editor.card.find(commonAncestorNode, true); + let isEditable = card?.isEditable; + const nodes = isEditable + ? card?.getSelectionNodes + ? card.getSelectionNodes() + : [] + : [commonAncestorNode]; + if (nodes.length === 0) { + isEditable = false; + nodes.push(commonAncestorNode); + } + // 不是选中可编辑器卡片内部就分割 + if (!isEditable) { + this.split(safeRange, safeRange.collapsed ? removeMark : undefined); + commonAncestorNode = safeRange.commonAncestorNode; + if (commonAncestorNode.type === getWindow().Node.TEXT_NODE) { + commonAncestorNode = commonAncestorNode.parent()!; + } + nodes[0] = commonAncestorNode; + } + + if ( + safeRange.collapsed && + (!isEditable || !card?.getSelectionNodes || nodes.length === 1) + ) { + this.merge(safeRange); + if (!range) change.apply(safeRange); + return; + } + + // 插入范围的开始和结束标记 + const selection = !isEditable + ? safeRange.createSelection('mark-unwrap') + : undefined; + if (selection && !selection.has()) { + this.merge(safeRange); + if (!range) change.apply(safeRange); + return; + } + // 遍历范围内的节点,获取目标 Mark + const markNodes: Array = []; + let started = isEditable ? true : false; + nodes.forEach((ancestor) => { + ancestor.traverse( + (child) => { + if (child.isText() || !selection?.anchor) return; + if (isEditable || !child.equal(selection.anchor)) { + if (started) { + if (isEditable || !child.equal(selection?.focus!)) { + if ( + node.isMark(child) && + !child.isCard() && + (isEditable || + safeRange.isPointInRange(child, 0)) + ) { + markNodes.push(child); + } + } + } + } else if (!isEditable) { + started = true; + // 光标开始位置在 abc123 就把 strong 加进去 + if ( + child.equal(selection.anchor) && + !selection.anchor.prev() + ) { + let parent = selection.anchor.parent(); + while ( + parent && + !parent.isCard() && + node.isMark(parent) + ) { + markNodes.push(parent); + parent = parent.parent(); + if (parent && parent.children().length > 1) + break; + } + } + } + }, + true, + true, + ); + }); + // 清除 Mark + const nodeApi = node; + markNodes.forEach((node) => { + removeMark = removeMark as NodeInterface | undefined; + if ( + !removeMark || + (!node.isCard() && this.compare(node, removeMark)) + ) { + nodeApi.unwrap(node); + } else if (removeMark) { + const styleMap = removeMark.css(); + Object.keys(styleMap).forEach((key) => { + node.css(key, ''); + }); + //移除符合规则的class + const removeClass = removeMark + .get() + ?.className.split(/\s+/); + if (removeClass) { + const { schema } = this.editor; + const schemas = schema.find( + (rule) => rule.name === node.name, + ); + for (let i = 0; i < schemas.length; i++) { + const schemaRule = schemas[i]; + removeClass.forEach((className) => { + className = className.trim(); + if (className === '') return; + if ( + schemaRule.attributes && + schema.checkValue( + schemaRule.attributes, + 'class', + className, + ) + ) { + node.removeClass(className); + } + }); + } + } + } else { + node.removeAttributes('class'); + node.removeAttributes('style'); + } + }); + selection?.move(); + this.merge(safeRange); + if (!range) change.apply(safeRange); + } + + /** + * 光标处插入mark标签 + * @param mark mark标签 + * @param range 指定光标,默认为编辑器选中的光标 + */ + insert(mark: NodeInterface | Node | string, range?: RangeInterface): void { + if (!isEngine(this.editor)) return; + const { change, node } = this.editor; + const safeRange = range || change.range.toTrusty(); + if (typeof mark === 'string' || isNode(mark)) { + const doc = getDocument(safeRange.startContainer); + mark = $(mark, doc); + } + // 范围为折叠状态时先删除内容 + if (!safeRange.collapsed) { + change.delete(safeRange); + } + // 插入新 Mark + node.insert(mark, safeRange)?.handleBr().select(mark).collapse(false); + if (!range) change.apply(safeRange); + } + + /** + * 查找对范围有效果的所有 Mark + * @param range 范围 + */ + findMarks(range: RangeInterface) { + const cloneRange = range.cloneRange(); + if (cloneRange.startNode.isRoot()) cloneRange.shrinkToElementNode(); + if (!cloneRange.startNode.inEditor()) return []; + const { node } = this.editor; + const handleRange = ( + allowBlock: boolean, + range: RangeInterface, + toStart: boolean = false, + ) => { + if (!range.collapsed) return; + const { startNode, startOffset } = range; + //没有父节点 + const startParent = startNode.findParent(); + if (!startParent) return; + //选择父节点内容 + const cloneRange = range.cloneRange(); + cloneRange.select(startParent, true); + //开始位置 + if (toStart) { + cloneRange.setEnd(startNode, startOffset); + cloneRange.enlargeFromTextNode(); + cloneRange.enlargeToElementNode(true); + const startChildren = startNode.children(); + const { endNode, endOffset } = cloneRange; + const endChildren = endNode.children(); + const endOffsetNode = endChildren.eq(endOffset); + const startOffsetNode = + startChildren.eq(startOffset) || + startChildren.eq(startOffset - 1); + if ( + !allowBlock && + endNode.type === Node.ELEMENT_NODE && + endOffsetNode && + node.isBlock(endOffsetNode) && + (startNode.type !== Node.ELEMENT_NODE || + (!!startOffsetNode && !node.isBlock(startOffsetNode))) + ) + return; + cloneRange.select(startParent, true); + cloneRange.setStart(endNode, endOffset); + cloneRange.shrinkToElementNode(); + cloneRange.shrinkToTextNode(); + range.setStart( + cloneRange.startContainer, + cloneRange.startOffset, + ); + range.collapse(true); + } else { + cloneRange.setStart(startNode, startOffset); + cloneRange.enlargeFromTextNode(); + cloneRange.enlargeToElementNode(true); + const startChildren = startNode.children(); + const startNodeClone = cloneRange.startNode; + const startOffsetClone = cloneRange.startOffset; + const startNodeCloneChildren = startNodeClone.children(); + const startOffsetNode = + startNodeCloneChildren.eq(startOffsetClone); + const startChildrenOffsetNode = + startChildren.eq(startOffset) || + startChildren.eq(startOffset - 1); + if ( + !allowBlock && + startNodeClone.type === Node.ELEMENT_NODE && + startOffsetNode && + node.isBlock(startOffsetNode) && + (startNode.type !== Node.ELEMENT_NODE || + (startChildrenOffsetNode && + !node.isBlock(startChildrenOffsetNode))) + ) + return; + cloneRange.select(startParent, true); + cloneRange.setEnd(startNodeClone, startOffsetClone); + cloneRange.shrinkToElementNode(); + cloneRange.shrinkToTextNode(); + range.setEnd(cloneRange.endContainer, cloneRange.endOffset); + range.collapse(false); + } + }; + // 左侧不动,只缩小右侧边界 + // foobar + // 改成 + // foobar + if (!cloneRange.collapsed) { + const leftRange = cloneRange.cloneRange(); + const rightRange = cloneRange.cloneRange(); + leftRange.collapse(true); + rightRange.collapse(false); + handleRange(true, leftRange, true); + handleRange(true, rightRange); + cloneRange.setStart( + leftRange.startContainer, + leftRange.startOffset, + ), + cloneRange.setEnd( + rightRange.startContainer, + rightRange.startOffset, + ); + } + handleRange(false, cloneRange); + const sc = cloneRange.startContainer; + const so = cloneRange.startOffset; + const ec = cloneRange.endContainer; + const eo = cloneRange.endOffset; + let startNode = sc; + let endNode = ec; + if ( + sc.nodeType === getWindow().Node.ELEMENT_NODE && + sc.childNodes[so] + ) { + startNode = sc.childNodes[so] || sc; + } + if ( + ec.nodeType === getWindow().Node.ELEMENT_NODE && + eo > 0 && + ec.childNodes[eo - 1] + ) { + endNode = ec.childNodes[eo - 1] || sc; + } + // 折叠状态时,按右侧位置的方式处理 + if (cloneRange.collapsed) { + startNode = endNode; + } + // 不存在时添加 + const addNode = (nodes: Array, nodeB: NodeInterface) => { + if (!nodes.some((nodeA) => nodeA.equal(nodeB))) { + nodes.push(nodeB); + } + }; + const nodeApi = node; + // 向上寻找 + const findNodes = (node: NodeInterface) => { + let nodes: Array = []; + while (node) { + if ( + node.type === getWindow().Node.ELEMENT_NODE && + node.isEditable() + ) { + break; + } + if ( + nodeApi.isMark(node) && + !node.attributes(CARD_KEY) && + !node.attributes(CARD_ELEMENT_KEY) + ) { + nodes.push(node); + } + const parent = node.parent(); + if (!parent) break; + node = parent; + } + return nodes; + }; + + const nodes = findNodes($(startNode)); + const { commonAncestorNode } = cloneRange; + const card = this.editor.card.find(commonAncestorNode, true); + let isEditable = card?.isEditable; + const selectionNodes = isEditable + ? card?.getSelectionNodes + ? card.getSelectionNodes() + : [] + : [commonAncestorNode]; + if (selectionNodes.length === 0) { + isEditable = false; + selectionNodes.push(commonAncestorNode); + } + if (!cloneRange.collapsed || isEditable) { + findNodes($(endNode)).forEach((nodeB) => { + return addNode(nodes, nodeB); + }); + if (sc !== ec || isEditable) { + let isBegin = false; + let isEnd = false; + selectionNodes.forEach((commonAncestorNode) => { + commonAncestorNode.traverse( + (child) => { + if (isEnd) return false; + //节点不是开始节点 + if (!child.equal(sc)) { + if (isBegin) { + //节点是结束节点,标记为结束 + if (child.equal(ec)) { + isEnd = true; + return false; + } + if ( + nodeApi.isMark(child) && + !child.attributes(CARD_KEY) && + !child.attributes(CARD_ELEMENT_KEY) + ) { + addNode(nodes, child); + } + } + } else { + //如果是开始节点,标记为开始 + isBegin = true; + } + return; + }, + true, + true, + ); + }); + } + } + return nodes; + } + + /** + * 从下开始往上遍历删除空 Mark,当遇到空 Block,添加 BR 标签 + * @param node 节点 + * @param addBr 是否添加br + */ + removeEmptyMarks(node: NodeInterface, addBr?: boolean) { + if ( + node.length === 0 || + node.isEditable() || + node.isCard() || + node.attributes(DATA_ELEMENT) + ) { + return; + } + const nodeApi = this.editor.node; + if (!node.attributes(DATA_ELEMENT)) { + const parent = node.parent(); + // 包含光标标签 + //

      + if ( + node.children().length === 1 && + node.first()?.attributes(DATA_ELEMENT) + ) { + if (nodeApi.isMark(node)) { + node.before(node.first()!); + node.remove(); + if (parent) this.removeEmptyMarks(parent, true); + } else if (addBr && nodeApi.isBlock(node)) { + node.prepend('
      '); + } + return; + } + + const html = nodeApi.html(node); + + if (html === '' || html === '\u200B') { + if (nodeApi.isMark(node)) { + node.remove(); + if (parent) this.removeEmptyMarks(parent, true); + } else if (addBr && nodeApi.isBlock(node)) { + nodeApi.html(node, '
      '); + } + } + } + } + + repairCursor(mark: NodeInterface | Node) { + const { node } = this.editor; + mark = isNode(mark) ? $(mark) : mark; + const isMark = node.isMark(mark); + if (isMark) { + const childrenNodes = mark.children(); + childrenNodes.each((_, index) => { + const child = childrenNodes.eq(index); + if (child?.isText()) { + const text = child.text(); + if (text.length === 1 && /\u200b/.test(text)) { + child.remove(); + return; + } + child.text(text.replace(/\u200b/g, '')); + } + }); + const children = mark + .children() + .toArray() + .filter((node) => !node.isCursor()); + if (children.length < 2) { + if ( + children.length === 0 || + (children.length === 1 && + children[0].isText() && + children[0].text().length === 0) + ) { + if (children.length > 0) children[0].remove(); + mark.prepend($('\u200b', null)); + } + } + const next = mark.next(); + const nextN = next?.next(); + if ( + next && + nextN && + next.isText() && + /^\u200b$/g.test(next.text()) && + !node.isInline(nextN) + ) { + next.remove(); + } + } + } +} + +export default Mark; diff --git a/packages/engine/src/mark/typing/backspace.ts b/packages/engine/src/mark/typing/backspace.ts new file mode 100644 index 00000000..14091fd6 --- /dev/null +++ b/packages/engine/src/mark/typing/backspace.ts @@ -0,0 +1,113 @@ +import { EngineInterface, NodeInterface } from '../../types'; + +class Backspace { + private engine: EngineInterface; + constructor(engine: EngineInterface) { + this.engine = engine; + } + /** + * 在inline节点处按下backspace键 + */ + trigger(event: KeyboardEvent) { + const { change, node, block } = this.engine; + const range = change.range.get(); + const { collapsed, endNode, startNode, startOffset, endOffset } = range + .cloneRange() + .shrinkToTextNode(); + // 空的block节点下不处理mark + if (collapsed) { + const blockNode = block.closest(startNode); + if (blockNode.length > 0 && node.isEmpty(blockNode)) { + return; + } + } + + if ( + endNode.type === Node.TEXT_NODE || + startNode.type === Node.TEXT_NODE + ) { + //光标展开的情况下,判断光标结束位置是否在mark节点内侧零宽字符后面,不在后面让光标选在后面 + if (!collapsed) { + let markNode = endNode.parent(); + if ( + markNode && + endNode.type === Node.TEXT_NODE && + node.isMark(markNode) + ) { + const text = endNode.text(); + const rightText = text.substr(endOffset); + //不位于零宽字符前,不处理 + if (!/^\u200b$/.test(rightText)) return true; + range.setEnd(endNode, endOffset + 1); + } + return true; + } + let markNode = startNode.parent(); + //开始节点在mark标签内 + if ( + markNode && + startNode.type === Node.TEXT_NODE && + node.isMark(markNode) + ) { + if (startOffset < 1) return true; + const text = startNode.text(); + const leftText = text.substr(startOffset - 1, 1); + //不位于零宽字符后,不处理 + if (!/^\u200b$/.test(leftText)) return true; + if (startOffset === 1) { + const prev = markNode.prev(); + if (prev && !node.isEmpty(prev)) { + const { startNode, startOffset } = range + .cloneRange() + .select(prev, true) + .shrinkToTextNode() + .collapse(false); + range.setStart(startNode, startOffset - 1); + } + //在段落的开始位置 + else if (!prev && node.isEmpty(markNode)) { + const parent = markNode.parent(); + const offset = markNode.getIndex(); + markNode.remove(); + if (parent) + range.setStart( + parent, + offset <= 0 ? 0 : offset - 1, + ); + } + } else { + range.setStart(startNode, startOffset - 1); + } + + return true; + } + //mark 标签后面的零宽字符后删除零宽字符前面的字符 + markNode = startNode.prev() || undefined; + if ( + markNode && + startNode.type === Node.TEXT_NODE && + node.isMark(markNode) + ) { + const text = startNode.text(); + const leftText = text.substr(startOffset - 1, 1); + //不位于零宽字符后,不处理 + if (!/^\u200b$/.test(leftText)) return true; + if (startOffset === 1) { + const { startNode, startOffset } = range + .cloneRange() + .select(markNode, true) + .shrinkToTextNode() + .collapse(false); + range.setStart(startNode, startOffset - 1); + } else { + range.setStart( + startNode, + startOffset > 0 ? startOffset - 1 : 0, + ); + } + } + } + return true; + } +} +export default Backspace; diff --git a/packages/engine/src/mark/typing/index.ts b/packages/engine/src/mark/typing/index.ts new file mode 100644 index 00000000..de0639f0 --- /dev/null +++ b/packages/engine/src/mark/typing/index.ts @@ -0,0 +1,3 @@ +import Backspace from './backspace'; + +export { Backspace }; diff --git a/packages/engine/src/node/entry.ts b/packages/engine/src/node/entry.ts new file mode 100644 index 00000000..b3e71345 --- /dev/null +++ b/packages/engine/src/node/entry.ts @@ -0,0 +1,1066 @@ +import { + DATA_ELEMENT, + ROOT, + ROOT_SELECTOR, + EDITABLE, + EDITABLE_SELECTOR, +} from '../constants/root'; +import { CARD_TAG, CARD_TYPE_KEY } from '../constants/card'; +import { ANCHOR, CURSOR, FOCUS } from '../constants/selection'; +import DOMEvent from './event'; +import $ from './parse'; +import { + toCamelCase, + getStyleMap, + getComputedStyle, + getAttrMap, + getDocument, + getWindow, +} from '../utils'; +import { Path } from 'sharedb'; +import { + NodeInterface, + EventInterface, + Selector, + Context, + EventListener, + ElementInterface, +} from '../types'; +import { isNode, isNodeEntry } from './utils'; + +/** + * 扩展 Node 类 + * @class NodeEntry + * @constructor + * @param nodes 需要扩展的 NodeList + * @param context 节点上下文,或根节点 + */ +class NodeEntry implements NodeInterface { + length: number = 0; + events: EventInterface[] = []; + document: Document | null = null; + context: Context | undefined; + name: string = ''; + type: number | undefined; + window: Window | null = null; + display: string | undefined; + fragment?: DocumentFragment; + [n: number]: Node; + + constructor(nodes: Node | NodeList | Array, context?: Context) { + if (isNode(nodes)) { + if (nodes.nodeType === getWindow().Node.DOCUMENT_FRAGMENT_NODE) { + this.fragment = nodes as DocumentFragment; + } + nodes = [nodes]; + } + + for (let i = 0; i < nodes.length; i++) { + this[i] = nodes[i]; + this.events[i] = new DOMEvent(); // 初始化事件对象 + } + + this.length = nodes.length; + + if (this.length > 0) { + this.document = getDocument(context); + this.context = context; + this.name = this[0].nodeName ? this[0].nodeName.toLowerCase() : ''; + this.type = this[0].nodeType; + this.window = getWindow(this[0]); + } + } + + /** + * 如果元素被指定的选择器字符串选择,Element.matches() 方法返回true; 否则返回false。 + * @param element 节点 + * @param selector 选择器 + */ + isMatchesSelector(element: ElementInterface, selector: string) { + if (element.nodeType !== getWindow().Node.ELEMENT_NODE || !selector) { + return false; + } + const defaultMatches = (element: Element, selector: string) => { + const domNode = new NodeEntry(element); + let matches = domNode.document?.querySelectorAll(selector), + i = matches ? matches.length : 0; + while (--i >= 0 && matches?.item(i) !== domNode.get()) {} + return i > -1; + }; + const matchesSelector = + element.matches || + element.webkitMatchesSelector || + element.mozMatchesSelector || + element.msMatchesSelector || + element.oMatchesSelector || + element.matchesSelector || + defaultMatches; + + return matchesSelector.call(element, selector); + } + + /** + * 遍历 + * @param {Function} callback 回调函数 + * @return 返回当前实例 + */ + each( + callback: (node: Node, index: number) => boolean | void, + ): NodeInterface { + for (let i = 0; i < this.length; i++) { + if (callback(this[i], i) === false) { + break; + } + } + return this; + } + + /** + * 将 NodeEntry 转换为 Array + * @return {Array} 返回数组 + */ + toArray(): Array { + const nodeArray: Array = []; + this.each((node) => { + nodeArray.push(new NodeEntry(node)); + }); + return nodeArray; + } + + /** + * 判断当前节点是否为 Node.ELEMENT_NODE 节点类型 + * @return {boolean} + */ + isElement(): boolean { + return this.type === getWindow().Node.ELEMENT_NODE; + } + + /** + * 判断当前节点是否为 Node.TEXT_NODE 节点类型 + * @return {boolean} + */ + isText(): boolean { + return this.type === getWindow().Node.TEXT_NODE; + } + + /** + * 判断当前节点是否为Card组件 + */ + isCard() { + return this.name === CARD_TAG || !!this.attributes(CARD_TYPE_KEY); + } + /** + * 判断当前节点是否为block类型的Card组件 + */ + isBlockCard() { + return 'block' === this.attributes(CARD_TYPE_KEY); + } + /** + * 判断当前节点是否为inline类型的Card组件 + * @returns + */ + isInlineCard() { + return 'inline' === this.attributes(CARD_TYPE_KEY); + } + /** + * 是否是可编辑器卡片 + * @returns + */ + isEditableCard() { + return this.find(EDITABLE_SELECTOR).length > 0; + } + + /** + * 判断当前节点是否为根节点 + */ + isRoot() { + return this.attributes(DATA_ELEMENT) === ROOT; + } + + isEditable() { + return this.isRoot() || this.attributes(DATA_ELEMENT) === EDITABLE; + } + + /** + * 判断当前是否在根节点内 + */ + inEditor() { + if (this.isRoot()) { + return false; + } + return this.closest(ROOT_SELECTOR).length > 0; + } + + /** + * 是否是光标标记节点 + * @returns + */ + isCursor() { + return ( + [ANCHOR, FOCUS, CURSOR].indexOf(this.attributes(DATA_ELEMENT)) > -1 + ); + } + + get(index: number = 0): E | null { + return this.length === 0 ? null : (this[index] as E); + } + + /** + * 获取当前第 index 节点 + * @param {number} index + * @return {NodeEntry|undefined} NodeEntry 类,或 undefined + */ + eq(index: number): NodeInterface | undefined { + return index > -1 && index < this.length + ? new NodeEntry(this[index]) + : undefined; + } + + /** + * 获取当前节点所在父节点中的索引 + * @return {number} 返回索引 + */ + index(): number { + let prev = this.get()?.previousSibling; + let index = 0; + + while (prev && prev.nodeType === getWindow().Node.ELEMENT_NODE) { + index++; + prev = prev.previousSibling; + } + return index; + } + + /** + * 获取当前节点父节点 + * @return 父节点 + */ + parent(): NodeInterface | undefined { + const node = this.get()?.parentNode; + return node ? new NodeEntry(node) : undefined; + } + + /** + * 查询当前节点的子节点 + * @param selector 查询器 + * @return 符合条件的子节点 + */ + children(selector?: string): NodeInterface { + if (0 === this.length) return new NodeEntry([]); + const childNodes = this.get()!.childNodes; + if (selector) { + let nodes = []; + for (let i = 0; i < childNodes.length; i++) { + const node = childNodes[i]; + if (this.isMatchesSelector(node, selector)) { + nodes.push(node); + } + } + return new NodeEntry(nodes); + } + return new NodeEntry(childNodes); + } + + /** + * 获取当前节点第一个子节点 + * @return NodeEntry 子节点 + */ + first(): NodeInterface | null { + if (this.fragment) return this.eq(0) || null; + const node = this.length === 0 ? null : this.get()?.firstChild; + return node ? new NodeEntry(node) : null; + } + + /** + * 获取当前节点最后一个子节点 + * @return NodeEntry 子节点 + */ + last(): NodeInterface | null { + if (this.fragment) return this.eq(this.length - 1) || null; + const node = this.length === 0 ? null : this.get()?.lastChild; + return node ? new NodeEntry(node) : null; + } + + /** + * 返回元素节点之前的兄弟节点(包括文本节点、注释节点) + * @return NodeEntry 节点 + */ + prev(): NodeInterface | null { + const node = this.length === 0 ? null : this.get()?.previousSibling; + return node ? new NodeEntry(node) : null; + } + + /** + * 返回元素节点之后的兄弟节点(包括文本节点、注释节点) + * @return NodeEntry 节点 + */ + next(): NodeInterface | null { + const node = this.length === 0 ? null : this.get()?.nextSibling; + return node ? new NodeEntry(node) : null; + } + + /** + * 返回元素节点之前的兄弟元素节点(不包括文本节点、注释节点) + * @return NodeEntry 节点 + */ + prevElement(): NodeInterface | null { + const node = + this.length === 0 || !this.isElement() + ? null + : this.get()!.previousElementSibling; + return node ? new NodeEntry(node) : null; + } + + /** + * 返回元素节点之后的兄弟元素节点(不包括文本节点、注释节点) + * @return NodeEntry 节点 + */ + nextElement(): NodeInterface | null { + const node = + this.length === 0 || !this.isElement() + ? null + : this.get()!.nextElementSibling; + return node ? new NodeEntry(node) : null; + } + + /** + * 返回元素节点所在根节点路径,默认根节点为 document.body + * @param context 根节点,默认为 document.body + * @param filter 获取index的时候过滤 + * @param callback 获取index的时候回调 + * @return 路径 + */ + getPath( + context?: Node | NodeInterface, + filter?: (node: Node) => boolean, + callback?: ( + index: number, + path: number[], + node: NodeInterface, + ) => number[] | undefined, + ): Array { + context = context || document.body; + let path: Array = []; + if (this.length > 0) { + const index = this.getIndex(filter); + if (callback) { + const value = callback(index, path, this); + if (value) path = value; + } else path.unshift(index); + } + if (this.equal(context)) return path; + let parent = this.parent(); + while (parent && !parent.equal(context)) { + const index = parent.getIndex(filter); + if (callback) { + const value = callback(index, path, parent); + if (value) path = value; + } else path.unshift(index); + parent = parent.parent(); + } + return path; + } + + /** + * 判断元素节点是否包含要查询的节点 + * @param {NodeInterface | Node} node 要查询的节点 + * @return {boolean} 是否包含 + */ + contains(node: NodeInterface | Node): boolean { + let domNode: Node | null = isNode(node) ? node : node.get(); + if (this.length === 0) { + return false; + } + if ( + this.get()!.nodeType === getWindow().Node.DOCUMENT_NODE && + domNode?.nodeType !== getWindow().Node.DOCUMENT_NODE + ) { + return true; + } + + if (domNode === this[0]) return true; + + while ((domNode = domNode?.parentNode || null)) { + if (domNode === this[0]) { + return true; + } + } + return false; + } + + /** + * 根据查询器查询当前元素节点 + * @param {string} selector 查询器 + * @return 返回一个 NodeEntry 实例 + */ + find(selector: string): NodeInterface { + if (this.length > 0 && this.isElement()) { + const nodeList = this.get()?.querySelectorAll(selector); + return new NodeEntry(nodeList || []); + } + return new NodeEntry([]); + } + + /** + * 根据查询器查询符合条件的离当前元素节点最近的父节点 + * @param selector 查询器 + * @return 返回一个 NodeEntry 实例 + */ + closest( + selector: string, + callback: (node: Node) => Node | undefined = (node) => { + return node.parentNode || undefined; + }, + ): NodeInterface { + const nodeList: Array = []; + let node: Node | undefined = this.get() || undefined; + while (node) { + if (this.isMatchesSelector(node, selector)) { + nodeList.push(node); + return new NodeEntry(nodeList); + } + node = callback(node); + } + return new NodeEntry(nodeList); + } + + /** + * 为当前元素节点绑定事件 + * @param {string} eventType 事件类型 + * @param {Function} listener 事件函数 + * @return 返回当前实例 + */ + on(eventType: string, listener: EventListener): NodeInterface { + this.each((node, i) => { + node.addEventListener(eventType, listener, false); + if (i < this.events.length) this.events[i].on(eventType, listener); + }); + return this; + } + + /** + * 移除当前元素节点事件 + * @param {string} eventType 事件类型 + * @param {Function} listener 事件函数 + * @return 返回当前实例 + */ + off(eventType: string, listener: EventListener): NodeInterface { + this.each((node, i) => { + node.removeEventListener(eventType, listener, false); + if (i < this.events.length) this.events[i].off(eventType, listener); + }); + return this; + } + + /** + * 获取当前元素节点相对于视口的位置 + * @param {Object} defaultValue 默认值 + * @return {Object} + * { + * top, + * bottom, + * left, + * right + * } + */ + getBoundingClientRect(defaultValue?: { + top: number; + bottom: number; + left: number; + right: number; + }): + | { top: number; bottom: number; left: number; right: number } + | undefined { + if (this.length === 0) return undefined; + try { + const element = this.get()!; + const rect = element.getBoundingClientRect(); + const top = document.documentElement.clientTop; + const left = document.documentElement.clientLeft; + return { + top: rect.top - top, + bottom: rect.bottom - top, + left: rect.left - left, + right: rect.right - left, + }; + } catch (error) { + console.error(error); + } + + return defaultValue; + } + + /** + * 移除当前元素所有已绑定的事件 + * @return 当前 NodeEntry 实例 + */ + removeAllEvents(): NodeInterface { + this.each((node, i) => { + if (!this.events[i]) { + return; + } + + Object.keys(this.events[i].listeners).forEach((eventType) => { + const listeners = this.events[i].listeners[eventType]; + for (let _i = 0; _i < listeners.length; _i++) { + node.removeEventListener(eventType, listeners[_i], false); + } + }); + }); + this.events = []; + return this; + } + + /** + * 获取或设置元素节点属性 + * @param {string|undefined} key 属性名称,key为空获取所有属性,返回Map + * @param {string|undefined} val 属性值,val为空获取当前key的属性,返回string|null + * @return {NodeEntry|{[k:string]:string}} 返回值或当前实例 + */ + attributes(): { [k: string]: string }; + attributes(key: { [k: string]: string }): string; + attributes(key: string, val: string | number): NodeEntry; + attributes(key: string): string; + attributes( + key?: string | { [k: string]: string }, + val?: string | number, + ): NodeEntry | { [k: string]: string } | string { + if (key === undefined) { + const element = this.clone(false).get(); + return getAttrMap(element?.outerHTML || ''); + } + + if (typeof key === 'object') { + Object.keys(key).forEach((k) => { + const v = key[k]; + this.attributes(k, v); + }); + return this; + } + + if (val === undefined) { + const element = this.get(); + return this.length > 0 && this.isElement() + ? element?.getAttribute(key) || '' + : ''; + } + + this.each((node) => { + const element = node as Element; + if (key === 'style' && val === '') element.removeAttribute('style'); + else element.setAttribute(key, val.toString()); + }); + return this; + } + + /** + * 移除元素节点属性 + * @param {string} key 属性名称 + * @return 返当前实例 + */ + removeAttributes(key: string): NodeInterface { + this.each((node) => { + const element = node; + element.removeAttribute(key); + }); + return this; + } + + /** + * 判断元素节点是否包含某个 class + * @param {string} className 样式名称 + * @return {boolean} 是否包含 + */ + hasClass(className: string): boolean { + if (this.length === 0) return false; + const element = this.get()!; + if (element.classList) { + for (let i = 0; i < element.classList.length; i++) { + if (element.classList[i] === className) { + return true; + } + } + } + return false; + } + + /** + * 为元素节点增加一个 class + * @param {string} className + * @return 返当前实例 + */ + addClass(className: string): NodeInterface { + this.each((node) => { + const element = node; + element.classList.add(className); + }); + return this; + } + + /** + * 移除元素节点 class + * @param {string} className + * @return 返当前实例 + */ + removeClass(className: string): NodeEntry { + this.each((node) => { + const element = node; + element.classList.remove(className); + }); + return this; + } + + /** + * 获取或设置元素节点样式 + * @param {string|undefined} key 样式名称 + * @param {string|undefined} val 样式值 + * @return {NodeEntry|{[k:string]:string}} 返回值或当前实例 + */ + css(): { [k: string]: string }; + css(key: { [k: string]: string | number }): NodeEntry; + css(key: string): string; + css(key: string, val: string | number): NodeEntry; + css( + key?: string | { [k: string]: string | number }, + val?: string | number, + ): NodeEntry | { [k: string]: string } | string { + if (key === undefined) { + // 没有参数,返回style所有属性 + return getStyleMap(this.attributes('style') || ''); + } + + if (typeof key === 'object') { + Object.keys(key).forEach((attr) => { + const value = key[attr]; + this.css(attr, value); + }); + return this; + } + + // 获取style样式值 + if (val === undefined) { + if (this.length === 0 || this.isText()) { + return ''; + } + const element = this.get(); + if (!element) return ''; + return ( + element.style[toCamelCase(key)] || + getComputedStyle(this[0], key) || + '' + ); + } + + this.each((node) => { + const element = node; + element.style[toCamelCase(key)] = val.toString(); + if (element.style.length === 0) element.removeAttribute('style'); + }); + return this; + } + + /** + * 获取元素节点宽度 + * @return {number} 宽度 + */ + width(): number { + let width = this.css('width'); + if (width === 'auto') { + const element = this.get()!; + width = element.offsetWidth.toString(); + } + return width ? parseFloat(width) || 0 : 0; + } + + /** + * 获取元素节点高度 + * @return {number} 高度 + */ + height(): number { + let height = this.css('height'); + if (height === 'auto') { + const element = this.get()!; + height = element.offsetHeight.toString(); + } + return height ? parseFloat(height) || 0 : 0; + } + + html(): string; + html(html: string): NodeEntry; + html(html?: string): NodeEntry | string { + if (html !== undefined) { + this.each((node) => { + (node as Element).innerHTML = html; + }); + return this; + } + return this.length > 0 ? (this[0] as Element).innerHTML : ''; + } + /** + * 获取或设置元素节点文本 + */ + text(): string; + text(text: string): NodeEntry; + text(text?: string): string | NodeEntry { + // 返回的数据包含 HTML 特殊字符,innerHTML 之前需要 escape + // https://developer.mozilla.org/en-US-US/docs/Web/API/Node/textContent + if (text !== undefined) { + this.each((node) => { + node.textContent = text; + }); + return this; + } + if (this.length === 0) return ''; + return this.get()?.textContent || ''; + } + + /** + * 设置元素节点为显示状态 + * @param {string} display display值 + * @return 当前实例 + */ + show(display?: string): NodeInterface { + if (display === undefined) { + display = this.display || ''; + } + + if (display === 'none') { + display = ''; + } + + if (this.css('display') !== 'none') { + return this; + } + + return this.css('display', display); + } + + /** + * 设置元素节点为隐藏状态 + * @return 当前实例 + */ + hide(): NodeInterface { + if (this.length === 0) { + return this; + } + + this.display = this.get()?.style.display; + return this.css('display', 'none'); + } + + /** + * 移除当前实例所有元素节点 + * @return 当前实例 + */ + remove(): NodeInterface { + this.each((node, index) => { + if (!node.parentNode) { + return; + } + node.parentNode.removeChild(node); + delete this[index]; + }); + this.length = 0; + return this; + } + + /** + * 清空元素节点下的所有子节点 + * @return 当前实例 + */ + empty(): NodeInterface { + this.each((node) => { + let child = node.firstChild; + while (child) { + if (!node.parentNode) { + return; + } + + const next = child.nextSibling; + child.parentNode?.removeChild(child); + child = next; + } + }); + return this; + } + + /** + * 比较两个元素节点是否相同 + * @param {NodeEntry|Node} node 比较的节点 + * @return {boolean} 是否相同 + */ + equal(node: NodeInterface | Node): boolean { + if (isNode(node)) return this.get() === node; + if (isNodeEntry(node)) return this.get() === node.get(); + return false; + } + + clone(deep?: boolean): NodeInterface { + const nodes: Array = []; + this.each((node) => { + nodes.push(node.cloneNode(deep)); + }); + return new NodeEntry(nodes); + } + /** + * 在元素节点的开头插入指定内容 + * @param selector 选择器或元素节点 + * @return 当前实例 + */ + prepend(selector: Selector): NodeInterface { + const nodes = $(selector, this.context); + this.each((node) => { + for (let i = nodes.length - 1; i >= 0; i--) { + const child = nodes[i]; + if (node.firstChild) { + node.insertBefore(child, node.firstChild); + } else { + node.appendChild(child); + } + } + }); + return this; + } + + /** + * 在元素节点的结尾插入指定内容 + * @param selector 选择器或元素节点 + * @return 当前实例 + */ + append(selector: Selector): NodeInterface { + const nodes = $(selector, this.context); + this.each((node) => { + for (let i = 0; i < nodes.length; i++) { + const child = nodes[i]; + if (typeof selector === 'string') { + node.appendChild(child.cloneNode(true)); + } else { + node.appendChild(child); + } + } + }); + return this; + } + + /** + * 在元素节点前插入新的节点 + * @param selector 选择器或元素节点 + * @return 当前实例 + */ + before(selector: Selector): NodeInterface { + this.each((node) => { + const nodes = $(selector, this.context); + nodes.forEach((child) => { + node.parentNode?.insertBefore(child, node); + node = child; + }); + }); + return this; + } + + /** + * 在元素节点后插入内容 + * @param selector 选择器或元素节点 + * @return 当前实例 + */ + after(selector: Selector): NodeInterface { + this.each((node) => { + const nodes = $(selector, this.context); + nodes.forEach((child) => { + if (node.nextSibling) { + node.parentNode?.insertBefore(child, node.nextSibling); + node = child; + } else { + node.parentNode?.appendChild(child); + } + }); + }); + return this; + } + + /** + * 将元素节点替换为新的内容 + * @param selector 选择器或元素节点 + * @return 当前实例 + */ + replaceWith(selector: Selector): NodeInterface { + const newNodes: Array = []; + this.each((node) => { + const nodes = $(selector, this.context); + const newNode = nodes[0]; + node.parentNode?.replaceChild(newNode, node); + newNodes.push(newNode); + }); + return new NodeEntry(newNodes); + } + + getRoot(): NodeInterface { + return this.closest(ROOT_SELECTOR); + } + + traverse( + callback: (node: NodeInterface) => boolean | void, + order: boolean = true, + includeEditableCard: boolean = false, + ) { + const walk = (node: NodeInterface) => { + let child = order ? node.first() : node.last(); + while (child) { + const next = order ? child.next() : child.prev(); + const result = callback(child); + + if (result === false) { + return; + } + + if (result !== true) { + if (child.isEditableCard() && includeEditableCard) { + const editableElements = child.find(EDITABLE_SELECTOR); + editableElements.each((_, index) => { + const editableElement = editableElements.eq(index); + if (editableElement) walk(editableElement); + }); + } else if (!child.isCard()) walk(child); + } + + child = next; + } + }; + + callback(this); + walk(this); + } + + getChildByPath(path: Path, filter?: (node: Node) => boolean): Node { + let node = this.get()!; + const getChildNodes = () => { + return filter + ? Array.from(node.childNodes).filter(filter) + : Array.from(node.childNodes); + }; + let childNodes = getChildNodes(); + for (let i = 0; path[i] !== undefined && childNodes[path[i]]; ) { + node = childNodes[path[i]]; + childNodes = getChildNodes(); + i++; + } + return node; + } + + getIndex(filter?: (node: Node) => boolean) { + const parent = this[0].parentNode; + if (!parent) return 0; + return ( + filter + ? Array.from(parent.childNodes).filter(filter) + : Array.from(parent.childNodes) + ).indexOf(this.get() as ChildNode); + } + + findParent( + container: Node | NodeInterface = this.closest(ROOT_SELECTOR), + ): NodeInterface | null { + if (isNode(container)) container = new NodeEntry(container); + if (this.length === 0 || !this.parent()) return null; + let node: NodeInterface = this; + while (!node.parent()?.equal(container)) { + if (!node.parent()) return null; + node = node.parent()!; + } + return node; + } + + allChildren(includeEditableCard: boolean = false) { + const childNodes: Array = []; + this.traverse( + (node) => { + childNodes.push(node); + }, + undefined, + includeEditableCard, + ); + childNodes.shift(); + return childNodes; + } + + getViewport(node?: NodeInterface) { + const { innerHeight, innerWidth } = this.window || { + innerHeight: 0, + innerWidth: 0, + }; + let top, left, bottom, right; + if (node && node.length > 0) { + const element = node.get()!; + const rect = element.getBoundingClientRect(); + top = rect.top; + left = rect.left; + bottom = rect.bottom; + right = rect.right; + } else { + const element = this.get()!; + const rect = element.getBoundingClientRect(); + top = rect.top; + left = rect.left; + bottom = rect.bottom; + right = rect.right; + } + return { + top, + left, + bottom: Math.min(bottom, innerHeight), + right: Math.min(right, innerWidth), + }; + } + + inViewport(node: NodeInterface, view: NodeInterface) { + let viewNode = null; + if (view.type !== getWindow().Node.ELEMENT_NODE) { + if (!view.document) return false; + viewNode = view.document.createElement('span'); + if (view.next()) { + view[0].parentNode?.insertBefore(viewNode, view[0].nextSibling); + } else { + view[0].parentNode?.appendChild(viewNode); + } + view = new NodeEntry(viewNode); + } + const viewElement = view[0] as Element; + const { top, left, right, bottom } = + viewElement.getBoundingClientRect(); + const vp = this.getViewport(node); + if (viewNode) viewNode.parentNode?.removeChild(viewNode); + return !( + top < vp.top || + bottom > vp.bottom || + left < vp.left || + right > vp.right + ); + } + + scrollIntoView( + node: NodeInterface, + view: NodeInterface, + align: 'start' | 'center' | 'end' | 'nearest' = 'nearest', + ) { + if (typeof view.document?.body.scrollIntoView === 'function') { + let viewElement = null; + if ( + view.type !== getWindow().Node.ELEMENT_NODE || + view.name.toLowerCase() === 'br' + ) { + viewElement = view.document.createElement('span'); + viewElement.innerHTML = ' '; + view[0].parentNode?.insertBefore(viewElement, view[0]); + view = new NodeEntry(viewElement); + } + if (!this.inViewport(node, view)) { + view.get()?.scrollIntoView({ + block: align, + inline: align, + }); + } + if (viewElement) viewElement.parentNode?.removeChild(viewElement); + } + } +} +export default NodeEntry; diff --git a/packages/engine/src/node/event.ts b/packages/engine/src/node/event.ts new file mode 100644 index 00000000..681fac56 --- /dev/null +++ b/packages/engine/src/node/event.ts @@ -0,0 +1,67 @@ +import { EventInterface, EventListener } from '../types/node'; + +/** + * 事件 + */ +class Event implements EventInterface { + readonly listeners: { [x: string]: EventListener[] } = {}; + + /** + * 绑定事件 + * @param {string} eventType 事件名称 + * @param {Function} listener 事件处理方法 + * @param {boolean} rewrite 是否重写事件 + */ + on(eventType: string, listener: EventListener, rewrite?: boolean): void { + if (!this.listeners[eventType] || rewrite) { + this.listeners[eventType] = []; + } + + this.listeners[eventType].push(listener); + } + + /** + * 解除绑定 + * @param {string} eventType + * @param listener + */ + off(eventType: string, listener: EventListener) { + const listeners = this.listeners[eventType]; + + if (!listeners) { + return; + } + + for (let i = 0; i < listeners.length; i++) { + if (listeners[i] === listener) { + listeners.splice(i, 1); + break; + } + } + } + + /** + * 触发事件 + * @param eventType 事件类型 + * @param args 事件参数 + */ + trigger(eventType: string, ...args: any) { + const listeners = this.listeners[eventType]; + if (listeners) { + let result; + + for (var i = 0; i < listeners.length; i++) { + result = listeners[i](...args); + + if (result === false) { + break; + } + } + + return result; + } + return; + } +} + +export default Event; diff --git a/packages/engine/src/node/hash.ts b/packages/engine/src/node/hash.ts new file mode 100644 index 00000000..21ceb4dc --- /dev/null +++ b/packages/engine/src/node/hash.ts @@ -0,0 +1,72 @@ +import md5 from 'blueimp-md5'; +import { NodeInterface } from '../types'; +import $ from '../node/query'; +import { DATA_ID } from '../constants'; +import { isNode, isNodeEntry } from './utils'; + +const _counters: { [key: string]: number } = {}; + +export const uuid = (len: number, radix: number = 16): string => { + const chars = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split( + '', + ); + let uuid = [], + i; + radix = radix || chars.length; + if (radix > chars.length) { + radix = chars.length; + } + if (len) { + // Compact form + for (i = 0; i < len; i++) uuid[i] = chars[0 | (Math.random() * radix)]; + } else { + // rfc4122, version 4 form + let r; + + // rfc4122 requires these characters + uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-'; + uuid[14] = '4'; + + // Fill in random data. At i==19 set the high bits of clock sequence as + // per rfc4122, sec. 4.1.5 + for (i = 0; i < 36; i++) { + if (!uuid[i]) { + r = 0 | (Math.random() * 16); + uuid[i] = chars[i == 19 ? (r & 0x3) | 0x8 : r]; + } + } + } + + return uuid.join(''); +}; + +export default ( + value: string | NodeInterface | Node, + unique: boolean = true, +) => { + let prefix = ''; + if (isNode(value)) value = $(value); + if (isNodeEntry(value)) { + const attributes = value.attributes(); + const styles = attributes['style']; + delete attributes['style']; + delete attributes[DATA_ID]; + prefix = value.name.substring(0, 1); + value = `${value.name}_${Object.keys(attributes || {}).join( + ',', + )}_${Object.values(attributes || {}).join(',')}_${Object.keys( + styles || {}, + ).join(',')}_${Object.values(styles || {}).join(',')}`; + } + const md5Value = md5(value); + let hash = + prefix + md5Value.substr(0, 4) + md5Value.substr(md5Value.length - 3); + if (unique) { + const counter = _counters[hash] || 0; + _counters[hash] = counter + 1; + hash = `${hash}-${uuid(8, 48 + _counters[hash])}`; + } + + return hash; +}; diff --git a/packages/engine/src/node/id.ts b/packages/engine/src/node/id.ts new file mode 100644 index 00000000..8e84f323 --- /dev/null +++ b/packages/engine/src/node/id.ts @@ -0,0 +1,155 @@ +import hashId from './hash'; +import { + EditorInterface, + NodeIdInterface, + NodeInterface, + SchemaRule, +} from '../types'; +import $ from './query'; +import { + CARD_SELECTOR, + DATA_ELEMENT, + DATA_ID, + UI, + UI_SELECTOR, +} from '../constants'; +import { getParentInRoot } from '../utils'; +import { isNode, isNodeEntry } from './utils'; + +class NodeId implements NodeIdInterface { + editor: EditorInterface; + #rules: { [key: string]: SchemaRule[] } = {}; + constructor(editor: EditorInterface) { + this.editor = editor; + } + + init() { + this.#rules = this.getRules(); + } + + /** + * 根据规则获取需要为节点创建 data-id 的标签名称集合 + * @returns + */ + getRules() { + const rules: { [key: string]: SchemaRule[] } = {}; + this.editor.schema.data.blocks.forEach((schema) => { + if (!Object.keys(rules).includes(schema.name)) { + rules[schema.name] = []; + } + rules[schema.name].push(schema); + }); + return rules; + } + + /** + * 给节点创建data-id + * @param node 节点 + * @returns + */ + create(node: Node | NodeInterface) { + if (isNode(node)) node = $(node); + const id = hashId(node); + node.attributes(DATA_ID, id); + return id; + } + + /** + * 在根节点内为需要创建data-id的子节点创建data-id + * @param root 根节点 + */ + generateAll( + root: Element | NodeInterface | DocumentFragment = this.editor + .container, + force: boolean = false, + ) { + const rules = this.#rules; + const tagNames = Object.keys(rules).join(','); + if (isNodeEntry(root) && root.fragment) { + root = root.fragment; + } + const nodes = + (isNode(root) ? root : root.get())?.querySelectorAll( + tagNames, + ) || []; + nodes.forEach((child) => { + const node = $(child); + // 有ID不再生成 + if (node.attributes(DATA_ID)) return; + + this.generate(node, force); + }); + } + /** + * 为节点创建一个随机data-id + * @param node 节点 + * @param isCreate 如果有,是否需要重新创建 + * @returns + */ + generate(node: Node | NodeInterface, force: boolean = false) { + if (isNode(node)) node = $(node); + const rules = this.#rules; + // 不符合规则 + const nodeRules = rules[node.name]; + if ( + (nodeRules && nodeRules.length === 0) || + !nodeRules.some((rule) => + this.editor.schema.checkNode( + node as NodeInterface, + rule.attributes, + ), + ) + ) { + return; + } + // 检测节点是否再ui或者不可编辑卡片内 + const closestNode = node.closest( + `${CARD_SELECTOR},${UI_SELECTOR}`, + getParentInRoot, + ); + // ui 节点内 + if ( + closestNode.length > 0 && + closestNode.attributes(DATA_ELEMENT) === UI + ) { + return; + } + // 卡片内 + if ( + !node.isCard() && + closestNode.length > 0 && + closestNode.isCard() && + !closestNode.isEditableCard() + ) { + return; + } + if (!force) { + const id = node.attributes(DATA_ID); + if (id) return id; + } + return this.create(node); + } + /** + * 判断一个节点是否需要创建data-id + * @param name 节点名称 + * @returns + */ + isNeed(node: NodeInterface) { + const rules = this.#rules; + // 不符合规则 + const nodeRules = rules[node.name]; + if ( + nodeRules.length === 0 || + !nodeRules.some((rule) => + this.editor.schema.checkNode( + node as NodeInterface, + rule.attributes, + ), + ) + ) { + return false; + } + return true; + } +} +export default NodeId; diff --git a/packages/engine/src/node/index.ts b/packages/engine/src/node/index.ts new file mode 100644 index 00000000..413c6028 --- /dev/null +++ b/packages/engine/src/node/index.ts @@ -0,0 +1,999 @@ +import Entry from './entry'; +import Event from './event'; +import { + NodeInterface, + NodeModelInterface, + EditorInterface, + PluginEntry, + SchemaInterface, + SchemaBlock, + RangeInterface, +} from '../types'; +import { + ANCHOR, + CARD_ELEMENT_KEY, + CARD_KEY, + CARD_SELECTOR, + CURSOR, + DATA_ELEMENT, + EDITABLE_SELECTOR, + FOCUS, + READY_CARD_KEY, + READY_CARD_SELECTOR, +} from '../constants'; +import { getDocument, getStyleMap, getWindow, isEngine } from '../utils'; +import $ from './query'; +import getHashId from './hash'; +import { isNode, isNodeEntry } from './utils'; + +class NodeModel implements NodeModelInterface { + private editor: EditorInterface; + constructor(editor: EditorInterface) { + this.editor = editor; + } + + isVoid( + node: NodeInterface | Node | string, + schema: SchemaInterface = this.editor.schema, + ) { + let name = typeof node === 'string' ? node : ''; + if (isNode(node)) name = node.nodeName.toLowerCase(); + else if (isNodeEntry(node)) name = node.name; + return schema + .find((rule) => rule.name === name) + .some((rule) => rule.isVoid); + } + + isMark( + node: NodeInterface | Node, + schema: SchemaInterface = this.editor.schema, + ) { + if (isNode(node)) node = $(node); + return schema.getType(node) === 'mark'; + } + + /** + * 是否是inline标签 + * @param node 节点 + */ + isInline( + node: NodeInterface | Node, + schema: SchemaInterface = this.editor.schema, + ) { + if (isNode(node)) node = $(node); + return schema.getType(node) === 'inline'; + } + + /** + * 是否是块级节点 + * @param node 节点 + */ + isBlock( + node: NodeInterface | Node, + schema: SchemaInterface = this.editor.schema, + ) { + if (isNode(node)) node = $(node); + return schema.getType(node) === 'block'; + } + + /** + * 判断block节点的子节点是否不包含blcok 节点 + */ + isNestedBlock(node: NodeInterface) { + if (!this.isBlock(node)) return false; + let child = node.first(); + while (child) { + if (this.isBlock(child)) return false; + child = child.next(); + } + return true; + } + + /** + * 判断节点是否是顶级根节点,父级为编辑器根节点,且,子级节点没有block节点 + * @param node 节点 + * @returns + */ + isRootBlock(node: NodeInterface, schema?: SchemaInterface) { + //父级不是根节点 + if (!node.parent()?.isEditable()) return false; + if (!this.isNestedBlock(node)) return false; + //并且规则上不可以设置子节点 + return (schema || this.editor.schema) + .find((schema) => schema.name === node.name) + .every((schema) => { + if (schema.type !== 'block') return false; + const allowIn = (schema as SchemaBlock).allowIn; + if (!allowIn) return true; + return allowIn.indexOf('$root') > -1; + }); + } + /** + * 判断节点下的文本是否为空 + * @param withTrim 是否 trim + */ + isEmpty(node: NodeInterface, withTrim?: boolean) { + if (node.isElement()) { + // 卡片不为空 + if ( + node.attributes(CARD_KEY) || + (node.find(CARD_SELECTOR).length > 0 && + node.find(EDITABLE_SELECTOR).length === 0) + ) { + return false; + } + // 只读卡片不为空 + if ( + node.attributes(READY_CARD_KEY) || + (node.find(READY_CARD_SELECTOR).length > 0 && + node.find(EDITABLE_SELECTOR).length === 0) + ) { + return false; + } + // 非br节点的空节点不为空 + if (node.name !== 'br' && this.isVoid(node)) { + return false; + } + // 多个br节点不为空 + if (node.find('br').length > 1) { + return false; + } + } + + let value = node.isText() ? node[0].nodeValue || '' : node.text(); + value = value?.replace(/\u200B/g, ''); + value = value?.replace(/\r\n|\n/, ''); + + if (value && withTrim) { + value = value.trim(); + } + + return value === ''; + } + + /** + * 判断一个节点下的文本是否为空,或者只有空白字符 + */ + isEmptyWithTrim(node: NodeInterface) { + return this.isEmpty(node, true); + } + /** + * 判断节点包括子节点是否为空 + * @param node 节点 + * @returns + */ + isEmptyWidthChild(node: NodeInterface) { + if (node.length === 0) return true; + const { childNodes } = node[0]; + if (childNodes.length === 0) return true; + for (let i = 0; i < childNodes.length; i++) { + const child = childNodes[i]; + if (child.nodeType === getWindow().Node.TEXT_NODE) { + if (child['data'].replace(/\u200b/g, '') !== '') return false; + } else if (child.nodeType === getWindow().Node.ELEMENT_NODE) { + if ( + child.nodeName.toLowerCase() === 'li' && + !this.editor.list.isEmptyItem($(child)) + ) + return false; + if ((child as Element).hasAttribute(CARD_KEY)) return false; + if (!this.isEmptyWidthChild($(child))) { + return false; + } + } + } + return true; + } + /** + * 判断节点是否为列表节点 + * @param node 节点或者节点名称 + */ + isList(node: NodeInterface | string | Node) { + let name = typeof node === 'string' ? node : ''; + if (isNode(node)) name = node.nodeName.toLowerCase(); + else if (isNodeEntry(node)) name = node.name; + return ['ul', 'ol'].indexOf(name) > -1; + } + + /** + * 判断节点是否是自定义列表 + * @param node 节点 + */ + isCustomize(node: NodeInterface) { + const { list } = this.editor; + switch (node.name) { + case 'li': + return node.hasClass(list.CUSTOMZIE_LI_CLASS); + + case 'ul': + return node.hasClass(list.CUSTOMZIE_UL_CLASS); + + default: + return false; + } + } + /** + * 去除包裹 + * @param node 需要去除包裹的节点 + */ + unwrap(node: NodeInterface) { + let child = node.first(); + const nodes: NodeInterface[] = []; + while (child) { + const next = child.next(); + node.before(child); + nodes.push(child); + child = next; + } + node.remove(); + return nodes; + } + /** + * 包裹节点 + * @param source 需要包裹的节点 + * @param outer 包裹的外部节点 + * @param mergeSame 合并相同名称的节点样式和属性在同一个节点上 + */ + wrap( + source: NodeInterface | Node, + outer: NodeInterface, + mergeSame: boolean = false, + ) { + const { node, mark } = this.editor; + if (isNode(source)) source = $(source); + outer = node.clone(outer, false); + // 文本节点 + if (source.isText()) { + outer.append(node.clone(source, false)); + return source.replaceWith(outer); + } + + // 包裹样式节点 + if (mergeSame && this.isMark(outer)) { + //合并属性和样式值 + const outerClone = node.clone(outer, false); + if (source.name === outer.name) { + const attributes = source.attributes(); + delete attributes.style; + Object.keys(attributes).forEach((key) => { + if (!outer.attributes(key)) + outer.attributes(key, attributes[key]); + else { + const attributes = outer.attributes(key).split(','); + if (attributes.indexOf(attributes[key]) < 0) + attributes.push(attributes[key]); + outer.attributes(key, attributes.join(',')); + } + }); + + const styles = source.css(); + Object.keys(styles).forEach((key) => { + if (!outer.css(key)) outer.css(key, styles[key]); + }); + outer.append(node.clone(source, true).children()); + } else { + outer.append(node.clone(source, true)); + } + + const children = outer.allChildren(); + children.forEach((child) => { + if ( + !child.isText() && + this.isMark(child) && + mark.compare(child, outerClone) + ) { + this.unwrap(child); + } + }); + return source.replaceWith(outer); + } + // 其它情况 + const shadowNode = node.clone(source, false); + source.after(shadowNode); + outer.append(source); + return shadowNode.replaceWith(outer); + } + + /** + * 合并节点 + * @param source 合并的节点 + * @param target 需要合并的节点 + * @param remove 合并后是否移除 + */ + merge( + source: NodeInterface, + target: NodeInterface, + remove: boolean = true, + ) { + //要合并的节点是文本,就直接追加 + if (target.isText()) { + source.append(target); + this.removeSide(source); + return; + } + const { block, mark, list } = this.editor; + let mergedNode = target; + const toIsList = this.isList(source); + const fromIsList = this.isList(target.name); + // p 与列表合并时需要做特殊处理 + if (toIsList && !fromIsList) { + const liBlocks = source.find('li'); + //没有li标签 + if (liBlocks.length === 0) { + return; + } + //设置被合并节点为最后一个li标签 + source = $(liBlocks[liBlocks.length - 1]); + } + //被合并的节点为列表 + if (!toIsList && fromIsList) { + //查找li节点 + const liBlocks = target.find('li'); + if (liBlocks.length > 0) { + //设置需要合并的节点为第一个li节点 + target = $(liBlocks[0]); + } + if (liBlocks[1]) { + mergedNode = $(liBlocks[0]); + } + } + // 自定义列表合并 + if (this.isCustomize(source)) { + // 源节点如果还有card节点, + let sourceFirst = source.first(); + if (!sourceFirst?.isCard()) { + // 源节点没有卡片节点就添加 + const plugins = list.getPlugins(); + const pluginName = list.getPluginNameByNode(source); + const plugin = plugins.find( + (p) => + (p.constructor as PluginEntry).pluginName === + pluginName, + ); + if (plugin?.cardName) { + list.addCardToCustomize(source, plugin.cardName); + sourceFirst = source.first(); + } + } + // 源节点卡片名称与目标节点卡片一样就删除目标节点的第一个卡片节点 + if (this.isCustomize(target)) { + const targetFirst = target.first(); + if ( + targetFirst?.isCard() && + sourceFirst!.attributes(CARD_KEY) === + targetFirst.attributes(CARD_KEY) + ) + targetFirst.remove(); + } + } + //被合并的节点最后一个子节点为br,则移除 + const toNodeLast = source.last(); + let child = target.first(); + const plugin = block.findPlugin(source); + //循环追加 + while (child) { + const next = child.next(); + const markPlugin = mark.findPlugin(child); + if ( + plugin && + markPlugin && + plugin.disableMark && + plugin.disableMark!.indexOf( + (markPlugin.constructor as PluginEntry).pluginName, + ) > -1 + ) { + const result = this.unwrap(child!); + result.forEach((children) => { + source.append(children); + }); + } + // 孤立的零宽字符删除 + else if (child.isText() && /\u200b/.test(child.text())) { + const parent = child.parent(); + const prev = child.prev(); + const next = child.next(); + // 不在mark里面,或者没有父级节点,它的上级节点或者下级节点不是inline + if ( + !parent || + (!this.isMark(parent) && + ((prev && !this.isInline(prev)) || + (next && !this.isInline(next)))) + ) { + child.remove(); + child = next; + continue; + } + } + // 移除mark插件下面的所有零宽字符 + else if (markPlugin && child.children().length === 1) { + const prev = child.prev(); + if (!prev || prev.isText()) { + child.allChildren().forEach((child) => { + const text = child.text(); + if (child.type === getDocument().TEXT_NODE && !!text) { + child.text(text.replace(/\u200b/, '')); + } + }); + } + } + + //追加到要合并的列表中 + if (child.length > 0 && !child.equal(source)) source.append(child); + child = next; + } + //移除需要合并的节点 + if (remove) mergedNode.remove(); + + if (toNodeLast && toNodeLast.name === 'br') { + let next = toNodeLast.next(); + while (next) { + if ( + [CURSOR, ANCHOR, FOCUS].indexOf( + next.attributes(DATA_ELEMENT), + ) + ) { + toNodeLast.remove(); + break; + } + next = next.next(); + } + } + this.removeSide(source); + } + + /** + * 将源节点的子节点追加到目标节点,并替换源节点 + * @param source 旧节点 + * @param target 新节点 + */ + replace(source: NodeInterface, target: NodeInterface) { + const clone = this.clone(target, false, false); + let childNode = + this.isCustomize(source) && + source.name === 'li' && + source.first()?.isCard() + ? source.first()?.next() + : source.first(); + + while (childNode) { + const nextNode = childNode.next(); + clone.append(childNode); + childNode = nextNode; + } + + return source.replaceWith(clone); + } + + /** + * 光标位置插入文本 + * @param text 文本 + * @param range 光标 + */ + insertText(text: string, range?: RangeInterface) { + if (!isEngine(this.editor)) return; + const { change } = this.editor; + const safeRange = range || change.range.toTrusty(); + + const doc = getDocument(safeRange.startContainer); + // 范围为折叠状态时先删除内容 + if (!safeRange.collapsed) { + change.delete(range); + } + const node = doc.createTextNode(text); + this.insert(node, safeRange)?.handleBr(); + if (!range) change.apply(safeRange); + return safeRange; + } + + /** + * 在光标位置插入一个节点 + * @param node 节点 + * @param range 光标 + */ + insert(node: Node | NodeInterface, range?: RangeInterface) { + if (isNodeEntry(node)) { + if (node.length === 0) throw 'Not found node'; + node = node[0]; + } + const editor = this.editor; + if (!isEngine(editor)) return; + const { change, block, schema, mark } = editor; + range = range || change.range.get(); + const nodeApi = editor.node; + const { startNode, startOffset } = range + .cloneRange() + .shrinkToTextNode(); + const prev = startNode.prev(); + const parent = startNode.parent(); + let text = startNode.text() || ''; + const leftText = text.substr(0, startOffset); + //文本节点 + if (startNode.isText() && /\u200b$/.test(leftText)) { + //零宽字符前面还有其它字符。或者节点前面还有节点,不能是inline节点。或者前面没有节点了,并且父级不是inline节点 + if ( + text.length > 1 || + (prev && !nodeApi.isInline(prev)) || + (!prev && parent && !nodeApi.isInline(parent)) + ) { + startNode + .get()! + .splitText(text.length - 1) + .remove(); + } + } + // 检测是否位于卡片两边节点 + const elementType = parent?.attributes(CARD_ELEMENT_KEY); + if (parent && elementType && ['left', 'right'].includes(elementType)) { + const cardComponent = editor.card.find(parent); + if (cardComponent) { + if (elementType === 'left') { + range.setStartBefore(cardComponent.root); + } else { + range.setStartAfter(cardComponent.root); + } + } + } + + if (nodeApi.isBlock(node)) { + const splitNode = block.split(range); + let blockNode = block.closest( + range.startNode.isEditable() + ? range + .cloneRange() + .shrinkToElementNode() + .shrinkToTextNode().startNode + : range.startNode, + ); + if ( + !blockNode.isCard() && + schema.isAllowIn(blockNode.name, node.nodeName.toLowerCase()) + ) { + blockNode.find('br').remove(); + blockNode.append(node); + } else { + let parentBlock = blockNode.parent(); + while ( + parentBlock && + this.isBlock(parentBlock) && + !blockNode.isEditable() && + !schema.isAllowIn( + parentBlock.name, + node.nodeName.toLowerCase(), + ) + ) { + blockNode = parentBlock; + parentBlock = blockNode.parent(); + } + if ( + blockNode.isEditable() && + blockNode.children().length === 0 + ) { + blockNode.append(node); + } else { + if ( + this.isEmptyWidthChild(blockNode) || + block.isLastOffset(range, 'start') + ) { + blockNode.after(node); + // 没有分割就不会有新增的行就不用删除 + if (this.isEmptyWidthChild(blockNode) && splitNode) + blockNode.remove(); + } else { + blockNode.before(node); + } + } + } + } else { + const targetNode = block.closest( + range.startNode.isEditable() + ? range + .cloneRange() + .shrinkToElementNode() + .shrinkToTextNode().startNode + : range.startNode, + ); + const targetPlugin = targetNode + ? block.findPlugin(targetNode) + : undefined; + //先移除不能放入块级节点的mark标签 + if (targetPlugin) { + const nodeDom = $(node); + const isUnwrap = (markNode: NodeInterface) => { + if (this.isMark(markNode)) { + const markPlugin = mark.findPlugin(markNode); + if (!markPlugin) return; + if ( + targetPlugin.disableMark && + targetPlugin.disableMark.indexOf( + (markPlugin.constructor as PluginEntry) + .pluginName, + ) > -1 + ) { + return true; + } + } + return false; + }; + nodeDom.allChildren().forEach((markNode) => { + if (isUnwrap(markNode)) { + this.unwrap(markNode); + } + }); + if (isUnwrap(nodeDom)) { + const fragment = nodeDom.document!.createDocumentFragment(); + nodeDom.children().each((child) => { + fragment.append(child); + }); + nodeDom.remove(); + node = fragment.childNodes[fragment.childNodes.length - 1]; + range.insertNode(fragment); + } else range.insertNode(node); + if (nodeDom.length === 0) return range; + } else range.insertNode(node); + } + return range + .select( + node, + this.isVoid(node) || node.nodeType === Node.TEXT_NODE + ? false + : true, + ) + .shrinkToElementNode() + .collapse(false); + } + + /** + * 设置节点属性 + * @param node 节点 + * @param props 属性 + */ + setAttributes(node: NodeInterface, attrs: any) { + let { style, ...attributes } = attrs; + Object.keys(attributes).forEach((key) => { + if (key === 'className') { + const value = attributes[key]; + if (Array.isArray(value)) { + value.forEach((name) => node.addClass(name)); + } else node.addClass(value); + } else node.attributes(key, attributes[key].toString()); + }); + if (typeof style === 'number') style = {}; + if (typeof style === 'string') style = getStyleMap(style); + style = style || {}; + const keys = Object.keys(style); + keys.forEach((key) => { + let val = (<{ [k: string]: string | number }>style)[key]; + if (/^0(px|em)?$/.test(val.toString())) { + val = ''; + } + + node.css(key, val.toString()); + }); + + if (keys.length === 0 || Object.keys(node.css()).length === 0) { + node.removeAttributes('style'); + } + + return node; + } + + /** + * 移除值为负的样式 + * @param node 节点 + * @param style 样式名称 + */ + removeMinusStyle(node: NodeInterface, style: string) { + if (this.isBlock(node)) { + const val = parseInt(node.css(style), 10) || 0; + if (val < 0) node.css(style, ''); + } + } + + /** + * 合并节点下的子节点,两个相同的相邻节点的子节点 + * @param node 当前节点 + */ + mergeChild(node: NodeInterface) { + const { schema, list } = this.editor; + const topTags = schema.getAllowInTags(); + //获取第一个子节点 + let childDom: NodeInterface | null = node.first(); + //遍历全部子节点 + while (childDom) { + //获取下一个子节点 + let nextNode = childDom.next(); + while ( + //如果下一个子节点不为空,并且与上一个子节点名称一样 + nextNode && + childDom.name === nextNode.name && + //并且上一个节点是可拥有block子节点的节点 或者是 ul、li 并且list列表类型是一致的 + ((topTags.indexOf(childDom.name) > -1 && + !this.isList(childDom)) || + (this.isList(childDom) && list.isSame(childDom, nextNode))) + ) { + //获取下一个节点的下一个节点 + const nNextNode = nextNode.next(); + //合并下一个节点 + let nextChildNode = nextNode.first(); + //循环要合并节点的子节点 + while (nextChildNode) { + const next = nextChildNode.next(); + childDom.append(nextChildNode); + nextChildNode = next; + } + nextNode.remove(); + //继续合并当前子节点的子节点 + this.mergeChild(childDom); + nextNode = nNextNode; + } + childDom = nextNode; + } + } + /** + * 删除节点两边标签 + * @param node 节点 + * @param tagName 标签名称,默认为br标签 + */ + removeSide(node: NodeInterface, tagName: string = 'br') { + // 删除第一个 BR + const firstNode = node.first(); + if ( + firstNode?.name === tagName && + node + .children() + .toArray() + .filter((node) => !node.isCursor()).length > 1 + ) { + firstNode.remove(); + } + // 删除最后一个 BR + const lastNode = node.last(); + if ( + lastNode?.name === tagName && + node + .children() + .toArray() + .filter((node) => !node.isCursor()).length > 1 + ) { + lastNode.remove(); + } + } + /** + * 扁平化节点 + * @param node 节点 + * @param root 根节点 + */ + flat(node: NodeInterface, root: NodeInterface = node) { + const { block } = this.editor; + //第一个子节点 + let childNode = node.first(); + const rootElement = root.fragment ? root.fragment : root.get(); + const tempNode = node.fragment ? $('

      ') : this.clone(node, false); + while (childNode) { + //获取下一个兄弟节点 + let nextNode = childNode.next(); + //如果当前子节点是块级的Card组件,或者是简单的block + if (childNode.isBlockCard() || this.isNestedBlock(childNode)) { + block.flat(childNode, $(rootElement || [])); + } + //如果当前是块级标签,递归循环 + else if (this.isBlock(childNode)) { + childNode = this.flat(childNode, $(rootElement || [])); + } else { + const cloneNode = this.clone(tempNode, false); + const isLI = 'li' === cloneNode.name; + childNode.before(cloneNode); + while (childNode) { + nextNode = childNode.next(); + + const isBR = 'br' === childNode.name && !isLI; + if (isBR && childNode.parent()?.isRoot()) { + cloneNode.append(childNode); + } + //判断当前节点末尾是否是换行符,有换行符就跳出 + if (childNode.isText()) { + let text = childNode.text(); + //先移除开头的换行符 + let match = /^((\n|\r)+)/.exec(text); + let isBegin = false; + if (match) { + text = text.substring(match[1].length); + isBegin = true; + if (text.length === 0) { + childNode.remove(); + break; + } + } + //移除末尾换行符 + match = /((\n|\r)+)$/.exec(text); + if (match) { + childNode.text(text.substr(0, match.index)); + cloneNode.append(childNode); + break; + } else if (isBegin) { + childNode.text(text); + } + } + cloneNode.append(childNode); + //判断下一个节点的开头是换行符,有换行符就跳出 + if (nextNode?.isText()) { + const text = nextNode.text(); + let match = /^(\n|\r)+/.exec(text); + if (match) { + break; + } + } + if ( + isBR || + !nextNode || + this.isBlock(nextNode) || + nextNode.isBlockCard() + ) + break; + + childNode = nextNode; + } + this.removeSide(cloneNode); + block.flat(cloneNode, $(rootElement || [])); + if ( + cloneNode.name === 'p' && + cloneNode + .children() + .toArray() + .filter((node) => !node.isCursor()).length === 0 + ) { + cloneNode.append($('
      ')); + } + } + if ( + childNode.name === 'p' && + childNode + .children() + .toArray() + .filter((node) => !node.isCursor()).length === 0 + ) { + childNode.append($('
      ')); + } + this.removeSide(childNode); + childNode = nextNode; + } + // 重新更新框架的引用 + if (node.fragment) { + node = $(node.fragment); + } + //如果没有子节点了,就移除当前这个节点 + childNode = node.first(); + if (!childNode) node.remove(); + return node; + } + + /** + * 标准化节点 + * @param node 节点 + */ + normalize(node: NodeInterface) { + node = this.flat(node); + this.mergeChild(node); + return node; + } + + /** + * 获取或设置元素节点html文本 + * @param {string|undefined} val html文本 + * @return {NodeEntry|string} 当前实例或html文本 + */ + html(node: NodeInterface): string; + html(node: NodeInterface, val: string): NodeInterface; + html(node: NodeInterface, val?: string): NodeInterface | string { + if (val === undefined) { + return node.length > 0 + ? node.get()?.innerHTML || '' + : ''; + } + + node.each((node) => { + const element = node; + element.innerHTML = val; + this.editor.nodeId.generateAll(element); + }); + return node; + } + + /** + * 复制元素节点 + * @param {boolean} deep 是否深度复制 + * @return 复制后的元素节点 + */ + clone( + node: NodeInterface, + deep?: boolean, + copyId: boolean = false, + ): NodeInterface { + const { nodeId } = this.editor; + const nodes: Array = []; + node.each((node) => { + const cloneNode = node.cloneNode(deep); + const nodeDom = $(cloneNode); + if (copyId) { + nodeId.generateAll(nodeDom, true); + if (nodeId.isNeed(nodeDom)) { + nodeId.generate(nodeDom, true); + } + } + nodes.push(cloneNode); + }); + return $(nodes); + } + + /** + * 获取批量追加子节点后的outerHTML + * @param nodes 节点集合 + * @param selector 追加的节点 + */ + getBatchAppendHTML(nodes: Array, selector: string) { + if (nodes.length === 0) return selector; + let appendNode = + selector.startsWith('\\u') || selector.startsWith('&#') + ? $(selector, null) + : $(selector); + nodes.forEach((node) => { + node = node.clone(false); + node.append(appendNode); + appendNode = node; + }); + return appendNode.get()?.outerHTML || ''; + } + + removeZeroWidthSpace(node: NodeInterface) { + node.traverse((child) => { + const node = child[0]; + if (node.nodeType !== getWindow().Node.TEXT_NODE) { + return; + } + const text = node.nodeValue; + if (text?.length !== 2) { + return; + } + const next = node.nextSibling; + const prev = node.previousSibling; + if ( + text.charCodeAt(1) === 0x200b && + next && + next.nodeType === getWindow().Node.ELEMENT_NODE && + [ANCHOR, FOCUS, CURSOR].indexOf( + (next).getAttribute(DATA_ELEMENT) || '', + ) >= 0 + ) { + return; + } + + const parent = child.parent(); + + if ( + text.charCodeAt(1) === 0x200b && + ((!next && parent && this.isInline(parent)) || + (next && this.isInline(next))) + ) { + return; + } + + if ( + text.charCodeAt(0) === 0x200b && + ((!prev && parent && this.isInline(parent)) || + (prev && this.isInline(prev))) + ) { + return; + } + + if (text.charCodeAt(0) === 0x200b) { + const newNode = (node).splitText(1); + if (newNode.previousSibling) + newNode.parentNode?.removeChild(newNode.previousSibling); + } + }); + } +} + +export default NodeModel; + +export { Entry as NodeEntry, Event, $, getHashId }; diff --git a/packages/engine/src/node/parse.ts b/packages/engine/src/node/parse.ts new file mode 100644 index 00000000..6d4fe650 --- /dev/null +++ b/packages/engine/src/node/parse.ts @@ -0,0 +1,90 @@ +import { Selector, Context } from '../types/node'; +import { getDocument, getWindow } from '../utils/node'; +import { isNode, isNodeEntry, isNodeList } from '../node/utils'; + +/** + * 解析节点 + * @param selector 选择器 + * @param isSpecialText 是否为特殊文本比如 \u200b 0宽字符 + * @param context 上下文节点,默认使用 getDocument 获取document + */ +function domParser( + selector: Selector, + context?: Context | null | false, +): NodeList | Array { + if (!selector) return []; + //文本字符串 + if (typeof selector === 'string') { + //特殊字符,或者html代码 + if (!context || /<.+>/.test(selector)) { + const isTr = selector.indexOf(']*-->/g, ''); + /** + * 无法单独解析 tr、td 标签,如果有tr、td标签这里需要补充 table 节点的结构 + */ + if (isTr) { + selector = ''.concat( + selector, + '
      ', + ); + } + + if (isTd) { + selector = ''.concat( + selector, + '
      ', + ); + } + //创建一个空节点,用来包裹需要生成的节点 + const container = getDocument().createElement('div'); + container.innerHTML = selector; + + if (isTr) { + const tbody = container.querySelector('tbody'); + return tbody ? tbody.childNodes : []; + } + + if (isTd) { + const tr = container.querySelector('tr'); + return tr ? tr.childNodes : []; + } + // 返回解析后的所有子级 + return container.childNodes; + } + //默认根据选择器查询所有 + return context.querySelectorAll(selector); + } + //类型为 NodeList ,node数组 直接返回 + if (isNodeList(selector) || Array.isArray(selector)) { + return selector; + } + + //类型为 DOMNode 类型 + if (isNodeEntry(selector)) { + const nodes: Array = []; + selector.each((node) => { + nodes.push(node); + }); + return nodes; + } + // 片段 + if ( + isNode(selector) && + selector.nodeType === getWindow().Node.DOCUMENT_FRAGMENT_NODE + ) { + const nodes: Node[] = []; + let node = selector.firstChild; + while (node) { + nodes.push(node); + node = node.nextSibling; + } + + return nodes; + } + // 其他 + return [selector as Node]; +} + +export default domParser; diff --git a/packages/engine/src/node/query.ts b/packages/engine/src/node/query.ts new file mode 100644 index 00000000..808fdac4 --- /dev/null +++ b/packages/engine/src/node/query.ts @@ -0,0 +1,27 @@ +import Node from './entry'; +import { NodeInterface, Selector, Context, NodeEntry } from '../types/node'; +import { getDocument, getWindow } from '../utils/node'; +import Parse from './parse'; +import { isNode } from './utils'; + +/** + * 查询节点返回NodeInterface + * @param selector 选择器 + * @param context 节点上下文,或根节点 + * @param nodeConstructor 需要使用的模型,默认 DOMNOde + */ +export default ( + selector: Selector, + context?: Context | null | false, + clazz?: NodeEntry, +): NodeInterface => { + if (context === undefined) context = getDocument(); + const nodes = Parse(selector, context); + const entry = new (clazz || Node)(nodes, context ? context : undefined); + if ( + isNode(selector) && + selector.nodeType === getWindow().Node.DOCUMENT_FRAGMENT_NODE + ) + entry.fragment = selector as DocumentFragment; + return entry; +}; diff --git a/packages/engine/src/node/utils.ts b/packages/engine/src/node/utils.ts new file mode 100644 index 00000000..e9ae16af --- /dev/null +++ b/packages/engine/src/node/utils.ts @@ -0,0 +1,13 @@ +import { NodeInterface, Selector } from '../types'; + +export const isNodeEntry = (selector: Selector): selector is NodeInterface => { + return !!selector && (selector as NodeInterface).get !== undefined; +}; + +export const isNodeList = (selector: Selector): selector is NodeList => { + return !!selector && (selector as NodeList).entries !== undefined; +}; + +export const isNode = (selector: Selector): selector is Node => { + return !!selector && (selector as Node).nodeType !== undefined; +}; diff --git a/packages/engine/src/ot/consumer.ts b/packages/engine/src/ot/consumer.ts new file mode 100644 index 00000000..27c31fda --- /dev/null +++ b/packages/engine/src/ot/consumer.ts @@ -0,0 +1,544 @@ +import Range from '../range'; +import { unescapeDots, unescape } from '../utils/string'; +import { JSON0_INDEX } from '../constants/ot'; +import { EngineInterface } from '../types/engine'; +import { Op, Path, StringInsertOp } from 'sharedb'; +import { + ConsumerInterface, + RemoteAttr, + RemotePath, + TargetOp, +} from '../types/ot'; +import { NodeInterface } from '../types/node'; +import { getDocument, getWindow } from '../utils'; +import { isCursorOp, isTransientElement, updateIndex, toDOM } from './utils'; +import { $ } from '../node'; +import { DATA_ID, EDITABLE_SELECTOR } from '../constants'; +import { RangePath } from '../types'; +import { isNodeEntry } from '../node/utils'; + +class Consumer implements ConsumerInterface { + private engine: EngineInterface; + constructor(engine: EngineInterface) { + this.engine = engine; + } + + getElementFromPath = ( + node: Node | NodeInterface, + path: Path, + ): { + startNode: Node; + startOffset: number; + endNode: Node; + endOffset: number; + } => { + if (isNodeEntry(node)) node = node[0]; + const index = path[0] as number; + if (index === JSON0_INDEX.ATTRIBUTE) + return { + startNode: node, + startOffset: index, + endNode: node, + endOffset: index, + }; + const offset = index - JSON0_INDEX.ELEMENT; + const childNode = Array.from(node.childNodes).filter((node) => { + const childNode = $(node); + return !isTransientElement(childNode); + })[offset]; + const pathOffset = path[1]; + if ( + 1 === path.length || + pathOffset === JSON0_INDEX.TAG_NAME || + pathOffset === JSON0_INDEX.ATTRIBUTE || + childNode.nodeType === getWindow().Node.TEXT_NODE + ) { + return { + startNode: childNode, + startOffset: offset, + endNode: node, + endOffset: (pathOffset as number) || 0, + }; + } + return this.getElementFromPath(childNode, path.slice(1)); + }; + + fromRemoteAttr(attr: RemoteAttr) { + if (!attr) return; + const { id, leftText, rightText } = attr; + const idNode = $(`[${DATA_ID}="${id}"]`); + if (idNode.length === 0) return; + const node = idNode.get()!; + const text = node.textContent || ''; + if (text === '') + return { + container: node, + offset: 0, + }; + if (text?.startsWith(leftText)) { + let nextChild: Node | null | undefined = node.firstChild; + let offset = leftText.length; + while ( + nextChild && + (nextChild.nodeType !== 3 || + (nextChild.textContent?.length || 0) < offset) + ) { + if ((nextChild.textContent?.length || 0) < offset) { + offset -= nextChild.textContent?.length || 0; + nextChild = nextChild.nextSibling; + } else { + nextChild = nextChild.firstChild; + } + } + return { + container: nextChild, + offset, + }; + } + if (text?.endsWith(rightText)) { + let offset = rightText.length; + let prevChild: Node | null | undefined = node.lastChild; + while ( + prevChild && + (prevChild.nodeType !== 3 || + (prevChild.textContent?.length || 0) < offset) + ) { + if (prevChild.textContent?.length || 0 < offset) { + offset -= prevChild?.textContent?.length || 0; + prevChild = prevChild.previousSibling; + } else { + prevChild = prevChild.lastChild; + } + } + return { + container: prevChild, + offset: prevChild?.textContent?.length || 0 - offset, + }; + } + let offset = 0; + while (text[offset] === leftText[offset]) { + offset++; + } + let nextChild: Node | null | undefined = node.firstChild; + while ( + nextChild && + (nextChild.nodeType !== 3 || + (nextChild.textContent?.length || 0) < offset) + ) { + if (nextChild.textContent?.length || 0 < offset) { + offset -= nextChild.textContent?.length || 0; + nextChild = nextChild.nextSibling; + } else { + nextChild = nextChild.firstChild; + } + } + return { + container: nextChild, + offset, + }; + } + + getSideText(node: NodeInterface, offset: number): RemoteAttr | undefined { + const idNode = this.engine.block.closest(node); + if (idNode.length > 0) { + const id = idNode.attributes(DATA_ID); + const leftRange = Range.create(this.engine); + const rightRange = Range.create(this.engine); + leftRange.setStart(idNode[0], 0); + leftRange.setEnd(node[0], offset); + rightRange.setStart(node[0], offset); + rightRange.setEnd(idNode[0], idNode[0].childNodes.length); + return { + id, + leftText: leftRange.toString(), + rightText: rightRange.toString(), + }; + } + return; + } + + setAttribute( + root: NodeInterface, + path: Path, + attr: string, + value: string, + isRemote?: boolean, + ) { + const { card } = this.engine; + const { startNode } = this.getElementFromPath(root, path); + const domNode = $(startNode); + if ( + (domNode && domNode.length > 0 && !domNode.isRoot()) || + /^data-selection-/.test(attr) + ) { + attr = unescapeDots(attr); + value = unescape(value); + domNode.get()?.setAttribute(attr, value); + if (domNode.isCard()) { + const component = card.find(domNode); + if (!component) return; + card.reRender(component); + if (component.isEditable && component.onChange) + component.onChange(isRemote ? 'remote' : 'local', domNode); + } + } + return domNode; + } + + removeAttribute(root: NodeInterface, path: Path, attr: string) { + const { startNode } = this.getElementFromPath(root, path); + const domNode = $(startNode); + if ( + (domNode.length > 0 && !domNode.isRoot()) || + /^data-selection-/.test(attr) + ) { + domNode.get()?.removeAttribute(attr); + } + return domNode; + } + + insertNode(root: NodeInterface, path: Path, value: string | Op[] | Op[][]) { + const { engine } = this; + const { startNode, endNode } = this.getElementFromPath(root, path); + const domBegine = $(startNode); + const domEnd = $(endNode); + if (domEnd.length > 0 && !domBegine.isRoot()) { + const element = + typeof value === 'string' + ? document.createTextNode(value) + : toDOM(value); + if (domBegine && domBegine.parent()) { + domEnd.get()?.insertBefore(element, domBegine.get()); + } else { + domEnd.get()?.insertBefore(element, null); + } + const node = $(element); + engine.card.render(node); + return node; + } + return; + } + + deleteNode(root: NodeInterface, path: Path, isRemote?: boolean) { + const { card } = this.engine; + const { startNode } = this.getElementFromPath(root, path); + const domBegine = $(startNode); + if (domBegine.length > 0 && !domBegine.isRoot()) { + const parent = domBegine.parent(); + if (domBegine.isCard()) { + if (isRemote) card.removeRemote(domBegine); + else { + card.remove(domBegine, false); + } + } else domBegine.remove(); + return parent?.isRoot() ? undefined : parent; + } + return; + } + + insertText(root: NodeInterface, path: Path, offset: number, text: string) { + const { startNode, endNode } = this.getElementFromPath(root, path); + const node = $(startNode); + if (startNode && !node.isText()) return; + const nodeValue = + startNode && startNode.nodeValue ? startNode.nodeValue : ''; + const value = + nodeValue.substring(0, offset) + text + nodeValue.substring(offset); + if (startNode && startNode.parentNode === endNode) + startNode.nodeValue = value; + else if (!!value) { + const textNode = document.createTextNode(value); + if (endNode.firstChild?.nodeName === 'BR') + endNode.firstChild.remove(); + endNode.insertBefore(textNode, endNode.firstChild); + } + return node; + } + + deleteText(root: NodeInterface, path: Path, offset: number, text: string) { + const { startNode } = this.getElementFromPath(root, path); + const node = $(startNode); + if (!node.isText()) return; + const nodeValue = + startNode && startNode.nodeValue ? startNode.nodeValue : ''; + const value = + nodeValue.substring(0, offset) + + nodeValue.substring(offset + text.length); + startNode.nodeValue = value; + return node; + } + + handleOperation(op: TargetOp, isRemote?: boolean) { + let path = op.p; + let attr: string, offset: number; + if (path.length !== 0) { + let root = this.engine.container; + if ('id' in op && op.id && op.bi && op.bi > -1) { + const target = this.engine.container.find( + `[${DATA_ID}="${op.id}"]`, + ); + if (target.inEditor()) { + root = target; + path = path.slice(op.bi); + } + } + if ('si' in op || 'sd' in op) { + offset = path[path.length - 1] as number; + path = path.slice(0, -1); + } + if ('oi' in op || 'od' in op) { + attr = path[path.length - 1].toString(); + path = path.slice(0, -1); + } + if ('oi' in op) { + return this.setAttribute(root, path, attr!, op.oi, isRemote); + } else if ('od' in op) { + return this.removeAttribute(root, path, attr!); + } else if ('sd' in op) { + return this.deleteText(root, path, offset!, op.sd); + } else if ('si' in op) { + return this.insertText(root, path, offset!, op.si); + } else if ('ld' in op) { + return this.deleteNode(root, path, isRemote); + } else if ('li' in op) { + return this.insertNode(root, path, op.li); + } + return; + } + return; + } + + handleRemoteOperations(ops: Op[]) { + try { + const path = this.getRangeRemotePath(); + const applyNodes: Array = []; + ops.forEach((op) => { + const applyNode = this.handleOperation(op, true); + if (applyNode) applyNodes.push(applyNode); + }); + if (path) this.setRangeByRemotePath(path); + if (ops.some((op) => !isCursorOp(op))) + this.engine.change.change(true, applyNodes); + return applyNodes; + } catch (error) { + console.log(error); + return []; + } + } + + handleSelfOperations(ops: Op[]) { + const applyNodes: Array = []; + ops.forEach((op) => { + const applyNode = this.handleOperation(op); + if (applyNode) applyNodes.push(applyNode); + }); + this.engine.change.change(); + return applyNodes; + } + + handleIndex(ops: Op[], applyNodes: NodeInterface[]) { + if (!ops.every((op) => isCursorOp(op))) { + const targetElements: Node[] = []; + applyNodes.forEach((node) => { + let target = node.isRoot() ? node : node.parent() || node; + if (target.isEditable() && !target.isRoot()) { + target = + this.engine.card.find(target, true)?.root || target; + } + if ( + target && + target.length > 0 && + !targetElements.includes(target[0]) && + !targetElements.find((element) => + element.contains(target[0]), + ) + ) { + let index = -1; + while ( + (index = targetElements.findIndex((element) => + target[0].contains(element), + )) && + index > -1 + ) { + targetElements.splice(index, 1); + } + targetElements.push(target[0]); + } + }); + targetElements.forEach((element) => updateIndex($(element))); + } + } + + setRangeAfterOp(op: TargetOp) { + const { engine } = this; + let offset: number; + let path = op.p; + if ('si' in op || 'sd' in op) { + offset = path[path.length - 1] as number; + path = path.slice(0, -1); + } + if ('oi' in op || 'od' in op) { + path = path.slice(0, -1); + } + let root = this.engine.container; + if ('id' in op && op.id && op.bi && op.bi > -1) { + const target = this.engine.container.find( + `[${DATA_ID}="${op.id}"]`, + ); + if (target.inEditor()) { + root = target; + path = path.slice(op.bi); + } + } + const { startNode, endNode } = this.getElementFromPath(root, path); + const range = Range.create(this.engine); + if ('si' in op || 'sd' in op) { + const node = startNode['data'] === '' ? endNode : startNode; + const stringInsertOp = op as StringInsertOp; + const rangeOffset = + offset! + (stringInsertOp.si ? stringInsertOp.si.length : 0); + range.setOffset(node, rangeOffset, rangeOffset); + engine.change.range.select(range); + return; + } + range + .select(startNode || endNode.lastChild || endNode, true) + .shrinkToElementNode() + .collapse(false); + engine.change.range.select(range); + } + + getRangeRemotePath(): RemotePath | undefined { + try { + if (window.getSelection()?.rangeCount === 0) return; + const range = Range.from(this.engine); + if (!range || range.inCard()) return; + if (range.startNode.isRoot()) range.shrinkToElementNode(); + const { startNode, startOffset, endNode, endOffset } = range; + return { + start: this.getSideText(startNode, startOffset), + end: this.getSideText(endNode, endOffset), + }; + } catch (error) { + console.log(error); + return; + } + } + + setRangeByRemotePath(path: RemotePath) { + try { + const selection = window.getSelection(); + const range = selection + ? Range.from(this.engine, selection)?.cloneRange() + : undefined; + if (!range) return; + const { start, end } = path; + + let startInfo; + let endInfo; + if (start) startInfo = this.fromRemoteAttr(start); + if (end) endInfo = this.fromRemoteAttr(end); + + if (startInfo && startInfo.container) { + range.setStart(startInfo.container, startInfo.offset); + } + if (endInfo && endInfo.container) { + range.setEnd(endInfo.container, endInfo.offset); + } + this.engine.change.range.select(range); + } catch (error) { + console.error(error); + } + } + + setRangeByPath(path: { start: RangePath; end: RangePath }) { + if (path) { + let { start, end } = path; + if (start && end) { + const beginOffset = start.path[start.path.length - 1] as number; + const endOffset = end.path[end.path.length - 1] as number; + const startClone = start.path.slice(); + const endClone = end.path.slice(); + startClone.pop(); + endClone.pop(); + const { container, change } = this.engine; + const startChild = container.getChildByPath( + startClone, + (child) => !isTransientElement($(child)), + ); + const endChild = container.getChildByPath( + endClone, + (child) => !isTransientElement($(child)), + ); + const getMaxOffset = (node: Node, offset: number) => { + if (node.nodeType === getDocument().TEXT_NODE) { + const text = node.textContent || ''; + return text.length < offset ? text.length : offset; + } else { + const childNodes = node.childNodes; + return childNodes.length < offset + ? childNodes.length + : offset; + } + }; + try { + const range = change.range.get(); + if ( + startChild.nodeName === 'BR' || + this.engine.node.isVoid(startChild) + ) { + range.select(startChild).collapse(false); + } else { + range.setStart( + startChild, + getMaxOffset(startChild, beginOffset), + ); + range.setEnd( + endChild, + getMaxOffset(endChild, endOffset), + ); + } + if (!range.collapsed) { + const startCard = this.engine.card.find( + range.startNode, + true, + ); + const endCard = this.engine.card.find( + range.endNode, + true, + ); + if ( + startCard && + endCard && + startCard?.root.equal(endCard.root) + ) { + let startEditableElement = + range.startNode.closest(EDITABLE_SELECTOR); + if (startEditableElement.length === 0) + startEditableElement = + range.startNode.find(EDITABLE_SELECTOR); + let endEditableElement = + range.endNode.closest(EDITABLE_SELECTOR); + if (endEditableElement.length === 0) + endEditableElement = + range.endNode.find(EDITABLE_SELECTOR); + if ( + startEditableElement.length > 0 && + endEditableElement.length > 0 && + !startEditableElement.equal(endEditableElement) + ) { + range.collapse(true); + } + } + } + + change.range.select(range); + range.scrollRangeIntoView(); + } catch (error) { + console.error(error); + } + } + } + } +} +export default Consumer; diff --git a/packages/engine/src/ot/doc.ts b/packages/engine/src/ot/doc.ts new file mode 100644 index 00000000..af23e296 --- /dev/null +++ b/packages/engine/src/ot/doc.ts @@ -0,0 +1,42 @@ +import { EventEmitter2 } from 'eventemitter2'; +import { Op } from 'sharedb'; +import OTJSON from 'ot-json0'; +import { EngineInterface } from '../types/engine'; +import { toJSON0 } from './utils'; +import { DocInterface } from '../types/ot'; + +class Doc extends EventEmitter2 implements DocInterface { + private engine: EngineInterface; + type = null; + data: T | undefined = undefined; + + constructor(engine: EngineInterface) { + super(); + this.engine = engine; + this.create(); + } + + create() { + this.data = toJSON0(this.engine.container) as any; + } + + apply(ops: Op[]) { + if (ops.length) { + try { + this.data = OTJSON.type.apply(this.data, ops); + } catch (error) { + console.error(error); + } + } + } + + submitOp(ops: Op[]) { + this.apply(ops); + } + + destroy() { + delete this.data; + } +} + +export default Doc; diff --git a/packages/engine/src/ot/index.css b/packages/engine/src/ot/index.css new file mode 100644 index 00000000..692bae2e --- /dev/null +++ b/packages/engine/src/ot/index.css @@ -0,0 +1,64 @@ +.ot-user-cursor { + position: absolute; + z-index: 125; + width: 2px; +} + +.ot-user-cursor-trigger { + position: absolute; + top: -5px; + left: -2px; + border-radius: 100%; + color: #ffffff; + width: 6px; + height: 6px; + font-size: 0; + overflow: hidden; + transition: all 0.1s linear; +} + +.ot-user-cursor-trigger-active { + top: -17px; + border-radius: 2px; + color: #ffffff; + font-size: 10px; + line-height: 18px; + height: 18px; + width: auto; + padding: 0 3px; + white-space: nowrap; +} + +.ot-card-mask { + position: absolute; + z-index: 10; + background: transparent; + cursor: not-allowed; +} + +.ot-user-background { + z-index: 120; +} + +.ot-user-cursor-card { + position: absolute; +} + +.ot-user-cursor-card .ot-user-cursor-trigger { + display: none; +} + +.ot-user-cursor-card .ot-user-cursor-trigger-active { + position: absolute; + display: block; + top: -19px; + left: 0; + border-radius: 2px; + color: #ffffff; + font-size: 10px; + line-height: 18px; + height: 18px; + width: auto; + padding: 0 3px; + white-space: nowrap; +} \ No newline at end of file diff --git a/packages/engine/src/ot/index.ts b/packages/engine/src/ot/index.ts new file mode 100644 index 00000000..e5b9b088 --- /dev/null +++ b/packages/engine/src/ot/index.ts @@ -0,0 +1,310 @@ +import { debounce, cloneDeep } from 'lodash-es'; +import { EventEmitter2 } from 'eventemitter2'; +import { Doc, Op } from 'sharedb'; +import { EngineInterface } from '../types/engine'; +import { filterOperations, updateIndex } from './utils'; + +import { + ConsumerInterface, + Attribute, + DocInterface, + Member, + MutationInterface, + OTInterface, + RangeColoringInterface, + SelectionInterface, + TargetOp, +} from '../types/ot'; +import OTSelection from './selection'; +import RangeColoring from './range-coloring'; +import OTDoc from './doc'; +import Consumer from './consumer'; +import Mutation from './mutation'; +import { toJSON0 } from './utils'; +import { random } from '../utils'; +import './index.css'; + +class OTModel extends EventEmitter2 implements OTInterface { + private engine: EngineInterface; + private members: Array; + private currentMember?: Member; + private waitingOps: Array = []; + private clientId: string; + selection: SelectionInterface; + private rangeColoring: RangeColoringInterface; + consumer: ConsumerInterface; + private mutation: MutationInterface | null; + private doc: DocInterface | Doc | null = null; + + constructor(engine: EngineInterface) { + super(); + this.engine = engine; + this.members = []; + this.selection = new OTSelection(engine); + this.rangeColoring = new RangeColoring(engine); + this.consumer = new Consumer(engine); + this.mutation = new Mutation(engine.container, { engine }); + this.mutation.on('onChange', (ops) => this.handleChange(ops)); + this.clientId = random(8); + this.waitingOps = []; + this.engine.on('select', () => { + this.updateSelection(); + }); + } + + private updateRangeColoringPosition = debounce(() => { + this.updateSelection(); + this.rangeColoring.updatePosition(); + }, 100); + + private applyWaitingOps = debounce(() => { + const operations = filterOperations(this.waitingOps); + if (operations.length > 0) { + this.waitingOps = []; + this.apply(operations); + this.engine.history.handleRemoteOps(operations); + const selections = this.selection.getSelections(); + this.renderSelection(selections); + } + }, 0); + + colors = [ + '#597EF7', + '#73D13D', + '#FF4D4F', + '#9254DE', + '#36CFC9', + '#FFA940', + '#F759AB', + '#40A9FF', + ]; + + initLocal() { + if (this.doc) return; + this.stopMutation(); + this.doc = new OTDoc(this.engine); + this.mutation?.setDoc(this.doc); + this.startMutation(); + } + + initRemote(doc: Doc, defaultValue?: string) { + // 没有启动协同,或者当前doc对象没有注销,就去注销 + const isDestroy = !this.doc || this.doc.type === null; + this.stopMutation(); + if (!isDestroy) { + this.doc!.destroy(); + } + // 设置文档对象 + this.doc = doc; + this.mutation?.setDoc(doc); + // 同步数据 + this.syncValue(defaultValue); + // 监听操作 + doc.on('op', (op, clientId) => { + if (this.clientId !== clientId.toString()) { + this.waitingOps = this.waitingOps.concat(op); + this.applyWaitingOps(); + } + }); + this.initSelection(); + this.startMutation(); + if (isDestroy) { + this.emit('load'); + } + } + + handleChange(ops: Op[]) { + this.submitOps(ops); + this.engine.history.handleSelfOps(ops); + if (this.doc && this.doc?.type !== null) { + this.updateRangeColoringPosition(); + } + this.engine.trigger('ops', ops); + } + + submitOps(ops: Op[]) { + if (!this.doc) return; + try { + (this.doc as Doc).submitOp(ops, { + source: this.clientId, + }); + } catch (error) { + console.error( + 'SubmitOps Error:', + 'MSG:', + error, + 'OPS:', + ops, + 'DATA:', + this.doc.data, + ); + } + } + + apply(ops: Op[]) { + this.stopMutation(); + const applyNodes = this.consumer.handleRemoteOperations(ops); + this.consumer.handleIndex( + ops, + ops.some((op) => op['bi'] < 0) + ? [this.engine.container] + : applyNodes, + ); + this.startMutation(); + } + + syncValue(defaultValue?: string) { + const { doc, engine } = this; + if (!doc) return; + // 除了div 和 selection-data 外 还必须有其它节点 + if (doc.type && Array.isArray(doc.data) && doc.data.length > 2) { + // 远端有数据就设置数据到当前编辑器 + this.engine.setJsonValue(doc.data, () => { + updateIndex(this.engine.container); + }); + return; + } + // 如果有设置默认值,就设置编辑器的值 + if (defaultValue) + engine.setValue(defaultValue, () => { + updateIndex(this.engine.container); + }); + // 没有数据,就把当前编辑器值提交 + doc.on('create', () => { + const data = toJSON0(engine.container); + (doc as Doc).submitOp( + [ + { + p: [], + oi: data, + }, + ], + { + source: this.clientId, + }, + ); + }); + } + + startMutation() { + if (this.mutation) this.mutation.start(); + } + + stopMutation() { + if (this.mutation) this.mutation.stop(); + } + + startMutationCache() { + if (this.mutation) this.mutation.startCache(); + } + + submitMutationCache() { + if (this.mutation) this.mutation.submitCache(); + } + + destroyMutationCache() { + if (this.mutation) this.mutation.destroyCache(); + } + + /** + * 获取缓存的记录 + * @returns + */ + getCaches(): MutationRecord[] { + return this.mutation?.getCaches() || []; + } + + getColors() { + return this.colors; + } + + setColors(colors: string[]) { + this.colors = colors; + } + + setMemberColor(member: Member) { + let index = member.index || this.members.length + 1; + index = (index - 1) % this.colors.length; + member.color = this.colors[index]; + } + + getMembers() { + return cloneDeep(this.members); + } + + setMembers(members: Array) { + members = cloneDeep(members); + members.forEach((member) => { + this.setMemberColor(member); + }); + this.members = members; + } + + addMember(member: Member) { + member = cloneDeep(member); + this.setMemberColor(member); + if (!this.members.find((m) => m.uuid === member.uuid)) { + this.members.push(member); + } + } + + removeMember(member: Member) { + member = cloneDeep(member); + if (!member.uuid) return; + this.members = this.members.filter((m) => { + return m.uuid !== member.uuid; + }); + this.selection.remove(member.uuid); + const attributes = this.selection.getSelections(); + this.renderSelection(attributes); + } + + setCurrentMember(member: Member) { + member = cloneDeep(member); + this.setMemberColor(member); + const findMember = this.members.find((m) => m.uuid === member.uuid); + if (!findMember) return; + this.currentMember = findMember; + } + + getCurrentMember() { + return this.currentMember; + } + + renderSelection(attributes: Array, isDraw: boolean = false) { + const { members, currentMember } = this; + attributes = attributes.filter( + (item) => item.uuid !== currentMember?.uuid, + ); + this.rangeColoring.render(attributes, members, isDraw); + this.rangeColoring.updatePosition(); + } + + updateSelection() { + if (!this.engine.change.isComposing() && this.currentMember) { + const range = this.selection.updateSelections( + this.currentMember, + this.members, + ).range; + this.rangeColoring.updateBackgroundAlpha(range); + } + } + + initSelection() { + if (!this.currentMember) return; + const data = this.selection.updateSelections( + this.currentMember, + this.members, + ).data; + this.renderSelection(data, true); + } + + destroy() { + if (this.doc) this.doc.destroy(); + this.stopMutation(); + this.rangeColoring.destroy(); + this.mutation = null; + } +} + +export default OTModel; diff --git a/packages/engine/src/ot/mutation.ts b/packages/engine/src/ot/mutation.ts new file mode 100644 index 00000000..e031cbfe --- /dev/null +++ b/packages/engine/src/ot/mutation.ts @@ -0,0 +1,127 @@ +import { EventEmitter2 } from 'eventemitter2'; +import { Doc, Op } from 'sharedb'; +import { EngineInterface } from '../types/engine'; +import { NodeInterface } from '../types/node'; +import { DocInterface, MutationInterface } from '../types/ot'; +import Producer from './producer'; + +const config = { + childList: true, + subtree: true, + attributes: true, + characterData: true, + attributeOldValue: true, + characterDataOldValue: true, +}; + +type Options = { + engine: EngineInterface; + doc?: DocInterface | Doc; +}; + +class Mutation extends EventEmitter2 implements MutationInterface { + private node: NodeInterface; + private engine: EngineInterface; + private doc?: DocInterface | Doc; + private isStopped: boolean; + private observer: MutationObserver; + private producer: Producer; + private isCache: boolean = false; + private cache: MutationRecord[] = []; + + constructor(node: NodeInterface, options: Options) { + super(); + this.node = node; + this.isStopped = true; + this.engine = options.engine; + this.doc = options.doc; + this.producer = new Producer(this.engine, { doc: this.doc }); + //https://dom.spec.whatwg.org/#mutationobserver + this.observer = new MutationObserver((records) => { + if (this.isCache) { + this.cache.push(...records); + } + if (!this.isStopped && !this.isCache) { + this.producer.handleMutations(records); + } + }); + this.producer.on('ops', (ops) => { + this.onChange(ops); + }); + } + + setDoc(doc: DocInterface | Doc) { + this.doc = doc; + this.producer.setDoc(doc); + } + + start() { + if (this.isStopped) { + this.observer.observe(this.node[0], config); + this.isStopped = false; + } + } + + stop() { + if (!this.isStopped) { + this.observer.disconnect(); + this.isStopped = true; + } + } + + /** + * 开始缓存操作 + */ + startCache() { + if (!this.isCache) { + this.cache = []; + this.isCache = true; + } + } + /** + * 将缓存提交处理,最后停止缓存 + */ + submitCache() { + if (this.isCache) { + setTimeout(() => { + if (this.engine.change.isComposing()) return; + this.isCache = false; + this.cache = this.cache.map((record) => { + if (record.type === 'characterData') { + if (record.target.nodeType === document.TEXT_NODE) { + record['text-data'] = record.target.textContent; + } + } + return record; + }); + if (this.cache.length > 0) + this.producer.handleMutations(this.cache); + this.cache = []; + }, 20); + } + } + /** + * 将缓存情况注销掉 + */ + destroyCache() { + if (this.isCache) { + setTimeout(() => { + this.isCache = false; + this.cache = []; + }, 20); + } + } + + /** + * 获取缓存的记录 + * @returns + */ + getCaches(): MutationRecord[] { + return this.cache; + } + + onChange(ops: Op[]) { + if (!this.isStopped) this.emit('onChange', ops); + } +} +export default Mutation; diff --git a/packages/engine/src/ot/producer.ts b/packages/engine/src/ot/producer.ts new file mode 100644 index 00000000..7da59e5a --- /dev/null +++ b/packages/engine/src/ot/producer.ts @@ -0,0 +1,463 @@ +import { EventEmitter2 } from 'eventemitter2'; +import { + diff_match_patch, + DIFF_DELETE, + patch_obj, + DIFF_EQUAL, + DIFF_INSERT, +} from 'diff-match-patch'; +import { + isCursorOp, + isTransientAttribute, + isTransientElement, + filterOperations, + updateIndex, + opsSort, +} from './utils'; +import { escapeDots, escape } from '../utils/string'; +import { toJSON0, getValue } from './utils'; +import { EngineInterface } from '../types/engine'; +import { Op, Path, StringInsertOp, StringDeleteOp, Doc } from 'sharedb'; +import { NodeInterface } from '../types/node'; +import { DocInterface, RepairOp } from '../types/ot'; +import { $ } from '../node'; +import { DATA_ID, JSON0_INDEX, UI_SELECTOR } from '../constants'; +import { getDocument } from '../utils/node'; + +class Producer extends EventEmitter2 { + private engine: EngineInterface; + private doc?: DocInterface | Doc; + private dmp: diff_match_patch; + private cacheNodes: Array = []; + private cacheTransientElements?: Array; + timer: NodeJS.Timeout | null = null; + lineStart: boolean = false; + + constructor( + engine: EngineInterface, + options: { doc?: DocInterface | Doc }, + ) { + super(); + this.engine = engine; + this.doc = options.doc; + this.dmp = new diff_match_patch(); + } + + textToOps(path: Path, text1: string, text2: string) { + const ops: Array = []; + const patches = this.dmp.patch_make(text1, text2); + Object.keys(patches).forEach((key) => { + const patch: patch_obj = patches[key]; + let start1 = patch.start1; + patch.diffs.forEach((diff) => { + const [type, data] = diff; + if (type !== DIFF_DELETE) { + if (type !== DIFF_INSERT) { + if (type === DIFF_EQUAL) { + (start1 as number) += data.length; + } + } else { + const p: Path = []; + + ops.push({ + si: data, + p: p.concat([...path], [start1 as number]), + }); + } + } else { + const p: Path = []; + ops.push({ + sd: data, + p: p.concat([...path], [start1 as number]), + }); + } + }); + }); + return ops; + } + + cacheNode(node: Node) { + this.cacheNodes.push(node); + node.childNodes.forEach((child) => { + this.cacheNodes.push(child); + this.cacheNode(child); + }); + } + + clearCache() { + this.cacheNodes = []; + } + + inCache(node: Node) { + return this.cacheNodes.find((n) => n === node); + } + + isTransientMutation( + record: MutationRecord, + transientElements?: Array, + ) { + const { addedNodes, removedNodes, target, type, attributeName } = + record; + const targetNode = $(target); + if (type === 'childList') { + const childs: Array = []; + if (addedNodes[0]) { + childs.push($(addedNodes[0])); + } + if (removedNodes[0]) { + childs.push($(removedNodes[0])); + } + childs.push(targetNode); + if ( + childs.some((child) => + isTransientElement(child, transientElements), + ) + ) + return true; + } + return ( + (type === 'attributes' && + (isTransientAttribute(targetNode, attributeName || '') || + isTransientElement(targetNode, transientElements))) || + (type === 'characterData' && + isTransientElement(targetNode, transientElements)) + ); + } + + /** + * 从DOM变更记录中生产 ops + * @param records DOM变更记录集合 + * @param path 路径 + * @param oldPath + * @param node 开始遍历的节点,默认为编辑器根节点 + * @returns + */ + generateOps( + records: MutationRecord[], + node: NodeInterface = this.engine.container, + ) { + const addNodes: Array = []; + const allOps: Array = []; + let ops: Array = []; + let attrOps: Array = []; + const cacheNodes: Array = []; + // 文本数据变更标记 + let isValueString = false; + const pathCaches: Map = new Map(); + const filter = (element: NodeInterface) => { + //父节点就是编辑器根节点,就不需要过滤 + return element.parent()?.isRoot() + ? undefined + : (node: Node) => + !isTransientElement( + $(node), + this.cacheTransientElements, + ); + }; + + const getPath = (root: NodeInterface) => { + return root.isRoot() ? [] : root.getPath(node, filter(root)); + }; + + const getIndex = (element: NodeInterface) => { + return element.getIndex(filter(element)); + }; + // 循环记录集合 + for (let i = 0; records[i]; ) { + const record = records[i]; + const { target, addedNodes, removedNodes, type } = record; + // 当前节点在需要增加的节点记录集合中就跳过 + const inCache = this.inCache(target); + + const targetElement = $(target); + if ( + inCache || + (!targetElement.inEditor() && !targetElement.isRoot()) + ) { + i++; + continue; + } + // 最近的block节点 + const blockElement = targetElement.attributes(DATA_ID) + ? targetElement + : this.engine.block.closest(targetElement); + // 最近的block节点id + const rootId = blockElement.attributes(DATA_ID); + let path = pathCaches.get(targetElement); + if (path === undefined) { + path = getPath(targetElement).map( + (index) => index + JSON0_INDEX.ELEMENT, + ); + pathCaches.set(targetElement, path); + } + // block 节点在 path 中的开始位置 + let beginIndex = -1; + if (!!rootId) { + if (targetElement.equal(blockElement)) { + beginIndex = path.length; + } else { + let path = pathCaches.get(blockElement); + if (!path) { + path = getPath(blockElement); + pathCaches.set(blockElement, path); + } + beginIndex = path.length; + } + } + + const oldPath = path.slice(); + ops.forEach((op) => { + for (let p = 0; p < path!.length; p++) { + if (('li' in op || 'ld' in op) && op.p.length === p + 1) { + if (op.p[p] <= path![p]) { + if ('li' in op) oldPath[p] = oldPath[p] - 1; + else if ('ld' in op) oldPath[p] = oldPath[p] + 1; + } + } + } + }); + ops = []; + attrOps = []; + // 子节点变更 + if (type === 'childList') { + // DOM中变更为移除 + if (removedNodes[0]) { + // 循环要移除的节点 + Array.from(removedNodes).forEach((removedNode) => { + // 要移除的节点同时又在增加的就不处理 + if ( + !addNodes.find((n) => n === removedNode) && + !cacheNodes.find((n) => n === removedNode) + ) { + // 获取移除节点在编辑器中的索引 + const rIndex = + removedNode['__index'] + JSON0_INDEX.ELEMENT; + let p: Path = []; + p = p.concat([...path!], [rIndex]); + let op: Path = []; + op = op.concat([...oldPath], [rIndex]); + ops.push({ + id: rootId, + bi: beginIndex, + ld: true, + p, + newPath: p.slice(), + oldPath: op, + }); + } + }); + } + if (addedNodes[0]) { + Array.from(addedNodes).forEach((addedNode) => { + if (cacheNodes.includes(addedNode)) return; + const domAddedNode = $(addedNode); + const data = toJSON0(domAddedNode); + if (addedNode.parentNode === target) { + const index = + getIndex(domAddedNode) + JSON0_INDEX.ELEMENT; + let p: Path = []; + p = p.concat([...path!], [index]); + ops.push({ + id: rootId, + bi: beginIndex, + li: data, + p, + newPath: p.slice(), + }); + cacheNodes.push(addedNode); + this.cacheNode(addedNode); + } else { + addNodes.push(addedNode); + } + }); + } + } else if (type === 'characterData') { + if (!isValueString) { + if ( + typeof getValue(this.doc?.data, oldPath) === 'string' && + (record['text-data'] || target['data']).length > 0 + ) { + attrOps.push({ + id: rootId, + bi: beginIndex, + path, + oldPath, + newValue: record['text-data'] || target['data'], + }); + isValueString = true; + } + } + } else if (type === 'attributes') { + let { oldValue, attributeName } = record; + let attrValue = attributeName + ? (target as Element).getAttribute(attributeName) + : ''; + if (!oldValue) oldValue = ''; + attrValue = attrValue ? escape(attrValue) : ''; + if (oldValue !== attrValue) { + const p: Path = []; + const newOp: any = { + id: rootId, + bi: beginIndex, + }; + newOp.p = p.concat( + [...path], + [1, escapeDots(attributeName || '')], + ); + if (oldValue) newOp.od = oldValue; + if (attrValue) newOp.oi = attrValue; + + attrOps.push(newOp); + } + } + i++; + ops.forEach((op) => { + if ('ld' in op) { + const pathValue = getValue( + this.doc?.data, + op.oldPath || [], + ); + if (pathValue !== undefined) { + const ldOp = { + id: op.id, + bi: beginIndex, + ld: pathValue, + p: op.p, + }; + // 重复删除的过滤掉 + if ( + !allOps.find( + (op) => + 'ld' in op && + JSON.stringify(op) === JSON.stringify(ldOp), + ) + ) + allOps.push(ldOp); + } + } + if ('li' in op) { + allOps.push({ + id: op.id, + bi: beginIndex, + li: op.li, + p: op.p, + }); + } + }); + allOps.push(...attrOps); + } + + return allOps; + } + + /** + * 处理DOM节点变更记录 + * @param records 记录集合 + */ + handleMutations(records: MutationRecord[]) { + //需要先过滤标记为非协同节点的变更,包括 data-element=ui、data-transient-element 等标记的节点,可以在 isTransientMutation 中查看逻辑 + //记录大于300的时候,先获取所有的不需要参与协同交互的节点,以提高效率 + if (records.length > 299) { + this.cacheTransientElements = []; + //非可编辑卡片的子节点 + const { card, container } = this.engine; + card.each((card) => { + if (!card.isEditable) { + card.root.allChildren().forEach((child) => { + if (child.type === getDocument().ELEMENT_NODE) + this.cacheTransientElements?.push(child[0]); + }); + } + }); + //所有的UI子节点 + const uiElements = container.find(`${UI_SELECTOR}`); + uiElements.each((_, index) => { + const ui = uiElements.eq(index); + ui?.allChildren().forEach((child) => { + if (child.type === getDocument().ELEMENT_NODE) + this.cacheTransientElements?.push(child[0]); + }); + }); + } + const targetElements: Node[] = []; + records = records.filter((record) => { + const isTransient = this.isTransientMutation( + record, + this.cacheTransientElements, + ); + if ( + !isTransient && + !targetElements.includes(record.target) && + !targetElements.find((element) => + element.contains(record.target), + ) + ) { + let index = -1; + while ( + (index = targetElements.findIndex((element) => + record.target.contains(element), + )) && + index > -1 + ) { + targetElements.splice(index, 1); + } + targetElements.push(record.target); + } + return !isTransient; + }); + this.clearCache(); + let ops = this.generateOps(records); + //重置缓存 + this.cacheTransientElements = undefined; + ops = filterOperations(ops); + if (!ops.every((op) => isCursorOp(op))) { + targetElements.map((element) => { + let node = $(element); + if (node.isEditable() && !node.isRoot()) { + node = this.engine.card.find(node, true)?.root || node; + } + updateIndex( + node, + (child) => + !isTransientElement( + $(child), + this.cacheTransientElements, + ), + ); + }); + } + if (ops.length !== 0) { + this.emitOps(ops); + } + } + + emitOps(ops: ((RepairOp & { newValue?: string; path?: number[] }) | Op)[]) { + let emitOps: Op[] = []; + ops.forEach((op) => { + if ('path' in op && op.newValue !== undefined) { + const pathValue = getValue(this.doc?.data, op.oldPath || []); + const newOps = this.textToOps( + [...op.path!], + pathValue, + op.newValue, + ); + newOps.forEach((nOp) => { + nOp['id'] = op.id; + nOp['bi'] = op.bi; + }); + emitOps = emitOps.concat(newOps); + } else if (op.p.length !== 0) { + emitOps.push(op); + } + }); + if (emitOps.length !== 0) { + opsSort(emitOps); + this.emit('ops', emitOps); + } + } + + setDoc(doc: DocInterface | Doc) { + this.doc = doc; + } +} +export default Producer; diff --git a/packages/engine/src/ot/range-coloring.ts b/packages/engine/src/ot/range-coloring.ts new file mode 100644 index 00000000..4a1f2d05 --- /dev/null +++ b/packages/engine/src/ot/range-coloring.ts @@ -0,0 +1,730 @@ +import tinycolor2 from 'tinycolor2'; +import { removeUnit, escape } from '../utils'; +import { TinyCanvas } from '../utils'; +import { Tooltip } from '../toolbar'; +import { EngineInterface } from '../types/engine'; +import { RangeInterface } from '../types/range'; +import { NodeInterface } from '../types/node'; +import { + Attribute, + CursorRect, + Member, + RangeColoringInterface, +} from '../types/ot'; +import { DrawStyle, TinyCanvasInterface } from '../types/tiny-canvas'; +import Range, { isRangeInterface } from '../range'; +import { CardEntry, CardInterface } from '../types/card'; +import { + DATA_ELEMENT, + EDITABLE_SELECTOR, + DATA_UUID, + DATA_COLOR, +} from '../constants'; +import { $ } from '../node'; + +const USER_BACKGROUND_CLASS = 'ot-user-background'; +const USER_CURSOR_CLASS = 'ot-user-cursor'; +const USER_CURSOR_CARD_CLASS = 'ot-user-cursor-card'; +const USER_MASK_CLASS = 'ot-card-mask'; +const USER_CURSOR_TRIGGER_CLASS = 'ot-user-cursor-trigger'; +const USER_CURSOR_TRIGGER_ACTIVE_CLASS = 'ot-user-cursor-trigger-active'; + +class RangeColoring implements RangeColoringInterface { + private engine: EngineInterface; + private root: NodeInterface; + private hideCursorInfoTimeoutMap: { + [k: string]: NodeJS.Timeout; + }; + + constructor(engine: EngineInterface) { + this.engine = engine; + this.root = engine.root; + this.hideCursorInfoTimeoutMap = {}; + } + + destroy() { + this.root.children(`.${USER_BACKGROUND_CLASS}`).remove(); + this.root.children(`.${USER_CURSOR_CLASS}`).remove(); + this.root.children(`.${USER_MASK_CLASS}`).remove(); + } + + getRectWithRange(node: NodeInterface, range: RangeInterface) { + const rangeReact = range.getClientRect(); + const react = node.get()?.getBoundingClientRect(); + return new DOMRect( + rangeReact.left - (react?.left || 0), + rangeReact.top - (react?.top || 0), + rangeReact.right - rangeReact.left, + rangeReact.bottom - rangeReact.top, + ); + } + + isWrapByRange(range: RangeInterface) { + const clientReact = range.cloneRange().collapse(true).getClientRect(); + const clientReact1 = range.cloneRange().collapse(false).getClientRect(); + return clientReact.bottom !== clientReact1.bottom; + } + + drawSubRang( + node: NodeInterface, + canvas: TinyCanvasInterface, + range: RangeInterface, + style: DrawStyle, + ) { + let startOffset = range.startOffset; + while (startOffset < range.endOffset) { + range.setStart(range.commonAncestorContainer, startOffset); + range.setEnd(range.commonAncestorContainer, startOffset + 1); + const rect = this.getRectWithRange(node, range); + canvas.clearRect(rect); + canvas.drawRect({ ...rect.toJSON(), ...style }); + startOffset++; + } + range.setStart(range.startContainer, range.startOffset); + range.setEnd(range.endContainer, range.endOffset); + } + + drawBackground( + range: RangeInterface, + options: { uuid: string; color: string }, + ) { + const { card } = this.engine; + const { uuid, color } = options; + const tinyColor = tinycolor2(color); + tinyColor.setAlpha(0.3); + let targetCanvas: TinyCanvasInterface; + const rgb = tinyColor.toRgbString(); + let child = this.root.children( + `.${USER_BACKGROUND_CLASS}[${DATA_UUID}="${uuid}"]`, + ); + if (child && child.length > 0) { + child.attributes(DATA_COLOR, color.toString()); + targetCanvas = child[0]['__canvas']; + targetCanvas.clear(); + } else { + child = $( + `

      `, + ); + child.css({ + position: 'absolute', + top: 0, + left: 0, + 'pointer-events': 'none', + }); + this.root.append(child); + targetCanvas = new TinyCanvas({ + container: child.get()!, + }); + + child[0]['__canvas'] = targetCanvas; + } + child[0]['__range'] = range.cloneRange(); + const parentWidth = this.root.width(); + const parentHeight = this.root.height(); + targetCanvas.resize(parentWidth, parentHeight); + let cardInfo = card.find(range.commonAncestorNode, true); + //如果是卡片,并且选区不在内容模块中,而是在卡片两侧的光标位置处,就不算作卡片 + if (cardInfo && !cardInfo.isCenter(range.commonAncestorNode)) { + cardInfo = undefined; + } + + const fill = { + fill: rgb, + }; + + let subRanges = range.getSubRanges(); + + if (cardInfo?.isEditable && cardInfo.drawBackground) { + const result = cardInfo.drawBackground(child, range, targetCanvas); + if (result === false) return [range]; + if (!!result) { + if (Array.isArray(result)) subRanges = result; + else { + targetCanvas.clearRect(result); + targetCanvas.drawRect({ ...result.toJSON(), ...fill }); + return [range]; + } + } + } else if (cardInfo) { + return [range]; + } + + subRanges.forEach((subRange) => { + if (this.isWrapByRange(subRange)) { + this.drawSubRang(child, targetCanvas, subRange, fill); + } else { + const rect = this.getRectWithRange(child, subRange); + targetCanvas.clearRect(rect); + targetCanvas.drawRect({ ...rect.toJSON(), ...fill }); + } + }); + return subRanges; + } + + getNodeRect(node: NodeInterface, rect: DOMRect) { + //自定义列表项的第一个card跳过 + if ( + node.isCard() && + node.parent()?.hasClass('data-list-item') && + node.parent()?.first()?.equal(node) && + node.next() + ) { + node = node.next()!; + } + + if (node.isElement()) { + rect = node.get()!.getBoundingClientRect(); + } + + if (node.isText()) { + const range = Range.create(this.engine).cloneRange(); + range.select(node, true); + rect = range.getClientRect(); + } + return rect; + } + + getCursorRect( + selector: RangeInterface | NodeInterface, + leftSpace: number = 2, + ): CursorRect { + const parentRect = this.root + .get() + ?.getBoundingClientRect() || { + top: 0, + left: 0, + }; + + if (isRangeInterface(selector)) { + const range = selector; + const { startNode } = range; + range.shrinkToElementNode(); + let rect = range.getClientRect(); + if (startNode.isElement() && rect.height === 0) { + let childNode: NodeInterface | null = $( + startNode[0].childNodes[range.startOffset], + ); + if (childNode && childNode.length > 0) { + rect = this.getNodeRect(childNode, rect); + } else { + childNode = startNode.first(); + if (childNode && childNode.length > 0) { + rect = this.getNodeRect(childNode, rect); + } + } + } + + const top = rect.top - (parentRect.top || 0); + const left = rect.left - (parentRect.left || 0) - leftSpace; + const height = rect.height; + return { + top: top + 'px', + left: left + 'px', + height: height > 0 ? height + 'px' : -1, + elementHeight: rect.height || 0, + }; + } + + const node = selector; + const outlineWidth = removeUnit(node.css('outline-width')); + const rect = node.get()?.getBoundingClientRect() || { + top: 0, + left: 0, + height: 0, + }; + let top = rect.top - parentRect.top - 1; + let left = rect.left - parentRect.left; + if (outlineWidth) { + top -= outlineWidth + 1; + left -= 2; + } + return { + left: left + 'px', + top: top + 'px', + height: 0, + elementHeight: rect.height || 0, + }; + } + + setCursorRect(node: NodeInterface, rect: CursorRect) { + if (-1 !== rect.height) { + if (0 === rect.height) { + node.css(rect); + node.addClass(USER_CURSOR_CARD_CLASS); + return; + } + node.css(rect); + node.removeClass(USER_CURSOR_CARD_CLASS); + } else node.remove(); + } + + showCursorInfo(node: NodeInterface, member: Member) { + const { uuid, color } = member; + if (this.hideCursorInfoTimeoutMap[uuid]) { + clearTimeout(this.hideCursorInfoTimeoutMap[uuid]); + } + + const trigger = node.find(`.${USER_CURSOR_TRIGGER_CLASS}`); + const bgColor = node.css('background-color'); + node.attributes('data-old-background-color', bgColor); + trigger.addClass(`${USER_CURSOR_TRIGGER_ACTIVE_CLASS}`); + node.css('background-color', color); + trigger.css('background-color', color); + } + + hideCursorInfo(node: NodeInterface) { + const trigger = node.find(`.${USER_CURSOR_TRIGGER_CLASS}`); + const bgColor = node.attributes('data-old-background-color'); + trigger.removeClass(`${USER_CURSOR_TRIGGER_ACTIVE_CLASS}`); + node.css('background-color', bgColor); + trigger.css('background-color', bgColor); + } + + drawCursor(selector: RangeInterface | NodeInterface, member: Member) { + const { uuid, name, color } = member; + let cursorRect = this.getCursorRect(selector); + let childCursor = this.root.children( + `.${USER_CURSOR_CLASS}[${DATA_UUID}="${uuid}"]`, + ); + if (childCursor && childCursor.length > 0) { + this.setCursorRect(childCursor, cursorRect); + } else { + const userCursor = ` +
      +
      ${escape( + name || '', + )}
      +
      `; + childCursor = $(userCursor); + const trigger = childCursor.find(`.${USER_CURSOR_TRIGGER_CLASS}`); + + if (cursorRect.elementHeight === 0) { + // 刚加载获取不到高度,就定时循环获取,获取次数超过50次就不再获取 + let count = 0; + const getRect = () => { + count++; + cursorRect = this.getCursorRect(selector); + if (cursorRect.elementHeight < 20 && count <= 50) { + setTimeout(() => { + getRect(); + }, 20); + } else { + this.setCursorRect(childCursor, cursorRect); + } + }; + getRect(); + } else { + this.setCursorRect(childCursor, cursorRect); + } + childCursor.on('mouseenter', () => { + return this.showCursorInfo(childCursor!, member); + }); + let transitionState = true; + childCursor.on('transitionstart', () => { + transitionState = false; + }); + + childCursor.on('transitionend', () => { + transitionState = true; + }); + + childCursor.on('mouseleave', () => { + if (transitionState) { + this.hideCursorInfo(childCursor!); + } + }); + childCursor.css('background-color', color); + trigger.css('background-color', color); + this.root.append(childCursor); + } + if (childCursor && childCursor[0]) { + childCursor.css('z-index', ''); + // 如果当前有最大化的卡片,并且要画的光标不在最大化卡片内就隐藏这个光标 + const maximizeCard = this.engine.card.components.find( + (component) => component.isMaximize, + ); + if (maximizeCard) { + const card = this.engine.card.closest( + isRangeInterface(selector) ? selector.startNode : selector, + true, + ); + if (!card || !maximizeCard.root.equal(card)) { + childCursor.css('z-index', 120); + } + } + childCursor[0]['__target'] = isRangeInterface(selector) + ? selector.toPath(true) + : selector; + this.showCursorInfo(childCursor, member); + if (this.hideCursorInfoTimeoutMap[uuid]) { + clearTimeout(this.hideCursorInfoTimeoutMap[uuid]); + } + this.hideCursorInfoTimeoutMap[uuid] = setTimeout(() => { + this.hideCursorInfo(childCursor!); + }, 2000); + return childCursor; + } + return; + } + + drawCard(node: NodeInterface, cursor: NodeInterface, member: Member) { + const { language } = this.engine; + const parentRect = this.root + .get() + ?.getBoundingClientRect() || { + left: 0, + top: 0, + width: 0, + height: 0, + }; + let nodeRect = node.get()?.getBoundingClientRect() || { + left: 0, + top: 0, + width: 0, + height: 0, + }; + let mask = this.root.children( + `.${USER_MASK_CLASS}[${DATA_UUID}="${member.uuid}"]`, + ); + if (mask && mask.length > 0) { + mask[0]['__node'] = node[0]; + mask.css({ + left: nodeRect.left - parentRect.left + 'px', + top: nodeRect.top - parentRect.top + 'px', + }); + return; + } + mask = $( + `
      `, + ); + mask[0]['__node'] = node[0]; + if (nodeRect.height === 0) { + // 刚加载获取不到高度,就定时循环获取,获取次数超过50次就不再获取 + let count = 0; + const getRect = () => { + count++; + nodeRect = node.get()?.getBoundingClientRect() || { + left: 0, + top: 0, + width: 0, + height: 0, + }; + if (nodeRect.height < 20 && count <= 50) { + setTimeout(() => { + getRect(); + }, 20); + } else { + mask.css({ + left: nodeRect.left - parentRect.left + 'px', + top: nodeRect.top - parentRect.top + 'px', + width: nodeRect.width + 'px', + height: nodeRect.height + 'px', + }); + } + }; + getRect(); + } else { + mask.css({ + left: nodeRect.left - parentRect.left + 'px', + top: nodeRect.top - parentRect.top + 'px', + width: nodeRect.width + 'px', + height: nodeRect.height + 'px', + }); + } + + mask.on('mouseenter', () => { + this.showCursorInfo(cursor, member); + Tooltip.show(mask, language.get('card', 'lockAlert').toString(), { + placement: 'bottomLeft', + }); + }); + + mask.on('mousemove', (event: MouseEvent) => { + const tooltipElement = $(`div[${DATA_ELEMENT}=tooltip]`); + tooltipElement.css({ + left: event.pageX - 16 + 'px', + top: event.pageY + 32 + 'px', + }); + }); + + mask.on('mouseleave', () => { + this.hideCursorInfo(cursor); + Tooltip.hide(); + }); + + mask.on('click', (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }); + + mask.on('mousedown', (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }); + this.root.append(mask); + } + + setCardSelectedByOther(card: CardInterface, member?: Member) { + const { uuid, color } = member || {}; + if (color) { + const tinyColor = tinycolor2(color); + tinyColor.setAlpha(0.3); + const rgb = tinyColor.toRgbString(); + let customNode; + if (!card.selectedByOther) { + customNode = card.onSelectByOther(true, { + color, + rgb, + }); + } + card.selectedByOther = uuid!; + return customNode; + } + if (card.selectedByOther) { + card.onSelectByOther(false); + } + card.selectedByOther = false; + } + + setCardActivatedByOther(card: CardInterface, member?: Member) { + if (card.isEditable) return; + const { uuid, color } = member || {}; + if (color) { + const tinyColor = tinycolor2(color); + tinyColor.setAlpha(0.3); + const rgb = tinyColor.toRgbString(); + let customNode; + if (!card.activatedByOther) { + customNode = card.onActivateByOther(true, { + color, + rgb, + }); + } + card.activatedByOther = uuid!; + return customNode; + } + if (card.activatedByOther) { + card.onActivateByOther(false); + } + card.activatedByOther = false; + } + + drawRange(range: RangeInterface, member: Member) { + const { card } = this.engine; + const { uuid } = member; + const { commonAncestorNode } = range; + let cardInfo = card.find(commonAncestorNode); + //如果是卡片,并且选区不在内容模块中,而是在卡片两侧的光标位置处,就不算作卡片 + if (cardInfo && !cardInfo.isCenter(commonAncestorNode)) { + cardInfo = undefined; + } + + card.each((cardComponent) => { + if (cardComponent.isEditable) return; + if (!cardInfo || !cardComponent.root.equal(cardInfo.root)) { + if (cardComponent.activatedByOther === uuid) { + this.setCardActivatedByOther(cardComponent); + } + this.root + .children(`.${USER_MASK_CLASS}[${DATA_UUID}="${uuid}"]`) + .remove(); + Tooltip.hide(); + } + }); + if (cardInfo && !cardInfo.isEditable) { + const root = + this.setCardActivatedByOther(cardInfo, member) || cardInfo.root; + + const collab = (cardInfo.constructor as CardEntry).collab; + if (collab === undefined || collab === true) { + const cursor = this.drawCursor(root, member); + if (cursor) this.drawCard(root, cursor, member); + this.drawBackground(range, member); + } + } else { + //可编辑卡片 + if (cardInfo) { + this.drawBackground(range, member); + return; + } + card.each((cardComponent) => { + const centerNode = cardComponent.getCenter(); + if (centerNode && centerNode.length > 0) { + if (cardComponent.isEditable) { + if ( + centerNode.contains(range.startNode) && + centerNode.contains(range.endNode) && + (range.startNode.closest(EDITABLE_SELECTOR).length > + 0 || + range.endNode.closest(EDITABLE_SELECTOR) + .length > 0) + ) { + this.setCardSelectedByOther(cardComponent); + return; + } + } + if (range.isPointInRange(centerNode.get()!, 0)) { + this.setCardSelectedByOther(cardComponent, member); + } else if (cardComponent.selectedByOther === uuid) { + this.setCardSelectedByOther(cardComponent); + } + } + }); + const singleCard = card.getSingleSelectedCard(range); + if (singleCard) { + if (singleCard.isEditable) { + const center = singleCard.getCenter(); + if ( + center.contains(range.startNode) && + center.contains(range.endNode) && + (range.startNode.closest(EDITABLE_SELECTOR).length > + 0 || + range.endNode.closest(EDITABLE_SELECTOR).length > 0) + ) { + return; + } + } + const root = + this.setCardSelectedByOther(singleCard, member) || + singleCard.root; + this.drawCursor(root, member); + } else { + range.shrinkToElementNode(); + const ranges = this.drawBackground(range, member); + if (!range.collapsed) { + ranges.forEach((sub) => { + if (!sub.collapsed) { + range = sub; + } + }); + range.shrinkToElementNode(); + range.collapse(false); + } + this.drawCursor(range, member); + } + } + } + + updateBackgroundPosition() { + this.root.children(`.${USER_BACKGROUND_CLASS}`).each((child) => { + const node = $(child); + const range = child['__range']; + const uuid = node.attributes(DATA_UUID); + const color = node.attributes(DATA_COLOR); + this.drawBackground(range, { + uuid, + color, + }); + }); + } + + updateCursorPosition() { + this.root.children(`.${USER_CURSOR_CLASS}`).each((child) => { + const node = $(child); + let target = child['__target']; + if (!target) { + node.remove(); + return; + } + if (!target.name) + target = Range.fromPath(this.engine, target, true); + if ( + target.startContainer || + 0 !== $(target).closest('body').length + ) { + const rect = this.getCursorRect(target); + this.setCursorRect(node, rect); + } else node.remove(); + }); + } + + updateCardPosition() { + const parentRect = this.root + .get() + ?.getBoundingClientRect() || { + left: 0, + top: 0, + }; + this.root.children(`.${USER_MASK_CLASS}`).each((child) => { + const node = $(child); + const target = child['__node']; + if (0 !== $(target).closest('body').length) { + const rect = target.getBoundingClientRect(); + node.css({ + left: rect.left - parentRect.left + 'px', + top: rect.top - parentRect.top + 'px', + }); + } else node.remove(); + }); + } + + updatePosition() { + this.updateBackgroundPosition(); + this.updateCursorPosition(); + this.updateCardPosition(); + } + + updateBackgroundAlpha(range: RangeInterface) { + const cursorRect = this.getCursorRect(range); + this.root.children(`.${USER_CURSOR_CLASS}`).each((child) => { + const node = $(child); + const trigger = node.find(`.${USER_CURSOR_TRIGGER_CLASS}`); + const left = node.css('left'); + const top = node.css('top'); + const bgColor = tinycolor2(node.css('background-color')); + if (cursorRect.left === left && cursorRect.top === top) { + bgColor.setAlpha(0.3); + } else { + bgColor.setAlpha(1); + } + node.css('background-color', bgColor.toRgbString()); + trigger.css('background-color', bgColor.toRgbString()); + }); + } + + render(data: Array, members: Array, idDraw: boolean) { + const { engine } = this; + const info = {}; + data.forEach((item) => { + const { path, uuid, active } = item; + const member = members.find((m) => m.uuid === uuid); + if (member && (idDraw || active)) { + if (path) { + const range = Range.fromPath(engine, path, true); + this.drawRange(range, member); + } else { + info[uuid] = true; + } + } + }); + this.root.children(`[${DATA_UUID}]`).each((child) => { + const domChild = $(child); + const uuid = domChild.attributes(DATA_UUID); + const member = members.find((m) => m.uuid === uuid); + if (!member || info[uuid]) { + if (domChild.hasClass(USER_MASK_CLASS)) { + const target = $(domChild[0]['__node']); + const component = engine.card.find(target); + if ( + component && + !component.isEditable && + component.activatedByOther === uuid + ) { + this.setCardActivatedByOther(component); + } + } + domChild.remove(); + } + }); + engine.card.each((component) => { + if (component.isEditable) return; + const member = members.find( + (m) => m.uuid === component.selectedByOther, + ); + if (!member || info[member.uuid]) { + this.setCardSelectedByOther(component); + } + }); + } +} +export default RangeColoring; diff --git a/packages/engine/src/ot/selection.ts b/packages/engine/src/ot/selection.ts new file mode 100644 index 00000000..6fba6fbc --- /dev/null +++ b/packages/engine/src/ot/selection.ts @@ -0,0 +1,145 @@ +import { EngineInterface } from '../types/engine'; +import { Attribute, Member, SelectionInterface } from '../types/ot'; +import { RangePath } from '..'; +import { CardType } from '../card/enum'; + +class OTSelection implements SelectionInterface { + private engine: EngineInterface; + currentRangePath?: { start: RangePath; end: RangePath }; + + constructor(engine: EngineInterface) { + this.engine = engine; + } + + getSelections() { + const { container } = this.engine; + const data: Array = []; + const attributes = container.get()?.attributes; + if (!attributes) return data; + for (let i = 0; i < attributes.length; i++) { + const item = attributes.item(i); + if (!item) continue; + const { nodeName, nodeValue } = item; + if (/^data-selection-/.test(nodeName) && nodeValue) { + const value = JSON.parse(decodeURIComponent(nodeValue)); + if (value) { + data.push(value); + } + } + } + return data; + } + + setSelections(data: Array) { + const { container } = this.engine; + const dataState: { [key: string]: boolean } = {}; + data.forEach((item) => { + if (item) { + const name = 'data-selection-'.concat(item.uuid); + dataState[name] = true; + const value = container.attributes(name); + const value_str = encodeURIComponent(JSON.stringify(item)); + if (value !== value_str) { + container.attributes(name, value_str); + } + } + }); + const attributes = container.get()?.attributes; + if (!attributes) return; + for (let i = 0; i < attributes.length; i++) { + const item = attributes.item(i); + if (!item) continue; + const { nodeName } = item; + if (/^data-selection-/.test(nodeName) && !dataState[nodeName]) { + container.removeAttributes(nodeName); + } + } + } + + remove(uuid: string) { + const { container } = this.engine; + container.removeAttributes(`data-selection-${uuid}`); + } + + updateSelections(currentMember: Member, members: Array) { + const { change, card } = this.engine; + const range = change.range.get().cloneRange(); + const activeCard = card.active; + if (activeCard && !activeCard.isEditable) { + const center = activeCard.getCenter(); + if (center && center.length > 0) { + range.select(center.get()!, true); + } + } else if ( + activeCard?.isEditable && + activeCard.updateBackgroundSelection + ) { + activeCard.updateBackgroundSelection(range); + } + if (!activeCard && !range.collapsed) { + const startCard = this.engine.card.find(range.startNode, true); + if (startCard && startCard.type === CardType.BLOCK) { + range.setStart(startCard.getCenter().parent()!, 1); + } + const endCard = this.engine.card.find(range.endNode, true); + if (endCard && endCard.type === CardType.BLOCK) { + range.setEnd(endCard.getCenter().parent()!, 1); + } + } + // 显示协作信息时包含左右光标位置 + const path = range.toPath(true); + // 用作历史记录的不包含卡片左右光标位置 + this.currentRangePath = range.toPath(); + const pathString = JSON.stringify(path); + let data: Array = this.getSelections(); + let isMember = false; + let isUpdate = false; + data = data.map((attr) => { + if (!attr) { + isUpdate = true; + return null; + } + + if (attr.uuid === currentMember.uuid) { + isMember = true; + if (pathString !== JSON.stringify(attr.path)) { + isUpdate = true; + attr.path = path; + attr.active = true; + } + return attr; + } else { + if (members.find((member) => member.uuid === attr.uuid)) { + attr.active = false; + return attr; + } else { + isUpdate = true; + return null; + } + } + }); + + const newData: Array = []; + data.forEach((attr) => { + if (!!attr) newData.push(attr); + }); + + if (!isMember) { + isUpdate = true; + newData.push({ + path, + uuid: currentMember.uuid, + active: true, + }); + } + if (isUpdate) { + this.setSelections(newData); + } + return { + data: newData, + range, + }; + } +} + +export default OTSelection; diff --git a/packages/engine/src/ot/utils.ts b/packages/engine/src/ot/utils.ts new file mode 100644 index 00000000..1f73f482 --- /dev/null +++ b/packages/engine/src/ot/utils.ts @@ -0,0 +1,364 @@ +import { isEqual } from 'lodash-es'; +import { NodeInterface } from '../types/node'; +import { FOCUS, ANCHOR, CURSOR } from '../constants/selection'; +import { CARD_KEY, CARD_SELECTOR, READY_CARD_KEY } from '../constants/card'; +import { + Op, + Path, + ObjectInsertOp, + ObjectDeleteOp, + ListInsertOp, + ListDeleteOp, + StringInsertOp, + StringDeleteOp, +} from 'sharedb'; +import { + DATA_ELEMENT, + DATA_TRANSIENT_ATTRIBUTES, + DATA_TRANSIENT_ELEMENT, + UI, + UI_SELECTOR, +} from '../constants/root'; +import { + getParentInRoot, + getWindow, + toHex, + unescapeDots, + unescape, +} from '../utils'; + +export const isTransientElement = ( + node: NodeInterface, + transientElements?: Array, +) => { + if (node.isElement()) { + //范围标记 + if ( + [CURSOR, ANCHOR, FOCUS].indexOf(node.attributes(DATA_ELEMENT)) > -1 + ) { + return true; + } + + //data-element=ui 属性 + if ( + !!node.attributes(DATA_TRANSIENT_ELEMENT) || + node.attributes(DATA_ELEMENT) === UI + ) { + return true; + } + const parent = node.parent(); + if (node.isRoot() || parent?.isRoot()) return false; + + const isCard = node.isCard(); + //父级是卡片,并且没有可编辑区域 + if (!isCard && parent?.isCard() && !parent.isEditableCard()) { + return true; + } + + if (transientElements) { + if ( + !isCard && + transientElements.find((element) => element === node[0]) + ) + return true; + } else { + let closestNode = node.closest( + `${CARD_SELECTOR},${UI_SELECTOR}`, + getParentInRoot, + ); + if ( + closestNode.length > 0 && + closestNode.attributes(DATA_ELEMENT) === UI + ) { + return true; + } + //在卡片里面,并且卡片不是可编辑卡片 或者是标记为正在异步渲染时的卡片 + if ( + !isCard && + closestNode.length > 0 && + closestNode.isCard() && + !closestNode.isEditableCard() + ) { + return true; + } + if (closestNode.length === 0) return false; + } + if (!isCard || node.isEditableCard()) return false; + //当前是卡片,父级也是卡片 + const parentCard = parent?.closest(CARD_SELECTOR, getParentInRoot); + if (parentCard && parentCard.isCard() && !parentCard.isEditableCard()) { + return true; + } + } + return false; +}; + +export const isTransientAttribute = (node: NodeInterface, attr: string) => { + if (node.isRoot() && !/^data-selection-/.test(attr)) return true; + if (node.isCard() && ['id', 'class', 'style'].includes(attr)) return true; + const transient = node.attributes(DATA_TRANSIENT_ATTRIBUTES); + if ( + transient === '*' || + transient + .split(',') + .some((value) => value.trim().toLowerCase() === attr.toLowerCase()) + ) + return true; + return false; +}; + +export const filterOperations = (ops: Op[]) => { + const data: Op[] = []; + for (let i = 0; i < ops.length; i++) { + const op = ops[i]; + const next = ops[i + 1]; + isReverseOp(op, next) ? i++ : data.push(op); + } + return data; +}; + +export const isCursorOp = (op: Op) => { + const insertOp = op as ObjectInsertOp; + const deleteOp = op as ObjectDeleteOp; + return ( + (insertOp.oi || deleteOp.od) && + op.p && + op.p.length === 2 && + op.p[0] === 1 && + op.p[1].toString().startsWith('data-selection-') + ); +}; + +export const isReverseOp = (op: Op, next: Op) => { + const insertOp = op as ListInsertOp; + const deleteOp = op as ListDeleteOp; + const insertNext = next as ListInsertOp; + const deleteNext = next as ListDeleteOp; + const insertStringOp = op as StringInsertOp; + const deleteStringOp = op as StringDeleteOp; + const insertStringNext = next as StringInsertOp; + const deleteStringNext = next as StringDeleteOp; + + if (!op || !next) return false; + + // 节点增加和删除 + if (insertOp.li && deleteNext.ld) { + return ( + isEqual(insertOp.li, deleteNext.ld) && + (isEqual(op.p, next.p) || + isReversePath(op.p, next.p) || + isReversePath(next.p, op.p)) + ); + } + + if (deleteOp.ld && insertNext.li) { + return isEqual(deleteOp.ld, insertNext.li) && isEqual(op.p, next.p); + } + + // 文本增加和删除 + if (insertStringOp.si && deleteStringNext.sd) { + return ( + isEqual(insertStringOp.si, deleteStringNext.sd) && + (isEqual(op.p, next.p) || + isReversePath(op.p, next.p, insertStringOp.si.length) || + isReversePath(next.p, op.p, insertStringOp.si.length)) + ); + } + + if (deleteStringOp.sd && insertStringNext.si) { + return ( + isEqual(deleteStringOp.sd, insertStringNext.si) && + isEqual(op.p, next.p) + ); + } + + return false; +}; + +const isReversePath = (op: Path, next: Path, length: number = 1): boolean => { + if (op.length !== next.length) return false; + const nextClone = next.slice(); + nextClone[nextClone.length - 1] = + (nextClone[nextClone.length - 1] as number) - length; + + return isEqual(op.slice(), nextClone); +}; + +export const updateIndex = ( + root: NodeInterface, + filter?: (child: NodeInterface) => boolean, +) => { + if (root.isText()) return; + let childrens = root.children().toArray(); + if (!root.isEditable()) { + childrens = filter ? childrens.filter(filter) : childrens; + } + childrens.forEach((child, index) => { + child[0]['__index'] = index; + if (!child.isText()) updateIndex(child); + }); +}; + +export const opsSort = (ops: Op[]) => { + ops.sort((op1, op2) => { + let diff = 0; + for (let p = 0; p < op1.p.length; p++) { + const v1 = op1.p[p]; + // od oi 最后一个参数是属性名称 + if (typeof v1 === 'string') break; + // op2 中没有这个索引路径,op1 < op2 + if (p >= op2.p.length) { + diff = -1; + break; + } + const v2 = op2.p[p]; + if (v1 < v2) { + diff = -1; + break; + } else if (v1 > v2) { + diff = 1; + break; + } + } + // 文字删除,排再最前面 + if ('sd' in op1) { + // 相同的文字删除,位置大的排再前面 + if ('sd' in op2) { + if (diff === -1) return 1; + if (diff === 0) return 0; + } + return -1; + } + // 属性删除,排在节点删除最前面 + if ('od' in op1 && diff < 1 && 'ld' in op2) { + return -1; + } + // 如果删除节点比增加的节点索引小,排在加入节点前面 + if (diff < 1 && 'ld' in op1 && 'li' in op2) return -1; + + const isLi = 'li' in op1 && 'li' in op2; + const isLd = 'ld' in op1 && 'ld' in op2; + // 都是新增节点,越小排越前面 + if (isLi) { + return diff; + } + // 都是删除节点,越大排越前面 + else if (isLd) { + if (diff === -1) return 1; + if (diff === 1) return -1; + } + return 0; + }); +}; + +export const toDOM = (ops: Op[] | Op[][]): Node => { + const fragment = document.createDocumentFragment(); + let elementName: string | null = null; + let i = 0; + let element: HTMLElement | null = null; + if (typeof ops[0] === 'string') { + elementName = ops[0]; + i = 1; + } + for (; i < ops.length; i++) { + if (Array.isArray(ops[i])) { + const prevOp = ops[i - 1]; + if ( + prevOp && + prevOp.toString() === '[object Object]' && + Object.keys(prevOp).includes(READY_CARD_KEY) + ) { + continue; + } + fragment.appendChild(toDOM(ops[i] as Op[])); + } else if ('[object Object]' === ops[i].toString()) { + if (elementName) { + element = document.createElement( + elementName, + ) as HTMLElement | null; + for (let attr in ops[i]) { + element?.setAttribute( + unescapeDots(attr), + unescape(ops[i][attr]), + ); + } + // 不是待渲染Card,转换为待渲染Card + const attributeKeys = Object.keys(ops[i]); + if ( + attributeKeys.indexOf(CARD_KEY) > -1 && + attributeKeys.indexOf(READY_CARD_KEY) < 0 + ) { + element?.setAttribute(READY_CARD_KEY, ops[i][CARD_KEY]); + element?.removeAttribute(CARD_KEY); + } + } + } else if ('number' === typeof ops[i] || 'string' === typeof ops[i]) { + const textNode = document.createTextNode(ops[i].toString()); + fragment.appendChild(textNode); + } + } + if (!element && elementName) { + element = document.createElement(elementName); + } + if (element) { + element.appendChild(fragment); + return element; + } else { + return fragment.childNodes[0]; + } +}; + +const childToJSON0 = (node: NodeInterface, values: Array<{} | string>) => { + const childNodes = node.children(); + if (0 !== childNodes.length) { + for (let i = 0; i < childNodes.length; i++) { + const child = childNodes.eq(i); + if (!child) continue; + const data = toJSON0(child); + if (data) { + values.push(data); + } + } + } +}; + +export const toJSON0 = ( + node: NodeInterface, +): string | undefined | (string | {})[] => { + let values: Array<{} | string>; + if (!isTransientElement(node)) { + const { attributes, nodeValue } = node.get()!; + if (node.type === getWindow().Node.ELEMENT_NODE) { + values = [node.name]; + const data = {}; + for (let i = 0; attributes && i < attributes.length; i++) { + const { name, specified, value } = attributes[i]; + if (specified && !isTransientAttribute(node, name)) { + if (name === 'style') { + data['style'] = toHex( + node.get()?.style.cssText || value, + ); + } else if ('string' === typeof value) { + data[name] = value; + } + } + } + values.push(data); + childToJSON0(node, values); + return values; + } + return node.type === getWindow().Node.TEXT_NODE + ? String(nodeValue) + : undefined; + } + return; +}; + +export const getValue = (data: any, path: Path) => { + if (path.length === 0) return data; + let value = data; + for (let i = 0; i < path.length && value !== undefined; i++) { + value = value[path[i]]; + } + return value; +}; diff --git a/packages/engine/src/parser/conversion.ts b/packages/engine/src/parser/conversion.ts new file mode 100644 index 00000000..af24bd70 --- /dev/null +++ b/packages/engine/src/parser/conversion.ts @@ -0,0 +1,125 @@ +import { cloneDeep } from 'lodash-es'; +import { + CARD_KEY, + CARD_TYPE_KEY, + CARD_VALUE_KEY, + DATA_ELEMENT, + READY_CARD_KEY, +} from '../constants'; +import { + EditorInterface, + NodeInterface, + ConversionInterface, + ConversionData, + ConversionFromValue, + ConversionToValue, +} from '../types'; + +class Conversion implements ConversionInterface { + private editor: EditorInterface; + + private data: ConversionData = []; + + constructor(editor: EditorInterface) { + this.editor = editor; + } + + getData() { + return this.data; + } + + clone() { + const dupData = cloneDeep(this.data); + const dupConversion = new Conversion(this.editor); + dupConversion.data = dupData; + return dupConversion; + } + + add(from: ConversionFromValue, to: ConversionToValue) { + this.data.push({ from, to }); + } + + transform( + node: NodeInterface, + filter?: (item: { + from: ConversionFromValue; + to: ConversionToValue; + }) => boolean, + ) { + let name = node.name; + let attributes = node.attributes(); + let style = node.css(); + //删除属性中的style属性 + delete attributes.style; + // 光标相关节点 + if (node.isCursor()) { + name = attributes[DATA_ELEMENT].toLowerCase(); + attributes = {}; + style = {}; + } + + const rule = this.data.find((item) => { + if (filter && !filter(item)) return; + const { from, to } = item; + let result = false; + if (typeof from === 'string') { + result = from === name; + } else if (typeof from === 'function') { + result = from(name, style, attributes); + } else { + const elementNames = Object.keys(from); + result = + elementNames.indexOf(name) >= 0 && + elementNames.some((elementName: string) => { + const elementRules = from[elementName]; + return ( + Object.keys(elementRules.style || {}).every( + (styleName) => { + const styleValue = + elementRules.style![styleName]; + return !!style[styleName] && + Array.isArray(styleValue) + ? styleValue.indexOf(style[styleName]) > + -1 + : styleValue === style[styleName]; + }, + ) && + Object.keys(elementRules.attributes || {}).every( + (attributesName) => { + const attributesValue = + elementRules.attributes![ + attributesName + ]; + return !!style[attributesName] && + Array.isArray(attributesValue) + ? attributesValue.indexOf( + style[attributesName], + ) > -1 + : attributesValue === + style[attributesName]; + }, + ) + ); + }); + } + if (result) { + if (typeof to === 'string') { + name = to; + style = {}; + attributes = {}; + } else { + const node = + typeof to === 'function' + ? to(name, style, attributes) + : to; + name = node.name; + style = node.css(); + attributes = node.attributes(); + } + } + return result; + }); + return rule ? { rule, node: { name, style, attributes } } : undefined; + } +} +export default Conversion; diff --git a/packages/engine/src/parser/index.ts b/packages/engine/src/parser/index.ts new file mode 100644 index 00000000..98827114 --- /dev/null +++ b/packages/engine/src/parser/index.ts @@ -0,0 +1,558 @@ +import { NodeInterface } from '../types/node'; +import { DATA_ELEMENT, DATA_ID, EDITABLE } from '../constants/root'; +import { EditorInterface } from '../types/engine'; +import { + SchemaInterface, + ParserInterface, + Callbacks, + ConversionInterface, + ConversionRule, + SchemaRule, +} from '../types'; +import { CARD_ELEMENT_KEY, CARD_KEY, READY_CARD_KEY } from '../constants'; +import { + escape, + unescape, + removeUnit, + toHex, + transformCustomTags, + getListStyle, + getWindow, +} from '../utils'; +import TextParser from './text'; +import { $ } from '../node'; +import { isNodeEntry } from '../node/utils'; + +const attrsToString = (attributes: { [k: string]: string }) => { + let attrsString = ''; + Object.keys(attributes).forEach((key) => { + if (key === 'style') { + return; + } + const val = escape(attributes[key]); + attrsString += ' '.concat(key, '="').concat(val, '"'); + }); + return attrsString.trim(); +}; + +const stylesToString = (styles: { [k: string]: string }) => { + let stylesString = ''; + Object.keys(styles).forEach((key) => { + let val = escape(styles[key]); + + if ( + /^(padding|margin|text-indent)/.test(key) && + removeUnit(val) === 0 + ) { + return; + } + + if (/[^a-z]color$/.test(key)) { + val = toHex(val); + } + + stylesString += ' '.concat(key, ': ').concat(val, ';'); + }); + return stylesString.trim(); +}; + +class Parser implements ParserInterface { + private root: NodeInterface; + private editor: EditorInterface; + constructor( + source: string | Node | NodeInterface, + editor: EditorInterface, + paserBefore?: (node: NodeInterface) => void, + ) { + this.editor = editor; + const { node } = this.editor; + if (typeof source === 'string') { + source = source.replace(//gi, ''); + source = source.replace(/]+?)\/>/gi, (_, t) => { + return ''); + }); + // 在 p 里包含 div 标签时 DOMParser 解析错误 + //

      foo

      + // 变成 + //
      foo
      + source = source + .replace(/|\s+[^>]*>)/gi, '/gi, ''); + source = transformCustomTags(source); + const doc = new (getWindow().DOMParser)().parseFromString( + source, + 'text/html', + ); + this.root = $(doc.body); + const p = $('

      '); + const paragraphs = this.root.find('paragraph'); + paragraphs.each((_, index) => { + const cNode = paragraphs.eq(index); + if (!cNode) return; + const pNode = p.clone(); + const attributes = cNode.attributes(); + Object.keys(attributes).forEach((name) => { + pNode.attributes(name, attributes[name]); + }); + node.replace(cNode, pNode); + }); + } else if (isNodeEntry(source)) { + this.root = source; + } else { + this.root = $(source); + } + if (paserBefore) paserBefore(this.root); + } + normalize( + root: NodeInterface, + schema: SchemaInterface, + conversion: ConversionInterface | null, + ) { + const nodeApi = this.editor.node; + const inlineApi = this.editor.inline; + //转换标签和分割 mark 和 inline 标签 + root.traverse((node) => { + if ( + node.equal(root) || + ['style', 'script', 'meta'].includes(node.name) + ) + return; + if (node.isElement()) { + //转换标签 + if (conversion && (!schema.getType(node) || node.isCard())) { + let value = conversion.transform(node); + const oldRules: Array = []; + while (value) { + const { rule } = value; + oldRules.push(rule); + const { name, attributes, style } = value.node; + const newNode = $(`<${name} />`); + nodeApi.setAttributes(newNode, { + ...attributes, + style, + }); + //把旧节点的子节点追加到新节点下 + newNode.append(node.children()); + if ( + node.attributes(CARD_KEY) || + node.attributes(READY_CARD_KEY) + ) { + node.before(newNode); + node.remove(); + value = undefined; + continue; + } else { + if (!nodeApi.isBlock(newNode)) { + //把包含旧子节点的新节点追加到旧节点下 + node.append(newNode); + } else { + // 替换 + node.before(newNode); + node.remove(); + break; + } + } + //排除之前的过滤规则后再次过滤 + value = conversion.transform( + node, + (r) => oldRules.indexOf(r) < 0, + ); + } + } + if ( + node.attributes(CARD_KEY) || + node.attributes(READY_CARD_KEY) + ) + return; + //分割 + const filter = (node: NodeInterface) => { + //获取节点属性样式 + const attributes = node.attributes(); + const style = node.css(); + delete attributes.style; + if ( + Object.keys(attributes).length === 0 && + Object.keys(style).length === 0 + ) + return; + //过滤不符合当前节点规则的属性样式 + schema.filter(node, attributes, style); + //复制一个节点 + const newNode = node.clone(); + //移除 data-id,以免在下次判断类型的时候使用缓存 + newNode.removeAttributes(DATA_ID); + //移除符合当前节点的属性样式,剩余的属性样式组成新的节点 + Object.keys(attributes).forEach((name) => { + if (attributes[name]) { + newNode.removeAttributes(name); + } + }); + Object.keys(style).forEach((name) => { + if (style[name]) { + newNode.css(name, ''); + } + }); + if (newNode.attributes('style').trim() === '') + newNode.removeAttributes('style'); + return newNode; + }; + //当前节点是 inline 节点,inline 节点不允许嵌套、不允许放入mark节点 + inlineApi.flat(node); + //当前节点是 mark 节点 + if (nodeApi.isMark(node)) { + //过滤掉当前mark节点属性样式并使用剩下的属性样式组成新的节点 + const oldRules: Array = []; + let rule = schema.getRule(node); + if (rule) { + oldRules.push(rule); + let newNode = filter(node); + if (!newNode) return; + //获取这个新的节点所属类型,并且不能是之前节点一样的规则 + let type = schema.getType( + newNode, + (rule) => + rule.name === newNode!.name && + rule.type === 'mark' && + oldRules.indexOf(rule) < 0, + ); + //如果是mark节点,使用新节点包裹旧节点子节点 + while (type === 'mark') { + const children = node.children(); + newNode.append( + children.length > 0 + ? children + : $('\u200b', null), + ); + node.append(newNode); + newNode = filter(newNode); + if (!newNode) break; + //获取这个新的节点所属类型,并且不能是之前节点一样的规则 + type = schema.getType( + newNode, + (rule) => + rule.name === newNode!.name && + rule.type === 'mark' && + oldRules.indexOf(rule) < 0, + ); + if (!type) break; + rule = schema.getRule(newNode); + if (!rule) break; + oldRules.push(rule); + } + } + } + } + }); + } + + traverse( + node: NodeInterface, + schema: SchemaInterface | null = null, + conversion: ConversionInterface | null, + callbacks: Callbacks, + includeCard?: boolean, + ) { + const nodeApi = this.editor.node; + + let child = node.first(); + while (child) { + if (['style', 'script', 'meta'].includes(child.name)) { + child = child.next(); + continue; + } + if (child.isElement()) { + let name = child.name; + let attributes = child.attributes(); + let styles = child.css(); + //删除属性中的style属性 + delete attributes.style; + + // Card Combine 相关节点 + if ( + ['left', 'right'].indexOf(attributes[CARD_ELEMENT_KEY]) >= 0 + ) { + child = child.next(); + continue; + } + let passed = true; + let type: 'inline' | 'block' | 'mark' | undefined = undefined; + if (schema && attributes[DATA_ELEMENT] !== EDITABLE) { + //不符合规则,跳过 + type = schema.getType(child); + if (type === undefined) { + passed = false; + } else { + //过滤不符合规则的属性和样式 + schema.filter(child, attributes, styles); + } + } + // 执行回调函数 + if ( + attributes[CARD_ELEMENT_KEY] !== 'center' && + callbacks.onOpen && + passed + ) { + const result = callbacks.onOpen( + child, + name, + attributes, + styles, + ); + //终止遍历当前节点 + if (result === false) { + child = child.next(); + continue; + } + } + // Card不遍历子节点 + if (name !== 'card' || includeCard) { + this.traverse( + child, + schema, + conversion, + callbacks, + includeCard, + ); + } + // 执行回调函数 + if ( + attributes[CARD_ELEMENT_KEY] !== 'center' && + callbacks.onClose && + passed + ) { + callbacks.onClose(child, name, attributes, styles); + } + } else if (child.isText()) { + let text = child[0].nodeValue ? escape(child[0].nodeValue) : ''; + if (text === '' && nodeApi.isBlock(child.parent()!)) { + if (!child.prev()) { + text = text.replace(/^[ \n]+/, ''); + } + + if (!child.next()) { + text = text.replace(/[ \n]+$/, ''); + } + } + const childPrev = child.prev(); + const childNext = child.next(); + if ( + childPrev && + nodeApi.isBlock(childPrev) && + childNext && + nodeApi.isBlock(childNext) && + text.trim() === '' + ) { + text = text.trim(); + } + // 删除 zero width space,删除后会导致空行中如果有mark节点,那么空行会没有高度 + // text = text.replace(/\u200B/g, ''); + if (callbacks.onText) { + callbacks.onText(child, text); + } + } + child = child.next(); + } + } + /** + * 遍历 DOM 树,生成符合标准的 XML 代码 + * @param schema 标签保留规则 + * @param conversion 标签转换规则 + * @param replaceSpaces 是否替换空格 + * @param customTags 是否将光标、卡片节点转换为标准代码 + */ + toValue( + schema: SchemaInterface | null = null, + conversion: ConversionInterface | null = null, + replaceSpaces: boolean = false, + customTags: boolean = false, + ) { + const result: Array = []; + const nodeApi = this.editor.node; + const root = this.root.clone(true); + if (schema) this.normalize(root, schema, conversion); + this.editor.trigger('parse:value-before', root); + this.traverse(root, schema, conversion, { + onOpen: (child, name, attributes, styles) => { + if ( + this.editor.trigger( + 'parse:value', + child, + attributes, + styles, + result, + ) === false + ) + return false; + + result.push('<'); + result.push(name); + + if (Object.keys(attributes).length > 0) { + result.push(' ' + attrsToString(attributes)); + } + + if (Object.keys(styles).length > 0) { + const stylesString = stylesToString(styles); + if (stylesString !== '') { + result.push(' style="'); + result.push(stylesString); + result.push('"'); + } + } + + if (nodeApi.isVoid(name, schema ? schema : undefined)) { + result.push(' />'); + } else { + result.push('>'); + } + return; + }, + onText: (_, text) => { + if (replaceSpaces && text.length > 1) { + text = text.replace(/[\u00a0 ]+/g, (item) => { + const strArray = []; + item = item.replace(/\u00a0/g, ' '); + for (let n = 0; n < item.length; n++) + strArray[n] = n % 2 == 0 ? item[n] : ' '; + return strArray.join(''); + }); + } + result.push(text); + }, + onClose: (_, name) => { + if (nodeApi.isVoid(name, schema ? schema : undefined)) return; + result.push('')); + }, + }); + this.editor.trigger('parse:value-after', result); + //移除前后的换行符 + if (result.length > 0 && /^\n+/g.test(result[0])) { + result[0] = result[0].replace(/^\n+/g, ''); + } + if (result.length > 0 && /^\n+/g.test(result[result.length - 1])) { + result[result.length - 1] = result[result.length - 1].replace( + /^\n+/g, + '', + ); + } + const value = result.join(''); + return customTags ? transformCustomTags(value) : value; + } + + /** + * 转换为HTML代码 + * @param inner 内包裹节点 + * @param outter 外包裹节点 + */ + toHTML(inner?: Node, outter?: Node) { + const element = $('
      '); + if (inner && outter) { + $(inner).append(this.root).css(this.editor.container.css()); + element.append(outter); + } else { + element.append(this.root); + } + this.editor.trigger('parse:html-before', this.root); + element.traverse((domNode) => { + const node = domNode.get(); + if ( + node && + node.nodeType === getWindow().Node.ELEMENT_NODE && + 'none' === node.style['user-select'] && + node.parentNode + ) { + node.parentNode.removeChild(node); + } + }); + this.editor.trigger('parse:html', element); + element.find('p').css(this.editor.container.css()); + this.editor.trigger('parse:html-after', element); + return { + html: element.html(), + text: new Parser(element, this.editor).toText( + this.editor.schema, + true, + ), + }; + } + + /** + * 返回DOM树 + */ + toDOM( + schema: SchemaInterface | null = null, + conversion: ConversionInterface | null, + ) { + const value = this.toValue(schema, conversion, false, true); + const doc = new DOMParser().parseFromString(value, 'text/html'); + const fragment = doc.createDocumentFragment(); + const nodes = doc.body.childNodes; + + while (nodes.length > 0) { + fragment.appendChild(nodes[0]); + } + return fragment; + } + + /** + * 转换为文本 + * @param schema Schema 规则 + * @param includeCard 是否遍历卡片内部 + * @param formatOL 是否格式化有序列表,
      1. a
      2. b
      -> 1. a 2. b + */ + toText( + schema: SchemaInterface | null = null, + includeCard?: boolean, + formatOL: boolean = true, + ) { + const root = this.root.clone(true); + const result: Array = []; + this.traverse( + root, + null, + null, + { + onOpen: (node, name) => { + if (name === 'br') { + result.push('\n'); + } + if (formatOL && node.name === 'li') { + if (node.hasClass('data-list-item')) { + return; + } + const parent = node.parent(); + const styleType = parent?.css('listStyleType'); + if (parent?.name === 'ol') { + const start = parent[0]['start']; + const index = start ? start : 1; + result.push(`${getListStyle(styleType, index)}. `); + parent.attributes('start', index + 1); + } else if (parent?.name === 'ul') { + result.push(getListStyle(styleType) + ' '); + } + } + }, + onText: (_, text) => { + text = unescape(text); + text = text.replace(/\u00a0/g, ' '); + result.push(text); + }, + onClose: (node, name) => { + if ( + name === 'p' || + this.editor.node.isBlock( + node, + schema || this.editor.schema, + ) + ) { + result.push('\n'); + } + }, + }, + includeCard, + ); + return result.join('').trim(); + } +} +export default Parser; +export { TextParser }; diff --git a/packages/engine/src/parser/text.ts b/packages/engine/src/parser/text.ts new file mode 100644 index 00000000..38ca66a5 --- /dev/null +++ b/packages/engine/src/parser/text.ts @@ -0,0 +1,23 @@ +import { escape } from '../utils/string'; +class TextParser { + source: any; + + constructor(source: any) { + this.source = source; + } + + toHTML() { + let html = escape(this.source); + html = html + .replace(/\n/g, '

      ') + .replace(/

      <\/p>/g, '


      ') + .replace(/^\s/, ' ') + .replace(/\s$/, ' ') + .replace(/\s\s/g, '  '); + if (html.indexOf('

      ') >= 0) { + html = '

      '.concat(html, '

      '); + } + return html; + } +} +export default TextParser; diff --git a/packages/engine/src/plugin/base.ts b/packages/engine/src/plugin/base.ts new file mode 100644 index 00000000..249322af --- /dev/null +++ b/packages/engine/src/plugin/base.ts @@ -0,0 +1,59 @@ +import { CardInterface } from '../types/card'; +import { EditorInterface } from '../types/engine'; +import { PluginOptions, PluginInterface } from '../types/plugin'; + +abstract class PluginEntry + implements PluginInterface +{ + protected readonly editor: EditorInterface; + protected options: T; + constructor(editor: EditorInterface, options: PluginOptions) { + this.editor = editor; + this.options = (options || {}) as T; + const { disabled } = this.options; + this.disabled = disabled; + // TODO:this.disabledPlugins = disabledPlugins || []; + } + static readonly pluginName: string; + readonly kind: string = 'plugin'; + disabled?: boolean; + // TODO:disabledPlugins: Array = []; + /** + * 初始化 + */ + init?(): void; + /** + * 查询插件状态 + * @param args 插件需要的参数 + */ + queryState?(...args: any): any; + /** + * 执行插件 + * @param args 插件需要的参数 + */ + abstract execute(...args: any): void; + /** + * 插件热键绑定,返回需要匹配的组合键字符,如 mod+b,匹配成功即执行插件,还可以带上插件执行所需要的参数,多个参数以数组形式返回{key:"mod+b",args:[]} + * @param event 键盘事件 + */ + hotkey?( + event?: KeyboardEvent, + ): + | string + | { key: string; args: any } + | Array<{ key: string; args: any }> + | Array; + /** + * 插件等待动作 + * @param callback 有待等待的动作时回调 + */ + async waiting?( + callback?: ( + name: string, + card?: CardInterface, + ...args: any + ) => boolean | number | void, + ): Promise; +} + +export default PluginEntry; diff --git a/packages/engine/src/plugin/block.ts b/packages/engine/src/plugin/block.ts new file mode 100644 index 00000000..b8a7901f --- /dev/null +++ b/packages/engine/src/plugin/block.ts @@ -0,0 +1,54 @@ +import ElementPluginEntry from './element'; +import { SchemaBlock, BlockInterface, NodeInterface } from '../types'; + +abstract class BlockEntry + extends ElementPluginEntry + implements BlockInterface +{ + readonly kind: string = 'block'; + /** + * 标签名称 + */ + abstract readonly tagName: string | Array; + + /** + * 该节点允许可以放入的block节点 + */ + readonly allowIn?: Array; + /** + * 禁用的mark插件样式 + */ + readonly disableMark?: Array; + /** + * 是否能够合并 + */ + readonly canMerge?: boolean; + + schema(): SchemaBlock | Array { + const schema = super.schema(); + if (Array.isArray(schema)) { + return schema.map((schema) => { + return { + ...schema, + allowIn: this.allowIn, + } as SchemaBlock; + }); + } + return { + ...schema, + allowIn: this.allowIn, + canMerge: this.canMerge, + } as SchemaBlock; + } + /** + * Markdown 处理 + */ + markdown?( + event: KeyboardEvent, + text: string, + block: NodeInterface, + node: NodeInterface, + ): boolean | void; +} + +export default BlockEntry; diff --git a/packages/engine/src/plugin/element.ts b/packages/engine/src/plugin/element.ts new file mode 100644 index 00000000..1c02a606 --- /dev/null +++ b/packages/engine/src/plugin/element.ts @@ -0,0 +1,247 @@ +import { + PluginOptions, + ElementPluginInterface, + NodeInterface, + ConversionData, +} from '../types'; +import { + SchemaAttributes, + SchemaGlobal, + SchemaRule, + SchemaStyle, + SchemaValue, +} from '../types/schema'; +import { toHex } from '../utils'; +import { $ } from '../node'; +import PluginEntry from './base'; +import { isNode } from '../node/utils'; + +abstract class ElementPluginEntry + extends PluginEntry + implements ElementPluginInterface +{ + /** + * 规则缓存 + */ + private sechamCache?: SchemaRule | SchemaGlobal | Array; + /** + * 标签名称,没有标签名称,style 和 attributes 将以全局属性方式添加 + */ + readonly tagName?: string | Array; + /** + * 标签样式,可选 + * 使用变量表示值时,固定规则:@var0 @var1 @var2 ... 分别表示执行 command.execute 时传入的 参数1 参数2 参数3 ... + * { value:string,format:(value:string) => string } 可以在获取节点属性值时,对值进行自定义格式化处理 + */ + readonly style?: { + [key: string]: + | string + | { value: string; format: (value: string) => string }; + }; + /** + * 标签属性,可选 + * 使用变量表示值时,固定规则:@var0 @var1 @var2 ... 分别表示执行 command.execute 时传入的 参数1 参数2 参数3 ... + * { value:string,format:(value:string) => string } 可以在获取节点属性值时,对值进行自定义格式化处理 + */ + readonly attributes?: { + [key: string]: + | string + | { value: string; format: (value: string) => string }; + }; + /** + * 在 style 或者 attributes 使用变量表示的值规则 + * key 为如上所诉的变量名称 @var0 @var1 @var2 ... + */ + readonly variable?: { [key: string]: SchemaValue }; + /** + * 初始化 + */ + init(): void { + const { schema, conversion } = this.editor; + schema.add(this.schema()); + if (this.conversion) { + this.conversion().forEach(({ from, to }) => { + conversion.add(from, to); + }); + } + } + /** + * 将当前插件style属性应用到节点 + * @param node 节点 + * @param args style 对应 variable 中的变量参数 + */ + setStyle(node: NodeInterface | Node, ...args: Array) { + if (isNode(node)) node = $(node); + if (this.style) { + Object.keys(this.style).forEach((styleName) => { + let styleValue = this.style![styleName]; + if (typeof styleValue === 'object') + styleValue = styleValue.value; + //替换变量 + styleValue.match(/@var\d/g)?.forEach((regMatch) => { + const index = parseInt(regMatch.replace('@var', ''), 10); + styleValue = (styleValue as string).replace( + new RegExp(regMatch, 'gm'), + args[index] || '', + ); + }); + (node as NodeInterface).css(styleName, styleValue); + }); + } + } + /** + * 将当前插件attributes属性应用到节点 + * @param node 节点 + * @param args attributes 对应 variable 中的变量参数 + */ + setAttributes(node: NodeInterface | Node, ...args: Array) { + if (isNode(node)) node = $(node); + if (this.attributes) { + Object.keys(this.attributes).forEach((attributesName) => { + let attributesValue = this.attributes![attributesName]; + if (typeof attributesValue === 'object') + attributesValue = attributesValue.value; + //替换变量 + attributesValue.match(/@var\d/g)?.forEach((regMatch) => { + const index = parseInt(regMatch.replace('@var', ''), 10); + attributesValue = (attributesValue as string).replace( + new RegExp(regMatch, 'gm'), + args[index] || '', + ); + }); + (node as NodeInterface).attributes( + attributesName, + attributesValue, + ); + }); + } + } + /** + * 获取节点符合当前插件规则的样式 + * @param node 节点 + * @returns 样式名称和样式值键值对 + */ + getStyle(node: NodeInterface | Node) { + if (isNode(node)) node = $(node); + const values: { [k: string]: string } = {}; + if (this.style && this.isSelf(node)) { + Object.keys(this.style).forEach((styleName) => { + node = node as NodeInterface; + let value = + styleName.toLowerCase().indexOf('color') > -1 + ? toHex(node.css(styleName) || '') + : node.css(styleName); + let styleValue = this.style![styleName]; + if (typeof styleValue === 'object') { + value = styleValue.format(value); + } + if (!!value) values[styleName] = value; + }); + } + return values; + } + /** + * 获取节点符合当前插件规则的属性 + * @param node 节点 + * @returns 属性名称和属性值键值对 + */ + getAttributes(node: NodeInterface | Node) { + if (isNode(node)) node = $(node); + const values: { [k: string]: string } = {}; + if (this.attributes && this.isSelf(node)) { + Object.keys(this.attributes).forEach((attributesName) => { + let value = (node as NodeInterface).attributes(attributesName); + let attributesValue = this.attributes![attributesName]; + if (typeof attributesValue === 'object') { + value = attributesValue.format(value); + } + if (!!value) values[attributesName] = value; + }); + } + return values; + } + /** + * 检测当前节点是否符合当前插件设置的规则 + * @param node 节点 + * @returns true | false + */ + isSelf(node: NodeInterface | Node) { + if (isNode(node)) node = $(node); + let schema: SchemaRule | SchemaGlobal | Array | undefined = + this.schema(); + if (Array.isArray(schema)) + schema = schema.find( + ({ name }) => name === (node as NodeInterface).name, + ); + if (!schema) return false; + return ( + (Array.isArray(this.tagName) + ? this.tagName.indexOf(node.name) > -1 + : node.name === this.tagName) && + this.editor.schema.checkNode(node, schema.attributes) + ); + } + + /** + * 获取插件设置的属性和样式所生成的规则 + */ + schema(): SchemaRule | SchemaGlobal | Array { + if (this.sechamCache) return this.sechamCache; + let attributes: SchemaAttributes | SchemaStyle = {}; + if (this.attributes) { + //替换变量 + Object.keys(this.attributes).forEach((attributesName) => { + let attributesValue = this.attributes![attributesName]; + if (typeof attributesValue === 'object') + attributesValue = attributesValue.value; + attributes[attributesName] = attributesValue; + attributesValue + .match(/@var\d/g) + ?.forEach((regMatch: string) => { + if (!this.variable) + throw 'Please specify the variable type'; + attributes[attributesName] = this.variable[regMatch]; + }); + }); + } + if (this.style) { + //替换变量 + const style: { [key: string]: SchemaValue } = {}; + Object.keys(this.style).forEach((styleName) => { + let styleValue = this.style![styleName]; + if (typeof styleValue === 'object') + styleValue = styleValue.value; + + styleValue.match(/@var\d/g)?.forEach((regMatch: string) => { + if (!this.variable) + throw 'Please specify the variable type'; + style[styleName] = this.variable[regMatch]; + }); + }); + attributes = { ...attributes, style }; + } + + this.sechamCache = { + type: this.kind as any, + attributes, + } as SchemaGlobal; + if (typeof this.tagName === 'string') + (this.sechamCache as SchemaRule).name = this.tagName.toLowerCase(); + else if (Array.isArray(this.tagName)) { + const sechamValue: Array = []; + this.tagName.forEach((name) => { + sechamValue.push({ ...(this.sechamCache as SchemaRule), name }); + }); + this.sechamCache = sechamValue; + } + + return this.sechamCache; + } + + /** + * 在粘贴时的标签转换,例如:b > strong + */ + conversion?(): ConversionData; +} + +export default ElementPluginEntry; diff --git a/packages/engine/src/plugin/index.ts b/packages/engine/src/plugin/index.ts new file mode 100644 index 00000000..44f3cd1f --- /dev/null +++ b/packages/engine/src/plugin/index.ts @@ -0,0 +1,75 @@ +import { EditorInterface } from '../types/engine'; +import { + PluginEntry, + PluginInterface, + PluginModelInterface, + PluginOptions, +} from '../types/plugin'; +import Plugin from './base'; +import ElementPlugin from './element'; +import BlockPlugin from './block'; +import InlinePlugin from './inline'; +import ListPlugin from './list'; +import MarkPlugin from './mark'; +import { BlockInterface } from '../types/block'; +import { isEngine } from '../utils'; + +class PluginModel implements PluginModelInterface { + protected data: { [k: string]: PluginEntry } = {}; + components: { [k: string]: PluginInterface } = {}; + protected editor: EditorInterface; + constructor(editor: EditorInterface) { + this.editor = editor; + } + + init(plugins: Array, config: { [k: string]: PluginOptions }) { + plugins.forEach((pluginClazz) => { + this.data[pluginClazz.pluginName] = pluginClazz; + const plugin = new pluginClazz( + this.editor, + config[pluginClazz.pluginName], + ); + this.components[pluginClazz.pluginName] = plugin; + if (plugin.init) plugin.init(); + }); + } + + add(clazz: PluginEntry, options?: PluginOptions) { + this.data[clazz.pluginName] = clazz; + options = { ...options }; + if (isEngine(this.editor)) { + const plugin = new clazz(this.editor, options); + if (plugin.init) plugin.init(); + this.components[clazz.pluginName] = plugin; + } + } + + each( + callback: ( + name: string, + clazz: PluginEntry, + index?: number, + ) => boolean | void, + ): void { + Object.keys(this.data).forEach((name, index) => { + if (callback && callback(name, this.data[name], index) === false) + return; + }); + } +} +export default PluginModel; + +export { + Plugin, + ElementPlugin, + MarkPlugin, + InlinePlugin, + BlockPlugin, + ListPlugin, +}; + +export const isBlockPlugin = ( + plugin: PluginInterface, +): plugin is BlockInterface => { + return plugin.kind === 'block'; +}; diff --git a/packages/engine/src/plugin/inline.ts b/packages/engine/src/plugin/inline.ts new file mode 100644 index 00000000..56967955 --- /dev/null +++ b/packages/engine/src/plugin/inline.ts @@ -0,0 +1,183 @@ +import ElementPluginEntry from './element'; + +import { + InlineInterface, + NodeInterface, + PluginEntry as PluginEntryType, + PluginInterface, +} from '../types'; +import { $ } from '../node'; +import { isEngine } from '../utils'; + +abstract class InlineEntry + extends ElementPluginEntry + implements InlineInterface +{ + readonly kind: string = 'inline'; + /** + * 标签名称 + */ + abstract readonly tagName: string; + /** + * Markdown 规则,可选 + */ + readonly markdown?: string; + + init() { + super.init(); + const editor = this.editor; + if (isEngine(editor) && this.markdown) { + editor.on( + 'paste:markdown-check', + (child) => !this.checkMarkdown(child)?.match, + ); + editor.on('paste:markdown', (child) => this.pasteMarkdown(child)); + } + } + + execute(...args: any) { + const editor = this.editor; + if (!isEngine(editor)) return; + const inlineNode = $(`<${this.tagName} />`); + this.setStyle(inlineNode, ...args); + this.setAttributes(inlineNode, ...args); + const { inline } = editor; + const trigger = this.isTrigger + ? this.isTrigger(...args) + : !this.queryState(); + if (trigger) { + inline.wrap(inlineNode); + } else { + inline.unwrap(); + } + } + + queryState() { + const editor = this.editor; + if (!isEngine(editor)) return; + const { change } = editor; + //如果没有属性和样式限制,直接查询是否包含当前标签名称 + if (!this.style && !this.attributes) + return change.inlines.some((node) => node.name === this.tagName); + //获取属性和样式限制内的值集合 + const values: Array = []; + change.inlines.forEach((node) => { + values.push(...Object.values(this.getStyle(node))); + values.push(...Object.values(this.getAttributes(node))); + }); + return values.length === 0 ? undefined : values; + } + + /** + * 是否触发执行增加当前inline标签包裹,否则将移除当前inline标签的包裹 + * @param args 在调用 command.execute 执行插件传入时的参数 + */ + isTrigger?(...args: any): boolean; + /** + * 解析markdown + * @param event 事件 + * @param text markdown文本 + * @param node 触发节点 + */ + triggerMarkdown(event: KeyboardEvent, text: string, node: NodeInterface) { + const editor = this.editor; + if (!isEngine(editor) || !this.markdown) return; + const { change, command } = editor; + const key = this.markdown.replace(/(\*|\^|\$)/g, '\\$1'); + const match = new RegExp(`^(.*)${key}(.+?)${key}$`).exec(text); + if (match) { + let range = change.range.get(); + const visibleChar = match[1] && /\S$/.test(match[1]); + const codeChar = match[2]; + event.preventDefault(); + let leftText = text.substr( + 0, + text.length - codeChar.length - 2 * this.markdown.length, + ); + node.get()!.splitText( + (leftText + codeChar).length + 2 * this.markdown.length, + ); + if (visibleChar) { + leftText += ' '; + } + node[0].nodeValue = leftText + codeChar; + range.setStart(node[0], leftText.length); + range.setEnd(node[0], (leftText + codeChar).length); + change.range.select(range); + command.execute((this.constructor as PluginEntryType).pluginName); + range = change.range.get(); + range.collapse(false); + const inline = editor.inline.closest(range.startNode); + const inlineNext = inline.next(); + if ( + inline && + inlineNext && + inlineNext.isText() && + /^\u200B/g.test(inlineNext.text()) + ) { + range.setStart(inlineNext, 1); + range.setEnd(inlineNext, 1); + } + change.range.select(range); + editor.node.insertText('\xa0'); + return false; + } + return; + } + + checkMarkdown(node: NodeInterface) { + if (!isEngine(this.editor) || !this.markdown) return; + if (!node.isText()) return; + + let text = node.text(); + if (!text) return; + + const key = this.markdown.replace(/(\*|\^|\$)/g, '\\$1'); + const reg = new RegExp(`(${key}([^${key}\r\n]+)${key})`); + + return { + reg, + match: reg.exec(text), + }; + } + + pasteMarkdown(node: NodeInterface) { + const result = this.checkMarkdown(node); + if (!result) return; + let { reg, match } = result; + if (!match) return; + let newText = ''; + let textNode = node.clone(true).get()!; + + while ( + textNode.textContent && + (match = reg.exec(textNode.textContent)) + ) { + //从匹配到的位置切断 + let regNode = textNode.splitText(match.index); + newText += textNode.textContent; + //从匹配结束位置分割 + textNode = regNode.splitText(match[0].length); + + //获取中间字符 + const inlineNode = $( + `<${this.tagName}>${match[2]}`, + ); + + this.setStyle(inlineNode); + this.setAttributes(inlineNode); + newText += inlineNode.get()?.outerHTML; + } + newText += textNode.textContent; + + node.text(newText); + } +} + +export default InlineEntry; + +export const isInlinePlugin = ( + plugin: PluginInterface, +): plugin is InlineInterface => { + return plugin.kind === 'inline'; +}; diff --git a/packages/engine/src/plugin/list/index.css b/packages/engine/src/plugin/list/index.css new file mode 100644 index 00000000..0e151a8e --- /dev/null +++ b/packages/engine/src/plugin/list/index.css @@ -0,0 +1,110 @@ +.am-engine ol, .am-engine-view ol, .am-engine ul, .am-engine-view ul { + margin: 0 0 0 3px; + padding: 0; + list-style: none; +} + +.am-engine ol ul,.am-engine-view ol ul,.am-engine ul ul,.am-engine-view ul ul,.am-engine ol ol,.am-engine-view ol ol,.am-engine ul ol,.am-engine-view ul ol { + margin-left: 0; +} + +.am-engine ol ul li,.am-engine-view ol ul li,.am-engine ul ul li,.am-engine-view ul ul li,.am-engine ol ol li,.am-engine-view ol ol li,.am-engine ul ol li,.am-engine-view ul ol li { + margin-left: 2em; +} + +.am-engine ol ol[data-indent-new="0"],.am-engine-view ol ol[data-indent-new="0"],.am-engine ul ol[data-indent-new="0"],.am-engine-view ul ol[data-indent-new="0"],.am-engine ol ol[data-indent-new="3"],.am-engine-view ol ol[data-indent-new="3"],.am-engine ul ol[data-indent-new="3"],.am-engine-view ul ol[data-indent-new="3"],.am-engine ol ol[data-indent-new="6"],.am-engine-view ol ol[data-indent-new="6"],.am-engine ul ol[data-indent-new="6"],.am-engine-view ul ol[data-indent-new="6"] { + list-style-type: decimal; +} + +.am-engine ol ol[data-indent-new="1"],.am-engine-view ol ol[data-indent-new="1"],.am-engine ul ol[data-indent-new="1"],.am-engine-view ul ol[data-indent-new="1"],.am-engine ol ol[data-indent-new="4"],.am-engine-view ol ol[data-indent-new="4"],.am-engine ul ol[data-indent-new="4"],.am-engine-view ul ol[data-indent-new="4"],.am-engine ol ol[data-indent-new="7"],.am-engine-view ol ol[data-indent-new="7"],.am-engine ul ol[data-indent-new="7"],.am-engine-view ul ol[data-indent-new="7"] { + list-style-type: lower-alpha; +} + +.am-engine ol ol[data-indent-new="2"],.am-engine-view ol ol[data-indent-new="2"],.am-engine ul ol[data-indent-new="2"],.am-engine-view ul ol[data-indent-new="2"],.am-engine ol ol[data-indent-new="5"],.am-engine-view ol ol[data-indent-new="5"],.am-engine ul ol[data-indent-new="5"],.am-engine-view ul ol[data-indent-new="5"],.am-engine ol ol[data-indent-new="8"],.am-engine-view ol ol[data-indent-new="8"],.am-engine ul ol[data-indent-new="8"],.am-engine-view ul ol[data-indent-new="8"] { + list-style-type: lower-roman; +} + +.am-engine ol ul[data-indent-new="3"],.am-engine-view ol ul[data-indent-new="3"],.am-engine ul ul[data-indent-new="3"],.am-engine-view ul ul[data-indent-new="3"],.am-engine ol ul[data-indent-new="6"],.am-engine-view ol ul[data-indent-new="6"],.am-engine ul ul[data-indent-new="6"],.am-engine-view ul ul[data-indent-new="6"] { + list-style-type: disc; +} + +.am-engine ol ul[data-indent-new="1"],.am-engine-view ol ul[data-indent-new="1"],.am-engine ul ul[data-indent-new="1"],.am-engine-view ul ul[data-indent-new="1"],.am-engine ol ul[data-indent-new="4"],.am-engine-view ol ul[data-indent-new="4"],.am-engine ul ul[data-indent-new="4"],.am-engine-view ul ul[data-indent-new="4"],.am-engine ol ul[data-indent-new="7"],.am-engine-view ol ul[data-indent-new="7"],.am-engine ul ul[data-indent-new="7"],.am-engine-view ul ul[data-indent-new="7"] { + list-style-type: circle; +} + +.am-engine ol ul[data-indent-new="2"],.am-engine-view ol ul[data-indent-new="2"],.am-engine ul ul[data-indent-new="2"],.am-engine-view ul ul[data-indent-new="2"],.am-engine ol ul[data-indent-new="5"],.am-engine-view ol ul[data-indent-new="5"],.am-engine ul ul[data-indent-new="5"],.am-engine-view ul ul[data-indent-new="5"],.am-engine ol ul[data-indent-new="8"],.am-engine-view ol ul[data-indent-new="8"],.am-engine ul ul[data-indent-new="8"],.am-engine-view ul ul[data-indent-new="8"] { + list-style-type: square; +} + +.am-engine li,.am-engine-view li { + margin-left: 23px; + position: relative; +} + +.am-engine ol,.am-engine-view ol,.am-engine ol[data-indent="3"],.am-engine-view ol[data-indent="3"],.am-engine ol[data-indent="6"],.am-engine-view ol[data-indent="6"] { + list-style-type: decimal; +} + +.am-engine ol[data-indent="1"],.am-engine-view ol[data-indent="1"],.am-engine ol[data-indent="4"],.am-engine-view ol[data-indent="4"],.am-engine ol[data-indent="7"],.am-engine-view ol[data-indent="7"] { + list-style-type: lower-alpha; +} + +.am-engine ol[data-indent="2"],.am-engine-view ol[data-indent="2"],.am-engine ol[data-indent="5"],.am-engine-view ol[data-indent="5"],.am-engine ol[data-indent="8"],.am-engine-view ol[data-indent="8"] { + list-style-type: lower-roman; +} + +.am-engine ul,.am-engine-view ul,.am-engine ul[data-indent="3"],.am-engine-view ul[data-indent="3"],.am-engine ul[data-indent="6"],.am-engine-view ul[data-indent="6"] { + list-style-type: disc; +} + +.am-engine ul[data-indent="1"],.am-engine-view ul[data-indent="1"],.am-engine ul[data-indent="4"],.am-engine-view ul[data-indent="4"],.am-engine ul[data-indent="7"],.am-engine-view ul[data-indent="7"] { + list-style-type: circle; +} + +.am-engine ul[data-indent="2"],.am-engine-view ul[data-indent="2"],.am-engine ul[data-indent="5"],.am-engine-view ul[data-indent="5"],.am-engine ul[data-indent="8"],.am-engine-view ul[data-indent="8"] { + list-style-type: square; +} + +.am-engine ol[data-indent="1"],.am-engine-view ol[data-indent="1"],.am-engine ul[data-indent="1"],.am-engine-view ul[data-indent="1"] { + padding-left: 2em; +} + +.am-engine ol[data-indent="2"],.am-engine-view ol[data-indent="2"],.am-engine ul[data-indent="2"],.am-engine-view ul[data-indent="2"] { + padding-left: 4em; +} + +.am-engine ol[data-indent="3"],.am-engine-view ol[data-indent="3"],.am-engine ul[data-indent="3"],.am-engine-view ul[data-indent="3"] { + padding-left: 6em; +} + +.am-engine ol[data-indent="4"],.am-engine-view ol[data-indent="4"],.am-engine ul[data-indent="4"],.am-engine-view ul[data-indent="4"] { + padding-left: 8em; +} + +.am-engine ol[data-indent="5"],.am-engine-view ol[data-indent="5"],.am-engine ul[data-indent="5"],.am-engine-view ul[data-indent="5"] { + padding-left: 10em; +} + +.am-engine ol[data-indent="6"],.am-engine-view ol[data-indent="6"],.am-engine ul[data-indent="6"],.am-engine-view ul[data-indent="6"] { + padding-left: 12em; +} + +.am-engine ol[data-indent="7"],.am-engine-view ol[data-indent="7"],.am-engine ul[data-indent="7"],.am-engine-view ul[data-indent="7"] { + padding-left: 14em; +} + +.am-engine ol[data-indent="8"],.am-engine-view ol[data-indent="8"],.am-engine ul[data-indent="8"],.am-engine-view ul[data-indent="8"] { + padding-left: 16em; +} + +.am-engine .data-list,.am-engine-view .data-list { + color: #262626; + text-indent: 0; +} + +.am-engine .data-list-item,.am-engine-view .data-list-item { + line-height: inherit; + position: relative; + list-style: none; + text-indent: 0; +} \ No newline at end of file diff --git a/packages/engine/src/plugin/list/index.ts b/packages/engine/src/plugin/list/index.ts new file mode 100644 index 00000000..bb388993 --- /dev/null +++ b/packages/engine/src/plugin/list/index.ts @@ -0,0 +1,93 @@ +import { NodeInterface } from '../../types'; +import { CARD_KEY, READY_CARD_KEY } from '../../constants'; +import { ListInterface } from '../../types/list'; +import { PluginEntry as PluginEntryType } from '../../types/plugin'; +import BlockEntry from '../block'; +import { $ } from '../../node'; +import { isEngine } from '../../utils'; +import './index.css'; + +abstract class ListEntry + extends BlockEntry + implements ListInterface +{ + cardName?: string; + private isPasteList: boolean = false; + + init() { + super.init(); + const editor = this.editor; + if (isEngine(editor)) { + editor.on('paste:before', (fragment) => this.pasteBefore(fragment)); + editor.on('paste:insert', () => this.pasteInsert()); + editor.on('paste:after', () => this.pasteAfter()); + } + } + + queryState() { + const editor = this.editor; + if (!isEngine(editor)) return false; + return ( + editor.list.getPluginNameByNodes(editor.change.blocks) === + (this.constructor as PluginEntryType).pluginName + ); + } + + /** + * 判断节点是否是当前列表所需要的节点 + * @param node 节点 + */ + abstract isCurrent(node: NodeInterface): boolean; + + pasteBefore(documentFragment: DocumentFragment) { + if (!this.cardName || !this.editor) return; + const { list } = this.editor; + const node = $(documentFragment); + const children = node.allChildren(); + children.forEach((domChild) => { + if ( + domChild.name === 'li' && + domChild.hasClass(list.CUSTOMZIE_LI_CLASS) + ) { + //自定义列表,没有卡片节点,就作为普通列表 + if (!domChild.first()?.isCard()) { + domChild.removeClass(list.CUSTOMZIE_LI_CLASS); + domChild.closest('ul').removeClass(list.CUSTOMZIE_UL_CLASS); + return; + } else { + domChild.closest('ul').addClass(list.CUSTOMZIE_UL_CLASS); + } + } + }); + this.isPasteList = children.some((child) => child.name === 'li'); + } + + pasteInsert() { + if (!this.cardName || !isEngine(this.editor)) return; + const { change, list } = this.editor; + const range = change.range.get(); + const rootBlock = range.getRootBlock(); + const nextBlock = rootBlock?.next(); + const customizeItems = nextBlock?.find(`li.${list.CUSTOMZIE_LI_CLASS}`); + if (customizeItems && customizeItems.length > 0) { + customizeItems.each((node) => { + const domNode = $(node); + if ( + 0 === + domNode.find( + `[${CARD_KEY}=${this.cardName}],[${READY_CARD_KEY}=${this.cardName}]`, + ).length + ) + list.addReadyCardToCustomize(domNode, this.cardName!); + }); + } + } + + pasteAfter() { + if (this.isPasteList) { + this.editor?.list.merge(); + } + } +} + +export default ListEntry; diff --git a/packages/engine/src/plugin/mark.ts b/packages/engine/src/plugin/mark.ts new file mode 100644 index 00000000..4d38b049 --- /dev/null +++ b/packages/engine/src/plugin/mark.ts @@ -0,0 +1,224 @@ +import ElementPluginEntry from './element'; +import { + MarkInterface, + NodeInterface, + SchemaMark, + PluginEntry as PluginEntryType, + PluginInterface, +} from '../types'; +import { $ } from '../node'; +import { isEngine } from '../utils'; + +abstract class MarkEntry + extends ElementPluginEntry + implements MarkInterface +{ + readonly kind: string = 'mark'; + /** + * 标签名称 + */ + abstract readonly tagName: string; + /** + * Markdown 规则,可选 + */ + readonly markdown?: string; + /** + * 回车后是否复制mark效果,默认为 true,允许 + *

      abc

      + * 在光标处回车后,第二行默认会继续 strong 样式,如果为 false,将不在加 strong 样式 + */ + readonly copyOnEnter?: boolean; + /** + * 是否跟随样式,开启后在此标签后输入将不在有mark标签效果,光标重合状态下也无非执行此mark命令。默认 true 跟随 + * abc 或者 abc + * 在此处输入,如果 followStyle 为 true,那么就会在 strong 节点后输入 或者 strong 节点前输入 + * abc 如果光标在中间为值,还是会继续跟随样式效果 + * abc123 如果 followStyle 为 true,后方还是有 strong 节点效果,那么还是会继续跟随样式,在 strong abc 后面完成输入 + */ + readonly followStyle: boolean = true; + /** + * 在包裹相通节点并且属性名称一致,值不一致的mark节点的时候,是合并前者的值到新的节点还是移除前者mark节点,默认 false 移除 + * 节点样式(style)的值将始终覆盖掉 + * abc + * 在使用 包裹上方节点时 + * 如果合并值,就是 abc 否则就是 abc + */ + readonly combineValueByWrap: boolean = false; + /** + * 合并级别,值越大就合并在越外围 + */ + readonly mergeLeval: number = 1; + + init() { + super.init(); + const editor = this.editor; + if (isEngine(editor) && this.markdown) { + editor.on( + 'paste:markdown-check', + (child) => !this.checkMarkdown(child)?.match, + ); + editor.on('paste:markdown', (node) => this.pasteMarkdown(node)); + } + } + + execute(...args: any) { + const editor = this.editor; + if (!isEngine(editor)) return; + const { change, mark } = editor; + const markNode = $(`<${this.tagName} />`); + this.setStyle(markNode, ...args); + this.setAttributes(markNode, ...args); + const trigger = this.isTrigger + ? this.isTrigger(...args) + : !this.queryState(); + if (trigger) { + if (!this.followStyle && change.range.get().collapsed) { + return; + } + mark.wrap(markNode); + } else { + mark.unwrap(markNode); + } + } + + queryState() { + if (!isEngine(this.editor)) return; + const { change } = this.editor; + //如果没有属性和样式限制,直接查询是否包含当前标签名称 + if (!this.style && !this.attributes) + return change.marks.some((node) => node.name === this.tagName); + //获取属性和样式限制内的值集合 + const values: Array = []; + change.marks.forEach((node) => { + values.push(...Object.values(this.getStyle(node))); + values.push(...Object.values(this.getAttributes(node))); + }); + return values.length === 0 ? undefined : values; + } + + schema(): SchemaMark | Array { + const schema = super.schema(); + if (Array.isArray(schema)) { + return schema.map((schema) => { + return { + ...schema, + } as SchemaMark; + }); + } + return { + ...schema, + } as SchemaMark; + } + /** + * 是否触发执行增加当前mark标签包裹,否则将移除当前mark标签的包裹 + * @param args 在调用 command.execute 执行插件传入时的参数 + */ + isTrigger?(...args: any): boolean; + /** + * 解析markdown + * @param event 事件 + * @param text markdown文本 + * @param node 触发节点 + */ + triggerMarkdown(event: KeyboardEvent, text: string, node: NodeInterface) { + const editor = this.editor; + if (!isEngine(editor) || !this.markdown) return; + const { block, change, command } = editor; + const key = this.markdown.replace(/(\*|\^|\$)/g, '\\$1'); + const match = new RegExp(`^(.*)${key}(.+?)${key}$`).exec(text); + + if (match) { + //限制block下某些禁用的mark插件 + const blockPlugin = block.findPlugin(node); + const pluginName = (this.constructor as PluginEntryType).pluginName; + if ( + blockPlugin && + blockPlugin.disableMark && + blockPlugin.disableMark.indexOf(pluginName) > -1 + ) + return; + let range = change.range.get(); + const visibleChar = match[1] && /\S$/.test(match[1]); + const codeChar = match[2]; + event.preventDefault(); + let leftText = text.substr( + 0, + text.length - codeChar.length - 2 * this.markdown.length, + ); + node.get()!.splitText( + (leftText + codeChar).length + 2 * this.markdown.length, + ); + if (visibleChar) { + leftText += ' '; + } + node[0].nodeValue = leftText + codeChar; + range.setStart(node[0], leftText.length); + range.setEnd(node[0], (leftText + codeChar).length); + change.range.select(range); + command.execute((this.constructor as PluginEntryType).pluginName); + range = change.range.get(); + range.collapse(false); + change.range.select(range); + editor.node.insertText('\xa0'); + return false; + } + return; + } + + checkMarkdown(node: NodeInterface) { + if (!isEngine(this.editor) || !this.markdown) return; + if (!node.isText()) return; + + let text = node.text(); + if (!text) return; + const key = this.markdown.replace(/(\*|\^|\$)/g, '\\$1'); + const reg = + key === '_' + ? new RegExp(`\\s+(${key}([^${key}\\r\\n]+)${key})\\s+`) + : new RegExp(`(${key}([^${key}\\r\\n]+)${key})`); + let match = reg.exec(text); + return { + reg, + match, + }; + } + + pasteMarkdown(node: NodeInterface) { + const result = this.checkMarkdown(node); + if (!result) return; + let { reg, match } = result; + if (!match) return; + let newText = ''; + let textNode = node.clone(true).get()!; + + while ( + textNode.textContent && + (match = reg.exec(textNode.textContent)) + ) { + //从匹配到的位置切断 + let regNode = textNode.splitText(match.index); + newText += textNode.textContent; + //从匹配结束位置分割 + textNode = regNode.splitText(match[0].length); + + //获取中间字符 + const markNode = $( + `<${this.tagName}>${match[2]}`, + ); + this.setStyle(markNode); + this.setAttributes(markNode); + newText += markNode.get()?.outerHTML; + } + newText += textNode.textContent; + + node.text(newText); + } +} + +export default MarkEntry; + +export const isMarkPlugin = ( + plugin: PluginInterface, +): plugin is MarkInterface => { + return plugin.kind === 'mark'; +}; diff --git a/packages/engine/src/position/index.ts b/packages/engine/src/position/index.ts new file mode 100644 index 00000000..ffe44b12 --- /dev/null +++ b/packages/engine/src/position/index.ts @@ -0,0 +1,119 @@ +import domAlign from 'dom-align'; +import { EditorInterface, NodeInterface } from '../types'; +import { $ } from '../node'; +import placements from './placements'; +import { isMobile } from '../utils/user-agent'; +import { isEngine } from '../utils'; + +class Position { + #editor: EditorInterface; + #container?: NodeInterface; + #target?: NodeInterface; + #align: keyof typeof placements = 'bottomLeft'; + #offset: Array = [0, 0]; + #root?: NodeInterface; + #onUpdate?: (rect: any) => void; + #updateTimeout?: NodeJS.Timeout; + #observer?: MutationObserver; + + constructor(editor: EditorInterface) { + this.#editor = editor; + } + + bind( + container: NodeInterface, + target: NodeInterface, + defaultAlign: keyof typeof placements = this.#align, + offset: Array = this.#offset, + onUpdate?: (rect: any) => void, + ) { + this.#container = container; + this.#target = target; + this.#align = defaultAlign; + this.#offset = offset; + this.#root = $( + `
      `, + ); + this.#root.append(this.#container); + this.#editor.root.append(this.#root); + this.#onUpdate = onUpdate; + if (!isMobile) window.addEventListener('scroll', this.updateListener); + window.addEventListener('resize', this.updateListener); + if (isEngine(this.#editor) && !isMobile) { + this.#editor.scrollNode?.on('scroll', this.updateListener); + } + let size = { width: target.width(), height: target.height() }; + this.#observer = new MutationObserver(() => { + const width = target.width(); + const height = target.height(); + + if (width === size.width && height === size.height) return; + size = { + width, + height, + }; + this.updateListener(); + }); + this.#observer.observe(target.get()!, { + attributes: true, + attributeFilter: ['style'], + attributeOldValue: true, + childList: true, + subtree: true, + }); + this.update(); + } + + setOffset(offset: Array) { + this.#offset = offset; + } + + updateListener = () => { + if (this.#updateTimeout) clearTimeout(this.#updateTimeout); + this.#updateTimeout = setTimeout(() => { + this.update(); + }, 50); + }; + + update = (triggerUpdate: boolean = true) => { + if ( + !this.#container || + this.#container.length === 0 || + !this.#target || + this.#target.length === 0 + ) + return; + const rect = domAlign( + this.#container.get(), + this.#target.get(), + { + ...placements[this.#align], + targetOffset: this.#offset, + }, + ); + + if (this.#onUpdate && triggerUpdate) { + const align = Object.keys(placements).find((p) => { + const points = placements[p].points; + return ( + points[0] === rect.points[0] && points[1] === rect.points[1] + ); + }); + this.#onUpdate({ ...rect, align }); + } + }; + + destroy() { + this.#onUpdate = undefined; + if (!isMobile) + window.removeEventListener('scroll', this.updateListener); + window.removeEventListener('resize', this.updateListener); + if (isEngine(this.#editor) && !isMobile) { + this.#editor.scrollNode?.off('scroll', this.updateListener); + } + this.#observer?.disconnect(); + this.#root?.remove(); + } +} + +export default Position; diff --git a/packages/engine/src/position/placements.ts b/packages/engine/src/position/placements.ts new file mode 100644 index 00000000..4c7f3b8b --- /dev/null +++ b/packages/engine/src/position/placements.ts @@ -0,0 +1,81 @@ +const autoAdjustOverflow = { + adjustX: true, + adjustY: true, +}; + +const targetOffset = [0, 0]; + +export default { + left: { + points: ['cr', 'cl'], + overflow: autoAdjustOverflow, + offset: [-4, 0], + targetOffset: targetOffset, + }, + right: { + points: ['cl', 'cr'], + overflow: autoAdjustOverflow, + offset: [4, 0], + targetOffset: targetOffset, + }, + top: { + points: ['bc', 'tc'], + overflow: autoAdjustOverflow, + offset: [0, -4], + targetOffset: targetOffset, + }, + bottom: { + points: ['tc', 'bc'], + overflow: autoAdjustOverflow, + offset: [0, 4], + targetOffset: targetOffset, + }, + topLeft: { + points: ['bl', 'tl'], + overflow: autoAdjustOverflow, + offset: [0, -4], + targetOffset: targetOffset, + }, + leftTop: { + points: ['tr', 'tl'], + overflow: autoAdjustOverflow, + offset: [-4, 0], + targetOffset: targetOffset, + }, + topRight: { + points: ['br', 'tr'], + overflow: autoAdjustOverflow, + offset: [0, -4], + targetOffset: targetOffset, + }, + rightTop: { + points: ['tl', 'tr'], + overflow: autoAdjustOverflow, + offset: [4, 0], + targetOffset: targetOffset, + }, + bottomRight: { + points: ['tr', 'br'], + overflow: autoAdjustOverflow, + offset: [0, 4], + targetOffset: targetOffset, + }, + rightBottom: { + points: ['bl', 'br'], + overflow: autoAdjustOverflow, + offset: [4, 0], + targetOffset: targetOffset, + }, + bottomLeft: { + points: ['tl', 'bl'], + overflow: autoAdjustOverflow, + offset: [0, 4], + targetOffset: targetOffset, + }, + leftBottom: { + points: ['br', 'bl'], + overflow: autoAdjustOverflow, + offset: [-4, 0], + targetOffset: targetOffset, + }, +}; diff --git a/packages/engine/src/range.ts b/packages/engine/src/range.ts new file mode 100644 index 00000000..eded8292 --- /dev/null +++ b/packages/engine/src/range.ts @@ -0,0 +1,953 @@ +import { NodeInterface } from './types/node'; +import { RangeInterface, RangePath } from './types/range'; +import { getWindow, isMobile } from './utils'; +import { CARD_ELEMENT_KEY, CARD_SELECTOR } from './constants/card'; +import { ANCHOR, CURSOR, FOCUS } from './constants/selection'; +import { + DATA_ELEMENT, + DATA_ID, + DATA_TRANSIENT_ELEMENT, + EDITABLE_SELECTOR, + UI, +} from './constants/root'; +import Selection from './selection'; +import { SelectionInterface } from './types/selection'; +import { EditorInterface } from './types/engine'; +import { Path } from 'sharedb'; +import { $ } from './node'; +import { CardEntry } from './types/card'; +import { isTransientElement } from './ot/utils'; +import { isNodeEntry } from './node/utils'; + +class Range implements RangeInterface { + private editor: EditorInterface; + static create: ( + editor: EditorInterface, + doc?: Document, + point?: { x: number; y: number }, + ) => RangeInterface; + static from: ( + editor: EditorInterface, + win?: Window | globalThis.Selection | globalThis.Range, + clone?: boolean, + ) => RangeInterface | null; + + static fromPath: ( + editor: EditorInterface, + path: { + start: RangePath; + end: RangePath; + }, + includeCardCursor?: boolean, + ) => RangeInterface; + + base: globalThis.Range; + + get collapsed() { + return this.base.collapsed; + } + + get endOffset() { + return this.base.endOffset; + } + + get startOffset() { + return this.base.startOffset; + } + + get startContainer() { + return this.base.startContainer; + } + + get endContainer() { + return this.base.endContainer; + } + + get commonAncestorContainer() { + return this.base.commonAncestorContainer; + } + + constructor(editor: EditorInterface, range: globalThis.Range) { + this.editor = editor; + this.base = range; + } + + cloneContents(): DocumentFragment { + return this.base.cloneContents(); + } + + deleteContents(): void { + return this.base.deleteContents(); + } + + extractContents(): DocumentFragment { + return this.base.extractContents(); + } + getBoundingClientRect(): DOMRect { + return this.base.getBoundingClientRect(); + } + getClientRects(): DOMRectList { + return this.base.getClientRects(); + } + + insertNode(node: Node | NodeInterface): void { + if (isNodeEntry(node)) node = node[0]; + const startNode = this.startNode; + if ( + !$(node).isCursor() && + startNode.name === 'p' && + startNode.children().length === 1 && + startNode.first()?.name === 'br' + ) { + startNode.first()?.remove(); + } else if (startNode.name === 'br') { + startNode.remove(); + } + return this.base.insertNode(node); + } + + isPointInRange(node: Node | NodeInterface, offset: number): boolean { + if (isNodeEntry(node)) node = node[0]; + return this.base.isPointInRange(node, offset); + } + + comparePoint(node: Node | NodeInterface, offset: number): number { + if (isNodeEntry(node)) node = node[0]; + return this.base.comparePoint(node, offset); + } + + setEnd(node: Node | NodeInterface, offset: number): void { + if (isNodeEntry(node)) node = node[0]; + return this.base.setEnd(node, offset); + } + setEndAfter(node: Node | NodeInterface): void { + if (isNodeEntry(node)) node = node[0]; + return this.base.setEndAfter(node); + } + setEndBefore(node: Node | NodeInterface): void { + if (isNodeEntry(node)) node = node[0]; + return this.base.setEndBefore(node); + } + setStart(node: Node | NodeInterface, offset: number): void { + if (isNodeEntry(node)) node = node[0]; + return this.base.setStart(node, offset); + } + setStartAfter(node: Node | NodeInterface): void { + if (isNodeEntry(node)) node = node[0]; + return this.base.setStartAfter(node); + } + setStartBefore(node: Node | NodeInterface): void { + if (isNodeEntry(node)) node = node[0]; + return this.base.setStartBefore(node); + } + + toString() { + return this.base.toString(); + } + + get startNode() { + return $(this.base.startContainer); + } + + get endNode() { + return $(this.base.endContainer); + } + + get commonAncestorNode() { + return $(this.base.commonAncestorContainer); + } + + toRange = (): globalThis.Range => { + return this.base; + }; + + collapse = (toStart?: boolean) => { + this.base.collapse(toStart); + return this; + }; + + cloneRange = () => { + return Range.from(this.editor, this.base.cloneRange())!; + }; + /** + * 选中一个节点 + * @param node 节点 + * @param contents 是否只选中内容 + */ + select = (node: NodeInterface | Node, contents?: boolean) => { + if (contents) { + this.base.selectNodeContents(isNodeEntry(node) ? node[0] : node); + } else { + this.base.selectNode(isNodeEntry(node) ? node[0] : node); + } + return this; + }; + + getText = (): string | null => { + const contents = this.cloneContents(); + return contents.textContent; + }; + + /** + * 获取光标所占的区域 + */ + getClientRect = (): DOMRect => { + let item = this.getClientRects().item(0); + if (!item) { + item = this.getBoundingClientRect(); + } + return item; + }; + + /** + * 将选择标记从 TextNode 扩大到最近非TextNode节点 + * range 实质所选择的内容不变 + */ + enlargeFromTextNode = () => { + const enlargePosition = (node: Node, offset: number, type: string) => { + if (node.nodeType !== getWindow().Node.TEXT_NODE) { + return; + } + if (offset === 0) { + switch (type) { + case 'start': + this.setStartBefore(node); + break; + case 'end': + this.setEndBefore(node); + break; + } + } else if (offset === node.nodeValue?.length) { + switch (type) { + case 'start': + this.setStartAfter(node); + break; + case 'end': + this.setEndAfter(node); + break; + } + } + }; + enlargePosition(this.startContainer, this.startOffset, 'start'); + enlargePosition(this.endContainer, this.endOffset, 'end'); + return this; + }; + + /** + * 将选择标记从非 TextNode 缩小到TextNode节点上,与 enlargeFromTextNode 相反 + * range 实质所选择的内容不变 + */ + shrinkToTextNode = () => { + const shrinkPosition = (node: Node, offset: number, type: string) => { + if (node.nodeType !== getWindow().Node.ELEMENT_NODE) { + return; + } + + const childNodes = node.childNodes; + if (childNodes.length === 0) { + return; + } + + let left; + let right; + let child; + + if (offset > 0) { + left = childNodes[offset - 1]; + } + + if (offset < childNodes.length) { + right = childNodes[offset]; + } + + if (left && left.nodeType === getWindow().Node.TEXT_NODE) { + child = left; + offset = child.nodeValue?.length || 0; + } + + if (right && right.nodeType === getWindow().Node.TEXT_NODE) { + child = right; + offset = 0; + } + + if (!child) { + return; + } + switch (type) { + case 'start': + this.setStart(child, offset); + break; + case 'end': + this.setEnd(child, offset); + break; + } + }; + shrinkPosition(this.startContainer, this.startOffset, 'start'); + shrinkPosition(this.endContainer, this.endOffset, 'end'); + return this; + }; + + /** + * 扩大边界 + *

      [123abc]def

      + * to + *

      [123abc]def

      + * @param range 选区 + * @param toBlock 是否扩大到块级节点 + */ + enlargeToElementNode = (toBlock?: boolean) => { + const range = this.enlargeFromTextNode(); + const nodeApi = this.editor.node; + const enlargePosition = ( + node: Node, + offset: number, + isStart: boolean, + ) => { + let domNode = $(node); + if ( + domNode.type === getWindow().Node.TEXT_NODE || + (!toBlock && nodeApi.isBlock(domNode)) || + domNode.isEditable() + ) { + return; + } + let parent; + if (offset === 0) { + while (!domNode.prev()) { + parent = domNode.parent(); + if (!parent || (!toBlock && nodeApi.isBlock(parent))) { + break; + } + if (!parent.inEditor() || parent.isEditable()) { + break; + } + domNode = parent; + } + if (isStart) { + range.setStartBefore(domNode[0]); + } else { + range.setEndBefore(domNode[0]); + } + } else if (offset === domNode.children().length) { + while (!domNode.next()) { + parent = domNode.parent(); + if (!parent || (!toBlock && nodeApi.isBlock(parent))) { + break; + } + if (!parent.inEditor() || parent.isEditable()) { + break; + } + domNode = parent; + } + if (isStart) { + range.setStartAfter(domNode[0]); + } else { + range.setEndAfter(domNode[0]); + } + } + }; + enlargePosition(range.startContainer, range.startOffset, true); + enlargePosition(range.endContainer, range.endOffset, false); + return this; + }; + + /** + * 缩小边界 + * [

      123

      ] + * to + *

      [123]

      + * @param range 选区 + */ + shrinkToElementNode = () => { + const { node } = this.editor; + let child; + let childDom; + while ( + this.startContainer.nodeType === getWindow().Node.ELEMENT_NODE && + (child = this.startContainer.childNodes[this.startOffset]) && + (childDom = $(child)) && + child.nodeType === getWindow().Node.ELEMENT_NODE && + !childDom.isCursor() && + !node.isVoid(child) && + (!childDom.isCard() || + childDom.isEditableCard() || + childDom.closest(EDITABLE_SELECTOR).length > 0) + ) { + this.setStart(child, 0); + } + while ( + this.endContainer.nodeType === getWindow().Node.ELEMENT_NODE && + this.endOffset > 0 && + (child = this.endContainer.childNodes[this.endOffset - 1]) && + (childDom = $(child)) && + child.nodeType === getWindow().Node.ELEMENT_NODE && + !node.isVoid(child) && + !childDom.isCursor() && + (!childDom.isCard() || + childDom.isEditableCard() || + childDom.closest(EDITABLE_SELECTOR).length > 0) + ) { + this.setEnd(child, child.childNodes.length); + } + return this; + }; + + /** + * 创建 selection,通过插入 span 节点标记位置 + * @param key 唯一标识 + */ + createSelection = (key: string = ''): SelectionInterface => { + const selection = new Selection(this.editor, this, key); + selection.create(); + return selection; + }; + + /** + * 获取子选区集合 + * @param range + */ + getSubRanges = (includeCard: boolean = false) => { + const ranges: Array = []; + this.commonAncestorNode.traverse((child) => { + if (child.isText()) { + let offset = 0; + const childNode = child.get()!; + const valueLength = childNode.nodeValue?.length || 0; + const start = this.comparePoint(childNode, offset); + const end = this.comparePoint(childNode, valueLength); + const docRange = Range.create(this.editor); + if (start < 0) { + if (end < 0) return; + if (end === 0) { + docRange.setOffset( + childNode, + this.startOffset, + valueLength, + ); + } else { + docRange.setOffset( + childNode, + this.startOffset, + this.endOffset, + ); + } + } else { + if (start !== 0) return; + if (end < 0) return; + if (end === 0) { + docRange.setOffset(childNode, offset, valueLength); + } else { + docRange.setOffset(childNode, offset, this.endOffset); + } + } + ranges.push(docRange); + } else if ( + includeCard && + child.isCard() && + !child.isEditableCard() + ) { + const cardComponent = this.editor.card.find(child); + if ( + !cardComponent || + (cardComponent.constructor as CardEntry) + .singleSelectable === false + ) + return; + const center = cardComponent.getCenter(); + const body = center.get()?.parentNode; + if (!body) return; + const offset = center.index(); + const childNode = child.get()!; + const start = this.comparePoint(body, offset); + const end = this.comparePoint(body, offset + 1); + const docRange = Range.create(this.editor); + if (start < 0) { + if (end < 0) return; + if (end === 0) { + docRange.setOffset( + childNode, + this.startOffset, + offset + 1, + ); + } else { + docRange.setOffset( + childNode, + this.startOffset, + this.endOffset, + ); + } + } else { + if (start !== 0) return; + if (end < 0) return; + if (end === 0) { + docRange.setOffset(body, offset, offset + 1); + } else { + docRange.setOffset(body, offset, this.endOffset); + } + } + ranges.push(docRange); + } + }); + return ranges; + }; + + setOffset = ( + node: Node | NodeInterface, + start: number, + end: number, + ): RangeInterface => { + if (isNodeEntry(node)) node = node[0]; + this.setStart(node, start); + this.setEnd(node, end); + return this; + }; + + findElements = () => { + const { + startContainer, + endContainer, + startOffset, + endOffset, + collapsed, + } = this; + const elements: Array = []; + if ( + startContainer !== endContainer || + collapsed === true || + startContainer.nodeType === getWindow().Node.TEXT_NODE + ) { + return elements; + } + + const { childNodes } = startContainer; + for (let i = startOffset; i < endOffset; i++) { + elements.push(childNodes[i]); + } + return elements; + }; + + inCard = () => { + const card = this.startNode.closest(CARD_SELECTOR); + return card && card.length > 0; + }; + + getStartOffsetNode = (): Node => { + const { startContainer, startOffset } = this; + if (startContainer.nodeType === getWindow().Node.ELEMENT_NODE) { + return ( + startContainer.childNodes[startOffset] || + startContainer.childNodes[startOffset - 1] || + startContainer + ); + } + return startContainer; + }; + + getEndOffsetNode = (): Node => { + const { endContainer, endOffset } = this; + if (endContainer.nodeType === getWindow().Node.ELEMENT_NODE) { + return ( + endContainer.childNodes[endOffset] || + endContainer.childNodes[endOffset - 1] || + endContainer + ); + } + return endContainer; + }; + + scrollIntoView = () => { + const endElement = this.endNode.get(); + if (isMobile && endElement && endElement.scrollIntoView) { + endElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }); + } + }; + + scrollRangeIntoView = () => { + const node = this.getEndOffsetNode(); + const root = + node.nodeType === getWindow().Node.TEXT_NODE + ? node.parentNode + : node; + const rect = this.collapsed + ? (root as Element).getBoundingClientRect() + : this.getClientRect(); + const innerHeight = window.innerHeight; + if (rect.bottom >= innerHeight || rect.bottom <= 0) { + (root as Element).scrollIntoView({ + block: 'center', + }); + } + }; + + scrollIntoViewIfNeeded = (node: NodeInterface, view: NodeInterface) => { + if (this.collapsed) { + node.scrollIntoView(view, $(this.getEndOffsetNode())); + } else { + const startNode = this.getStartOffsetNode(); + const endNode = this.getEndOffsetNode(); + + node.scrollIntoView(view, $(startNode)); + if (!node.inViewport(view, $(endNode))) + node.scrollIntoView(view, $(endNode)); + } + }; + + containsCard = () => { + const { collapsed, commonAncestorNode } = this; + return ( + !collapsed && + ((3 !== commonAncestorNode.type && + commonAncestorNode.find(CARD_SELECTOR).length > 0) || + commonAncestorNode.closest(CARD_SELECTOR).length > 0) + ); + }; + + /** + * 在光标位置对blcok添加或者删除br标签 + * @param isLeft + */ + handleBr = (isLeft?: boolean) => { + const block = this.editor.block.closest(this.commonAncestorNode); + block.find('br').each((br) => { + const domBr = $(br); + if ( + ((!domBr.prev() || + (domBr.parent()?.hasClass('data-list-item') && + domBr.parent()?.first()?.equal(domBr.prev()!))) && + domBr.next() && + domBr.next()!.name !== 'br' && + ![CURSOR, ANCHOR, FOCUS].includes( + domBr.next()!.attributes(DATA_ELEMENT), + )) || + (!domBr.next() && domBr.prev() && domBr.prev()?.name !== 'br') + ) { + if ( + isLeft && + domBr.prev() && + !( + domBr.parent()?.hasClass('data-list-item') && + domBr.parent()?.first()?.equal(domBr.prev()!) + ) + ) + return; + domBr.remove(); + } + }); + + if ( + !block.first() || + (block.children().length === 1 && + block.hasClass('data-list-item') && + block.first()?.isCard()) + ) { + block.append($('
      ')); + return this; + } + + if ( + block.children().length === 2 && + block.hasClass('data-list-item') && + block.first()?.isCard() && + ['cursor', 'anchor', 'focus'].includes( + block.last()?.attributes(DATA_ELEMENT) || '', + ) + ) { + block.first()?.after('
      '); + } + return this; + }; + + /** + * 获取开始位置前的节点 + * foo|bar + */ + getPrevNode = () => { + this.enlargeFromTextNode(); + const { startNode, startOffset } = this; + + if (startNode.isText()) { + return; + } + const childNodes = startNode.children(); + if (childNodes.length === 0) { + return; + } + return childNodes.eq(startOffset - 1); + }; + + /** + * 获取结束位置后的节点 + * foo|bar + */ + getNextNode = () => { + this.enlargeFromTextNode(); + const { endNode, endOffset } = this; + + if (endNode.isText()) { + return; + } + const childNodes = endNode.children(); + if (childNodes.length === 0) { + return; + } + return childNodes.eq(endOffset); + }; + + deepCut() { + if (!this.collapsed) this.extractContents(); + const { startNode } = this; + if (!startNode.isEditable()) { + let node = startNode; + if (node && !node.isEditable()) { + let parentNode = node.parent(); + while (parentNode && !parentNode.isEditable()) { + node = parentNode; + parentNode = parentNode.parent(); + } + this.setEndAfter(node[0]); + const contents = this.extractContents(); + this.insertNode(contents); + this.collapse(true); + } + } + } + + /** + * 对比两个范围是否相等 + * @param range 范围 + */ + equal(range: RangeInterface | globalThis.Range) { + return ( + this.startContainer === range.startContainer && + this.startOffset === range.startOffset && + this.endContainer === range.endContainer && + this.endOffset === range.endOffset + ); + } + + /** + * 获取当前选区最近的根节点 + */ + getRootBlock() { + if (this.startNode.isEditable()) + return this.startNode.children().eq(this.startOffset); + let node: NodeInterface | undefined = this.startNode; + while (node?.parent() && !node.parent()!.isEditable()) { + node = node.parent(); + } + return node; + } + + filterPath(includeCardCursor: boolean = false) { + const cardCaches: NodeInterface[] = []; + return (node: Node) => { + const element = $(node); + if ( + includeCardCursor && + ['left', 'right', 'center', 'body'].includes( + element.attributes(CARD_ELEMENT_KEY), + ) + ) { + const cardElement = this.editor.card.closest(element); + if (cardElement && cardElement?.length > 0) + cardCaches.push(cardElement); + return true; + } + if ( + includeCardCursor && + element.isCard() && + cardCaches.includes(element) + ) + return true; + return !isTransientElement(element); + }; + } + + toPath(includeCardCursor: boolean = false) { + const range = this.cloneRange(); + const node = range.commonAncestorNode; + if (!node.isRoot() && !node.inEditor()) return; + range.shrinkToElementNode().shrinkToTextNode(); + + const getPath = (node: NodeInterface, offset: number): RangePath => { + let rootBeginId: string = node.attributes(DATA_ID); + let rootBeginIndex: number = rootBeginId ? 0 : -1; + const path = node.getPath( + this.editor.container, + node.parent()?.isRoot() + ? undefined + : this.filterPath(includeCardCursor), + (index, path, node) => { + // 找不到索引,就重置之前的位置 + if (index === -1) { + rootBeginId = ''; + rootBeginIndex = -1; + return []; + } + if (!rootBeginId) { + rootBeginId = node.attributes(DATA_ID); + rootBeginIndex = path.length; + } + path.unshift(index); + return; + }, + ); + rootBeginIndex = path.length - rootBeginIndex; + path.push(offset); + return { path, id: rootBeginId, bi: rootBeginIndex }; + }; + return { + start: getPath(range.startNode, range.startOffset), + end: getPath(range.endNode, range.endOffset), + }; + } +} + +Range.create = ( + editor: EditorInterface, + doc: Document = document, + point?: { x: number; y: number }, +): RangeInterface => { + let range: globalThis.Range; + if (point) range = doc.caretRangeFromPoint(point.x, point.y)!; + else range = doc.createRange(); + return Range.from(editor, range)!; +}; + +Range.from = ( + editor: EditorInterface, + win: Window | globalThis.Selection | globalThis.Range = window, +): RangeInterface | null => { + if (!isRange(win)) { + const selection = isSelection(win) ? win : win.getSelection(); + if (selection && selection.rangeCount > 0) { + win = selection.getRangeAt(0); + } else return null; + } + return new Range(editor, win); +}; + +Range.fromPath = ( + editor: EditorInterface, + path: { + start: RangePath; + end: RangePath; + }, + includeCardCursor: boolean = false, +) => { + const startPath = path.start.path.slice(); + const endPath = path.end.path.slice(); + const startOffset = startPath.pop(); + const endOffset = endPath.pop(); + + const getNode = (path: Path, context: NodeInterface = editor.container) => { + let domNode = context; + for (let i = 0; i < path.length; i++) { + let p = path[i]; + if (p < 0) { + p = 0; + } + let needNode = undefined; + let domChild = domNode.first(); + let offset = 0; + while (domChild && domChild.length > 0) { + if ( + (!domChild.attributes(DATA_TRANSIENT_ELEMENT) && + domChild.attributes(DATA_ELEMENT) !== UI) || + (includeCardCursor && + ['left', 'right'].includes( + domChild.attributes(CARD_ELEMENT_KEY), + )) + ) { + if (offset === p || !domChild.next()) { + needNode = domChild; + break; + } + offset++; + domChild = domChild.next(); + } else { + domChild = domChild.next(); + } + } + if (!needNode) break; + domNode = needNode; + } + return domNode; + }; + + const setRange = ( + method: string, + range: RangeInterface, + node: Node | null, + offset: number, + ) => { + if (node !== null) { + if (offset < 0) { + offset = 0; + } + if ( + node.nodeType === getWindow().Node.ELEMENT_NODE && + offset > node.childNodes.length + ) { + offset = node.childNodes.length; + } + if ( + node.nodeType === getWindow().Node.TEXT_NODE && + offset > (node.nodeValue?.length || 0) + ) { + offset = node.nodeValue?.length || 0; + } + range[method](node, offset); + } + }; + const beginContext = path.start.id + ? editor.container.find(`[${DATA_ID}="${path.start.id}"]`) + : editor.container; + const startNode = getNode( + path.start.bi > -1 ? startPath.slice(path.start.bi) : startPath, + beginContext, + ); + const endContext = path.end.id + ? editor.container.find(`[${DATA_ID}="${path.end.id}"]`) + : editor.container; + const endNode = getNode( + path.end.bi > -1 ? endPath.slice(path.end.bi) : endPath, + endContext, + ); + const range = Range.create(editor, document); + setRange( + 'setStart', + range, + startNode.get(), + startOffset === undefined ? 0 : startOffset, + ); + setRange( + 'setEnd', + range, + endNode.get(), + endOffset === undefined ? 0 : endOffset, + ); + return range; +}; + +export default Range; + +export const isSelection = ( + param: Window | globalThis.Selection | globalThis.Range, +): param is globalThis.Selection => { + return (param as globalThis.Selection).getRangeAt !== undefined; +}; +export const isRange = ( + param: Window | globalThis.Selection | globalThis.Range, +): param is globalThis.Range => { + return (param as globalThis.Range).collapsed !== undefined; +}; +export const isRangeInterface = ( + selector: NodeInterface | RangeInterface, +): selector is RangeInterface => { + return !!selector && (selector).base !== undefined; +}; diff --git a/packages/engine/src/request/ajax/constants.ts b/packages/engine/src/request/ajax/constants.ts new file mode 100644 index 00000000..479bb5d2 --- /dev/null +++ b/packages/engine/src/request/ajax/constants.ts @@ -0,0 +1,8 @@ +export const HTTP_REG = /^http/; +export const PROTOCO_REG = /(^\w+):\/\//; +export const TWO_HUNDO = /^(20\d|1223)$/; // http://stackoverflow.com/questions/10046972/msie-returns-status-code-of-1223-for-ajax-request +export const READY_STATE = 'readyState'; +export const CONTENT_TYPE = 'Content-Type'; +export const REQUESTED_WITH = 'X-Requested-With'; +export const XML_HTTP_REQUEST = 'XMLHttpRequest'; +export const X_DOMAIN_REQUEST = 'XDomainRequest'; diff --git a/packages/engine/src/request/ajax/index.ts b/packages/engine/src/request/ajax/index.ts new file mode 100644 index 00000000..d06d4e47 --- /dev/null +++ b/packages/engine/src/request/ajax/index.ts @@ -0,0 +1,470 @@ +import { startsWith } from 'lodash-es'; +import { getDocument, getWindow } from '../../utils'; +import { AjaxInterface, AjaxOptions, SetupOptions } from '../../types/request'; +import { isFormData, toQueryString, urlAppend } from './utils'; +import { + CONTENT_TYPE, + HTTP_REG, + PROTOCO_REG, + READY_STATE, + REQUESTED_WITH, + TWO_HUNDO, + XML_HTTP_REQUEST, + X_DOMAIN_REQUEST, +} from './constants'; +import globalSetup from './setup'; + +class Ajax implements AjaxInterface { + private options: AjaxOptions; + private headNode?: HTMLHeadElement; + private request?: XMLHttpRequest; + private isAborted: boolean = false; + private isTimeout: boolean = false; + private timeout?: NodeJS.Timeout; + private callbackData?: any; + private callbackPrefix: string = 'request_' + new Date(); + private uuid: number = 0; + private promise?: Promise; + private __resolve?: (value: unknown) => void; + private __reject?: (reason?: any) => void; + + /** + * 设置全局选项 + * @param options 选项 + */ + static setup = (options: SetupOptions) => { + Object.keys(options).forEach((key) => { + if (globalSetup[key]) globalSetup[key] = options[key]; + }); + }; + + constructor(options: AjaxOptions | string) { + if (typeof options === 'string') { + options = { + url: options, + }; + } + + let { url } = options; + if (startsWith(url, '//')) { + url = window.location.protocol + url; + } + this.options = { + ...globalSetup, + ...options, + url, + context: options.context || getWindow(), + doc: options.doc || getDocument(), + jsonpCallback: options.jsonpCallback || 'callback', + method: options.method || 'GET', + }; + this.headNode = this.options.doc?.getElementsByTagName('head')[0]; + this.initPromise(); + this.init(); + } + + initPromise() { + this.promise = new Promise((resolve, reject) => { + this.__resolve = resolve; + this.__reject = reject; + }).catch(() => {}); + } + + init() { + const timedOut = () => { + this.isTimeout = true; + this.request?.abort(); + }; + + if (this.timeout) { + clearTimeout(this.timeout); + } + + this.timeout = undefined; + + if (this.options.timeout) { + this.timeout = setTimeout(timedOut, this.options.timeout); + } + + const error = (errorMsg: string, request?: XMLHttpRequest) => { + this.triggerError(errorMsg, request); + }; + + const success = (request: XMLHttpRequest) => { + this.triggerSuccess(request); + }; + this.request = this.getRequest(success, error); + } + + abort() { + this.request?.abort(); + } + + defaultXHR() { + const { context, crossOrigin } = this.options; + if (!context) return; + // is it x-domain + if (crossOrigin === true) { + const xhrInstance = context[XML_HTTP_REQUEST] + ? new context[XML_HTTP_REQUEST]() + : null; + if (xhrInstance && 'withCredentials' in xhrInstance) { + return xhrInstance; + } + if (context[X_DOMAIN_REQUEST]) { + return new context[X_DOMAIN_REQUEST](); + } + throw new Error('Browser does not support cross-origin requests'); + } else if (context[XML_HTTP_REQUEST]) { + return new context[XML_HTTP_REQUEST](); + } else { + return new context.ActiveXObject('Microsoft.XMLHTTP'); + } + } + + succeed() { + const { url, context } = this.options; + const protocol = PROTOCO_REG.exec(url); + let protocolValue = protocol ? protocol[1] : ''; + if (!protocolValue) { + protocolValue = context?.location.protocol || ''; + } + return HTTP_REG.test(protocolValue) + ? TWO_HUNDO.test(this.request?.status?.toString() || '') + : !!this.request?.response; + } + + noop() {} + + handleReadyState( + success: (request?: XMLHttpRequest) => void, + error: (statusText: string, request?: XMLHttpRequest) => void, + ) { + // use _aborted to mitigate against IE err c00c023f + // (can't read props on aborted request objects) + if (this.isAborted) { + return error('Request is aborted', this.request); + } + if (this.isTimeout) { + return error('Request is aborted: timeout', this.request); + } + if (this.request && this.request[READY_STATE] === 4) { + this.request.onreadystatechange = this.noop; + if (this.succeed()) { + success(this.request); + } else { + error(this.request.statusText, this.request); + } + } + } + + setHeaders(request: XMLHttpRequest) { + const headers = this.options.headers || {}; + let h = undefined; + headers.Accept = + headers.Accept || globalSetup.accept[this.options.type || '*']; + // breaks cross-origin requests with legacy browsers + if (!this.options.crossOrigin && !headers[REQUESTED_WITH]) { + headers[REQUESTED_WITH] = globalSetup.requestedWith; + } + if (!headers[CONTENT_TYPE] && !isFormData(this.options.data)) { + headers[CONTENT_TYPE] = + this.options.contentType || globalSetup.contentType; + } + Object.keys(headers).forEach((name) => { + request.setRequestHeader(name, headers[name]); + }); + } + + setCredentials(request: XMLHttpRequest) { + if ( + typeof this.options.withCredentials !== 'undefined' && + typeof request.withCredentials !== 'undefined' + ) { + request.withCredentials = !!this.options.withCredentials; + } + } + + generalCallback(data: any) { + this.callbackData = data; + } + + getCallbackPrefix(id: string | number) { + return this.callbackPrefix + '_' + id; + } + + handleJsonp( + url: string, + success: (data: any) => void, + error: (errorMsg: string) => void, + ): XMLHttpRequest | undefined { + const { jsonpCallback, jsonpCallbackName, doc, context } = this.options; + if (!doc || !context) return; + const requestId = this.uuid++; + const cbkey = jsonpCallback || 'callback'; // the 'callback' key + let cbval = jsonpCallbackName || this.getCallbackPrefix(requestId); + const cbreg = new RegExp('((^|\\?|&)' + cbkey + ')=([^&]+)'); + const match = url.match(cbreg); + const script = doc.createElement('script'); + let loaded = 0; + const isIE10 = navigator.userAgent.indexOf('MSIE 10.0') !== -1; + + if (match) { + if (match[3] === '?') { + url = url.replace(cbreg, '$1=' + cbval); // wildcard callback func name + } else { + cbval = match[3]; // provided callback func name + } + } else { + url = urlAppend(url, cbkey + '=' + cbval); // no callback details, add 'em + } + + context[cbval] = this.generalCallback; + + script.type = 'text/javascript'; + script.src = url; + script.async = true; + if (typeof script['onreadystatechange'] !== 'undefined' && !isIE10) { + // need this for IE due to out-of-order onreadystatechange(), binding script + // execution to an event listener gives us control over when the script + // is executed. See http://jaubourg.net/2010/07/loading-script-as-onclick-handler-of.html + script.htmlFor = script.id = '_request_' + requestId; + } + + script.onload = script['onreadystatechange'] = () => { + if ( + (script[READY_STATE] && + script[READY_STATE] !== 'complete' && + script[READY_STATE] !== 'loaded') || + loaded + ) { + return false; + } + script.onload = script['onreadystatechange'] = null; + if (script.onclick) { + (script as any).onclick(); + } + // Call the user callback with the last value stored and clean up values and scripts. + success(this.callbackData); + this.callbackData = undefined; + this.headNode?.removeChild(script); + loaded = 1; + return true; + }; + + // Add the script to the DOM head + this.headNode?.appendChild(script); + + // Enable JSONP timeout + return { + abort: () => { + script.onload = script['onreadystatechange'] = null; + error('Request is aborted: timeout'); + this.callbackData = undefined; + this.headNode?.removeChild(script); + loaded = 1; + }, + } as XMLHttpRequest; + } + + getRequest( + success: (data: any) => void, + error: (errorMsg: string, request?: XMLHttpRequest) => void, + ): XMLHttpRequest | undefined { + const method = this.options.method?.toUpperCase() || 'GET'; + // convert non-string objects to query-string form unless o.processData is false + const { processData, traditional, type, context, xhr, async, before } = + this.options; + if (!context) return; + + let { data, url } = this.options; + if ( + (this.options.contentType?.indexOf('json') || -1) > -1 && + typeof data === 'object' + ) { + data = JSON.stringify(data); + } + data = + processData !== false && + data && + typeof data !== 'string' && + !isFormData(data) + ? toQueryString(data, traditional || globalSetup.traditional) + : data || null; + + let http: XMLHttpRequest | undefined = undefined; + let sendWait = false; + + // if we're working on a GET request and we have data then we should append + // query string to end of URL and not post data + if ((type === 'jsonp' || method === 'GET') && data) { + url = urlAppend(url, data); + data = null; + } + + if (type === 'jsonp') { + return this.handleJsonp(url, success, error); + } + + // get the xhr from the factory if passed + // if the factory returns null, fall-back to ours + http = + (typeof xhr === 'function' ? xhr(this.options) : xhr) || + this.defaultXHR(); + if (!http) return; + http.open(method, url, async === false ? false : true); + this.setHeaders(http); + this.setCredentials(http); + if ( + context[X_DOMAIN_REQUEST] && + http instanceof context[X_DOMAIN_REQUEST] + ) { + http.onload = success; + http.onerror = function () { + error('http error', http); + }; + // NOTE: see + // http://social.msdn.microsoft.com/Forums/en-US-US/iewebdevelopment/thread/30ef3add-767c-4436-b8a9-f1ca19b4812e + http.onprogress = this.noop; + sendWait = true; + } else { + http.onreadystatechange = () => { + this.handleReadyState(success, error); + }; + } + if (before) { + before(http); + } + if (sendWait) { + setTimeout(() => { + http?.send(data); + }, 200); + } else { + http.send(data); + } + return http; + } + + getType(type?: string | null) { + // json, javascript, text/plain, text/html, xml + if (!type) { + return undefined; + } + if (type.match('json')) { + return 'json'; + } + if (type.match('javascript')) { + return 'js'; + } + if (type.match('text')) { + return 'html'; + } + if (type.match('xml')) { + return 'xml'; + } + return undefined; + } + + triggerSuccess(request: XMLHttpRequest) { + const { dataFilter, context, success } = this.options; + if (!context) return; + let { type } = this.options; + // use global data filter on response text + const data = (dataFilter || globalSetup.dataFilter)( + request.responseText, + type, + ); + if (!type) { + type = + request && + this.getType(request.getResponseHeader('Content-Type')); + } + // resp can be undefined in IE + let response: any = type !== 'jsonp' ? this.request : request; + try { + response['responseText'] = data; + } catch (e) { + // can't assign this in IE<=8, just ignore + } + if (data) { + switch (type) { + case 'json': + try { + response = context.JSON.parse(data); + } catch (err) { + return this.triggerError( + 'Could not parse JSON in response', + response, + ); + } + break; + case 'html': + response = data; + break; + case 'xml': + response = + response.responseXML && + response.responseXML.parseError && + response.responseXML.parseError.errorCode && + response.responseXML.parseError.reason + ? null + : response.responseXML; + break; + default: + break; + } + } + + if (success) { + success(response); + } + this.triggerComplete(response); + if (this.__resolve) this.__resolve(response); + } + + triggerError(errorMsg: string, request?: XMLHttpRequest) { + const { error } = this.options; + const e = new Error(errorMsg); + e['xhr'] = request; + if (error) { + error(e); + } + this.triggerComplete(e); + if (this.__reject) this.__reject(e); + } + + triggerComplete(request: XMLHttpRequest | Error) { + const { complete } = this.options; + if (this.timeout) { + clearTimeout(this.timeout); + } + this.timeout = undefined; + + if (complete) { + complete(request); + } + } + + retry() { + this.initPromise(); + this.init(); + } + + then(success: (data: any) => void, fail?: (reason?: any) => void) { + return this.promise?.then(success, fail); + } + + always(fn: (data: any) => void) { + return this.promise?.then(fn, fn); + } + + fail(fn: (reason?: any) => void) { + return this.promise?.then(undefined, fn); + } + + catch(fn: (reason?: any) => void) { + return this.fail(fn); + } +} + +export default Ajax; diff --git a/packages/engine/src/request/ajax/setup.ts b/packages/engine/src/request/ajax/setup.ts new file mode 100644 index 00000000..a7be2785 --- /dev/null +++ b/packages/engine/src/request/ajax/setup.ts @@ -0,0 +1,18 @@ +import { XML_HTTP_REQUEST } from './constants'; + +export default { + traditional: false, + contentType: 'application/x-www-form-urlencoded', + requestedWith: XML_HTTP_REQUEST, + accept: { + '*': 'text/javascript, text/html, application/xml, text/xml, */*', + xml: 'application/xml, text/xml', + html: 'text/html', + text: 'text/plain', + json: 'application/json, text/javascript', + js: 'application/javascript, text/javascript', + }, + dataFilter: (data: any) => { + return data; + }, +}; diff --git a/packages/engine/src/request/ajax/utils.ts b/packages/engine/src/request/ajax/utils.ts new file mode 100644 index 00000000..9f8396c9 --- /dev/null +++ b/packages/engine/src/request/ajax/utils.ts @@ -0,0 +1,101 @@ +export const buildParams = ( + prefix: string, + data: any, + traditional: boolean, + add: (key: string, value?: string | (() => string)) => void, +) => { + let name = undefined; + let i = undefined; + let v = undefined; + const rbracket = /\[\]$/; + + if (Array.isArray(data)) { + // Serialize array item. + for (i = 0; i < data.length; i++) { + const value = data[i]; + if (traditional || rbracket.test(prefix)) { + // Treat each array item as a scalar. + add(prefix, value); + } else { + buildParams( + prefix + + '[' + + ((typeof value === 'undefined' + ? 'undefined' + : typeof value) === 'object' + ? i + : '') + + ']', + value, + traditional, + add, + ); + } + } + } else if (data.toString() === '[object Object]') { + // Serialize object item. + for (name in data) { + if (data.hasOwnProperty(name)) { + buildParams( + prefix + '[' + name + ']', + data[name], + traditional, + add, + ); + } + } + } else { + // Serialize scalar item. + add(prefix, data); + } +}; + +/** + * URL 追加 + * @param url url + * @param text 要追加的文本 + * @returns + */ +export const urlAppend = (url: string, text: string) => { + return url + (/\?/.test(url) ? '&' : '?') + text; +}; + +export const toQueryString = ( + data: Array<{ name: string; value: any }> | { name: string; value: any }, + traditional: boolean = false, +) => { + let prefix = undefined; + let values: Array = []; + + const add = (key: string, value?: string | (() => string)) => { + // If value is a function, invoke it and return its value + if (typeof value === 'function') { + value = value(); + } else if (value === null || value === undefined) { + value = ''; + } + values[values.length] = + encodeURIComponent(key) + '=' + encodeURIComponent(value); + }; + + // If an array was passed in, assume that it is an array of form elements. + if (Array.isArray(data)) { + for (let i = 0; i < data.length; i++) { + add(data[i].name, data[i].value); + } + } else { + // If traditional, encode the "old" way (the way 1.3.2 or older + // did it), otherwise encode params recursively. + for (prefix in data) { + if (data.hasOwnProperty(prefix)) { + buildParams(prefix, data[prefix], traditional, add); + } + } + } + // spaces should be + according to spec + return values.join('&').replace(/%20/g, '+'); +}; + +export const isFormData = (data: any) => { + return typeof FormData !== 'undefined' && data instanceof FormData; +}; diff --git a/packages/engine/src/request/index.ts b/packages/engine/src/request/index.ts new file mode 100644 index 00000000..c95c52df --- /dev/null +++ b/packages/engine/src/request/index.ts @@ -0,0 +1,71 @@ +import { + RequestInterface, + AjaxOptions, + UploaderOptions, + OpenDialogOptions, +} from '../types'; +import Ajax from './ajax'; +import Uploader, { getExtensionName, getFileSize } from './uploader'; + +class Request implements RequestInterface { + ajax(options: AjaxOptions | string) { + return new Ajax(options); + } + + upload(options: UploaderOptions, files: Array, name?: string) { + return new Uploader(options).request(files, name); + } + + getFiles(options?: OpenDialogOptions) { + let { event, accept, multiple } = options || {}; + accept = accept || '*'; + multiple = typeof multiple === undefined ? 100 : multiple; + const input = document.createElement('input'); + input.type = 'file'; + input.accept = accept; + input.style.display = 'none'; + input.multiple = multiple !== false; + + const remove = () => { + input.remove(); + document.removeEventListener('mousedown', remove); + }; + + return new Promise>((resolve) => { + const change = () => { + const files = []; + for (let i = 0; i < (input.files?.length || 0); i++) { + if (typeof multiple === 'number' && i > multiple) break; + + files.push(input.files![i]); + } + input.removeEventListener('change', change); + remove(); + resolve(files); + }; + + input.addEventListener('change', change); + + document.body.appendChild(input); + if (!event) { + event = document.createEvent('MouseEvents'); + event.initEvent('click', true, true); + } + try { + if (!!input.dispatchEvent) { + input.dispatchEvent(event); + } else if (!!input['fireEvent']) { + input['fireEvent'](event); + } else throw ''; + + document.addEventListener('mousedown', remove); + } catch (error) { + input.removeEventListener('change', change); + remove(); + resolve([]); + } + }); + } +} +export { getExtensionName, Ajax, Uploader, getFileSize }; +export default Request; diff --git a/packages/engine/src/request/uploader/index.ts b/packages/engine/src/request/uploader/index.ts new file mode 100644 index 00000000..5bb0bd45 --- /dev/null +++ b/packages/engine/src/request/uploader/index.ts @@ -0,0 +1,154 @@ +import { + UploaderInterface, + FileInfo, + UploaderOptions, + File, +} from '../../types/request'; +import { getExtensionName, getFileSize } from './utils'; +import Ajax from '../ajax'; + +class Uploader implements UploaderInterface { + private options: UploaderOptions; + private uploadingFiles: Array = []; + + constructor(options: UploaderOptions) { + this.options = options; + } + + createUid(text: string | number) { + return Date.now() + '-' + text; + } + + async request(files: Array, name?: string) { + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (!file.uid) file.uid = this.createUid(i); + if ((await this.handleBefore(file, files)) === false) { + files.splice(i, 1); + } + } + this.upload(files, name); + } + + private async upload(files: Array, name: string = 'file') { + files.forEach(async (file) => { + const formData = new FormData(); + formData.append(name, file, file.name); + if (file.data) { + Object.keys(file.data).forEach((key) => { + formData.append(key, file.data![key]); + }); + } + const { + url, + data, + onUploading, + onSuccess, + onError, + crossOrigin, + headers, + } = this.options; + if (data) { + Object.keys(data).forEach((key) => { + formData.append(key, data![key]); + }); + } + await new Ajax({ + xhr: () => { + const xhr = new window.XMLHttpRequest(); + xhr.upload.addEventListener( + 'progress', + (evt) => { + if (evt.lengthComputable) { + if (onUploading) + onUploading(file, { + percent: parseInt( + ( + (evt.loaded / evt.total) * + 100 + ).toString(), + 10, + ), + }); + } + }, + false, + ); + return xhr; + }, + url, + data: formData, + contentType: this.options.contentType, + type: this.options.type || 'json', + crossOrigin: crossOrigin, + headers: headers, + success: (response: any) => { + if (onSuccess) onSuccess(response, file); + }, + error: (err) => { + if (onError) onError(err, file); + }, + method: 'POST', + processData: true, + }); + }); + } + + async handleBefore(file: File, files: Array) { + const { type, uid, name, size } = file; + const ext = getExtensionName(file); + const { onBefore } = this.options; + if (onBefore && (await onBefore(file)) === false) { + return false; + } + return new Promise((resolve, reject) => { + const fileReader = new FileReader(); + fileReader.addEventListener( + 'load', + () => { + this.uploadingFiles[uid!] = { + uid, + src: fileReader.result, + name, + size, + type, + ext, + }; + //全部文件读取完成后再插入编辑器 + if ( + files.every((file) => !!this.uploadingFiles[file.uid!]) + ) { + Promise.all([ + ...files.map((file) => { + return new Promise(async (resolve) => { + if (this.options.onReady) { + await this.options.onReady( + this.uploadingFiles[file.uid!], + file, + ); + } + resolve(true); + }); + }), + ]).then(() => { + resolve(true); + }); + } else { + resolve(true); + } + }, + false, + ); + + fileReader.addEventListener('error', () => { + reject(false); + }); + + fileReader.readAsDataURL(file); + }); + } +} + +export default Uploader; + +export { getExtensionName, getFileSize }; diff --git a/packages/engine/src/request/uploader/mime.ts b/packages/engine/src/request/uploader/mime.ts new file mode 100644 index 00000000..75fff4f3 --- /dev/null +++ b/packages/engine/src/request/uploader/mime.ts @@ -0,0 +1,78 @@ +export default { + 'image/jpeg': ['jpeg', 'jpg', 'jpe'], + 'image/png': ['png'], + 'image/gif': ['gif'], + 'image/vnd.wap.wbmp': ['wbmp'], + 'image/tiff': ['tiff', 'tiff'], + 'image/vnd.adobe.photoshop': ['psd'], + 'image/svg+xml': ['svg', 'svgz'], + 'text/jsx': ['jsx'], + 'text/less': ['less'], + 'text/css': ['css'], + 'text/x-sass': ['sass'], + 'text/x-scss': ['scss'], + 'text/csv': ['cvs'], + 'text/xml': ['xml'], + 'text/x-vcard': ['vcf'], + 'text/x-vcalendar': ['vcs'], + 'text/markdown': ['md'], + 'text/plain': ['txt'], + 'text/richtext': ['rtx'], + 'text/rtf': ['rtf'], + 'text/html': ['html', 'htm', 'shtml'], + 'text/jade': ['jade'], + 'text/javascript': ['js'], + 'text/yaml': ['yaml', 'yml'], + 'audio/mp3': ['mp3'], + 'audio/mp4': ['mp4', 'mp4a'], + 'video/mp4': ['mp4'], + 'audio/mpeg': ['mpeg', 'mp2', 'mp3'], + 'audio/ogg': ['oga'], + 'audio/wav': ['wav'], + 'audio/wave': ['wav'], + 'audio/webm': ['weba'], + 'video/x-msvideo': ['avi'], + 'video/quicktime': ['mov', 'qt'], + 'video/ogg': ['ogv'], + 'video/x-sgi-movie': ['movie'], + 'application/rss+xml': ['rss'], + 'application/json': ['json'], + 'application/zip': ['zip'], + 'application/gzip': ['gzip'], + 'application/pdf': ['pdf'], + 'application/postscript': ['ai', 'eps', 'ps'], + 'application/msword': ['doc', 'dot'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': [ + 'docx', + ], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.template': [ + 'dotx', + ], + 'application/vnd.ms-excel': ['xls', 'xlm', 'xla', 'xlc', 'xlt', 'xlw'], + 'application/vnd.ms-excel.sheet.macroenabled.12': ['xlsm'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [ + 'xlsx', + ], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.template': [ + 'xltx', + ], + 'application/vnd.ms-powerpoint': ['ppt', 'pps', 'pot'], + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': + ['pptx'], + 'application/vnd.openxmlformats-officedocument.presentationml.slide': [ + 'sldx', + ], + 'application/vnd.openxmlformats-officedocument.presentationml.slideshow': [ + 'ppsx', + ], + 'application/vnd.openxmlformats-officedocument.presentationml.template': [ + 'potx', + ], + 'application/vnd.ms-fontobject': ['eot'], + 'application/vnd.android.package-archive': ['apk'], + 'application/x-apple-diskimage': ['dmg'], + 'application/x-iwork-keynote-sffkey': ['key'], + 'application/x-iwork-pages-sffpages': ['pages'], + 'application/x-iwork-keynote-sffnumbers': ['numbers'], + 'application/x-iwork-numbers-sffnumbers': ['numbers'], +}; diff --git a/packages/engine/src/request/uploader/utils.ts b/packages/engine/src/request/uploader/utils.ts new file mode 100644 index 00000000..5e56e272 --- /dev/null +++ b/packages/engine/src/request/uploader/utils.ts @@ -0,0 +1,46 @@ +import filesize from 'filesize'; +import { isWindows } from '../../utils'; +import mime from './mime'; + +/** + * 获取文件扩展名 + * @param file 文件 或 文件名 + * @returns + */ +export const getExtensionName = (file: File | string | Blob) => { + if (typeof file === 'string') { + return file.split('.').pop(); + } + + let ext = mime[file.type] && mime[file.type][0]; + if (!ext && file['name']) { + ext = file['name'].split('.').pop(); + } + return ext; +}; + +/** + * 获取文件大小 + * @param size + * @param base + * @returns + */ +export const getFileSize = ( + size: number, + base: number = isWindows ? 2 : 10, +) => { + //1M以下 + if (size < 1048576) { + return filesize(size, { + base, + exponent: 1, + round: 0, + }); + } + + return filesize(size, { + base, + exponent: 2, + round: 1, + }); +}; diff --git a/packages/engine/src/schema.ts b/packages/engine/src/schema.ts new file mode 100644 index 00000000..c07bdc93 --- /dev/null +++ b/packages/engine/src/schema.ts @@ -0,0 +1,494 @@ +import { cloneDeep, isEqual, merge, omit } from 'lodash-es'; +import { + NodeInterface, + SchemaAttributes, + SchemaBlock, + SchemaGlobal, + SchemaInterface, + SchemaRule, + SchemaStyle, + SchemaValue, + SchemaValueObject, +} from './types'; +import { getWindow, validUrl } from './utils'; +import { getHashId } from './node'; +import { DATA_ID } from './constants'; + +/** + * 标签规则 + */ +class Schema implements SchemaInterface { + private _all: Array = []; + private _typeMap: { + [key: string]: SchemaRule; + } = {}; + + data: { + blocks: Array; + inlines: Array; + marks: Array; + globals: { [key: string]: SchemaAttributes | SchemaStyle }; + } = { + blocks: [], + inlines: [], + marks: [], + globals: {}, + }; + + /** + * 增加规则 + * 只有 type 和 attributes 时,将作为此类型全局属性,与其它所有同类型标签属性将合并 + * @param rules 规则 + */ + add(rules: SchemaRule | SchemaGlobal | Array) { + rules = cloneDeep(rules); + if (!Array.isArray(rules)) { + rules = [rules]; + } + + rules.forEach((rule) => { + if (isSchemaRule(rule)) { + //删除全局属性已有的规则 + if (rule.attributes) { + Object.keys(rule.attributes).forEach((key) => { + if (!this.data.globals[rule.type]) return; + if (key === 'style') { + Object.keys(rule.attributes!.style).forEach( + (styleName) => { + if ( + this.data.globals[rule.type][key] && + this.data.globals[rule.type][key][ + styleName + ] === rule.attributes!.style[styleName] + ) { + delete rule.attributes!.style[ + styleName + ]; + } + }, + ); + } else if ( + this.data.globals[rule.type][key] === + rule.attributes![key] + ) { + delete rule.attributes![key]; + } + }); + } + if (rule.type === 'block') { + this.data.blocks.push(rule); + } else if (rule.type === 'inline') { + this.data.inlines.push(rule); + } else if (rule.type === 'mark') { + this.data.marks.push(rule); + } + } else if (!!this.data[`${rule.type}s`]) { + this.data.globals[rule.type] = merge( + { ...this.data.globals[rule.type] }, + rule.attributes, + ); + } + }); + + //按照必要属性个数排序 + const getCount = (rule: SchemaRule) => { + const aAttributes = rule.attributes || {}; + const aStyles = aAttributes.style || {}; + let aCount = 0; + let sCount = 0; + Object.keys(aAttributes).forEach((attributesName) => { + const attributesValue = aAttributes[attributesName]; + if ( + isSchemaValueObject(attributesValue) && + attributesValue.required + ) + aCount++; + }); + Object.keys(aStyles).forEach((stylesName) => { + const stylesValue = aStyles[stylesName]; + if (isSchemaValueObject(stylesValue) && stylesValue.required) + sCount++; + }); + return [aCount, sCount]; + }; + const { blocks, marks, inlines } = this.data; + this._all = [...blocks, ...marks, ...inlines].sort((a, b) => { + const [aACount, aSCount] = getCount(a); + const [bACount, bSCount] = getCount(b); + + if (aACount > bACount) return -1; + if (aACount === bACount) + return aSCount === bSCount ? 0 : aSCount > bSCount ? -1 : 1; + return 1; + }); + } + // 移除一个规则 + remove(rule: SchemaRule) { + let index = this._all.findIndex((r) => isEqual(r, rule)); + if (index > -1) this._all.splice(index, 1); + const rules = this.data[`${rule.type}s`]; + if (rules) { + index = rules.findIndex((r) => isEqual(r, rule)); + if (index > -1) rules.splice(index, 1); + } + this._typeMap = {}; + } + /** + * 克隆当前schema对象 + */ + clone() { + const schema = new Schema(); + schema._all = cloneDeep(this._all); + schema._typeMap = cloneDeep(this._typeMap); + schema.data = cloneDeep(this.data); + return schema; + } + + /** + * 查找规则 + * @param callback 查找条件 + */ + find(callback: (rule: SchemaRule) => boolean): Array { + let schemas: Array = []; + Object.keys(this.data).some((key) => { + if (key !== 'globals') { + const rules = (this.data[key] as Array).filter( + (rule) => callback(rule), + ); + if (rules && rules.length > 0) { + schemas = schemas.concat(rules); + } + } + return; + }); + return schemas; + } + + getType(node: NodeInterface, filter?: (rule: SchemaRule) => boolean) { + if (node.type !== getWindow().Node.ELEMENT_NODE) return undefined; + let id = node.attributes(DATA_ID); + if (!id) id = getHashId(node, false); + else id = id.split('-')[0]; + if (!!this._typeMap[id] && (!filter || filter(this._typeMap[id]!))) + return this._typeMap[id].type; + const reuslt = this.getRule(node, filter); + if (reuslt) this._typeMap[id] = reuslt; + return reuslt?.type; + } + + /** + * 根据节点获取符合的规则 + * @param node 节点 + * @param filter 过滤 + * @returns + */ + getRule(node: NodeInterface, filter?: (rule: SchemaRule) => boolean) { + filter = filter || ((rule) => rule.name === node.name); + if (node.type !== getWindow().Node.ELEMENT_NODE) return undefined; + return this._all.find( + (rule) => filter!(rule) && this.checkNode(node, rule.attributes), + ); + } + + /** + * 检测节点是否符合某一属性规则 + * @param node 节点 + * @param attributes 属性规则 + */ + checkNode( + node: NodeInterface, + attributes?: SchemaAttributes | SchemaStyle, + ): boolean { + //获取节点属性 + const nodeAttributes = node.attributes(); + const nodeStyles = node.css(); + delete nodeAttributes['style']; + const styles = (attributes || {}).style as SchemaAttributes; + attributes = omit({ ...attributes }, 'style'); + //需要属性每一项都能效验通过 + const attrResult = Object.keys(attributes).every((attributesName) => { + return this.checkValue( + attributes as SchemaAttributes, + attributesName, + nodeAttributes[attributesName], + ); + }); + if (!attrResult) return false; + return Object.keys(styles || {}).every((styleName) => { + return this.checkValue(styles, styleName, nodeStyles[styleName]); + }); + } + /** + * 检测值是否符合规则 + * @param rule 规则 + * @param attributesName 属性名称 + * @param attributesValue 属性值 + */ + checkValue( + schema: SchemaAttributes, + attributesName: string, + attributesValue: string, + force?: boolean, + ): boolean { + if (!schema[attributesName]) return false; + let rule = schema[attributesName]; + if (isSchemaValueObject(rule)) { + //如果没有值,强制状态就返回 false,非强制就返回 true + if (attributesValue === undefined) return !rule.required; + rule = rule.value; + } + //默认都不为强制的 + else if (!force || attributesValue === undefined) return true; + /** + * 自定义规则解析 + */ + if (typeof rule === 'string' && rule.charAt(0) === '@') { + switch (rule) { + case '@number': + rule = /^-?\d+(\.\d+)?$/; + break; + + case '@length': + rule = /^-?\d+(\.\d+)?(\w*|%)$/; + break; + + case '@color': + rule = /^(rgb(.+?)|#\w{3,6}|\w+)$/i; + break; + + case '@url': + rule = validUrl; + break; + default: + break; + } + } + /** + * 字符串解析 + */ + if (typeof rule === 'string') { + if (rule === '*') { + return true; + } + + if (attributesName === 'class') { + return (attributesValue || '') + .split(/\s+/) + .some((value) => value.trim() === rule); + } + + return rule === attributesValue; + } + /** + * 数组解析 + */ + if (Array.isArray(rule)) { + if (attributesName === 'class') { + return (attributesValue || '') + .split(/\s+/) + .every((value) => + value.trim() === '' + ? true + : (rule as Array).indexOf(value.trim()) > + -1, + ); + } + return rule.indexOf(attributesValue) > -1; + } + /** + * 解析正则表达式 + */ + if (typeof rule === 'object' && typeof rule.test === 'function') { + if (attributesName === 'class') { + return (attributesValue || '') + .split(/\s+/) + .every((value) => + value.trim() === '' + ? true + : (rule as RegExp).test(value.trim()), + ); + } + return rule.test(attributesValue || ''); + } + /** + * 自定义函数解析 + */ + if (typeof rule === 'function') { + return rule(attributesValue); + } + return true; + } + /** + * 过滤节点样式 + * @param styles 样式 + * @param rule 规则 + */ + filterStyles(styles: { [k: string]: string }, rule: SchemaRule) { + Object.keys(styles).forEach((styleName) => { + //没有设置规则,全部删除 + if (!rule.attributes?.style) { + delete styles[styleName]; + return; + } + if ( + !this.checkValue( + rule?.attributes?.style! as SchemaAttributes, + styleName, + styles[styleName], + true, + ) + ) + delete styles[styleName]; + }); + } + /** + * 过滤节点属性 + * @param attributes 属性 + * @param rule 规则 + */ + filterAttributes(attributes: { [k: string]: string }, rule: SchemaRule) { + Object.keys(attributes).forEach((attributesName) => { + //没有设置规则,全部删除 + if (!rule.attributes) { + delete attributes[attributesName]; + return; + } + if ( + !this.checkValue( + rule?.attributes! as SchemaAttributes, + attributesName, + attributes[attributesName], + true, + ) + ) + delete attributes[attributesName]; + }); + } + + /** + * 过滤满足node节点规则的属性和样式 + * @param node 节点,用于获取规则 + * @param attributes 属性 + * @param styles 样式 + * @returns + */ + filter( + node: NodeInterface, + attributes: { [k: string]: string }, + styles: { [k: string]: string }, + ) { + const rule = this.getRule(node); + if (!rule) return; + let allRule: SchemaRule = { ...rule }; + const globalRule = Object.keys(this.data.globals).find( + (dataType) => rule.type === dataType, + ); + if (globalRule) { + allRule.attributes = merge( + { + ...allRule.attributes, + }, + { ...this.data.globals[globalRule] }, + ); + } + this.filterAttributes(attributes, allRule); + this.filterStyles(styles, allRule); + } + + /** + * 查找节点符合规则的最顶层的节点名称 + * @param name 节点名称 + * @param callback 回调函数,判断是否继续向上查找,返回false继续查找 + * @returns 最顶级的block节点名称 + */ + closest(name: string) { + let topName = name; + this.data.blocks + .filter((rule) => rule.name === name) + .forEach((block) => { + const schema = block as SchemaBlock; + if (schema.allowIn) { + schema.allowIn.forEach((parentName) => { + if (this.isAllowIn(parentName, topName)) { + topName = parentName; + } + }); + topName = this.closest(topName); + } + }); + return topName; + } + /** + * 判断子节点名称是否允许放入指定的父节点中 + * @param source 父节点名称 + * @param target 子节点名称 + * @returns true | false + */ + isAllowIn(source: string, target: string) { + //p节点下不允许放其它block节点 + if (source === 'p') return false; + return this.data.blocks + .filter((rule) => rule.name === target) + .some((block) => { + const schema = block as SchemaBlock; + if (schema.allowIn && schema.allowIn.indexOf(source) > -1) { + return true; + } + return; + }); + } + addAllowIn(parent: string, child: string = 'p') { + const rule = this.data.blocks.find( + (rule) => rule.name === child, + ) as SchemaBlock; + if (!rule.allowIn) { + rule.allowIn = []; + } + if (!rule.allowIn.includes(parent)) { + rule.allowIn.push(parent); + } + } + /** + * 获取允许有子block节点的标签集合 + * @returns + */ + getAllowInTags() { + const tags: Array = []; + this.data.blocks.forEach((rule) => { + const schema = rule as SchemaBlock; + if (schema.allowIn) { + schema.allowIn.forEach((name) => { + if (tags.indexOf(name) < 0) tags.push(name); + }); + } + }); + return tags; + } + /** + * 获取能够合并的block节点的标签集合 + * @returns + */ + getCanMergeTags() { + const tags: Array = []; + this.data.blocks.forEach((rule) => { + const schema = rule as SchemaBlock; + if (schema.canMerge === true) { + if (tags.indexOf(schema.name) < 0) tags.push(schema.name); + } + }); + return tags; + } +} +export default Schema; + +export const isSchemaValueObject = ( + value: SchemaValue, +): value is SchemaValueObject => { + return (value as SchemaValueObject).required !== undefined; +}; + +export const isSchemaRule = ( + rule: SchemaRule | SchemaGlobal, +): rule is SchemaRule => { + return !!rule['name']; +}; diff --git a/packages/engine/src/scrollbar/index.css b/packages/engine/src/scrollbar/index.css new file mode 100644 index 00000000..90a2d8e1 --- /dev/null +++ b/packages/engine/src/scrollbar/index.css @@ -0,0 +1,84 @@ +.data-scrollable::-webkit-scrollbar { + display: none; + overflow: hidden; + } + .data-scrollable.scroll-x { + padding-bottom: 10px; + overflow-x: hidden; + } + + .data-scrollable.scroll-y { + padding-right: 10px; + overflow-y: hidden; + } + + .data-scrollable:hover .data-scrollbar { + display: block; + } + .data-scrollable.scrolling { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + } + .data-scrollable.scrolling .data-scrollbar { + display: block; + } + .data-scrollable .data-scrollbar { + display: none; + position: absolute; + cursor: default; + transition: opacity 0.3s ease-in-out; + } + .data-scrollable .data-scrollbar .data-scrollbar-trigger { + position: absolute; + background: #c1c1c1; + border-radius: 10px; + cursor: pointer; + } + .data-scrollable .data-scrollbar .data-scrollbar-trigger:hover { + background: #888; + } + .data-scrollable .data-scrollbar.data-scrollbar-x { + width: 100%; + height: 8px; + bottom: 0px; + } + .data-scrollable .data-scrollbar.data-scrollbar-x .data-scrollbar-trigger { + height: 8px; + min-width: 60px; + } + .data-scrollable .data-scrollbar.data-scrollbar-y { + top: 0; + width: 8px; + height: 100%; + right: 0px; + } + .data-scrollable .data-scrollbar.data-scrollbar-y .data-scrollbar-trigger { + width: 8px; + min-height: 60px; + } + .data-scrollable .scrollbar-shadow-left { + position: absolute; + z-index: 10; + left: 0; + top: 0; + bottom: 0; + width: 4px; + opacity: 0.8; + background: linear-gradient(270deg, rgba(99, 114, 130, 0) 0, rgba(99, 114, 130, 0.16)); + background: -webkit-linear-gradient(right, rgba(99, 114, 130, 0), rgba(99, 114, 130, 0.16)); + pointer-events: none; + } + .data-scrollable .scrollbar-shadow-right { + position: absolute; + z-index: 10; + left: 0; + top: 0; + bottom: 0; + width: 4px; + opacity: 0.8; + background: linear-gradient(90deg, rgba(99, 114, 130, 0) 0, rgba(99, 114, 130, 0.16)); + background: -webkit-linear-gradient(left, rgba(99, 114, 130, 0), rgba(99, 114, 130, 0.16)); + pointer-events: none; + } \ No newline at end of file diff --git a/packages/engine/src/scrollbar/index.ts b/packages/engine/src/scrollbar/index.ts new file mode 100644 index 00000000..c35ca87f --- /dev/null +++ b/packages/engine/src/scrollbar/index.ts @@ -0,0 +1,532 @@ +import { EventEmitter2 } from 'eventemitter2'; +import { DATA_ELEMENT, UI } from '../constants'; +import { NodeInterface } from '../types'; +import { $ } from '../node'; +import { isFirefox, isMobile, removeUnit } from '../utils'; +import { isNode } from '../node/utils'; +import './index.css'; + +export type ScrollbarDragging = { + point: number; + position: number; +}; + +class Scrollbar extends EventEmitter2 { + private container: NodeInterface; + private x: boolean; + private y: boolean; + private shadow: boolean; + private scrollBarX?: NodeInterface; + private slideX?: NodeInterface; + private slideXDragging?: ScrollbarDragging; + private scrollBarY?: NodeInterface; + private slideY?: NodeInterface; + private slideYDragging?: ScrollbarDragging; + private shadowLeft?: NodeInterface; + private shadowRight?: NodeInterface; + private oWidth: number = 0; + private oHeight: number = 0; + private sWidth: number = 0; + private sHeight: number = 0; + private xWidth: number = 0; + private yHeight: number = 0; + private maxScrollLeft: number = 0; + #observer?: MutationObserver; + #reverse?: boolean; + #content?: NodeInterface; + shadowTimer?: NodeJS.Timeout; + #enableScroll: boolean = true; + /** + * @param {nativeNode} container 需要添加滚动条的元素 + * @param {boolean} x 横向滚动条 + * @param {boolean} y 竖向滚动条 + * @param {boolean} needShadow 是否显示阴影 + */ + constructor( + container: NodeInterface | Node, + x: boolean = true, + y: boolean = false, + shadow: boolean = true, + ) { + super(); + this.container = isNode(container) ? $(container) : container; + this.x = x; + this.y = y; + this.shadow = shadow; + this.init(); + } + + /** + * 设置滚动条内容节点 + * @param content + */ + setContentNode(content?: NodeInterface | Node) { + this.#content = content + ? isNode(content) + ? $(content) + : content + : content; + } + + init() { + const children = this.container.children(); + let hasScrollbar = false; + children.each((child) => { + if ($(child).hasClass('data-scrollbar')) { + hasScrollbar = true; + } + }); + if (!hasScrollbar) { + this.container.css('position', 'relative'); + this.container.addClass('data-scrollable'); + if (this.x) { + this.scrollBarX = $( + `
      `, + ); + this.slideX = this.scrollBarX.find('.data-scrollbar-trigger'); + this.container.append(this.scrollBarX); + this.container.addClass('scroll-x'); + } + if (this.y) { + this.scrollBarY = $( + `
      `, + ); + this.slideY = this.scrollBarY.find('.data-scrollbar-trigger'); + this.container.append(this.scrollBarY); + this.container.addClass('scroll-y'); + } + if (this.shadow) { + this.shadowLeft = $( + `
      `, + ); + this.shadowRight = $( + `
      `, + ); + this.container.append(this.shadowLeft); + this.container.append(this.shadowRight); + } + this.refresh(); + this.bindEvents(); + } + } + + refresh() { + const element = this.container.get(); + if (element) { + const { offsetWidth, offsetHeight, scrollLeft, scrollTop } = + element; + + const contentElement = this.#content?.get(); + const sPLeft = removeUnit(this.container.css('padding-left')); + const sPRight = removeUnit(this.container.css('padding-right')); + const sPTop = removeUnit(this.container.css('padding-top')); + const sPBottom = removeUnit(this.container.css('padding-bottom')); + const scrollWidth = contentElement + ? contentElement.offsetWidth + sPLeft + sPRight + : element.scrollWidth; + const scrollHeight = contentElement + ? contentElement.offsetHeight + sPTop + sPBottom + : element.scrollHeight; + this.oWidth = + offsetWidth - + removeUnit(this.container.css('border-left-width')) - + removeUnit(this.container.css('border-right-width')); + this.oHeight = + offsetHeight - + removeUnit(this.container.css('border-top-width')) - + removeUnit(this.container.css('border-bottom-width')); + this.sWidth = scrollWidth; + this.sHeight = scrollHeight; + this.xWidth = Math.floor((this.oWidth * this.oWidth) / scrollWidth); + this.yHeight = Math.floor( + (this.oHeight * this.oHeight) / scrollHeight, + ); + this.maxScrollLeft = scrollWidth - this.oWidth; + if (this.x) { + this.slideX?.css('width', this.xWidth + 'px'); + const display = + this.oWidth === this.sWidth || + (contentElement && + contentElement.offsetWidth <= this.oWidth) + ? 'none' + : 'block'; + this.slideX?.css('display', display); + this.emit('display', display); + this.shadowLeft?.css('display', display); + this.shadowRight?.css('display', display); + } + if (this.y) { + this.slideY?.css('height', this.yHeight + 'px'); + const display = + this.oHeight === this.sHeight || + (contentElement && + contentElement.offsetHeight <= this.oHeight) + ? 'none' + : 'block'; + this.slideY?.css('display', display); + this.emit('display', display); + } + // 实际内容宽度小于容器滚动宽度(有内容删除了) + if ( + this.x && + contentElement && + element.scrollWidth - sPLeft - sPRight !== + contentElement.offsetWidth + ) { + element.scrollLeft -= + element.scrollWidth - + sPLeft - + sPRight - + contentElement.offsetWidth; + return; + } + // 实际内容高度小于容器滚动高度(有内容删除了) + if ( + this.y && + contentElement && + element.scrollHeight - sPTop - sPBottom !== + contentElement.offsetHeight + ) { + element.scrollTop -= + element.scrollHeight - + sPTop - + sPBottom - + contentElement.offsetHeight; + return; + } + this.reRenderX(scrollLeft); + this.reRenderY(scrollTop); + } + } + + /** + * 启用鼠标在内容节点上滚动或在移动设备使用手指滑动 + */ + enableScroll() { + this.#enableScroll = true; + } + /** + * 禁用鼠标在内容节点上滚动或在移动设备使用手指滑动 + */ + disableScroll() { + this.#enableScroll = false; + } + + scroll = (event: Event) => { + const { target } = event; + if (!target) return; + + const { scrollTop, scrollLeft } = target as HTMLElement; + this.reRenderX(scrollLeft); + this.reRenderY(scrollTop); + }; + + wheelXScroll = (event: any) => { + event.preventDefault(); + const wheelValue = event.wheelDelta / 120 || -event.detail; + const dir = wheelValue > 0 ? 'up' : 'down'; + const containerElement = this.container.get(); + if (!containerElement) return; + let left = containerElement.scrollLeft + (dir === 'up' ? -20 : 20); + left = + dir === 'up' + ? Math.max(0, left) + : Math.min(left, this.sWidth - this.oWidth); + containerElement.scrollLeft = left; + }; + + wheelYScroll = (event: any) => { + event.preventDefault(); + const wheelValue = event.wheelDelta / 120 || -event.detail; + const dir = wheelValue > 0 ? 'up' : 'down'; + const containerElement = this.container.get(); + if (!containerElement) return; + let top = containerElement.scrollTop + (dir === 'up' ? -20 : 20); + top = + dir === 'up' + ? Math.max(0, top) + : Math.min(top, this.sHeight - this.oHeight); + containerElement.scrollTop = top; + }; + + bindWheelScroll = (event: any) => { + if (!this.#enableScroll) return; + if (this.x && !this.y) { + if (this.slideX && this.slideX.css('display') !== 'none') + this.wheelXScroll(event); + } else if (this.y) { + if (this.slideY && this.slideY.css('display') !== 'none') + this.wheelYScroll(event); + } + }; + + /** + * 在节点上左右滑动手指 + * @param event + * @returns + */ + bindContainerTouchX = (event: TouchEvent) => { + if (!event.target || !this.#enableScroll) return; + if ($(event.target).hasClass('data-scrollbar-trigger')) return; + // 设置滚动方向相反 + this.#reverse = true; + this.scrollXStart(event); + }; + /** + * 在节点上上下滑动手指 + * @param event + * @returns + */ + bindContainerTouchY = (event: TouchEvent) => { + if (!event.target || !this.#enableScroll) return; + if ($(event.target).hasClass('data-scrollbar-trigger')) return; + // 设置滚动方向相反 + this.#reverse = true; + this.scrollYStart(event); + }; + + bindEvents() { + if (isMobile) { + // 在节点上滑动手指 + if (this.x) { + this.container.on('touchstart', this.bindContainerTouchX); + } + if (this.y) { + this.container.on('touchstart', this.bindContainerTouchY); + } + } else { + // 在节点上滚动鼠标滚轮 + this.container.on( + isFirefox ? 'DOMMouseScroll' : 'mousewheel', + this.bindWheelScroll, + ); + } + this.container.on('scroll', this.scroll); + const containerElement = this.container.get(); + if (!containerElement) return; + let size = { + width: this.container.width(), + height: this.container.height(), + }; + this.#observer = new MutationObserver(() => { + const width = this.container.width(); + const height = this.container.height(); + if (width === size.width && height === size.height) return; + size = { + width, + height, + }; + this.refresh(); + }); + this.#observer.observe(containerElement, { + attributes: true, + attributeFilter: ['style'], + attributeOldValue: true, + childList: true, + subtree: true, + }); + // 绑定滚动条事件 + this.bindXScrollEvent(); + this.bindYScrollEvent(); + } + /** + * 获取鼠标事件或者触摸事件的 clientX clientY + * @param event + * @returns + */ + getEventClientOffset = (event: MouseEvent | TouchEvent) => { + if (event instanceof MouseEvent) { + return { + x: event.clientX, + y: event.clientY, + }; + } + return { + x: event.touches[0].clientX, + y: event.touches[0].clientY, + }; + }; + /** + * 横向滚动 + * @param event + */ + scrollX = (event: MouseEvent | TouchEvent) => { + if (this.slideXDragging) { + const { point, position } = this.slideXDragging; + const offset = this.getEventClientOffset(event); + let left = this.#reverse + ? position - (offset.x - point) + : position + (offset.x - point); + left = Math.max(0, Math.min(left, this.oWidth - this.xWidth)); + this.slideX?.css('left', left + 'px'); + let min = left / (this.oWidth - this.xWidth); + min = Math.min(1, min); + this.container.get()!.scrollLeft = + (this.sWidth - this.oWidth) * min; + } + }; + + scrollY = (event: MouseEvent | TouchEvent) => { + if (this.slideYDragging) { + const { point, position } = this.slideYDragging; + const offset = this.getEventClientOffset(event); + let top = this.#reverse + ? position - (offset.y - point) + : position + (offset.y - point); + top = Math.max(0, Math.min(top, this.oHeight - this.yHeight)); + this.slideY?.css('top', top + 'px'); + let min = top / (this.oHeight - this.yHeight); + min = Math.min(1, min); + this.container.get()!.scrollTop = + (this.sHeight - this.oHeight) * min; + } + }; + + scrollXEnd = () => { + this.slideXDragging = undefined; + this.#reverse = false; + document.body.removeEventListener( + isMobile ? 'touchmove' : 'mousemove', + this.scrollX, + ); + document.body.removeEventListener( + isMobile ? 'touchend' : 'mouseup', + this.scrollXEnd, + ); + this.container.removeClass('scrolling'); + }; + + scrollYEnd = () => { + this.slideYDragging = undefined; + document.body.removeEventListener( + isMobile ? 'touchmove' : 'mousemove', + this.scrollY, + ); + document.body.removeEventListener( + isMobile ? 'touchend' : 'mouseup', + this.scrollYEnd, + ); + this.container.removeClass('scrolling'); + }; + + scrollXStart = (event: MouseEvent | TouchEvent) => { + const offset = this.getEventClientOffset(event); + this.container.addClass('scrolling'); + this.slideXDragging = { + point: offset.x, + position: parseInt(this.slideX?.css('left') || '0'), + }; + document.body.addEventListener( + isMobile ? 'touchmove' : 'mousemove', + this.scrollX, + ); + document.body.addEventListener( + isMobile ? 'touchend' : 'mouseup', + this.scrollXEnd, + ); + }; + + scrollYStart = (event: MouseEvent | TouchEvent) => { + const offset = this.getEventClientOffset(event); + this.container.addClass('scrolling'); + this.slideYDragging = { + point: offset.y, + position: parseInt(this.slideY?.css('top') || '0'), + }; + document.body.addEventListener( + isMobile ? 'touchmove' : 'mousemove', + this.scrollY, + ); + document.body.addEventListener( + isMobile ? 'touchend' : 'mouseup', + this.scrollYEnd, + ); + }; + + bindXScrollEvent = () => { + if (this.x) { + this.slideX?.on( + isMobile ? 'touchstart' : 'mousedown', + this.scrollXStart, + ); + } + }; + + bindYScrollEvent = () => { + if (this.y) { + this.slideY?.on( + isMobile ? 'touchstart' : 'mousedown', + this.scrollYStart, + ); + } + }; + + reRenderShadow = (width: number) => { + if (this.shadow) { + this.shadowLeft?.css('left', width + 'px'); + this.shadowRight?.css('left', width + this.oWidth - 4 + 'px'); + } + }; + + reRenderX = (left: number) => { + if (this.x) { + this.scrollBarX?.css('left', left + 'px'); + const value = this.sWidth - this.oWidth; + let min = value <= 0 ? 0 : left / value; + min = Math.min(1, min); + this.slideX?.css('left', (this.oWidth - this.xWidth) * min + 'px'); + this.reRenderShadow(left); + this.emit('change'); + } + }; + + reRenderY = (top: number) => { + if (this.y) { + this.scrollBarY?.css('top', top + 'px'); + const value = this.sHeight - this.oHeight; + let min = value <= 0 ? 0 : top / value; + min = Math.min(1, min); + this.slideY?.css('top', (this.oHeight - this.yHeight) * min + 'px'); + this.emit('change'); + } + }; + + destroy() { + this.slideX?.off( + isMobile ? 'touchstart' : 'mousedown', + this.scrollXStart, + ); + this.slideY?.off( + isMobile ? 'touchstart' : 'mousedown', + this.scrollYStart, + ); + if (isMobile) { + if (this.x) + this.container.off('touchstart', this.bindContainerTouchX); + if (this.y) + this.container.off('touchstart', this.bindContainerTouchY); + } else { + this.container.off( + isFirefox ? 'DOMMouseScroll' : 'mousewheel', + this.bindWheelScroll, + ); + } + this.container.off('scroll', this.scroll); + this.container.removeClass('data-scrollable'); + if (this.x) { + this.scrollBarX?.remove(); + this.container.removeClass('scroll-x'); + } + if (this.y) { + this.scrollBarY?.remove(); + this.container.removeClass('scroll-y'); + } + if (this.shadow) { + this.shadowLeft?.remove(); + this.shadowRight?.remove(); + } + this.#observer?.disconnect(); + } +} + +export default Scrollbar; diff --git a/packages/engine/src/selection.ts b/packages/engine/src/selection.ts new file mode 100644 index 00000000..339b5c63 --- /dev/null +++ b/packages/engine/src/selection.ts @@ -0,0 +1,357 @@ +import { + ANCHOR, + ANCHOR_SELECTOR, + CARD_LEFT_SELECTOR, + CARD_RIGHT_SELECTOR, + CARD_SELECTOR, + CURSOR, + CURSOR_SELECTOR, + DATA_ELEMENT, + FOCUS, + FOCUS_SELECTOR, + ROOT_SELECTOR, +} from './constants'; +import { EditorInterface, NodeInterface, RangeInterface } from './types'; +import { isEdge, isSafari } from './utils'; +import { SelectionInterface } from './types/selection'; +import { $ } from './node'; + +class Selection implements SelectionInterface { + private range: RangeInterface; + private editor: EditorInterface; + private key: string = ''; + anchor: NodeInterface | null = null; + focus: NodeInterface | null = null; + + /** + * 移除光标位置占位标签 + * @param value 需要移除的字符串 + */ + static removeTags = (value: string) => { + return value + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, ''); + }; + + constructor( + editor: EditorInterface, + range: RangeInterface, + key: string = '', + ) { + this.editor = editor; + this.range = range; + this.key = key; + } + + has() { + return !!this.focus && !!this.anchor; + } + + create() { + const { commonAncestorNode, startNode, endNode } = this.range; + // 超出编辑区域 + if ( + !commonAncestorNode.isEditable() && + !commonAncestorNode.inEditor() + ) { + return; + } + const { document } = commonAncestorNode; + if (!document) return; + // 为了增加容错性,删除已有的标记 + const root = commonAncestorNode.closest(ROOT_SELECTOR); + if (this.key) { + root.find(`[data-anchor-id="${this.key}"]`).remove(); + root.find(`[data-focus-id="${this.key}"]`).remove(); + root.find(`[data-cursor-id="${this.key}"]`).remove(); + } else { + const anchor = root.find(ANCHOR_SELECTOR); + anchor.each((_, index) => { + const node = anchor.eq(index); + if (node && !node.attributes('data-anchor-id')) node.remove(); + }); + const focus = root.find(FOCUS_SELECTOR); + focus.each((_, index) => { + const node = focus.eq(index); + if (node && !node.attributes('data-focus-id')) node.remove(); + }); + const cursor = root.find(CURSOR_SELECTOR); + cursor.each((_, index) => { + const node = cursor.eq(index); + if (node && !node.attributes('data-cursor-id')) node.remove(); + }); + } + + // card 组件 + const card = startNode.closest(CARD_SELECTOR); + if (card.length > 0) { + const cardLeft = startNode.closest(CARD_LEFT_SELECTOR); + if (cardLeft.length > 0) { + this.range.setStartBefore(card); + } + const cardRight = startNode.closest(CARD_RIGHT_SELECTOR); + if (cardRight.length > 0) { + this.range.setStartAfter(card); + } + } + + if (!startNode.equal(endNode)) { + const card = endNode.closest(CARD_SELECTOR); + if (card.length > 0) { + const _cardLeft = endNode.closest(CARD_LEFT_SELECTOR); + if (_cardLeft.length > 0) { + this.range.setEndBefore(card); + } + const _cardRight = endNode.closest(CARD_RIGHT_SELECTOR); + if (_cardRight.length > 0) { + this.range.setEndAfter(card); + } + } + } + // cursor + if (this.range.collapsed) { + const cursor = $(document.createElement('span')); + cursor.attributes(DATA_ELEMENT, CURSOR); + if (this.key) { + cursor.attributes('data-cursor-id', this.key); + } + this.range.insertNode(cursor); + this.anchor = cursor; + this.focus = cursor; + return; + } + // anchor + const startRange = this.range.cloneRange(); + startRange.collapse(true); + const anchor = $(document.createElement('span')); + anchor.attributes(DATA_ELEMENT, ANCHOR); + if (this.key) { + anchor.attributes('data-anchor-id', this.key); + } + startRange.insertNode(anchor); + this.range.setStartAfter(anchor); + // focus + const endRange = this.range.cloneRange(); + endRange.collapse(false); + const focus = $(document.createElement('span')); + focus.attributes(DATA_ELEMENT, FOCUS); + if (this.key) { + focus.attributes('data-focus-id', this.key); + } + endRange.insertNode(focus); + this.anchor = anchor; + this.focus = focus; + } + + move() { + if (!this.focus || !this.anchor) { + return; + } + // 在有指定key的情况下,如果标记节点被移除了就去查找 + if (this.key) { + const { commonAncestorNode } = this.range; + const root = commonAncestorNode.closest(ROOT_SELECTOR); + if (!this.focus.inEditor()) { + this.focus = root.find( + `[data-${this.focus.attributes(DATA_ELEMENT)}-id="${ + this.key + }"]`, + ); + } + if (!this.anchor.inEditor()) { + this.anchor = root.find( + `[data-${this.anchor.attributes(DATA_ELEMENT)}-id="${ + this.key + }"]`, + ); + } + } + const { node } = this.editor; + if (this.anchor.equal(this.focus)) { + const cursor = this.anchor; + const _parent = cursor.parent(); + if (!_parent) return; + node.removeZeroWidthSpace(_parent); + _parent[0].normalize(); + + let isCardCursor = false; + const prevNode = cursor.prev(); + const nextNode = cursor.next(); + // 具有 block css 属性的行内Card,不调整光标位置 + if (prevNode && prevNode.isCard()) { + const cardRight = prevNode.find(CARD_RIGHT_SELECTOR); + if (cardRight.length > 0) { + this.range.select(cardRight, true); + this.range.collapse(false); + isCardCursor = true; + } + } else if (nextNode && nextNode.isCard()) { + const cardLeft = nextNode.find(CARD_LEFT_SELECTOR); + if (cardLeft.length > 0) { + this.range.select(cardLeft, true); + this.range.collapse(false); + isCardCursor = true; + } + } + + if (!isCardCursor) { + this.range.setStartBefore(cursor[0]); + this.range.collapse(true); + } + + if (isEdge) { + _parent![0].normalize(); + cursor.remove(); + } else { + cursor.remove(); + _parent![0].normalize(); + } + if (_parent.name === 'p' && _parent.children().length === 0) { + _parent.append($('
      ')); + } + return; + } + // collapsed = false + // range start + let parent = this.anchor.parent(); + if (parent) { + node.removeZeroWidthSpace(parent); + this.range.setStartBefore(this.anchor); + this.anchor.remove(); + parent[0].normalize(); + } + + // range end + parent = this.focus.parent(); + if (parent) { + node.removeZeroWidthSpace(parent); + this.range.setEndBefore(this.focus); + this.focus.remove(); + parent[0].normalize(); + if (parent.name === 'p' && parent.children().length === 0) { + parent.append($('
      ')); + } + if (isSafari) { + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(this.range.base); + } + } + } + + getNode( + source: NodeInterface, + position: 'left' | 'center' | 'right' = 'center', + isClone: boolean = true, + callback: (node: NodeInterface) => boolean = () => true, + ) { + const node = isClone ? source.clone(true) : source; + if (!this.focus || !this.anchor) { + return node; + } + // 删除右侧 + if (position === 'left' || position === 'center') { + const selectionNode = + position !== 'center' ? this.anchor : this.focus; + let focus: NodeInterface | undefined = $( + this.key + ? `[data-${selectionNode.attributes(DATA_ELEMENT)}-id="${ + this.key + }"]` + : `[${DATA_ELEMENT}=${selectionNode.attributes( + DATA_ELEMENT, + )}]`, + node.get(), + ); + if (!this.key) { + focus = focus + .toArray() + .find( + (node) => + !node.attributes( + `data-${selectionNode.attributes( + DATA_ELEMENT, + )}-id`, + ), + ); + } + let isRemove = false; + node.traverse((node) => { + if (focus && node.equal(focus)) { + const parent = node.parent(); + focus.remove(); + if ( + parent?.name === 'p' && + parent.children().length === 0 + ) { + parent.append($('
      ')); + } + isRemove = true; + return; + } + if ( + isRemove && + callback(node) && + (node.attributes(DATA_ELEMENT) !== + selectionNode.attributes(DATA_ELEMENT) || + selectionNode.attributes(DATA_ELEMENT) === 'cursor') + ) + node.remove(); + }, true); + } + // 删除左侧 + if (position === 'right' || position === 'center') { + const selectionNode = + position !== 'center' ? this.focus : this.anchor; + let anchor: NodeInterface | undefined = $( + this.key + ? `[data-${selectionNode.attributes(DATA_ELEMENT)}-id="${ + this.key + }"]` + : `[${DATA_ELEMENT}=${selectionNode.attributes( + DATA_ELEMENT, + )}]`, + node.get(), + ); + if (!this.key) { + anchor = anchor + .toArray() + .find( + (node) => + !node.attributes( + `data-${selectionNode.attributes( + DATA_ELEMENT, + )}-id`, + ), + ); + } + let isRemove = false; + node.traverse((node) => { + if (anchor && node.equal(anchor)) { + const parent = node.parent(); + anchor.remove(); + if ( + parent?.name === 'p' && + parent.children().length === 0 + ) { + parent.append($('
      ')); + } + isRemove = true; + return; + } + if ( + isRemove && + callback(node) && + (node.attributes(DATA_ELEMENT) !== + selectionNode.attributes(DATA_ELEMENT) || + selectionNode.attributes(DATA_ELEMENT) === 'cursor') + ) + node.remove(); + }, false); + } + return node; + } +} + +export default Selection; diff --git a/packages/engine/src/toolbar/button.ts b/packages/engine/src/toolbar/button.ts new file mode 100644 index 00000000..630113e1 --- /dev/null +++ b/packages/engine/src/toolbar/button.ts @@ -0,0 +1,60 @@ +import { NodeInterface } from '../types/node'; +import { ButtonInterface, ButtonOptions } from '../types/toolbar'; +import Tooltip from './tooltip'; +import { $ } from '../node'; + +const template = (options: ButtonOptions) => { + return ` + + + ${options.content} + + `; +}; + +export default class Button implements ButtonInterface { + private options: ButtonOptions; + private root: NodeInterface; + constructor(options: ButtonOptions) { + this.options = options; + this.root = $(template(options)); + if (options.style) { + this.root.attributes('style', options.style); + } + if (options.class) { + this.root.addClass(options.class); + } + } + + render(container: NodeInterface) { + const { title, didMount, onClick } = this.options; + container.append(this.root); + + if (title) { + this.root.on('mouseenter', () => { + Tooltip.show( + this.root, + typeof title === 'function' ? title() : title, + ); + }); + this.root.on('mouseleave', () => { + Tooltip.hide(); + }); + this.root.on('mousedown', () => { + Tooltip.hide(); + }); + } + + this.root.find('a').on('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (onClick) onClick(e, this.root); + }); + + if (didMount) { + didMount(this.root); + } + } +} diff --git a/packages/engine/src/toolbar/dropdown/button.ts b/packages/engine/src/toolbar/dropdown/button.ts new file mode 100644 index 00000000..7aa4f2e8 --- /dev/null +++ b/packages/engine/src/toolbar/dropdown/button.ts @@ -0,0 +1,32 @@ +import { NodeInterface } from '../../types/node'; +import { DropdownButtonOptions } from '../../types/toolbar'; +import { $ } from '../../node'; + +const template = (options: DropdownButtonOptions) => { + return ` +
      + + ${options.content} + +
      `; +}; + +export default class { + private options: DropdownButtonOptions; + private root: NodeInterface | undefined; + + constructor(options: DropdownButtonOptions) { + this.options = options; + } + + renderTo(container: NodeInterface) { + this.root = $(template(this.options)); + container.append(this.root); + const { onClick } = this.options; + if (onClick) { + this.root.on('click', (event) => onClick(event, this.root!)); + } + } +} diff --git a/packages/engine/src/toolbar/dropdown/index.ts b/packages/engine/src/toolbar/dropdown/index.ts new file mode 100644 index 00000000..5e61de61 --- /dev/null +++ b/packages/engine/src/toolbar/dropdown/index.ts @@ -0,0 +1,116 @@ +import { NodeInterface } from '../../types/node'; +import Tooltip from '../tooltip'; +import Switch from './switch'; +import Button from './button'; +import { DropdownInterface, DropdownOptions } from '../../types/toolbar'; +import { $ } from '../../node'; + +const template = (options: DropdownOptions) => { + return ` + + ${options.content} + + `; +}; + +export default class Dropdown implements DropdownInterface { + private options: DropdownOptions; + private root: NodeInterface | undefined; + private dropdown: NodeInterface | undefined; + + constructor(options: DropdownOptions) { + this.options = options; + } + + documentMouseDown = (e: MouseEvent) => { + if (!this.root) return; + if ( + !this.root[0].contains(e.target as Node) && + this.dropdown?.hasClass('show') + ) { + this.hideDropdown(); + } + }; + + initToggleEvent() { + const dropdownBtn = this.root!.find('.data-toolbar-dropdown'); + dropdownBtn.on('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + dropdownBtn.on('click', (e) => { + e.stopPropagation(); + this.toggleDropdown(); + }); + document.addEventListener('mousedown', this.documentMouseDown, true); + } + + toggleDropdown() { + if (this.dropdown?.hasClass('show')) { + this.hideDropdown(); + } else { + this.showDropdown(); + } + } + + showDropdown() { + this.dropdown?.addClass('show'); + } + + hideDropdown() { + this.dropdown?.removeClass('show'); + } + + renderTooltip() { + const { title } = this.options; + if (title) { + this.root!.on('mouseenter', () => { + Tooltip.show( + this.root!, + typeof title === 'function' ? title() : title, + ); + }); + this.root!.on('mouseleave', () => { + Tooltip.hide(); + }); + this.root!.on('mousedown', () => { + Tooltip.hide(); + }); + } + } + + renderDropdown(container: NodeInterface) { + this.dropdown = container.find('.dropdown-container'); + const { items } = this.options; + items.forEach((item) => { + switch (item.type) { + case 'switch': + return new Switch(item).renderTo(this.dropdown!); + case 'button': + return new Button(item).renderTo(this.dropdown!); + } + }); + this.dropdown.on('click', (e) => { + e.stopPropagation(); + this.hideDropdown(); + }); + } + + render(container: NodeInterface) { + this.root = $(template(this.options)); + container.append(this.root); + this.initToggleEvent(); + this.renderTooltip(); + this.renderDropdown(container); + const { didMount } = this.options; + if (didMount) { + didMount(this.root); + } + } + + destroy() { + document.removeEventListener('mousedown', this.documentMouseDown, true); + } +} diff --git a/packages/engine/src/toolbar/dropdown/switch.ts b/packages/engine/src/toolbar/dropdown/switch.ts new file mode 100644 index 00000000..c7fb6f1b --- /dev/null +++ b/packages/engine/src/toolbar/dropdown/switch.ts @@ -0,0 +1,55 @@ +import { NodeInterface } from '../../types/node'; +import { DropdownSwitchOptions } from '../../types/toolbar'; +import { $ } from '../../node'; + +const template = (options: DropdownSwitchOptions) => { + let checked = !!options.checked; + if (options.getState) checked = options.getState(); + return ` +
      + ${options.content} + +
      `; +}; + +export default class { + private options: DropdownSwitchOptions; + private root: NodeInterface | undefined; + private switch: NodeInterface | undefined; + + constructor(options: DropdownSwitchOptions) { + this.options = options; + } + + renderTo(container: NodeInterface) { + this.root = $(template(this.options)); + this.switch = this.root.find('.ant-switch'); + container.append(this.root); + this.root.on('mousedown', (e) => e.preventDefault()); + const { onClick } = this.options; + this.root.on('click', (e) => { + e.stopPropagation(); + if (onClick) { + onClick(e, this.root!); + this.updateSwitch(); + } + }); + } + + updateSwitch() { + if (this.options.getState) { + if (this.options.getState()) { + this.switch?.addClass('ant-switch-checked'); + } else { + this.switch?.removeClass('ant-switch-checked'); + } + } + } +} diff --git a/packages/engine/src/toolbar/index.css b/packages/engine/src/toolbar/index.css new file mode 100644 index 00000000..39221486 --- /dev/null +++ b/packages/engine/src/toolbar/index.css @@ -0,0 +1,234 @@ +.data-toolbar { + position: absolute; + opacity: 0; + visibility: hidden; + width: auto; + line-height: 26px; + display: flex; + flex-direction: row; + font-size: 14px; + font-weight: normal; + text-indent: 0; + -webkit-user-select: none; + user-select: none; +} + +.data-toolbar-active { + opacity: 1; + visibility: visible; +} + +.data-toolbar-block { + top: auto; + bottom: -46px; + left: -1px; + right: auto; + height: 40px; + z-index: 125; +} + +.data-toolbar-btn { + line-height: 26px; + min-width: 28px; + display: inline-block; + text-align: center; + color: #595959; + transition: background-color 0.3s ease-in-out; + cursor: pointer; +} + +.data-toolbar-btn-disabled,.data-toolbar-btn-disabled:hover { + background-color: transparent; + box-shadow: none; + cursor: not-allowed; +} + +.data-toolbar-group { + border: 1px solid rgba(226, 226, 226, 0.84); + border-radius: 4px; + box-shadow: 0px 2px 4px 0px rgb(225 225 225 / 50%); + background: #fff; + position: relative; + display: inline-flex; + padding: 5px; + align-items: center; +} + +.data-toolbar-item { + position: relative; + display: inline-block; + line-height: 26px; + text-align: left; + color: #595959; + flex: 0 0 auto; + font-size: 12px; + cursor: pointer; +} + +.data-toolbar-item:not(.data-toolbar-item-input):hover, .data-toolbar-item.active:not(.data-toolbar-item-input){ + background-color: #f4f4f4; + border-radius: 2px; +} + +.data-toolbar-item > * { + font-size: 12px !important; +} + +.data-toolbar-item[disabled] { + opacity: 0.5; + cursor: not-allowed; +} + +.data-toolbar-item-split { + width: 1px; + height: 16px; + line-height: 16px; + margin: 6px 4px; + border-left: 1px solid #e8e8e8; + display: inline-block; +} + +.data-toolbar-item-dropdown-active { + opacity: 1; + visibility: visible; + transform: translateY(0px); +} + +.data-toolbar-item-input { + display: flex; + margin: 0 4px; +} + +.data-toolbar-item-input .data-toolbar-input { + width: 46px; + line-height: 12px; + font-size: 12px; + outline: none; + border: 1px solid #dadada; + border-radius: 4px; +} + +.data-toolbar-item-input .data-toolbar-input::selection { + color: inherit; + background:transparent +} + +.data-toolbar-item-input .data-toolbar-input:focus::selection +{ + color: #fff; + background: #1890ff; +} + +.data-toolbar-item-dropdown .dropdown-container { + display: none; + position: absolute; + padding: 8px 0; + top: 100%; + margin-top: 6px; + border-radius: 2px; + background-color: #fff; + box-shadow: 0 1px 4px -2px rgba(0, 0, 0, 0.13), 0 2px 8px 0 rgba(0, 0, 0, 0.08), 0 8px 16px 4px rgba(0, 0, 0, 0.04); + z-index: 99999; +} + +.data-toolbar-item-dropdown .dropdown-container.show { + display: block; +} + +.data-toolbar-dropdown-item { + padding: 2px 16px; + margin: 0; + white-space: nowrap; + line-height: 26px; + color: #404040; + cursor: pointer; +} + +.data-toolbar-dropdown-item:hover { + background-color: #f5f5f5; +} + +.data-toolbar-dropdown-switch { + display: flex; + align-items: center; +} + +.data-toolbar-dropdown-switch .data-toolbar-dropdown-item-content { + flex: 1; +} + +.data-toolbar-dropdown-switch .switch-btn { + margin: 0; + padding: 0; + color: #595959; + font-size: 14px; + font-variant: tabular-nums; + line-height: 1.5; + list-style: none; + -webkit-font-feature-settings: "tnum"; + font-feature-settings: "tnum"; + position: relative; + display: inline-block; + -webkit-box-sizing: border-box; + box-sizing: border-box; + vertical-align: middle; + background-color: rgba(0,0,0,.25); + border: 0; + border-radius: 100px; + cursor: pointer; + -webkit-transition: all .2s; + transition: all .2s; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + min-width: 28px; + height: 16px; + line-height: 16px; +} + +.data-toolbar-dropdown-switch .switch-btn.switch-checked { + background-color: #347EFF +} + +.data-toolbar-dropdown-switch .switch-btn .switch-handle { + top: 2px; + left: 2px; + width: 12px; + height: 12px; +} + +.data-toolbar-dropdown-switch .switch-btn .switch-handle, .data-toolbar-dropdown-switch .switch-btn .switch-handle:before { + position: absolute; + -webkit-transition: all .2s ease-in-out; + transition: all .2s ease-in-out; +} + +.data-toolbar-dropdown-switch .switch-btn.switch-checked .switch-handle { + left: calc(100% - 14px); +} + +.data-toolbar-dropdown-switch .switch-btn .switch-handle:before { + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: #fff; + border-radius: 9px; + -webkit-box-shadow: 0 2px 4px 0 rgb(0 35 11 / 20%); + box-shadow: 0 2px 4px 0 rgb(0 35 11 / 20%); + content: ""; +} + +.data-toolbar-dropdown-switch .switch-btn .switch-inner { + display: block; + margin: 0 5px 0 18px; + font-size: 12px; + color: #fff; + -webkit-transition: margin .2s; + transition: margin .2s; +} + +.data-toolbar-dropdown-switch .switch-btn.switch-checked .switch-inner { + margin: 0 18px 0 5px; +} \ No newline at end of file diff --git a/packages/engine/src/toolbar/index.ts b/packages/engine/src/toolbar/index.ts new file mode 100644 index 00000000..86ad7535 --- /dev/null +++ b/packages/engine/src/toolbar/index.ts @@ -0,0 +1,105 @@ +import { NodeInterface } from '../types/node'; +import { + ButtonOptions, + DropdownOptions, + InputOptions, + NodeOptions, + ToolbarOptions, + ToolbarInterface, +} from '../types/toolbar'; +import Button from './button'; +import Dropdown from './dropdown'; +import Input from './input'; +import Tooltip from './tooltip'; +import { DATA_ELEMENT } from '../constants'; +import { $ } from '../node'; +import './index.css'; + +const template = () => { + return `
      `; +}; + +class Toolbar implements ToolbarInterface { + private options: ToolbarOptions; + root: NodeInterface; + private items: Array = []; + + constructor(options: ToolbarOptions) { + this.options = { ...options }; + this.root = $(template()); + } + + addItems(node: NodeInterface) { + this.options.items.forEach((options) => { + let item; + if (options.type === 'button') { + item = new Button(options as ButtonOptions); + item.render(node); + } + if (options.type === 'input') { + const inputOptions = options as InputOptions; + item = new Input(inputOptions); + item.render(node); + } + if (options.type === 'dropdown') { + item = new Dropdown(options as DropdownOptions); + item.render(node); + } + if (options.type === 'node') { + const nodeOptions = options as NodeOptions; + const nodeItem: NodeInterface = nodeOptions.node; + nodeItem.addClass('data-toolbar-item'); + const { title } = nodeOptions; + if (title) { + nodeItem.on('mouseenter', () => { + Tooltip.show( + nodeItem, + typeof title === 'function' ? title() : title, + ); + }); + nodeItem.on('mouseleave', () => { + Tooltip.hide(); + }); + nodeItem.on('mousedown', () => { + Tooltip.hide(); + }); + } + node.append(nodeItem); + if (options.didMount) options.didMount(nodeItem); + } + if (item) this.items.push(item); + }); + } + + find(role: string) { + const expr = '[data-role='.concat(role, ']'); + return this.root.find(expr); + } + + destroy() { + this.root.remove(); + } + + hide() { + this.root.removeClass('data-toolbar-active'); + } + + show() { + this.root.addClass('data-toolbar-active'); + } + + render(container?: NodeInterface) { + const group = $('
      '); + this.root.append(group); + this.addItems(group); + if (container) { + container.append(this.root); + } + this.root.addClass('data-toolbar-block'); + return this.root; + } +} + +export default Toolbar; + +export { Button, Input, Dropdown, Tooltip }; diff --git a/packages/engine/src/toolbar/input.ts b/packages/engine/src/toolbar/input.ts new file mode 100644 index 00000000..d8365c2d --- /dev/null +++ b/packages/engine/src/toolbar/input.ts @@ -0,0 +1,77 @@ +import isHotkey from 'is-hotkey'; +import { NodeInterface } from '../types/node'; +import { InputInterface, InputOptions } from '../types/toolbar'; +import { escape } from '../utils'; +import { $ } from '../node'; + +const template = (options: InputOptions) => { + return ` + + ${ + options.prefix + ? "" + + escape(options.prefix) + + '' + : '' + }${ + options.suffix + ? "" + + escape(options.suffix) + + '' + : '' + } + `; +}; + +export default class Input implements InputInterface { + private options: InputOptions; + private root: NodeInterface; + onEnter: (value: string) => void; + onInput: (value: string) => void; + onChange: (value: string) => void; + + constructor(options: InputOptions) { + this.options = options; + this.root = $(template(options)); + this.onEnter = options.onEnter || (() => {}); + this.onInput = options.onInput || (() => {}); + this.onChange = options.onChange || (() => {}); + } + + find(role: string) { + const expr = '[data-role='.concat(role, ']'); + return this.root.find(expr); + } + + render(container: NodeInterface) { + const { value, didMount } = this.options; + const input = this.find('input'); + const inputElement = input.get(); + if (!inputElement) return; + inputElement.value = (value !== undefined ? value : '').toString(); + input.on('keydown', (e) => { + e.stopPropagation(); + if (isHotkey('enter', e)) { + e.preventDefault(); + inputElement.blur(); + this.onEnter(inputElement.value); + } + }); + + input.on('input', () => { + this.onInput(inputElement.value); + }); + + input.on('change', () => { + setTimeout(() => { + this.onChange(inputElement.value); + }, 10); + }); + container.append(this.root); + if (didMount) didMount(this.root); + } +} diff --git a/packages/engine/src/toolbar/tooltip/index.css b/packages/engine/src/toolbar/tooltip/index.css new file mode 100644 index 00000000..5282d351 --- /dev/null +++ b/packages/engine/src/toolbar/tooltip/index.css @@ -0,0 +1,141 @@ +.data-tooltip { + font-size: 14px; + font-variant: tabular-nums; + line-height: 1.5; + color: rgba(0, 0, 0, 0.65); + -webkit-box-sizing: border-box; + box-sizing: border-box; + margin: 0; + padding: 0; + list-style: none; + position: absolute; + z-index: 1060; + display: block; + visibility: visible; + max-width: 320px; + word-wrap:break-word; + top: 0; +} + +.data-tooltip-hidden { + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease-in-out; +} + +.data-tooltip-active { + opacity: 1; + visibility: visible; +} + +.data-tooltip-placement-top,.data-tooltip-placement-topLeft,.data-tooltip-placement-topRight { + padding-bottom: 8px; +} + +.data-tooltip-placement-right,.data-tooltip-placement-rightTop,.data-tooltip-placement-rightBottom { + padding-left: 8px; +} + +.data-tooltip-placement-bottom,.data-tooltip-placement-bottomLeft,.data-tooltip-placement-bottomRight { + padding-top: 8px; +} + +.data-tooltip-placement-left,.data-tooltip-placement-leftTop,.data-tooltip-placement-leftBottom { + padding-right: 8px; +} + +.data-tooltip-inner { + padding: 6px 8px; + color: #fff; + text-align: left; + text-decoration: none; + background-color: rgba(0, 0, 0, 0.75); + border-radius: 4px; + -webkit-box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + word-wrap: break-word; +} + +.data-tooltip-arrow { + position: absolute; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; +} + +.data-tooltip-placement-top .data-tooltip-arrow,.data-tooltip-placement-topLeft .data-tooltip-arrow,.data-tooltip-placement-topRight .data-tooltip-arrow { + bottom: 3px; + border-width: 5px 5px 0; + border-top-color: rgba(0, 0, 0, 0.75); +} + +.data-tooltip-placement-top .data-tooltip-arrow { + left: 50%; + margin-left: -5px; +} + +.data-tooltip-placement-topLeft .data-tooltip-arrow { + left: 16px; +} + +.data-tooltip-placement-topRight .data-tooltip-arrow { + right: 16px; +} + +.data-tooltip-placement-right .data-tooltip-arrow,.data-tooltip-placement-rightTop .data-tooltip-arrow,.data-tooltip-placement-rightBottom .data-tooltip-arrow { + left: 3px; + border-width: 5px 5px 5px 0; + border-right-color: rgba(0, 0, 0, 0.75); +} + +.data-tooltip-placement-right .data-tooltip-arrow { + top: 50%; + margin-top: -5px; +} + +.data-tooltip-placement-rightTop .data-tooltip-arrow { + top: 8px; +} + +.data-tooltip-placement-rightBottom .data-tooltip-arrow { + bottom: 8px; +} + +.data-tooltip-placement-left .data-tooltip-arrow,.data-tooltip-placement-leftTop .data-tooltip-arrow,.data-tooltip-placement-leftBottom .data-tooltip-arrow { + right: 3px; + border-width: 5px 0 5px 5px; + border-left-color: rgba(0, 0, 0, 0.75); +} + +.data-tooltip-placement-left .data-tooltip-arrow { + top: 50%; + margin-top: -5px; +} + +.data-tooltip-placement-leftTop .data-tooltip-arrow { + top: 8px; +} + +.data-tooltip-placement-leftBottom .data-tooltip-arrow { + bottom: 8px; +} + +.data-tooltip-placement-bottom .data-tooltip-arrow,.data-tooltip-placement-bottomLeft .data-tooltip-arrow,.data-tooltip-placement-bottomRight .data-tooltip-arrow { + top: 3px; + border-width: 0 5px 5px; + border-bottom-color: rgba(0, 0, 0, 0.75); +} + +.data-tooltip-placement-bottom .data-tooltip-arrow { + left: 50%; + margin-left: -5px; +} + +.data-tooltip-placement-bottomLeft .data-tooltip-arrow { + left: 16px; +} + +.data-tooltip-placement-bottomRight .data-tooltip-arrow { + right: 16px; +} \ No newline at end of file diff --git a/packages/engine/src/toolbar/tooltip/index.ts b/packages/engine/src/toolbar/tooltip/index.ts new file mode 100644 index 00000000..5a2e042a --- /dev/null +++ b/packages/engine/src/toolbar/tooltip/index.ts @@ -0,0 +1,62 @@ +import { DATA_ELEMENT } from '../../constants/root'; +import { NodeInterface } from '../../types/node'; +import { $ } from '../../node'; +import './index.css'; + +type Placement = + | 'top' + | 'topLeft' + | 'topRight' + | 'bottom' + | 'bottomLeft' + | 'bottomRight' + | 'left' + | 'right'; + +const template = (options: { placement: Placement }) => { + return ` +
      +
      +
      +
      +
      +
      `; +}; + +class Tooltip { + static show( + node: NodeInterface, + title: string | NodeInterface, + options: { placement: Placement } = { placement: 'top' }, + ) { + Tooltip.hide(); + const root = $(template(options)); + // 设置提示文字 + if (typeof title === 'string') + root.find('[data-role=tooltip]').html(title); + else root.find('[data-role=tooltip]').append(title); + // 计算定位 + const body = $(document.body); + body.append(root); + const element = root.get(); + const width = element?.clientWidth || 0; + const height = element?.clientHeight || 0; + const nodeElement = node.get()!; + const nodeWidth = nodeElement.clientWidth; + const nodeRect = nodeElement.getBoundingClientRect(); + const left = Math.round( + window.pageXOffset + nodeRect.left + nodeWidth / 2 - width / 2, + ); + const top = Math.round(window.pageYOffset + nodeRect.top - height - 2); + root.css({ + left: left + 'px', + top: top + 'px', + }); + root.addClass('data-tooltip-active'); + } + static hide() { + $(`div[${DATA_ELEMENT}=tooltip]`).remove(); + } +} + +export default Tooltip; diff --git a/packages/engine/src/types/block.ts b/packages/engine/src/types/block.ts new file mode 100644 index 00000000..48b85627 --- /dev/null +++ b/packages/engine/src/types/block.ts @@ -0,0 +1,196 @@ +import { NodeInterface } from './node'; +import { ElementPluginInterface } from './plugin'; +import { RangeInterface } from './range'; +import { SchemaBlock } from './schema'; +/** + * block 插件管理器 + */ +export interface BlockModelInterface { + /** + * 初始化 + */ + init(): void; + /** + * 根据节点查找block插件实例 + * @param node 节点 + */ + findPlugin(block: NodeInterface): BlockInterface | undefined; + /** + * 查找Block节点的一级节点。如 div -> H2 返回 H2节点 + * @param parentNode 父节点 + * @param childNode 子节点 + */ + findTop(parentNode: NodeInterface, childNode: NodeInterface): NodeInterface; + /** + * 获取最近的block节点,找不到返回 node + * @param node 节点 + */ + closest(node: NodeInterface): NodeInterface; + /** + * 在光标位置包裹一个block节点 + * @param block 节点 + * @param range 光标 + */ + wrap(block: NodeInterface | Node | string, range?: RangeInterface): void; + /** + * 移除光标所在block节点包裹 + * @param block 节点 + * @param range 光标 + */ + unwrap(block: NodeInterface | Node | string, range?: RangeInterface): void; + /** + * 获取节点相对于光标开始位置、结束位置下的兄弟节点集合 + * @param range 光标 + * @param block 节点 + */ + getSiblings( + range: RangeInterface, + block: NodeInterface, + ): Array<{ node: NodeInterface; position: 'left' | 'center' | 'right' }>; + /** + * 分割当前光标选中的block节点 + * @param range 光标 + * @returns 返回分割后的节点 + */ + split(range?: RangeInterface): NodeInterface | undefined; + /** + * 在当前光标位置插入block节点 + * @param block 节点 + * @param range 光标 + * @param splitNode 分割节点,默认为光标开始位置的block节点 + */ + insert( + block: NodeInterface | Node | string, + range?: RangeInterface, + splitNode?: (node: NodeInterface) => NodeInterface, + ): void; + /** + * 设置当前光标所在的所有block节点为新的节点或设置新属性 + * @param block 需要设置的节点或者节点属性 + * @param range 光标 + */ + setBlocks( + block: string | { [k: string]: any }, + range?: RangeInterface, + ): void; + /** + * 合并当前光标位置相邻的block + * @param range 光标 + */ + merge(range?: RangeInterface): void; + /** + * 查找对范围有效果的所有 Block + * @param range 范围 + */ + findBlocks(range: RangeInterface): Array; + /** + * 判断范围的 {Edge}Offset 是否在 Block 的开始位置 + * @param range 光标 + * @param edge start | end + */ + isFirstOffset(range: RangeInterface, edge: 'start' | 'end'): boolean; + /** + * 判断范围的 {Edge}Offset 是否在 Block 的最后位置 + * @param range 光标 + * @param edge start | end + */ + isLastOffset(range: RangeInterface, edge: 'start' | 'end'): boolean; + /** + * 获取范围内的所有 Block + * @param range 光标s + */ + getBlocks(range: RangeInterface): Array; + + /** + * 获取 Block 左侧文本 + * @param block 节点 + */ + getLeftText(block: NodeInterface | Node): string; + + /** + * 删除 Block 左侧文本 + * @param block 节点 + */ + removeLeftText(block: NodeInterface | Node): void; + /** + * 生成 cursor 左侧或右侧的节点,放在一个和父节点一样的容器里 + * isLeft = true:左侧 + * isLeft = false:右侧 + * @param param0 + */ + getBlockByRange({ + block, + range, + isLeft, + clone, + keepID, + }: { + block: NodeInterface | Node; + range: RangeInterface; + isLeft: boolean; + clone?: boolean; + keepID?: boolean; + }): NodeInterface; + /** + * 扁平化块级节点 + * @param node 节点 + * @param root 根节点 + */ + flat(node: NodeInterface, root: NodeInterface): void; + /** + * br 换行改成段落 + * @param block 节点 + */ + brToBlock(block: NodeInterface): void; + + /** + * 插入一个空的block节点 + * @param range 光标所在位置 + * @param block 节点 + * @returns + */ + insertEmptyBlock(range: RangeInterface, block: NodeInterface): void; + /** + * 在光标位置插入或分割节点 + * @param range 光标所在位置 + * @param block 节点 + */ + insertOrSplit(range: RangeInterface, block: NodeInterface): void; +} +/** + * block 插件 + */ +export interface BlockInterface extends ElementPluginInterface { + readonly kind: string; + /** + * 标签名称 + */ + readonly tagName: string | Array; + /** + * 该节点允许可以放入的block节点,默认为编辑器顶层节点 + */ + readonly allowIn?: Array; + /** + * 禁用的mark插件样式 + */ + readonly disableMark?: Array; + /** + * 是否能够合并 + */ + readonly canMerge?: boolean; + /** + * 获取当前插件定义规则 + */ + schema(): SchemaBlock | Array; + + /** + * Markdown 处理 + * @returns 返回false表示已拦截处理 + */ + markdown?( + event: KeyboardEvent, + text: string, + block: NodeInterface, + node: NodeInterface, + ): boolean | void; +} diff --git a/packages/engine/src/types/card.ts b/packages/engine/src/types/card.ts new file mode 100644 index 00000000..64cf69ca --- /dev/null +++ b/packages/engine/src/types/card.ts @@ -0,0 +1,622 @@ +import { EditorInterface } from './engine'; +import { NodeInterface } from './node'; +import { TinyCanvasInterface } from './tiny-canvas'; +import { RangeInterface } from './range'; +import { + DropdownButtonOptions, + DropdownSwitchOptions, + ToolbarItemOptions, +} from './toolbar'; +import { CardActiveTrigger, CardType } from '../card/enum'; + +export type CardOptions = { + editor: EditorInterface; + value?: CardValue; + root?: NodeInterface; +}; + +export type CardValue = { + id?: string; + type?: CardType; + [key: string]: any; +}; + +export interface CardToolbarInterface { + /** + * 创建卡片的toolbar + */ + create(): void; + /** + * 隐藏toolbar,包含dnd + */ + hide(): void; + /** + * 展示toolbar,包含dnd + * @param event 鼠标事件,用于定位 + */ + show(event?: MouseEvent): void; + /** + * 只隐藏卡片的toolbar,不包含dnd + */ + hideCardToolbar(): void; + /** + * 只显示卡片的toolbar,不包含dnd + * @param event 鼠标事件,用于定位 + */ + showCardToolbar(event?: MouseEvent): void; + /** + * 获取工具栏容器 + */ + getContainer(): NodeInterface | undefined; + /** + * 设置工具栏偏移量[上x,上y,下x,下y] + * @param offset 偏移量 [tx,ty,bx,by] + */ + setOffset(offset: Array): void; + /** + * 销毁 + */ + destroy(): void; +} + +export type CardToolbarItemOptions = + | { + type: 'dnd'; + content?: string; + title?: string; + } + | { + type: 'separator'; + node?: NodeInterface; + } + | { + type: 'delete' | 'maximize' | 'copy'; + disabled?: boolean; + content?: string; + title?: string; + } + | { + type: 'more'; + disabled?: boolean; + content?: string; + title?: string | (() => string); + items: Array; + }; + +export interface CardEntry { + prototype: CardInterface; + new (options: CardOptions): CardInterface; + /** + * 卡片名称 + */ + readonly cardName: string; + /** + * 卡片类型 block inline + */ + readonly cardType: CardType; + /** + * 是否能自动选中 + */ + readonly autoSelected: boolean; + /** + * 是否能自动激活 + */ + readonly autoActivate: boolean; + /** + * 是否能单独选中 + */ + readonly singleSelectable: boolean; + /** + * 是否能协作,默认为true + */ + readonly collab: boolean; + /** + * 是否能聚焦 + */ + readonly focus: boolean; + /** + * 卡片选中后的样式效果,默认为 border + */ + readonly selectStyleType: 'border' | 'background'; + /** + * toolbar 跟随鼠标点击位置 + */ + readonly toolbarFollowMouse: boolean; +} + +export interface CardInterface { + /** + * 初始化调用 + */ + init(): void; + /** + * 卡片ID + */ + readonly id: string; + /** + * 卡片名称 + */ + readonly name: string; + /** + * 卡片是否可编辑 + */ + readonly isEditable: boolean; + /** + * 卡片根节点 + */ + readonly root: NodeInterface; + /** + * 是否激活 + */ + readonly activated: boolean; + /** + * 是否选中 + */ + readonly selected: boolean; + /** + * 可编辑的节点 + */ + readonly contenteditable: Array; + /** + * 卡片类型,设置卡片类型会触发card重新渲染 + */ + type: CardType; + /** + * 是否最大化 + */ + isMaximize: boolean; + /** + * 激活者,协同状态下有效 + */ + activatedByOther: string | false; + /** + * 选中者,协同状态下有效 + */ + selectedByOther: string | false; + /** + * 工具栏 + */ + toolbarModel?: CardToolbarInterface; + /** + * 大小调整 + */ + resizeModel?: ResizeInterface; + /** + * 获取Card内的 DOM 节点 + * @param selector + */ + find(selector: string): NodeInterface; + /** + * 通过 data-card-element 的值,获取当前Card内的 DOM 节点 + * @param key key + */ + findByKey(key: string): NodeInterface; + /** + * 获取卡片的中心节点 + */ + getCenter(): NodeInterface; + /** + * 判断节点是否属于卡片的中心节点 + * @param node 节点 + */ + isCenter(node: NodeInterface): boolean; + /** + * 判断节点是否在卡片的左右光标处 + * @param node 节点 + */ + isCursor(node: NodeInterface): boolean; + /** + * 判断节点是否在卡片的左光标处 + * @param node 节点 + */ + isLeftCursor(node: NodeInterface): boolean; + /** + * 判断节点是否在卡片的右光标处 + * @param node 节点 + */ + isRightCursor(node: NodeInterface): boolean; + /** + * 聚焦卡片 + * @param range 光标 + * @param toStart 是否开始位置 + */ + focus(range: RangeInterface, toStart?: boolean): void; + /** + * 当卡片聚焦时触发 + */ + onFocus?(): void; + /** + * 激活Card + * @param activated 是否激活 + */ + activate(activated: boolean): void; + /** + * 选择Card + * @param selected 是否选中 + */ + select(selected: boolean): void; + /** + * 选中状态变化时触发 + * @param selected 是否选中 + */ + onSelect(selected: boolean): void; + /** + * 协同状态下,选中状态变化时触发 + * @param selected 是否选中 + * @param value { color:协同者颜色 , rgb:颜色rgb格式 } + */ + onSelectByOther( + selected: boolean, + value?: { + color: string; + rgb: string; + }, + ): NodeInterface | void; + /** + * 激活状态变化时触发 + * @param activated 是否激活 + */ + onActivate(activated: boolean): void; + /** + * 协同状态下,激活状态变化时触发 + * @param activated 是否激活 + * @param value { color:协同者颜色 , rgb:颜色rgb格式 } + */ + onActivateByOther( + activated: boolean, + value?: { + color: string; + rgb: string; + }, + ): NodeInterface | void; + /** + * 可编辑器区域值改变时触发 + * @param trigger 是否远程触发 + * @param node 可编辑器区域节点 + */ + onChange?(trigger: 'remote' | 'local', node: NodeInterface): void; + /** + * 设置卡片值 + * @param value 值 + */ + setValue(value: Partial): void; + /** + * 获取卡片值 + */ + getValue(): CardValue | undefined; + /** + * 工具栏配置项 + */ + toolbar?(): Array; + /** + * 是否可改变卡片大小,或者传入渲染节点 + */ + resize?: boolean | (() => NodeInterface | void); + /** + * 最大化 + */ + maximize(): void; + /** + * 最小化 + */ + minimize(): void; + /** + * 渲染卡片 + */ + render(...args: any): NodeInterface | string | void; + /** + * 销毁 + */ + destroy?(): void; + /** + * 插入后触发 + */ + didInsert?(): void; + /** + * 更新后触发 + */ + didUpdate?(): void; + /** + * 渲染后触发 + */ + didRender(): void; + /** + * 更新可编辑器卡片协同选择区域 + * @param range 光标 + */ + updateBackgroundSelection?(range: RangeInterface): void; + /** + * 渲染可编辑器卡片协同选择区域 + * @param node 背景画布 + * @param range 渲染光标 + */ + drawBackground?( + node: NodeInterface, + range: RangeInterface, + targetCanvas: TinyCanvasInterface, + ): DOMRect | RangeInterface[] | void | false; + /** + * 获取可编辑区域选中的所有节点 + */ + getSelectionNodes?(): Array; +} + +export interface CardModel { + prototype: CardModelInterface; + new (editor: EditorInterface): CardModelInterface; +} + +export interface CardModelInterface { + readonly classes: { [k: string]: CardEntry }; + /** + * 当前激活的卡片 + */ + readonly active: CardInterface | undefined; + /** + * 当前卡片实例集合 + */ + readonly components: Array; + /** + * 当前卡片实例长度 + */ + readonly length: number; + /** + * 实例化卡片 + * @param cards 卡片集合 + */ + init(cards: Array): void; + /** + * 增加卡片 + * @param name 名称 + * @param clazz 类 + */ + add(clazz: CardEntry): void; + /** + * 遍历所有已创建的卡片 + * @param callback 回调函数 + */ + each(callback: (card: CardInterface) => boolean | void): void; + /** + * 查询父节点距离最近的卡片 + * @param selector 查询器 + * @param ignoreEditable 是否忽略可编辑节点 + */ + closest( + selector: Node | NodeInterface, + ignoreEditable?: boolean, + ): NodeInterface | undefined; + /** + * 根据选择器查找Card + * @param selector 卡片ID,或者子节点 + * @param ignoreEditable 是否忽略可编辑节点 + */ + find( + selector: NodeInterface | Node | string, + ignoreEditable?: boolean, + ): CardInterface | undefined; + /** + * 根据选择器查找Block 类型 Card + * @param selector 卡片ID,或者子节点 + */ + findBlock(selector: Node | NodeInterface): CardInterface | undefined; + /** + * 获取单个卡片 + * @param range 光标范围 + */ + getSingleCard(range: RangeInterface): CardInterface | undefined; + /** + * 获取选区选中一个节点时候的卡片 + * @param rang 选区 + */ + getSingleSelectedCard(rang: RangeInterface): CardInterface | undefined; + /** + * 插入卡片 + * @param range 选区 + * @param card 卡片 + * @param args 插入时渲染时额外的参数 + */ + insertNode( + range: RangeInterface, + card: CardInterface, + ...args: any + ): CardInterface; + /** + * 移除卡片节点 + * @param card 卡片 + */ + removeNode(card: CardInterface): void; + /** + * 将指定节点替换成等待创建的Card DOM 节点 + * @param node 节点 + * @param name 卡片名称 + * @param value 卡片值 + */ + replaceNode( + node: NodeInterface, + name: string, + value?: CardValue, + ): NodeInterface; + /** + * 更新卡片重新渲染 + * @param card 卡片 + * @param value 值 + * @param args 更新时渲染时额外的参数 + */ + updateNode(card: CardInterface, value: CardValue, ...args: any): void; + /** + * 激活卡片节点所在的卡片 + * @param node 节点 + * @param trigger 激活方式 + * @param event 事件 + */ + activate( + node: NodeInterface, + trigger?: CardActiveTrigger, + event?: MouseEvent, + ): void; + /** + * 选中卡片 + * @param card 卡片 + */ + select(card: CardInterface): void; + /** + * 聚焦卡片 + * @param card 卡片 + * @param toStart 是否聚焦到开始位置 + */ + focus(card: CardInterface, toStart?: boolean): void; + /** + * 插入卡片 + * @param name 卡片名称 + * @param value 卡片值 + * @param args 插入时渲染时额外的参数 + */ + insert(name: string, value?: CardValue, ...args: any): CardInterface; + /** + * 更新卡片 + * @param selector 卡片选择器 + * @param value 要更新的卡片值 + * @param args 更新时渲染时额外的参数 + */ + update( + selector: NodeInterface | Node | string, + value: CardValue, + ...args: any + ): void; + /** + * 替换卡片 + * @param source 源卡片 + * @param name 新卡片名称 + * @param value 新卡片值 + * @param args 替换时渲染时额外的参数 + */ + replace( + source: CardInterface, + name: string, + value?: CardValue, + ...args: any + ): CardInterface; + /** + * 移除卡片 + * @param selector 卡片选择器 + */ + remove(selector: NodeInterface | Node | string, hasModify?: boolean): void; + /** + * 协作者移除卡片 + * @param selector 卡片选择器 + */ + removeRemote(selector: NodeInterface | Node | string): void; + /** + * 创建卡片 + * @param name 插件名称 + * @param options 选项 + */ + create( + name: string, + options?: { + value?: CardValue; + root?: NodeInterface; + }, + ): CardInterface; + /** + * 渲染 + * @param container 需要重新渲染包含卡片的节点,如果不传,则渲染全部待创建的卡片节点 + * @param callback 全部异步渲染完成后触发 + */ + render(container?: NodeInterface, callback?: (count: number) => void): void; + /** + * 重新渲染卡片 + * @param cards 卡片集合 + */ + reRender(...cards: Array): void; + /** + * 释放卡片 + */ + gc(): void; + /** + * 聚焦上一个块级节点 + * @param range 光标 + * @param hasModify 没有节点时,是否创建一个空节点并聚焦 + */ + focusPrevBlock( + card: CardInterface, + range: RangeInterface, + hasModify: boolean, + ): void; + /** + * 聚焦下一个块级节点 + * @param range 光标 + * @param hasModify 没有节点时,是否创建一个空节点并聚焦 + */ + focusNextBlock( + card: CardInterface, + range: RangeInterface, + hasModify: boolean, + ): void; +} + +export interface MaximizeInterface { + /** + * 恢复 + */ + restore(): void; + /** + * 最大化 + */ + maximize(): void; +} + +export type ResizeCreateOptions = { + /** + * 开始拖动 + */ + dragStart?: (point: { x: number; y: number }) => void; + /** + * 拖动中 + */ + dragMove?: (height: number) => void; + /** + * 拖动结束 + */ + dragEnd?: () => void; +}; + +export interface ResizeInterface { + /** + * 创建并绑定事件 + * @param options 可选项 + */ + create(options: ResizeCreateOptions): void; + /** + * 渲染 + * @param container 渲染到的目标节点,默认为当前卡片根节点 + * @param minHeight 最小高度,默认80px + */ + render(container?: NodeInterface, minHeight?: number): void; + /** + * 拉动开始 + * @param event 事件 + */ + dragStart(event: MouseEvent): void; + /** + * 拉动移动中 + * @param event 事件 + */ + dragMove(event: MouseEvent): void; + /** + * 拉动结束 + */ + dragEnd(event: MouseEvent): void; + /** + * 展示 + */ + show(): void; + /** + * 隐藏 + */ + hide(): void; + /** + * 注销 + */ + destroy(): void; +} diff --git a/packages/engine/src/types/change.ts b/packages/engine/src/types/change.ts new file mode 100644 index 00000000..999d507b --- /dev/null +++ b/packages/engine/src/types/change.ts @@ -0,0 +1,305 @@ +import { EventListener, NodeInterface } from './node'; +import { CardInterface } from './card'; +import { RangeInterface, RangePath } from './range'; +import { Path } from 'sharedb'; +import { ClipboardData } from './clipboard'; + +/** + * Change 事件 + */ +export interface ChangeEventInterface { + /** + * 是否组合输入中 + */ + isComposing: boolean; + /** + * 是否选择中 + */ + isSelecting: boolean; + /** + * 是否在卡片中输入 + * @param e + */ + isCardInput(e: Event): boolean; + /** + * 输入事件 + * @param callback + */ + onInput(callback: (event: InputEvent) => void): void; + /** + * 选择事件 + * @param callback + */ + onSelect(callback: (event: Event) => void): void; + /** + * 粘贴事件 + * @param callback + */ + onPaste( + callback: (data: ClipboardData & { isPasteText: boolean }) => void, + ): void; + /** + * 拖动事件 + * @param callback + */ + onDrop( + callback: (params: { + event: DragEvent; + range?: RangeInterface; + card?: CardInterface; + files: Array; + }) => void, + ): void; + /** + * 绑定事件到 document 中 + * @param eventType + * @param listener + * @param index + */ + onDocument( + eventType: string, + listener: EventListener, + index?: number, + ): void; + /** + * 绑定事件到 window 中 + * @param eventType + * @param listener + * @param index + */ + onWindow(eventType: string, listener: EventListener, index?: number): void; + /** + * 绑定事件到编辑器容器节点中 + * @param eventType + * @param listener + * @param index + */ + onContainer( + eventType: string, + listener: EventListener, + index?: number, + ): void; + /** + * 绑定事件到编辑器根节点中 + * @param eventType + * @param listener + * @param index + */ + onRoot(eventType: string, listener: EventListener, index?: number): void; + /** + * 销毁 + */ + destroy(): void; +} + +export type ChangeEventOptions = { + bindInput?: () => boolean; + bindSelect?: () => boolean; + bindPaste?: () => boolean; + bindDrop?: () => boolean; +}; + +export type ChangeOptions = { + /** + * 值改变事件 + */ + onChange?: (value: string, trigger: 'remote' | 'local' | 'both') => void; + /** + * 光标选择事件 + */ + onSelect?: () => void; + /** + * 值实时变化事件 + */ + onRealtimeChange?: (trigger: 'remote' | 'local') => void; + /** + * 设置值后触发 + */ + onSetValue?: () => void; +}; + +export interface ChangeConstructor { + /** + * 构造函数 + */ + new (container: NodeInterface, options: ChangeOptions): ChangeInterface; +} +export interface ChangeRangeInterface { + /** + * 获取当前选区的范围 + */ + get(): RangeInterface; + /** + * 获取安全可控的光标对象 + * @param range 默认当前光标 + */ + toTrusty(range?: RangeInterface): RangeInterface; + /** + * 选中指定的范围 + * @param range 光标 + */ + select(range: RangeInterface): void; + /** + * 聚焦编辑器 + * @param toStart true:开始位置,false:结束位置,默认为之前操作位置 + */ + focus(toStart?: boolean): void; + /** + * 取消焦点 + */ + blur(): void; +} +/** + * Change 接口 + */ +export interface ChangeInterface { + /** + * 初始化 + */ + init(): void; + /** + * 命令执行器的range位置 + */ + rangePathBeforeCommand?: { start: RangePath; end: RangePath }; + /** + * 事件对象 + */ + event: ChangeEventInterface; + /** + * Range 对象 + */ + range: ChangeRangeInterface; + /** + * 当前光标位置处的所有 mark 节点 + */ + marks: Array; + /** + * 当前光标位置处的所有 block 节点 + */ + blocks: Array; + /** + * 当前光标位置处的所有 inline 节点 + */ + inlines: Array; + /** + * 编辑器值改变触发 + */ + onChange: (value: string, trigger: 'remote' | 'local' | 'both') => void; + /** + * 编辑器中光标改变触发 + */ + onSelect: () => void; + /** + * 设置编辑器值后触发 + */ + onSetValue: () => void; + /** + * 触发一个编辑器值改变事件 + * @param isRemote 是否是远程操作 + * @param node 触发后变更的节点 + */ + change(isRemote?: boolean, node?: Array): void; + /** + * 应用一个具有改变dom结构的操作 + * @param range 光标 + */ + apply(range?: RangeInterface): void; + /** + * 把分隔开的文字组合成一个节点 + */ + combinText(): void; + /** + * 是否在组合输入法中 + */ + isComposing(): boolean; + /** + *光标是否在选择中 + */ + isSelecting(): boolean; + /** + * 初始化一个编辑器空值 + * @param range + */ + initValue(range?: RangeInterface): void; + /** + * 给编辑器设置一个值 + * @param value 值 + * @param onParse 解析回调 + * @param callback 渲染完成后回调 + */ + setValue( + value: string, + onParse?: (node: NodeInterface) => void, + callback?: (count: number) => void, + ): void; + /** + * 设置html,会格式化为合法的编辑器值 + * @param html html + * @param callback 异步渲染卡片后回调 + */ + setHtml(html: string, callback?: (count: number) => void): void; + /** + * 获取编辑器值 + */ + getOriginValue(): string; + /** + * 获取编辑值 + * @param options + */ + getValue(options: { ignoreCursor?: boolean }): string; + /** + * 在执行一个操作前缓存当前光标 + */ + cacheRangeBeforeCommand(): void; + /** + * 获取当前缓存的光标路径 + */ + getRangePathBeforeCommand(): + | { start: RangePath; end: RangePath } + | undefined; + /** + * 当前编辑器是否未空值 + */ + isEmpty(): boolean; + /** + * 插入片段 + * @param fragment 片段 + * @param callback 插入后的回调函数 + * @param followActiveMark 删除后空标签是否跟随当前激活的mark样式 + */ + insert( + fragment: DocumentFragment, + range?: RangeInterface, + callback?: (range: RangeInterface) => void, + followActiveMark?: boolean, + ): void; + /** + * 删除内容 + * @param range 光标,默认获取当前光标 + * @param isDeepMerge 删除后是否合并 + * @param followActiveMark 删除后空标签是否跟随当前激活的mark样式 + */ + delete( + range?: RangeInterface, + isDeepMerge?: boolean, + followActiveMark?: boolean, + ): void; + /** + * 去除当前光标最接近的block节点或传入的节点外层包裹 + * @param node 节点 + */ + unwrap(node?: NodeInterface): void; + /** + * 删除当前光标最接近的block节点或传入的节点的前面一个节点后合并 + * @param node 节点 + */ + mergeAfterDelete(node?: NodeInterface): void; + /** + * 销毁 + */ + destroy(): void; +} + +export type DragoverOptions = { + className?: string; +}; diff --git a/packages/engine/src/types/clipboard.ts b/packages/engine/src/types/clipboard.ts new file mode 100644 index 00000000..f990c207 --- /dev/null +++ b/packages/engine/src/types/clipboard.ts @@ -0,0 +1,36 @@ +import { RangeInterface } from './range'; + +export type ClipboardData = { + html?: string; + text?: string; + files: Array; +}; +export interface ClipboardInterface { + /** + * 获取剪贴板数据 + * @param event 事件 + */ + getData(event: DragEvent | ClipboardEvent): ClipboardData; + /** + * 写入剪贴板 + * @param event 事件 + * @param range 光标,默认获取当前光标位置 + * @param callback 回调 + */ + write( + event: ClipboardEvent, + range?: RangeInterface | null, + callback?: (data: { html: string; text: string }) => void, + ): void; + /** + * 在当前光标位置执行剪贴操作 + */ + cut(): void; + /** + * 复制 + * @param data 要复制的数据,可以是节点或者字符串 + * @param trigger 是否触发剪贴事件,通知插件 + * @returns 返回是否复制成功 + */ + copy(data: Node | string, trigger?: boolean): boolean; +} diff --git a/packages/engine/src/types/command.ts b/packages/engine/src/types/command.ts new file mode 100644 index 00000000..496df7ea --- /dev/null +++ b/packages/engine/src/types/command.ts @@ -0,0 +1,29 @@ +/** + * 编辑器命令接口 + */ +export interface CommandInterface { + /** + * 查询一个插件是否启用 + * @param name 插件名称 + */ + queryEnabled(name: string): boolean; + /** + * 查询插件的状态,需要插件实现这个方法 + * @param name 插件名称 + * @param args 查询参数 + */ + queryState(name: string, ...args: any): any; + /** + * 执行插件的命令,需要插件实现这个方法 + * @param name 插件名称 + * @param args 执行参数 + */ + execute(name: string, ...args: any): any; + /** + * 调用插件类中已经定义的一个方法 + * @param name 插件名称 + * @param method 插件中的方法 + * @param args 执行参数 + */ + executeMethod(name: string, method: string, ...args: any): any; +} diff --git a/packages/engine/src/types/conversion.ts b/packages/engine/src/types/conversion.ts new file mode 100644 index 00000000..b721aa90 --- /dev/null +++ b/packages/engine/src/types/conversion.ts @@ -0,0 +1,83 @@ +import { NodeInterface } from './node'; +/** + * 转换器值的源类型 + */ +export type ConversionFromValue = + | string + | { + [elementName: string]: { + style?: { [key: string]: string | Array }; + attributes?: { [key: string]: string | Array }; + }; + } + | (( + name: string, + style: { [key: string]: string }, + attributes: { [key: string]: string }, + ) => boolean); + +/** + * 转换器值的目标类型 + */ +export type ConversionToValue = + | string + | NodeInterface + | (( + name: string, + style: { [key: string]: string }, + attributes: { [key: string]: string }, + ) => NodeInterface); +/** + * 转换器规则 + */ +export type ConversionRule = { + from: ConversionFromValue; + to: ConversionToValue; +}; + +export type ConversionData = Array; + +/** + * 转换接口 + */ +export interface ConversionInterface { + /** + * 获取转换数据 + */ + getData(): ConversionData; + /** + * 复制当前转换器实例 + */ + clone(): ConversionInterface; + /** + * 增加转换规则 + * @param from 转换器值的源类型 + * @param to 转换器值的目标类型 + */ + add(from: ConversionFromValue, to: ConversionToValue): void; + /** + * 转换 + * @param node 要转换的节点 + * @param filter 过滤规则 + */ + transform( + node: NodeInterface, + filter?: (item: { + from: ConversionFromValue; + to: ConversionToValue; + }) => boolean, + ): + | { + rule: ConversionRule; + node: { + name: string; + style: { + [k: string]: string; + }; + attributes: { + [k: string]: string; + }; + }; + } + | undefined; +} diff --git a/packages/engine/src/types/engine.ts b/packages/engine/src/types/engine.ts new file mode 100644 index 00000000..1171b584 --- /dev/null +++ b/packages/engine/src/types/engine.ts @@ -0,0 +1,1761 @@ +import { + EventInterface, + NodeInterface, + Selector, + EventListener, + NodeModelInterface, +} from './node'; +import { ChangeInterface } from './change'; +import { OTInterface } from './ot'; +import { SchemaInterface } from './schema'; +import { ConversionInterface } from './conversion'; +import { HistoryInterface } from './history'; +import { PluginEntry, PluginModelInterface, PluginOptions } from './plugin'; +import { CommandInterface } from './command'; +import { CardEntry, CardInterface, CardModelInterface } from './card'; +import { ClipboardData, ClipboardInterface } from './clipboard'; +import { LanguageInterface } from './language'; +import { MarkModelInterface } from './mark'; +import { ListModelInterface } from './list'; +import { TypingInterface } from './typing'; +import { InlineModelInterface } from './inline'; +import { BlockModelInterface } from './block'; +import { RequestInterface } from './request'; +import { RangeInterface } from './range'; +import { Op } from 'sharedb'; +import { NodeIdInterface } from 'src'; + +/** + * 编辑器容器接口 + */ +export interface ContainerInterface { + /** + * 初始化 + */ + init(): void; + /** + * 是否聚焦 + */ + isFocus(): boolean; + /** + * 获取节点 + */ + getNode(): NodeInterface; + /** + * 设置是否可编辑 + * @param readonly 是否可编辑 + */ + setReadonly(readonly: boolean): void; + /** + * 显示占位符 + */ + showPlaceholder(): void; + /** + * 隐藏占位符 + */ + hidePlaceholder(): void; + /** + * 销毁 + */ + destroy(): void; +} + +export interface EditorInterface { + /** + * 类型 + */ + readonly kind: 'engine' | 'view'; + /** + * 语言 + */ + language: LanguageInterface; + /** + * 编辑器节点 + */ + container: NodeInterface; + /** + * 编辑器根节点,默认为编辑器父节点 + */ + root: NodeInterface; + /** + * 编辑器命令 + */ + command: CommandInterface; + /** + * 请求 + */ + request: RequestInterface; + /** + * 卡片 + */ + card: CardModelInterface; + /** + * 插件 + */ + plugin: PluginModelInterface; + /** + * 节点管理 + */ + node: NodeModelInterface; + /** + * 节点id管理器 + */ + nodeId: NodeIdInterface; + /** + * List 列表标签管理 + */ + list: ListModelInterface; + /** + * Mark 标签管理 + */ + mark: MarkModelInterface; + /** + * inline 标签管理 + */ + inline: InlineModelInterface; + /** + * block 标签管理 + */ + block: BlockModelInterface; + /** + * 事件 + */ + event: EventInterface; + /** + * 标签过滤规则 + */ + schema: SchemaInterface; + /** + * 标签转换规则 + */ + conversion: ConversionInterface; + /** + * 剪切板 + */ + clipboard: ClipboardInterface; + /** + * 绑定事件 + * @param eventType 事件类型 + * @param listener 事件回调 + * @param rewrite 是否重写 + */ + on(eventType: string, listener: EventListener, rewrite?: boolean): void; + /** + * 全选ctrl+a键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'keydown:all', + listener: (event: KeyboardEvent) => boolean | void, + rewrite?: boolean, + ): void; + /** + * 卡片最小化时触发 + * @param eventType + * @param listener name:插件名称、args:参数 + * @param rewrite + */ + on( + eventType: 'card:minimize', + listener: (card: CardInterface) => void, + rewrite?: boolean, + ): void; + /** + * 卡片最大化时触发 + * @param eventType + * @param listener name:插件名称、args:参数 + * @param rewrite + */ + on( + eventType: 'card:maximize', + listener: (card: CardInterface) => void, + rewrite?: boolean, + ): void; + /** + * 解析DOM节点,生成符合标准的 XML 代码之前触发 + * @param root DOM节点 + */ + on( + eventType: 'parse:value-before', + listener: (root: NodeInterface) => void, + rewrite?: boolean, + ): void; + /** + * 解析DOM节点,生成符合标准的 XML,遍历子节点时触发。返回false跳过当前节点 + * @param node 当前遍历的节点 + * @param attributes 当前节点已过滤后的属性 + * @param styles 当前节点已过滤后的样式 + * @param value 当前已经生成的xml代码 + */ + on( + eventType: 'parse:value', + listener: ( + node: NodeInterface, + attributes: { [key: string]: string }, + styles: { [key: string]: string }, + value: Array, + ) => boolean | void, + rewrite?: boolean, + ): void; + /** + * 解析DOM节点,生成符合标准的 XML。生成xml代码结束后触发 + * @param value xml代码 + */ + on( + eventType: 'parse:value-after', + listener: (value: Array) => void, + rewrite?: boolean, + ): void; + /** + * 转换为HTML代码之前触发 + * @param root 需要转换的根节点 + */ + on( + eventType: 'parse:html-before', + listener: (root: NodeInterface) => void, + rewrite?: boolean, + ): void; + /** + * 转换为HTML代码 + * @param root 需要转换的根节点 + */ + on( + eventType: 'parse:html', + listener: (root: NodeInterface) => void, + rewrite?: boolean, + ): void; + /** + * 转换为HTML代码之后触发 + * @param root 需要转换的根节点 + */ + on( + eventType: 'parse:html-after', + listener: (root: NodeInterface) => void, + rewrite?: boolean, + ): void; + /** + * 复制DOM节点时触发 + * @param node 当前遍历的子节点 + */ + on( + eventType: 'copy', + listener: (root: NodeInterface) => void, + rewrite?: boolean, + ): void; + /** + * 移除绑定事件 + * @param eventType 事件类型 + * @param listener 事件回调 + */ + off(eventType: string, listener: EventListener): void; + /** + * 全选ctrl+a键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + off( + eventType: 'keydown:all', + listener: (event: KeyboardEvent) => boolean | void, + ): void; + /** + * 卡片最小化时触发 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + off( + eventType: 'card:minimize', + listener: (card: CardInterface) => void, + ): void; + /** + * 卡片最大化时触发 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + off( + eventType: 'card:maximize', + listener: (card: CardInterface) => void, + ): void; + /** + * 解析DOM节点,生成符合标准的 XML 代码之前触发 + * @param root DOM节点 + */ + off( + eventType: 'parse:value-before', + listener: (root: NodeInterface) => void, + ): void; + /** + * 解析DOM节点,生成符合标准的 XML,遍历子节点时触发。返回false跳过当前节点 + * @param node 当前遍历的节点 + * @param attributes 当前节点已过滤后的属性 + * @param styles 当前节点已过滤后的样式 + * @param value 当前已经生成的xml代码 + */ + off( + eventType: 'parse:value', + listener: ( + node: NodeInterface, + attributes: { [key: string]: string }, + styles: { [key: string]: string }, + value: Array, + ) => boolean | void, + ): void; + /** + * 解析DOM节点,生成符合标准的 XML。生成xml代码结束后触发 + * @param value xml代码 + */ + off( + eventType: 'parse:value-after', + listener: (value: Array) => void, + ): void; + /** + * 转换为HTML代码之前触发 + * @param root 需要转换的根节点 + */ + off( + eventType: 'parse:html-before', + listener: (root: NodeInterface) => void, + ): void; + /** + * 转换为HTML代码 + * @param root 需要转换的根节点 + */ + off(eventType: 'parse:html', listener: (root: NodeInterface) => void): void; + /** + * 转换为HTML代码之后触发 + * @param root 需要转换的根节点 + */ + off( + eventType: 'parse:html-after', + listener: (root: NodeInterface) => void, + ): void; + /** + * 复制DOM节点时触发 + * @param node 当前遍历的子节点 + */ + off(eventType: 'copy', listener: (root: NodeInterface) => void): void; + /** + * 触发事件 + * @param eventType 事件名称 + * @param args 触发参数 + */ + trigger(eventType: string, ...args: any): any; + /** + * 全选ctrl+a键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + trigger(eventType: 'keydown:all', event: KeyboardEvent): boolean | void; + /** + * 卡片最小化时触发 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + trigger(eventType: 'card:minimize', card: CardInterface): void; + /** + * 卡片最大化时触发 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + trigger(eventType: 'card:maximize', card: CardInterface): void; + /** + * 解析DOM节点,生成符合标准的 XML 代码之前触发 + * @param root DOM节点 + */ + trigger(eventType: 'parse:value-before', root: NodeInterface): void; + /** + * 解析DOM节点,生成符合标准的 XML,遍历子节点时触发。返回false跳过当前节点 + * @param node 当前遍历的节点 + * @param attributes 当前节点已过滤后的属性 + * @param styles 当前节点已过滤后的样式 + * @param value 当前已经生成的xml代码 + */ + trigger( + eventType: 'parse:value', + node: NodeInterface, + attributes: { [key: string]: string }, + styles: { [key: string]: string }, + value: Array, + ): boolean | void; + /** + * 解析DOM节点,生成符合标准的 XML。生成xml代码结束后触发 + * @param value xml代码 + */ + trigger(eventType: 'parse:value-after', value: Array): void; + /** + * 转换为HTML代码之前触发 + * @param root 需要转换的根节点 + */ + trigger(eventType: 'parse:html-before', root: NodeInterface): void; + /** + * 转换为HTML代码 + * @param root 需要转换的根节点 + */ + trigger(eventType: 'parse:html', root: NodeInterface): void; + /** + * 转换为HTML代码之后触发 + * @param root 需要转换的根节点 + */ + trigger(eventType: 'parse:html-after', root: NodeInterface): void; + /** + * 复制DOM节点时触发 + * @param node 当前遍历的子节点 + */ + trigger(eventType: 'copy', root: NodeInterface): void; + /** + * 显示成功的信息 + * @param message 信息 + */ + messageSuccess(message: string): void; + /** + * 显示错误信息 + * @param error 错误信息 + */ + messageError(error: string): void; + /** + * 消息确认 + * @param message 消息 + */ + messageConfirm(message: string): Promise; +} + +export type EngineOptions = { + /** + * 本地化语言,默认 zh-CN + */ + lang?: string; + /** + * 本地化语言 + */ + locale?: { [key: string]: {} }; + /** + * 样式名称 + */ + className?: string; + /** + * tab 键的索引 + */ + tabIndex?: number; + /** + * 根节点 + */ + root?: Node; + /** + * 滚动条节点,查找父级样式 overflow 或者 overflow-y 为 auto 或者 scroll 的节点 + */ + scrollNode?: Node | (() => Node | null); + /** + * 插件配置 + */ + plugins?: Array; + /** + * 卡片配置 + */ + cards?: Array; + /** + * 插件的可选项 + */ + config?: { [k: string]: PluginOptions }; + /** + * 占位内容 + */ + placeholder?: string; + /** + * 是否只读 + */ + readonly?: boolean; +}; + +export interface Engine { + /** + * 构造函数 + */ + new (selector: Selector, options?: EngineOptions): EngineInterface; +} + +export interface EngineInterface extends EditorInterface { + /** + * 选项 + */ + options: EngineOptions; + /** + * 滚动条节点 + */ + readonly scrollNode: NodeInterface | null; + /** + * 是否只读 + */ + readonly: boolean; + /** + * 编辑器更改 + */ + change: ChangeInterface; + /** + * 按键处理 + */ + typing: TypingInterface; + /** + * 协同编辑 + */ + ot: OTInterface; + + /** + * 历史记录 + */ + history: HistoryInterface; + /** + * 聚焦到编辑器 + */ + focus(toStart?: boolean): void; + /** + * 让编辑器失去焦点 + */ + blur(): void; + /** + * 是否聚焦到编辑器 + */ + isFocus(): boolean; + /** + * 是否为空内容 + */ + isEmpty(): boolean; + /** + * 设置滚动节点 + * @param node 节点 + */ + setScrollNode(node: HTMLElement): void; + /** + * 获取编辑器值 + * @param ignoreCursor 是否包含光标位置信息 + */ + getValue(ignoreCursor?: boolean): string; + /** + * 异步获取编辑器值,将等候插件处理完成后再获取值 + * 比如插件上传等待中,将等待上传完成后再获取值 + * @param ignoreCursor 是否包含光标位置信息,默认不包含 + * @param callback 有插件还有动作未执行完时回调,返回 false 终止获取值,返回 number 设置当前动作等待时间,毫秒 + */ + getValueAsync( + ignoreCursor?: boolean, + callback?: ( + name: string, + card?: CardInterface, + ...args: any + ) => boolean | number | void, + ): Promise; + /** + * 获取编辑器的html + */ + getHtml(): string; + /** + * 设置编辑器值 + * @param value 值 + * @param callback 异步渲染卡片后的回调 + */ + setValue( + value: string, + callback?: (count: number) => void, + ): EngineInterface; + /** + * 设置html,会格式化为合法的编辑器值 + * @param html html + * @param callback 异步渲染卡片后的回调 + */ + setHtml(html: string, callback?: (count: number) => void): EngineInterface; + /** + * 设置json格式值,主要用于协同 + * @param callback 异步渲染卡片后的回调 + */ + setJsonValue( + value: Array, + callback?: (count: number) => void, + ): EngineInterface; + /** + * 获取JSON格式的值 + */ + getJsonValue(): string | undefined | (string | {})[]; + /** + * 展示 placeholder + */ + showPlaceholder(): void; + /** + * 隐藏 placeholder + */ + hidePlaceholder(): void; + /** + * 绑定事件 + * @param eventType 事件类型 + * @param listener 事件回调 + * @param rewrite 是否重写 + */ + on(eventType: string, listener: EventListener, rewrite?: boolean): void; + /** + * 全选ctrl+a键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'keydown:all', + listener: (event: KeyboardEvent) => boolean | void, + rewrite?: boolean, + ): void; + /** + * 卡片最小化时触发 + * @param eventType + * @param listener name:插件名称、args:参数 + * @param rewrite + */ + on( + eventType: 'card:minimize', + listener: (card: CardInterface) => void, + rewrite?: boolean, + ): void; + /** + * 卡片最大化时触发 + * @param eventType + * @param listener name:插件名称、args:参数 + * @param rewrite + */ + on( + eventType: 'card:maximize', + listener: (card: CardInterface) => void, + rewrite?: boolean, + ): void; + /** + * 解析DOM节点,生成符合标准的 XML 代码之前触发 + * @param root DOM节点 + */ + on( + eventType: 'parse:value-before', + listener: (root: NodeInterface) => void, + rewrite?: boolean, + ): void; + /** + * 解析DOM节点,生成符合标准的 XML,遍历子节点时触发。返回false跳过当前节点 + * @param node 当前遍历的节点 + * @param attributes 当前节点已过滤后的属性 + * @param styles 当前节点已过滤后的样式 + * @param value 当前已经生成的xml代码 + */ + on( + eventType: 'parse:value', + listener: ( + node: NodeInterface, + attributes: { [key: string]: string }, + styles: { [key: string]: string }, + value: Array, + ) => boolean | void, + rewrite?: boolean, + ): void; + /** + * 解析DOM节点,生成符合标准的 XML。生成xml代码结束后触发 + * @param value xml代码 + */ + on( + eventType: 'parse:value-after', + listener: (value: Array) => void, + rewrite?: boolean, + ): void; + /** + * 转换为HTML代码之前触发 + * @param root 需要转换的根节点 + */ + on( + eventType: 'parse:html-before', + listener: (root: NodeInterface) => void, + rewrite?: boolean, + ): void; + /** + * 转换为HTML代码 + * @param root 需要转换的根节点 + */ + on( + eventType: 'parse:html', + listener: (root: NodeInterface) => void, + rewrite?: boolean, + ): void; + /** + * 转换为HTML代码之后触发 + * @param root 需要转换的根节点 + */ + on( + eventType: 'parse:html-after', + listener: (root: NodeInterface) => void, + rewrite?: boolean, + ): void; + /** + * 当粘贴到编辑器事件发生时触发,返回false,将不在处理粘贴 + * @param data 粘贴板相关数据 + * @param source 粘贴的富文本 + */ + on( + eventType: 'paste:event', + listener: ( + data: ClipboardData & { isPasteText: boolean }, + source: string, + ) => boolean | void, + rewrite?: boolean, + ): void; + /** + * 设置本次粘贴所需保留标签的白名单,以及属性 + * @param schema 标签白名单管理实例 + */ + on( + eventType: 'paste:schema', + listener: (schema: SchemaInterface) => void, + rewrite?: boolean, + ): void; + /** + * 解析粘贴数据,还未生成符合编辑器数据的片段之前触发 + * @param root 粘贴的DOM节点 + */ + on( + eventType: 'paste:origin', + listener: (root: NodeInterface) => void, + rewrite?: boolean, + ): void; + /** + * 解析粘贴数据,生成符合编辑器数据的片段之后扁平化阶段触发 + * @param node 粘贴片段遍历的子节点 + */ + on( + eventType: 'paste:each', + listener: (root: NodeInterface) => void, + rewrite?: boolean, + ): void; + /** + * 解析粘贴数据,生成符合编辑器数据的片段之后扁平化阶段触发 + * @param node 所有粘贴片段遍历后的子节点 + */ + on( + eventType: 'paste:each-after', + listener: (root: NodeInterface) => void, + rewrite?: boolean, + ): void; + /** + * 生成粘贴数据DOM片段后,还未写入到编辑器之前触发 + * @param fragment 粘贴的片段 + */ + on( + eventType: 'paste:before', + listener: (fragment: DocumentFragment) => void, + rewrite?: boolean, + ): void; + /** + * 插入当前粘贴的片段后触发,此时还未渲染卡片 + * @param range 当前插入后的光标实例 + */ + on( + eventType: 'paste:insert', + listener: (range: RangeInterface) => void, + rewrite?: boolean, + ): void; + /** + * 粘贴完成后触发 + */ + on(eventType: 'paste:after', listener: () => void, rewrite?: boolean): void; + /** + * 复制DOM节点时触发 + * @param node 当前遍历的子节点 + */ + on( + eventType: 'copy', + listener: (root: NodeInterface) => void, + rewrite?: boolean, + ): void; + /** + * DOM改变触发 + * @param eventType + * @param ops + */ + on( + eventType: 'ops', + listener: (ops: Op[]) => void, + rewrite?: boolean, + ): void; + /** + * 移除绑定事件 + * @param eventType 事件类型 + * @param listener 事件回调 + */ + off(eventType: string, listener: EventListener): void; + /** + * 全选ctrl+a键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + off( + eventType: 'keydown:all', + listener: (event: KeyboardEvent) => boolean | void, + ): void; + /** + * 卡片最小化时触发 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + off( + eventType: 'card:minimize', + listener: (card: CardInterface) => void, + ): void; + /** + * 卡片最大化时触发 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + off( + eventType: 'card:maximize', + listener: (card: CardInterface) => void, + ): void; + /** + * 解析DOM节点,生成符合标准的 XML 代码之前触发 + * @param root DOM节点 + */ + off( + eventType: 'parse:value-before', + listener: (root: NodeInterface) => void, + ): void; + /** + * 解析DOM节点,生成符合标准的 XML,遍历子节点时触发。返回false跳过当前节点 + * @param node 当前遍历的节点 + * @param attributes 当前节点已过滤后的属性 + * @param styles 当前节点已过滤后的样式 + * @param value 当前已经生成的xml代码 + */ + off( + eventType: 'parse:value', + listener: ( + node: NodeInterface, + attributes: { [key: string]: string }, + styles: { [key: string]: string }, + value: Array, + ) => boolean | void, + ): void; + /** + * 解析DOM节点,生成符合标准的 XML。生成xml代码结束后触发 + * @param value xml代码 + */ + off( + eventType: 'parse:value-after', + listener: (value: Array) => void, + ): void; + /** + * 转换为HTML代码之前触发 + * @param root 需要转换的根节点 + */ + off( + eventType: 'parse:html-before', + listener: (root: NodeInterface) => void, + ): void; + /** + * 转换为HTML代码 + * @param root 需要转换的根节点 + */ + off(eventType: 'parse:html', listener: (root: NodeInterface) => void): void; + /** + * 转换为HTML代码之后触发 + * @param root 需要转换的根节点 + */ + off( + eventType: 'parse:html-after', + listener: (root: NodeInterface) => void, + ): void; + /** + * 当粘贴到编辑器事件发生时触发,返回false,将不在处理粘贴 + * @param data 粘贴板相关数据 + * @param source 粘贴的富文本 + */ + off( + eventType: 'paste:event', + listener: ( + data: ClipboardData & { isPasteText: boolean }, + source: string, + ) => boolean | void, + ): void; + /** + * 设置本次粘贴所需保留标签的白名单,以及属性 + * @param schema 标签白名单管理实例 + */ + off( + eventType: 'paste:schema', + listener: (schema: SchemaInterface) => void, + ): void; + /** + * 解析粘贴数据,还未生成符合编辑器数据的片段之前触发 + * @param root 粘贴的DOM节点 + */ + off( + eventType: 'paste:origin', + listener: (root: NodeInterface) => void, + ): void; + /** + * 解析粘贴数据,生成符合编辑器数据的片段之后扁平化阶段触发 + * @param node 粘贴片段遍历的子节点 + */ + off(eventType: 'paste:each', listener: (root: NodeInterface) => void): void; + /** + * 解析粘贴数据,生成符合编辑器数据的片段之后扁平化阶段触发 + * @param node 所有粘贴片段遍历后的子节点 + */ + off( + eventType: 'paste:each-after', + listener: (root: NodeInterface) => void, + ): void; + /** + * 生成粘贴数据DOM片段后,还未写入到编辑器之前触发 + * @param fragment 粘贴的片段 + */ + off( + eventType: 'paste:before', + listener: (fragment: DocumentFragment) => void, + ): void; + /** + * 插入当前粘贴的片段后触发,此时还未渲染卡片 + * @param range 当前插入后的光标实例 + */ + off( + eventType: 'paste:insert', + listener: (range: RangeInterface) => void, + ): void; + /** + * 粘贴完成后触发 + */ + off(eventType: 'paste:after', listener: () => void): void; + /** + * 复制DOM节点时触发 + * @param node 当前遍历的子节点 + */ + off(eventType: 'copy', listener: (root: NodeInterface) => void): void; + /** + * DOM改变触发 + * @param eventType + * @param ops + */ + off(eventType: 'ops', listener: (ops: Op[]) => void): void; + /** + * 触发事件 + * @param eventType 事件名称 + * @param args 触发参数 + */ + trigger(eventType: string, ...args: any): any; + /** + * 全选ctrl+a键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + trigger(eventType: 'keydown:all', event: KeyboardEvent): boolean | void; + /** + * 卡片最小化时触发 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + trigger(eventType: 'card:minimize', card: CardInterface): void; + /** + * 卡片最大化时触发 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + trigger(eventType: 'card:maximize', card: CardInterface): void; + /** + * 解析DOM节点,生成符合标准的 XML 代码之前触发 + * @param root DOM节点 + */ + trigger(eventType: 'parse:value-before', root: NodeInterface): void; + /** + * 解析DOM节点,生成符合标准的 XML,遍历子节点时触发。返回false跳过当前节点 + * @param node 当前遍历的节点 + * @param attributes 当前节点已过滤后的属性 + * @param styles 当前节点已过滤后的样式 + * @param value 当前已经生成的xml代码 + */ + trigger( + eventType: 'parse:value', + node: NodeInterface, + attributes: { [key: string]: string }, + styles: { [key: string]: string }, + value: Array, + ): boolean | void; + /** + * 解析DOM节点,生成符合标准的 XML。生成xml代码结束后触发 + * @param value xml代码 + */ + trigger(eventType: 'parse:value-after', value: Array): void; + /** + * 转换为HTML代码之前触发 + * @param root 需要转换的根节点 + */ + trigger(eventType: 'parse:html-before', root: NodeInterface): void; + /** + * 转换为HTML代码 + * @param root 需要转换的根节点 + */ + trigger(eventType: 'parse:html', root: NodeInterface): void; + /** + * 转换为HTML代码之后触发 + * @param root 需要转换的根节点 + */ + trigger(eventType: 'parse:html-after', root: NodeInterface): void; + /** + * 当粘贴到编辑器事件发生时触发,返回false,将不在处理粘贴 + * @param data 粘贴板相关数据 + * @param source 粘贴的富文本 + */ + trigger( + eventType: 'paste:event', + data: ClipboardData & { isPasteText: boolean }, + source: string, + ): boolean | void; + /** + * 设置本次粘贴所需保留标签的白名单,以及属性 + * @param schema 标签白名单管理实例 + */ + trigger(eventType: 'paste:schema', schema: SchemaInterface): void; + /** + * 解析粘贴数据,还未生成符合编辑器数据的片段之前触发 + * @param root 粘贴的DOM节点 + */ + trigger(eventType: 'paste:origin', root: NodeInterface): void; + /** + * 解析粘贴数据,生成符合编辑器数据的片段之后扁平化阶段触发 + * @param node 粘贴片段遍历的子节点 + */ + trigger(eventType: 'paste:each', root: NodeInterface): void; + /** + * 解析粘贴数据,生成符合编辑器数据的片段之后扁平化阶段触发 + * @param node 所有粘贴片段遍历后的子节点 + */ + trigger(eventType: 'paste:each-after', root: NodeInterface): void; + /** + * 生成粘贴数据DOM片段后,还未写入到编辑器之前触发 + * @param fragment 粘贴的片段 + */ + trigger(eventType: 'paste:before', fragment: DocumentFragment): void; + /** + * 插入当前粘贴的片段后触发,此时还未渲染卡片 + * @param range 当前插入后的光标实例 + */ + trigger(eventType: 'paste:insert', range: RangeInterface): void; + /** + * 粘贴完成后触发 + */ + trigger(eventType: 'paste:after'): void; + /** + * 复制DOM节点时触发 + * @param node 当前遍历的子节点 + */ + trigger(eventType: 'copy', root: NodeInterface): void; + /** + * DOM改变触发 + * @param eventType + * @param ops + */ + trigger(eventType: 'ops', ops: Op[]): void; + /** + * 回车键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'keydown:enter', + listener: (event: KeyboardEvent) => boolean | void, + rewrite?: boolean, + ): void; + /** + * 删除键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'keydown:backspace', + listener: (event: KeyboardEvent) => boolean | void, + rewrite?: boolean, + ): void; + /** + * Tab键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'keydown:tab', + listener: (event: KeyboardEvent) => boolean | void, + rewrite?: boolean, + ): void; + /** + * Shift-Tab键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'keydown:shift-tab', + listener: (event: KeyboardEvent) => boolean | void, + rewrite?: boolean, + ): void; + /** + * @ 符合键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'keydown:at', + listener: (event: KeyboardEvent) => boolean | void, + rewrite?: boolean, + ): void; + /** + * 空格键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'keydown:space', + listener: (event: KeyboardEvent) => boolean | void, + rewrite?: boolean, + ): void; + /** + * 反斜杠键按下,唤出Toolbar,返回false,终止处理其它监听 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'keydown:slash', + listener: (event: KeyboardEvent) => boolean | void, + rewrite?: boolean, + ): void; + /** + * 左方向键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'keydown:left', + listener: (event: KeyboardEvent) => boolean | void, + rewrite?: boolean, + ): void; + /** + * 右方向键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'keydown:right', + listener: (event: KeyboardEvent) => boolean | void, + rewrite?: boolean, + ): void; + /** + * 上方向键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'keydown:up', + listener: (event: KeyboardEvent) => boolean | void, + rewrite?: boolean, + ): void; + /** + * 下方向键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'keydown:down', + listener: (event: KeyboardEvent) => boolean | void, + rewrite?: boolean, + ): void; + /** + * 回车键按下弹起,返回false,终止处理其它监听 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'keyup:enter', + listener: (event: KeyboardEvent) => boolean | void, + rewrite?: boolean, + ): void; + /** + * 删除键按下弹起,返回false,终止处理其它监听 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'keyup:backspace', + listener: (event: KeyboardEvent) => boolean | void, + rewrite?: boolean, + ): void; + /** + * Tab键按下弹起,返回false,终止处理其它监听 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'keyup:tab', + listener: (event: KeyboardEvent) => boolean | void, + rewrite?: boolean, + ): void; + /** + * 空格键按下弹起,返回false,终止处理其它监听 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'keyup:space', + listener: (event: KeyboardEvent) => boolean | void, + rewrite?: boolean, + ): void; + /** + * 编辑器光标选择变化时触发 + * @param eventType + * @param listener + * @param rewrite + */ + on(eventType: 'select', listener: () => void, rewrite?: boolean): void; + /** + * 编辑器值变化时触发 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'change', + listener: (value: string, trigger: 'remote' | 'local' | 'both') => void, + rewrite?: boolean, + ): void; + /** + * 编辑器值有变化时就触发,与 change 相比,change 需要在组合输入法完成输入后才会触发,在一定时间内如果内容没有改版也不会触发 change + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'realtimeChange', + listener: (trigger: 'remote' | 'local') => void, + rewrite?: boolean, + ): void; + /** + * 设置编辑器值之前 + * @param eventType + * @param listener name:插件名称、args:参数 + * @param rewrite + */ + on( + eventType: 'beforeSetValue', + listener: (value: string) => void, + rewrite?: boolean, + ): void; + /** + * 设置编辑器值之后 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'afterSetValue', + listener: () => void, + rewrite?: boolean, + ): void; + /** + * 编辑器聚焦 + * @param eventType + * @param listener name:插件名称、args:参数 + * @param rewrite + */ + on(eventType: 'focus', listener: () => void, rewrite?: boolean): void; + /** + * 编辑器失去焦点 + * @param eventType + * @param listener name:插件名称、args:参数 + * @param rewrite + */ + on(eventType: 'blur', listener: () => void, rewrite?: boolean): void; + /** + * 编辑器只读切换时 + * @param eventType + * @param listener name:插件名称、args:参数 + * @param rewrite + */ + on( + eventType: 'readonly', + listener: (readonly: boolean) => void, + rewrite?: boolean, + ): void; + /** + * 执行命令之前 + * @param eventType + * @param listener name:插件名称、args:参数 + * @param rewrite + */ + on( + eventType: 'beforeCommandExecute', + listener: (name: string, ...args: any) => void, + rewrite?: boolean, + ): void; + /** + * 执行命令之后 + * @param eventType + * @param listener name:插件名称、args:参数 + * @param rewrite + */ + on( + eventType: 'afterCommandExecute', + listener: (name: string, ...args: any) => void, + rewrite?: boolean, + ): void; + /** + * 拖动文件到编辑器时触发 + * @param files 文件集合 + */ + on( + eventType: 'drop:files', + listener: (files: Array) => void, + rewrite?: boolean, + ): void; + /** + * 历史撤销 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'undo', + listener: () => boolean | void, + rewrite?: boolean, + ): void; + /** + * 历史重做 + * @param eventType + * @param listener + * @param rewrite + */ + on( + eventType: 'redo', + listener: () => boolean | void, + rewrite?: boolean, + ): void; + /** + * 回车键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + off( + eventType: 'keydown:enter', + listener: (event: KeyboardEvent) => boolean | void, + ): void; + /** + * 删除键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + off( + eventType: 'keydown:backspace', + listener: (event: KeyboardEvent) => boolean | void, + ): void; + /** + * Tab键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + off( + eventType: 'keydown:tab', + listener: (event: KeyboardEvent) => boolean | void, + ): void; + /** + * Shift-Tab键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + off( + eventType: 'keydown:shift-tab', + listener: (event: KeyboardEvent) => boolean | void, + ): void; + /** + * @ 符合键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + off( + eventType: 'keydown:at', + listener: (event: KeyboardEvent) => boolean | void, + ): void; + /** + * 空格键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + off( + eventType: 'keydown:space', + listener: (event: KeyboardEvent) => boolean | void, + ): void; + /** + * 反斜杠键按下,唤出Toolbar,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + off( + eventType: 'keydown:slash', + listener: (event: KeyboardEvent) => boolean | void, + ): void; + + /** + * 左方向键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + off( + eventType: 'keydown:left', + listener: (event: KeyboardEvent) => boolean | void, + ): void; + /** + * 右方向键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + off( + eventType: 'keydown:right', + listener: (event: KeyboardEvent) => boolean | void, + ): void; + /** + * 上方向键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + off( + eventType: 'keydown:up', + listener: (event: KeyboardEvent) => boolean | void, + ): void; + /** + * 下方向键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + off( + eventType: 'keydown:down', + listener: (event: KeyboardEvent) => boolean | void, + ): void; + /** + * 回车键按下弹起,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + off( + eventType: 'keyup:enter', + listener: (event: KeyboardEvent) => boolean | void, + ): void; + /** + * 删除键按下弹起,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + off( + eventType: 'keyup:backspace', + listener: (event: KeyboardEvent) => boolean | void, + ): void; + /** + * Tab键按下弹起,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + off( + eventType: 'keyup:tab', + listener: (event: KeyboardEvent) => boolean | void, + ): void; + /** + * 空格键按下弹起,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + off( + eventType: 'keyup:space', + listener: (event: KeyboardEvent) => boolean | void, + ): void; + /** + * 编辑器光标选择变化时触发 + * @param eventType + * @param listener + */ + off(eventType: 'select', listener: () => void): void; + /** + * 编辑器值变化时触发 + * @param eventType + * @param listener + */ + off( + eventType: 'change', + listener: (value: string, trigger: 'remote' | 'local' | 'both') => void, + ): void; + /** + * 编辑器值有变化时就触发,与 change 相比,change 需要在组合输入法完成输入后才会触发,在一定时间内如果内容没有改版也不会触发 change + * @param eventType + * @param listener + * @param rewrite + */ + off( + eventType: 'realtimeChange', + listener: (trigger: 'remote' | 'local') => void, + ): void; + /** + * 设置编辑器值之前 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + off(eventType: 'beforeSetValue', listener: (value: string) => void): void; + /** + * 设置编辑器值之后 + * @param eventType + * @param listener + */ + off(eventType: 'afterSetValue', listener: () => void): void; + /** + * 编辑器聚焦 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + off(eventType: 'focus', listener: () => void): void; + /** + * 编辑器失去焦点 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + off(eventType: 'blur', listener: () => void): void; + /** + * 编辑器只读切换时 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + off(eventType: 'readonly', listener: (readonly: boolean) => void): void; + /** + * 执行命令之前 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + off( + eventType: 'beforeCommandExecute', + listener: (name: string, ...args: any) => void, + ): void; + /** + * 执行命令之后 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + off( + eventType: 'afterCommandExecute', + listener: (name: string, ...args: any) => void, + ): void; + /** + * 拖动文件到编辑器时触发 + * @param files 文件集合 + */ + off(eventType: 'drop:files', listener: (files: Array) => void): void; + /** + * 历史撤销 + * @param eventType + * @param listener + */ + off(eventType: 'undo', listener: () => boolean | void): void; + /** + * 历史重做 + * @param eventType + * @param listener + */ + off(eventType: 'redo', listener: () => boolean | void): void; + /** + * 回车键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + trigger(eventType: 'keydown:enter', event: KeyboardEvent): boolean | void; + /** + * 删除键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + trigger( + eventType: 'keydown:backspace', + event: KeyboardEvent, + ): boolean | void; + /** + * Tab键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + trigger(eventType: 'keydown:tab', event: KeyboardEvent): boolean | void; + /** + * Shift-Tab键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + trigger( + eventType: 'keydown:shift-tab', + event: KeyboardEvent, + ): boolean | void; + /** + * @ 符合键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + trigger(eventType: 'keydown:at', event: KeyboardEvent): boolean | void; + /** + * 空格键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + trigger(eventType: 'keydown:space', event: KeyboardEvent): boolean | void; + /** + * 反斜杠键按下,唤出Toolbar,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + trigger(eventType: 'keydown:slash', event: KeyboardEvent): boolean | void; + /** + * 左方向键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + trigger(eventType: 'keydown:left', event: KeyboardEvent): boolean | void; + /** + * 右方向键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + trigger(eventType: 'keydown:right', event: KeyboardEvent): boolean | void; + /** + * 上方向键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + trigger(eventType: 'keydown:up', event: KeyboardEvent): boolean | void; + /** + * 下方向键按下,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + trigger(eventType: 'keydown:down', event: KeyboardEvent): boolean | void; + /** + * 回车键按下弹起,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + trigger(eventType: 'keyup:enter', event: KeyboardEvent): boolean | void; + /** + * 删除键按下弹起,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + trigger(eventType: 'keyup:backspace', event: KeyboardEvent): boolean | void; + /** + * Tab键按下弹起,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + trigger(eventType: 'keyup:tab', event: KeyboardEvent): boolean | void; + /** + * 空格键按下弹起,返回false,终止处理其它监听 + * @param eventType + * @param listener + */ + trigger(eventType: 'keyup:space', event: KeyboardEvent): boolean | void; + /** + * 编辑器光标选择变化时触发 + * @param eventType + * @param listener + */ + trigger(eventType: 'select'): void; + /** + * 编辑器值变化时触发 + * @param eventType + * @param listener + */ + trigger( + eventType: 'change', + value: string, + trigger: 'remote' | 'local' | 'both', + ): void; + /** + * 编辑器值有变化时就触发,与 change 相比,change 需要在组合输入法完成输入后才会触发,在一定时间内如果内容没有改版也不会触发 change + * @param eventType + * @param listener + * @param rewrite + */ + trigger( + eventType: 'realtimeChange', + trigger: 'remote' | 'local' | 'both', + ): void; + /** + * 设置编辑器值之前 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + trigger(eventType: 'beforeSetValue', value: string): void; + /** + * 设置编辑器值之后 + * @param eventType + * @param listener + */ + trigger(eventType: 'afterSetValue'): void; + /** + * 编辑器聚焦 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + trigger(eventType: 'focus'): void; + /** + * 编辑器失去焦点 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + trigger(eventType: 'blur'): void; + /** + * 编辑器只读切换时 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + trigger(eventType: 'readonly', readonly: boolean): void; + /** + * 执行命令之前 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + trigger( + eventType: 'beforeCommandExecute', + name: string, + ...args: any + ): void; + /** + * 执行命令之后 + * @param eventType + * @param listener name:插件名称、args:参数 + */ + trigger(eventType: 'afterCommandExecute', name: string, ...args: any): void; + /** + * 拖动文件到编辑器时触发 + * @param files 文件集合 + */ + trigger(eventType: 'drop:files', files: Array): void; + /** + * 历史撤销 + * @param eventType + */ + trigger(eventType: 'undo'): void; + /** + * 历史重做 + * @param eventType + */ + trigger(eventType: 'redo'): void; + /** + * 销毁 + */ + destroy(): void; +} diff --git a/packages/engine/src/types/history.ts b/packages/engine/src/types/history.ts new file mode 100644 index 00000000..ab51a0a6 --- /dev/null +++ b/packages/engine/src/types/history.ts @@ -0,0 +1,73 @@ +import { RangePath } from './range'; +import { Operation, TargetOp } from './ot'; + +export interface HistoryInterface { + /** + * 重置当前历史记录 + */ + reset(): void; + /** + * 是否有可撤销操作 + */ + hasUndo(): boolean; + /** + * 是否有可恢复操作 + */ + hasRedo(): boolean; + /** + * 执行撤销操作 + */ + undo(): void; + /** + * 执行恢复操作 + */ + redo(): void; + /** + * 清除当前所有历史记录 + */ + clear(): void; + /** + * 保存一个 op 到历史记录 + */ + saveOp(): void; + /** + * 处理本地自身操作 + * @param ops 操作集合 + */ + handleSelfOps(ops: TargetOp[]): void; + /** + * 处理远程操作 + * @param ops 操作集合 + */ + handleRemoteOps(ops: TargetOp[]): void; + /** + * 获取最近的可撤销操作 + */ + getUndoOp(): Operation | undefined; + /** + * 获取最近的可恢复操作 + */ + getRedoOp(): Operation | undefined; + /** + * 获取当前光标位置路径 + */ + getCurrentRangePath(): { start: RangePath; end: RangePath } | undefined; + /** + * 获取命令执行前缓存的光标位置路径 + */ + getRangePathBeforeCommand(): + | { start: RangePath; end: RangePath } + | undefined; + /** + * 监听过滤存入历史记录堆栈中 + * @param filter true 过滤排除,false 记录到历史堆栈中 + */ + onFilter(filter: (op: TargetOp) => boolean): void; + /** + * 监听当前变更ops + * @param collect 方法 undefined 默认延时保存,true 立即保存,false 立即丢弃。Promise 阻拦接下来的所有ops直到返回false或者true + */ + onSelf( + collect: (ops: TargetOp[]) => Promise | boolean | undefined, + ): void; +} diff --git a/packages/engine/src/types/hotkey.ts b/packages/engine/src/types/hotkey.ts new file mode 100644 index 00000000..7b435ffe --- /dev/null +++ b/packages/engine/src/types/hotkey.ts @@ -0,0 +1,22 @@ +/** + * 快捷键接口 + */ +export interface HotkeyInterface { + /** + * 触发匹配 + * @param e + */ + trigger(e: KeyboardEvent): void; + /** + * 启用快捷键拦截 + */ + enable(): void; + /** + * 禁用快捷键拦截 + */ + disable(): void; + /** + * 销毁 + */ + destroy(): void; +} diff --git a/packages/engine/src/types/index.ts b/packages/engine/src/types/index.ts new file mode 100644 index 00000000..12ca3ef7 --- /dev/null +++ b/packages/engine/src/types/index.ts @@ -0,0 +1,23 @@ +export * from './card'; +export * from './change'; +export * from './clipboard'; +export * from './command'; +export * from './view'; +export * from './conversion'; +export * from './engine'; +export * from './history'; +export * from './hotkey'; +export * from './language'; +export * from './node'; +export * from './ot'; +export * from './plugin'; +export * from './range'; +export * from './schema'; +export * from './toolbar'; +export * from './typing'; +export * from './mark'; +export * from './inline'; +export * from './block'; +export * from './request'; +export * from './tiny-canvas'; +export * from './parser'; diff --git a/packages/engine/src/types/inline.ts b/packages/engine/src/types/inline.ts new file mode 100644 index 00000000..03f491e5 --- /dev/null +++ b/packages/engine/src/types/inline.ts @@ -0,0 +1,108 @@ +import { NodeInterface } from './node'; +import { PluginInterface, ElementPluginInterface } from './plugin'; +import { RangeInterface } from './range'; + +export interface InlineModelInterface { + /** + * 初始化 + */ + init(): void; + /** + * 获取最近的 Inline 节点,找不到返回 node + */ + closest(node: NodeInterface): NodeInterface; + /** + * 获取向上第一个非 Inline 节点 + */ + closestNotInline(node: NodeInterface): NodeInterface; + /** + * 给当前光标节点添加inline包裹 + * @param inline inline标签 + * @param range 光标,默认获取当前光标 + */ + wrap(inline: NodeInterface | Node | string, range?: RangeInterface): void; + /** + * 移除inline包裹 + * @param range 光标,默认当前编辑器光标,或者需要移除的inline节点 + */ + unwrap(range?: RangeInterface | NodeInterface): void; + /** + * 插入inline标签 + * @param inline inline标签 + * @param range 光标 + */ + insert(inline: NodeInterface | Node | string, range?: RangeInterface): void; + /** + * 分割inline标签 + * @param range 光标,默认获取当前光标 + */ + split(range?: RangeInterface): void; + /** + * 获取光标范围内的所有 inline 标签 + * @param range 光标 + */ + findInlines(range: RangeInterface): Array; + /** + * 修复inline节点光标占位符 + * @param node inlne 节点 + */ + repairCursor(node: NodeInterface | Node): void; + /** + * 修复光标选区位置,​acde​ ->​acde​ + * 否则在ot中,可能无法正确的应用inline节点两边​的更改 + */ + repairRange(range?: RangeInterface): RangeInterface; + /** + * 标准化inline节点,不能嵌套在mark标签内,不能嵌套inline标签 + * @param node + */ + flat(node: NodeInterface | RangeInterface): void; +} + +export interface InlineInterface extends ElementPluginInterface { + readonly kind: string; + /** + * 标签名称 + */ + readonly tagName: string; + /** + * Markdown 规则,可选 + */ + readonly markdown?: string; + /** + * 初始化 + */ + init(): void; + /** + * 查询状态 + */ + queryState(): any; + + /** + * 是否触发执行增加当前mark标签包裹,否则将移除当前mark标签的包裹 + * @param args 在调用 command.execute 执行插件传入时的参数 + */ + isTrigger?(...args: any): boolean; + /** + * 解析markdown + * @param event 事件 + * @param text markdown文本 + * @param node 触发节点 + */ + triggerMarkdown( + event: KeyboardEvent, + text: string, + node: NodeInterface, + ): boolean | void; + /** + * 检测当前粘贴节点是否符合markdown解析规则 + */ + checkMarkdown( + node: NodeInterface, + ): { reg: RegExp; match: RegExpExecArray | null } | undefined; + /** + * 解析粘贴markdown + * @param node 节点 + */ + pasteMarkdown(node: NodeInterface): boolean | void; +} diff --git a/packages/engine/src/types/language.ts b/packages/engine/src/types/language.ts new file mode 100644 index 00000000..c139b78e --- /dev/null +++ b/packages/engine/src/types/language.ts @@ -0,0 +1,15 @@ +/** + * 语言管理器 + */ +export interface LanguageInterface { + /** + * 增加本地化语言 + * @param data + */ + add(data: {}): void; + /** + * 根据key获取语言的值 + * @param keys + */ + get(...keys: Array): T; +} diff --git a/packages/engine/src/types/list.ts b/packages/engine/src/types/list.ts new file mode 100644 index 00000000..9e90e617 --- /dev/null +++ b/packages/engine/src/types/list.ts @@ -0,0 +1,220 @@ +import { CardInterface } from './card'; +import { NodeInterface } from './node'; +import { BlockInterface } from './block'; +import { RangeInterface } from './range'; + +/** + * 列表删除键处理器 + */ +export interface BackspaceInterface { + trigger(event: KeyboardEvent, isDeepMerge?: boolean): boolean | undefined; +} +/** + * 列表接口 + */ +export interface ListInterface extends BlockInterface { + /** + * 自定义列表卡片名称 + */ + cardName?: string; + /** + * 判断节点是否是当前插件所需的list节点 + * @param node 节点 + */ + isCurrent(node: NodeInterface): boolean; +} + +/** + * 列表管理器 + */ +export interface ListModelInterface { + /** + * 自定义列表样式 + */ + readonly CUSTOMZIE_UL_CLASS: string; + /** + * 自定义列表样式 + */ + readonly CUSTOMZIE_LI_CLASS: string; + /** + * 列表缩进key + */ + readonly INDENT_KEY: string; + /** + * 删除事件处理器 + */ + backspaceEvent?: BackspaceInterface; + /** + * 初始化 + */ + init(): void; + /** + * 判断列表项节点是否为空 + * @param node 节点 + */ + isEmptyItem(node: NodeInterface): boolean; + /** + * 判断两个节点是否是一样的List节点 + * @param sourceNode 源节点 + * @param targetNode 目标节点 + */ + isSame(sourceNode: NodeInterface, targetNode: NodeInterface): boolean; + /** + * 判断节点集合是否是指定类型的List列表 + * @param blocks 节点集合 + * @param name 节点标签类型 + * @param card 是否是指定的自定义列表项的卡片名称 + */ + isSpecifiedType( + blocks: Array, + name?: 'ul' | 'ol', + card?: string, + ): boolean; + /** + * 获取所有List插件 + */ + getPlugins(): Array; + /** + * 根据列表节点获取列表插件名称 + * @param block 节点 + */ + getPluginNameByNode(block: NodeInterface): string; + /** + * 获取一个列表节点集合所属列表插件名称 + * @param blocks 节点集合 + */ + getPluginNameByNodes(blocks: Array): string; + /** + * 清除自定义列表节点相关属性 + * @param node 节点 + */ + unwrapCustomize(node: NodeInterface): NodeInterface; + /** + * 取消节点的列表 + * @param blocks 节点集合 + * @param normalBlock 要转换的block默认为

      + */ + unwrap(blocks: Array, normalBlock?: NodeInterface): void; + /** + * 获取当前选区的修复列表后的节点集合 + */ + normalize(): Array; + /** + * 将选中列表项列表分割出来单独作为一个列表 + */ + split(range?: RangeInterface): void; + /** + * 合并列表 + * @param blocks 节点集合,默认为当前选区的blocks + */ + merge(blocks?: Array, range?: RangeInterface): void; + /** + * 给列表添加start序号 + * @param block 列表节点 + */ + addStart(block?: NodeInterface): void; + /** + * 给列表节点增加缩进 + * @param block 列表节点 + * @param value 缩进值 + */ + addIndent(block: NodeInterface, value: number, maxValue?: number): void; + /** + * 给列表节点增加文字方向 + * @param block 列表项节点 + * @param align 方向 + * @returns + */ + addAlign( + block: NodeInterface, + align?: 'left' | 'center' | 'right' | 'justify', + ): void; + + /** + * 获取列表节点 indent 值 + * @param block 列表节点 + * @returns + */ + getIndent(block: NodeInterface): number; + + /** + * 为自定义列表项添加卡片节点 + * @param node 列表节点项 + * @param cardName 卡片名称,必须是支持inline卡片类型 + * @param value 卡片值 + */ + addCardToCustomize( + node: NodeInterface | Node, + cardName: string, + value?: any, + ): CardInterface | undefined; + /** + * 为自定义列表项添加待渲染卡片节点 + * @param node 列表节点项 + * @param cardName 卡片名称,必须是支持inline卡片类型 + * @param value 卡片值 + */ + addReadyCardToCustomize( + node: NodeInterface | Node, + cardName: string, + value?: any, + ): NodeInterface | undefined; + /** + * 给列表添加BR标签 + * @param node 列表节点项 + */ + addBr(node: NodeInterface): void; + /** + * 在列表处插入节点 + * @param nodes 节点集合 + * @param range 光标 + */ + insert(fragment: DocumentFragment, range?: RangeInterface): void; + /** + * block 节点转换为列表项节点 + * @param block block 节点 + * @param root 列表根节点 + * @param cardName 可选,自定义列表项卡片名称 + * @param value 可选,自定义列表项卡片值 + * @returns root 根节点 + */ + blockToItem( + block: NodeInterface, + root: NodeInterface, + cardName?: string, + value?: string, + ): NodeInterface; + /** + * 将节点转换为自定义节点 + * @param blocks 节点 + * @param cardName 卡片名称 + * @param value 卡片值 + */ + toCustomize( + blocks: Array | NodeInterface, + cardName: string, + value?: any, + tagName?: 'ol' | 'ul', + ): Array | NodeInterface; + /** + * 将节点转换为列表节点 + * @param blocks 节点 + * @param tagName 列表节点名称,ul 或者 ol,默认为ul + * @param start 有序列表开始序号 + */ + toNormal( + blocks: Array | NodeInterface, + tagName?: 'ul' | 'ol', + start?: number, + ): Array | NodeInterface; + /** + * 判断选中的区域是否在列表的开始 + * 选中的区域 + */ + isFirst(range: RangeInterface): boolean; + + /** + * 判断选中的区域是否在列表的末尾 + */ + isLast(range: RangeInterface): boolean; +} diff --git a/packages/engine/src/types/mark.ts b/packages/engine/src/types/mark.ts new file mode 100644 index 00000000..055c85b2 --- /dev/null +++ b/packages/engine/src/types/mark.ts @@ -0,0 +1,173 @@ +import { NodeInterface } from './node'; +import { ElementPluginInterface, PluginInterface } from './plugin'; +import { RangeInterface } from './range'; +import { SchemaMark } from './schema'; + +/** + * mark 节点管理器 + */ +export interface MarkModelInterface { + /** + * 初始化 + */ + init(): void; + /** + * 根据节点查找mark插件实例 + * @param node 节点 + */ + findPlugin(node: NodeInterface): MarkInterface | undefined; + /** + * 获取最近的 Mark 节点,找不到返回 node + */ + closest(node: NodeInterface): NodeInterface; + /** + * 获取向上第一个非 Mark 节点 + */ + closestNotMark(node: NodeInterface): NodeInterface; + /** + * 比较两个节点是否相同,包括attributes、style、class + * @param source 源节点 + * @param target 目标节点 + * @param isCompareValue 是否比较每项属性的值 + */ + compare( + source: NodeInterface, + target: NodeInterface, + isCompareValue?: boolean, + ): boolean; + /** + * 判断源节点是否包含目标节点的所有属性和样式 + * @param source 源节点 + * @param target 目标节点 + */ + contain(source: NodeInterface, target: NodeInterface): boolean; + /** + * 分割mark标签 + * @param range 光标,默认获取当前光标 + * @param removeMark 需要移除的空mark标签 + */ + split( + range?: RangeInterface, + removeMark?: NodeInterface | Node | string | Array, + ): void; + /** + * 在当前光标选区包裹mark标签 + * @param mark mark标签 + * @param both mark标签两侧节点 + */ + wrap(mark: NodeInterface | Node | string, range?: RangeInterface): void; + /** + * 去掉mark包裹 + * @param range 光标 + * @param removeMark 要移除的mark标签 + */ + unwrap( + removeMark?: NodeInterface | Node | string | Array, + range?: RangeInterface, + ): void; + /** + * 合并当前选区的mark节点 + * @param range 光标,默认当前选区光标 + */ + merge(range?: RangeInterface): void; + /** + * 光标处插入mark标签 + * @param mark mark标签 + * @param range 指定光标,默认为编辑器选中的光标 + */ + insert(mark: NodeInterface | Node | string, range?: RangeInterface): void; + /** + * 查找对范围有效果的所有 Mark + * @param range 范围 + */ + findMarks(range: RangeInterface): Array; + /** + * 从下开始往上遍历删除空 Mark,当遇到空 Block,添加 BR 标签 + * @param node 节点 + * @param addBr 是否添加br + */ + removeEmptyMarks(node: NodeInterface, addBr?: boolean): void; + /** + * 修复空 mark 节点占位符 + * @param node mark 节点 + */ + repairCursor(node: NodeInterface | Node): void; +} + +export interface MarkInterface extends ElementPluginInterface { + readonly kind: string; + /** + * 标签名称 + */ + readonly tagName: string; + /** + * Markdown 规则,可选 + */ + readonly markdown?: string; + /** + * 回车后是否复制mark效果,默认为 true,允许 + *

      abc

      + * 在光标处回车后,第二行默认会继续 strong 样式,如果为 false,将不在加 strong 样式 + */ + readonly copyOnEnter?: boolean; + /** + * 是否跟随样式,开启后在此标签后输入将不在有mark标签效果,光标重合状态下也无非执行此mark命令。默认 true 跟随 + * abc 或者 abc + * 在此处输入,如果 followStyle 为 true,那么就会在 strong 节点后输入 或者 strong 节点前输入 + * abc 如果光标在中间为值,还是会继续跟随样式效果 + * abc123 如果 followStyle 为 true,后方还是有 strong 节点效果,那么还是会继续跟随样式,在 strong abc 后面完成输入 + */ + readonly followStyle?: boolean; + /** + * 在包裹相通节点并且属性名称一致,值不一致的mark节点的时候,是合并前者的值到新的节点还是移除前者mark节点,默认 false 移除 + * 节点样式(style)的值将始终覆盖掉 + * abc + * 在使用 包裹上方节点时 + * 如果合并值,就是 abc 否则就是 abc + */ + readonly combineValueByWrap?: boolean; + /** + * 合并级别,值越大就合并在越外围,默认为1 + */ + readonly mergeLeval: number; + /** + * 初始化 + */ + init(): void; + /** + * 查询状态 + */ + queryState(): any; + /** + * 生成规则 + */ + schema(): SchemaMark | Array; + + /** + * 是否触发执行增加当前mark标签包裹,否则将移除当前mark标签的包裹 + * @param args 在调用 command.execute 执行插件传入时的参数 + */ + isTrigger?(...args: any): boolean; + /** + * 解析markdown + * @param event 事件 + * @param text markdown文本 + * @param node 触发节点 + */ + triggerMarkdown( + event: KeyboardEvent, + text: string, + node: NodeInterface, + ): boolean | void; + /** + * 检测当前粘贴节点是否符合markdown解析规则 + */ + checkMarkdown( + node: NodeInterface, + ): { reg: RegExp; match: RegExpExecArray | null } | undefined; + /** + * 解析粘贴markdown + * @param node 节点 + */ + pasteMarkdown(node: NodeInterface): boolean | void; +} diff --git a/packages/engine/src/types/node.ts b/packages/engine/src/types/node.ts new file mode 100644 index 00000000..542a330c --- /dev/null +++ b/packages/engine/src/types/node.ts @@ -0,0 +1,756 @@ +import { Path } from 'sharedb'; +import { RangeInterface } from './range'; +import { SchemaInterface, SchemaRule } from './schema'; + +/** + * 事件方法 + */ +export type EventListener = (...args: Array) => boolean | void; + +/** + * 事件接口 + */ +export interface EventInterface { + /** + * 事件集合 + */ + readonly listeners: { [x: string]: EventListener[] }; + /** + * 绑定事件 + * @param eventType 事件名称 + * @param listener 事件处理方法 + * @param rewrite 是否重写事件 + */ + on(eventType: string, listener: EventListener, rewrite?: boolean): void; + /** + * 解除绑定 + * @param eventType + * @param listener + */ + off(eventType: string, listener: EventListener): void; + /** + * 触发事件 + * @param eventType 事件类型 + * @param args 事件参数 + */ + trigger(eventType: string, ...args: any): any; +} +export type Selector = + | string + | HTMLElement + | Node + | Array + | NodeList + | NodeInterface + | EventTarget; +export type Context = Element | Document; + +export interface NodeEntry { + prototype: NodeInterface; + new ( + nodes: Node | NodeList | Array, + context?: Context, + ): NodeInterface; +} + +export interface NodeInterface { + /** + * Node集合长度 + */ + length: number; + /** + * 事件集合 + */ + events: EventInterface[]; + /** + * Document + */ + document: Document | null; + /** + * 根节点 + */ + context: Context | undefined; + /** + * 节点名称 + */ + name: string; + /** + * 节点类型 + */ + type: number | undefined; + /** + * Window + */ + window: Window | null; + /** + * 显示状态 + */ + display: string | undefined; + /** + * 片段 + */ + fragment?: DocumentFragment; + /** + * Node 集合 + */ + [n: number]: Node; + + /** + * 遍历 + * @param {Function} callback 回调函数 + * @return {NodeInterface} 返回当前实例 + */ + each( + callback: (node: Node, index: number) => boolean | void, + ): NodeInterface; + + /** + * 将 NodeInterface 转换为 Array + * @return {Array} 返回数组 + */ + toArray(): Array; + + /** + * 判断当前节点是否为 Node.ELEMENT_NODE 节点类型 + * @return {boolean} + */ + isElement(): boolean; + + /** + * 判断当前节点是否为 Node.TEXT_NODE 节点类型 + * @return {boolean} + */ + isText(): boolean; + + /** + * 判断当前节点是否为Card组件 + */ + isCard(): boolean; + + /** + * 判断当前节点是否为block类型的Card组件 + */ + isBlockCard(): boolean; + /** + * 判断当前节点是否为inline类型的Card组件 + * @returns + */ + isInlineCard(): boolean; + /** + * 是否是可编辑器卡片 + * @returns + */ + isEditableCard(): boolean; + /** + * 判断当前节点是否为根节点 + */ + isRoot(): boolean; + + /** + * 判断当前是否为可编辑节点 + */ + isEditable(): boolean; + + /** + * 判断当前是否在根节点内 + */ + inEditor(): boolean; + /** + * 是否是光标标记节点 + * @returns + */ + isCursor(): boolean; + /** + * 获取当前Node节点 + */ + get(): E | null; + + /** + * 获取当前第 index 节点 + * @param {Number} index + * @return {NodeInterface|undefined} NodeInterface 类,或 undefined + */ + eq(index: number): NodeInterface | undefined; + + /** + * 获取当前节点所在父节点中的索引,仅计算节点类型为ELEMENT_NODE的节点 + * @return {Number} 返回索引 + */ + index(): number; + + /** + * 获取当前节点父节点 + * @return {NodeInterface} 父节点 + */ + parent(): NodeInterface | undefined; + + /** + * 查询当前节点的子节点 + * @param {Node | string} selector 查询器 + * @return {NodeInterface} 符合条件的子节点 + */ + children(selector?: string): NodeInterface; + + /** + * 获取当前节点第一个子节点 + * @return {NodeInterface} NodeInterface 子节点 + */ + first(): NodeInterface | null; + + /** + * 获取当前节点最后一个子节点 + * @return {NodeInterface} NodeInterface 子节点 + */ + last(): NodeInterface | null; + + /** + * 返回元素节点之前的兄弟节点(包括文本节点、注释节点) + * @return {NodeInterface} NodeInterface 节点 + */ + prev(): NodeInterface | null; + + /** + * 返回元素节点之后的兄弟节点(包括文本节点、注释节点) + * @return {NodeInterface} NodeInterface 节点 + */ + next(): NodeInterface | null; + + /** + * 返回元素节点之前的兄弟元素节点(不包括文本节点、注释节点) + * @return {NodeInterface} NodeInterface 节点 + */ + prevElement(): NodeInterface | null; + + /** + * 返回元素节点之后的兄弟元素节点(不包括文本节点、注释节点) + * @return {NodeInterface} NodeInterface 节点 + */ + nextElement(): NodeInterface | null; + + /** + * 返回元素节点所在根节点路径,默认根节点为 document.body + * @param context 根节点,默认为 document.body + * @param filter 获取index的时候过滤 + * @param callback 获取index的时候回调 + * @return 路径 + */ + getPath( + context?: Node | NodeInterface, + filter?: (node: Node) => boolean, + callback?: ( + index: number, + path: number[], + node: NodeInterface, + ) => number[] | undefined, + ): Array; + + /** + * 判断元素节点是否包含要查询的节点 + * @param {NodeInterface | Node} node 要查询的节点 + * @return {Boolean} 是否包含 + */ + contains(node: NodeInterface | Node): boolean; + + /** + * 根据查询器查询当前元素节点 + * @param {String} selector 查询器 + * @return {NodeInterface} 返回一个 NodeInterface 实例 + */ + find(selector: string): NodeInterface; + + /** + * 根据查询器查询符合条件的离当前元素节点最近的父节点 + * @param {string} selector 查询器 + * @return {NodeInterface} 返回一个 NodeInterface 实例 + */ + closest( + selector: string, + callback?: (node: Node) => Node | undefined, + ): NodeInterface; + + /** + * 为当前元素节点绑定事件 + * @param {String} eventType 事件类型 + * @param {Function} listener 事件函数 + * @return {NodeInterface} 返回当前实例 + */ + on(eventType: string, listener: EventListener): NodeInterface; + + /** + * 移除当前元素节点事件 + * @param {String} eventType 事件类型 + * @param {Function} listener 事件函数 + * @return {NodeInterface} 返回当前实例 + */ + off(eventType: string, listener: EventListener): NodeInterface; + + /** + * 获取当前元素节点相对于视口的位置 + * @param {Object} defaultValue 默认值 + * @return {Object} + * { + * top, + * bottom, + * left, + * right + * } + */ + getBoundingClientRect(defaultValue?: { + top: number; + bottom: number; + left: number; + right: number; + }): + | { top: number; bottom: number; left: number; right: number } + | undefined; + + /** + * 移除当前元素所有已绑定的事件 + * @return {NodeInterface} 当前 NodeInterface 实例 + */ + removeAllEvents(): NodeInterface; + + /** + * 获取或设置元素节点属性 + * @param {string|undefined} key 属性名称,key为空获取所有属性,返回Map + * @param {string|undefined} val 属性值,val为空获取当前key的属性,返回string|null + * @return {NodeInterface|{[k:string]:string}} 返回值或当前实例 + */ + attributes(): { [k: string]: string }; + attributes(key: { [k: string]: string }): string; + attributes(key: string, val: string | number): NodeInterface; + attributes(key: string): string; + attributes( + key?: string | { [k: string]: string }, + val?: string | number, + ): NodeInterface | { [k: string]: string } | string; + + /** + * 移除元素节点属性 + * @param {String} key 属性名称 + * @return {NodeInterface} 返当前实例 + */ + removeAttributes(key: string): NodeInterface; + + /** + * 判断元素节点是否包含某个 class + * @param {String} className 样式名称 + * @return {Boolean} 是否包含 + */ + hasClass(className: string): boolean; + + /** + * 为元素节点增加一个 class + * @param {String} className + * @return {NodeInterface} 返当前实例 + */ + addClass(className: string): NodeInterface; + + /** + * 移除元素节点 class + * @param {String} className + * @return {NodeInterface} 返当前实例 + */ + removeClass(className: string): NodeInterface; + + /** + * 获取或设置元素节点样式 + * @param {String|undefined} key 样式名称 + * @param {String|undefined} val 样式值 + * @return {NodeInterface|{[k:string]:string}} 返回值或当前实例 + */ + css(): { [k: string]: string }; + css(key: { [k: string]: string | number }): NodeInterface; + css(key: string): string; + css(key: string, val: string | number): NodeInterface; + css( + key?: string | { [k: string]: string | number }, + val?: string | number, + ): NodeInterface | { [k: string]: string } | string; + + /** + * 获取元素节点宽度 + * @return {number} 宽度 + */ + width(): number; + + /** + * 获取元素节点高度 + * @return {Number} 高度 + */ + height(): number; + + /** + * 获取或设置元素节点html文本 + */ + html(): string; + html(html: string): NodeInterface; + html(html?: string): NodeInterface | string; + + /** + * 获取或设置元素节点文本 + */ + text(): string; + text(text: string): NodeInterface; + text(text?: string): string | NodeInterface; + + /** + * 设置元素节点为显示状态 + * @param {String} display display值 + * @return {NodeInterface} 当前实例 + */ + show(display?: string): NodeInterface; + + /** + * 设置元素节点为隐藏状态 + * @return {NodeInterface} 当前实例 + */ + hide(): NodeInterface; + + /** + * 移除当前实例所有元素节点 + * @return {NodeInterface} 当前实例 + */ + remove(): NodeInterface; + + /** + * 清空元素节点下的所有子节点 + * @return {NodeInterface} 当前实例 + */ + empty(): NodeInterface; + + /** + * 比较两个元素节点是否相同 + * @param {NodeInterface|Node} node 比较的节点 + * @return {Boolean} 是否相同 + */ + equal(node: NodeInterface | Node): boolean; + + /** + * 复制元素节点 + * @param deep 是否深度复制 + */ + clone(deep?: boolean): NodeInterface; + + /** + * 在元素节点的开头插入指定内容 + * @param {Selector} selector 选择器或元素节点 + * @return {NodeInterface} 当前实例 + */ + prepend(selector: Selector): NodeInterface; + + /** + * 在元素节点的结尾插入指定内容 + * @param {Selector} selector 选择器或元素节点 + * @return {NodeInterface} 当前实例 + */ + append(selector: Selector): NodeInterface; + + /** + * 在元素节点前插入新的节点 + * @param {Selector} selector 选择器或元素节点 + * @return {NodeInterface} 当前实例 + */ + before(selector: Selector): NodeInterface; + + /** + * 在元素节点后插入内容 + * @param {Selector} selector 选择器或元素节点 + * @return {NodeInterface} 当前实例 + */ + after(selector: Selector): NodeInterface; + + /** + * 将元素节点替换为新的内容 + * @param {Selector} selector 选择器或元素节点 + * @return {NodeInterface} 当前实例 + */ + replaceWith(selector: Selector): NodeInterface; + /** + * 获取节点所在编辑器的根节点 + */ + getRoot(): NodeInterface; + /** + * 遍历所有子节点 + * @param callback 回调函数,false:停止遍历 ,true:停止遍历当前节点及子节点,继续遍历下一个兄弟节点 + * @param order true:顺序 ,false:倒序,默认 true + * @param includeEditableCard 是否包含可编辑器卡片 + */ + traverse( + callback: (node: NodeInterface) => boolean | void, + order?: boolean, + includeEditableCard?: boolean, + ): void; + /** + * 根据路径获取子节点 + * @param path 路径 + */ + getChildByPath(path: Path, filter?: (node: Node) => boolean): Node; + + /** + * 获取当前节点所在父节点中的索引 + */ + getIndex(filter?: (node: Node) => boolean): number; + + /** + * 在指定容器里获取父节点 + * @param container 容器节点,默认为编辑器根节点 + */ + findParent(container?: Node | NodeInterface): NodeInterface | null; + + /** + * 获取节点下的所有子节点 + * @param includeEditableCard 是否包含可编辑卡片的节点 + */ + allChildren(includeEditableCard?: boolean): Array; + + /** + * 返回当前节点或者传入的节点所在当前节点的顶级window对象的视图边界 + * @param node 节点 + */ + getViewport(node?: NodeInterface): { + top: number; + left: number; + bottom: number; + right: number; + }; + + /** + * 判断view是否在node节点根据当前节点的顶级window对象计算的视图边界内 + * @param node 节点 + * @param view 是否在视图的节点 + */ + inViewport(node: NodeInterface, view: NodeInterface): boolean; + + /** + * 如果view节点不可见,将滚动到align位置,默认为nearest + * @param node 节点 + * @param view 视图节点 + * @param align 位置 + */ + scrollIntoView( + node: NodeInterface, + view: NodeInterface, + align?: 'start' | 'center' | 'end' | 'nearest', + ): void; +} + +export interface NodeModelInterface { + /** + * 是否是空节点 + * @param node 节点或节点名称 + */ + isVoid( + node: NodeInterface | Node | string, + schema?: SchemaInterface, + ): boolean; + /** + * 是否是mark标签 + * @param node 节点 + */ + isMark(node: NodeInterface | Node, schema?: SchemaInterface): boolean; + /** + * 是否是inline标签 + * @param node 节点 + */ + isInline(node: NodeInterface | Node, schema?: SchemaInterface): boolean; + /** + * 是否是block节点 + * @param node 节点 + */ + isBlock(node: NodeInterface | Node, schema?: SchemaInterface): boolean; + /** + * 判断block节点的子节点是否不包含blcok 节点 + */ + isNestedBlock(node: NodeInterface): boolean; + /** + * 判断节点是否是顶级根节点,父级为编辑器根节点,且,子级节点没有block节点 + * @param node 节点 + * @returns + */ + isRootBlock(node: NodeInterface, schema?: SchemaInterface): boolean; + /** + * 判断节点下的文本是否为空 + * @param node 节点 + * @param withTrim 是否 trim + */ + isEmpty(node: NodeInterface, withTrim?: boolean): boolean; + /** + * 判断一个节点下的文本是否为空,或者只有空白字符 + * @param node 节点 + */ + isEmptyWithTrim(node: NodeInterface): boolean; + /** + * 判断一个节点是否为空 + * @param node 节点 + */ + isEmptyWidthChild(node: NodeInterface): boolean; + /** + * 判断节点是否为列表节点 + * @param node 节点或者节点名称 + */ + isList(node: NodeInterface | string | Node): boolean; + /** + * 判断节点是否是自定义列表 + * @param node 节点 + */ + isCustomize(node: NodeInterface): boolean; + /** + * 去除包裹 + * @param node 需要去除包裹的节点 + * @returns 返回移除外层后的所有子节点 + */ + unwrap(node: NodeInterface): NodeInterface[]; + /** + * 包裹节点 + * @param source 需要包裹的节点 + * @param outer 包裹的外部节点 + * @param mergeSame 合并相同名称的节点样式和属性在同一个节点上 + */ + wrap( + source: NodeInterface | Node, + outer: NodeInterface, + mergeSame?: boolean, + ): NodeInterface; + /** + * 合并节点 + * @param source 合并的节点 + * @param target 需要合并的节点 + * @param remove 合并后是否移除 + */ + merge(source: NodeInterface, target: NodeInterface, remove?: boolean): void; + /** + * 将源节点的子节点追加到目标节点,并替换源节点 + * @param source 旧节点 + * @param target 新节点 + */ + replace(source: NodeInterface, target: NodeInterface): NodeInterface; + /** + * 在光标位置插入一个节点 + * @param node 节点 + * @param range 光标 + */ + insert( + node: Node | NodeInterface, + range?: RangeInterface, + ): RangeInterface | undefined; + /** + * 光标位置插入文本 + * @param text 文本 + * @param range 光标 + */ + insertText( + text: string, + range?: RangeInterface, + ): RangeInterface | undefined; + /** + * 设置节点属性 + * @param node 节点 + * @param props 属性 + */ + setAttributes(node: NodeInterface, attributes: any): NodeInterface; + /** + * 移除值为负的样式 + * @param node 节点 + * @param style 样式名称 + */ + removeMinusStyle(node: NodeInterface, style: string): void; + /** + * 合并节点下的子节点,两个相同的相邻节点的子节点,通常是 blockquote、ul、ol 标签 + * @param node 当前节点 + */ + mergeChild(node: NodeInterface): void; + /** + * 删除节点两边标签 + * @param node 节点 + * @param tagName 标签名称,默认为br标签 + */ + removeSide(node: NodeInterface, tagName?: string): void; + /** + * 扁平化节点 + * @param node 节点 + * @param root 根节点,默认为node节点 + */ + flat(node: NodeInterface, root?: NodeInterface): NodeInterface; + /** + * 标准化节点 + * @param node 节点 + */ + normalize(node: NodeInterface): NodeInterface; + /** + * 获取或设置元素节点html文本 + * @param {string|undefined} val html文本 + * @return {NodeEntry|string} 当前实例或html文本 + */ + html(node: NodeInterface): string; + html(node: NodeInterface, val: string): NodeInterface; + html(node: NodeInterface, val?: string): NodeInterface | string; + /** + * 复制元素节点 + * @param node 节点 + * @param deep 是否深度复制 + * @param copyId 是否复制data-id,默认复制 + * @return 复制后的元素节点 + */ + clone(node: NodeInterface, deep?: boolean, copyId?: boolean): NodeInterface; + /** + * 获取批量追加子节点后的outerHTML + * @param nodes 节点集合 + * @param appendExp 追加的节点 + */ + getBatchAppendHTML(nodes: Array, selector: Selector): string; + + /** + * 移除占位符 \u200B + * @param node 节点 + */ + removeZeroWidthSpace(node: NodeInterface): void; +} + +export interface NodeIdInterface { + /** + * 初始化 + */ + init(): void; + /** + * 根据规则获取需要为节点创建 data-id 的标签名称集合 + * @returns + */ + getRules(): { [key: string]: SchemaRule[] }; + + /** + * 给节点创建data-id + * @param node 节点 + * @returns + */ + create(node: Node | NodeInterface): string; + + /** + * 在根节点内为需要创建data-id的子节点创建data-id + * @param root 根节点 + */ + generateAll(root?: Element | NodeInterface, force?: boolean): void; + /** + * 为节点创建一个随机data-id + * @param node 节点 + * @param isCreate 如果有,是否需要重新创建 + * @returns + */ + generate( + root: Element | NodeInterface | DocumentFragment, + force?: boolean, + ): string | undefined; + /** + * 判断一个节点是否需要创建data-id + * @param name 节点名称 + * @returns + */ + isNeed(node: NodeInterface): boolean; +} + +export interface ElementInterface extends Element { + matchesSelector(selectors: string): boolean; + mozMatchesSelector(selectors: string): boolean; + msMatchesSelector(selectors: string): boolean; + oMatchesSelector(selectors: string): boolean; +} diff --git a/packages/engine/src/types/ot.ts b/packages/engine/src/types/ot.ts new file mode 100644 index 00000000..9b492455 --- /dev/null +++ b/packages/engine/src/types/ot.ts @@ -0,0 +1,540 @@ +import { EventEmitter2 } from 'eventemitter2'; +import { Doc, Op, Path } from 'sharedb'; +import { Type } from 'sharedb/lib/sharedb'; +import { CardInterface } from './card'; +import { NodeInterface } from './node'; +import { RangeInterface, RangePath } from './range'; +import { DrawStyle, TinyCanvasInterface } from './tiny-canvas'; + +/** + * 写作者光标属性 + */ +export type Attribute = { + /** + * 协作者id + */ + uuid: string; + /** + * 光标位置 + */ + path?: { start: RangePath; end: RangePath }; + /** + * 是否激活 + */ + active: boolean; +}; + +/** + * 协作者信息 + */ +export type Member = { + /** + * 协作者id + */ + uuid: string; + /** + * 协作者名称 + */ + name: string; + /** + * 协作者颜色 + */ + color: string; + /** + * 协作者索引,用来随机颜色 + */ + index: number; +}; + +/** + * 操作属性 + */ +export type Operation = { + self?: boolean; + ops?: TargetOp[]; + rangePath?: { start: RangePath; end: RangePath }; + startRangePath?: { start: RangePath; end: RangePath }; + id?: string; + type?: 'undo' | 'redo'; + remoteOps?: TargetOp[]; +}; + +export interface DocInterface extends EventEmitter2 { + // json0 类型,默认本地初始化为null + type: Type | null; + // 文档数据 + data: T; + // 从文档中创建json0数据 + create(): void; + // 把操作应用到文档 + apply(ops: Op[]): void; + // 提交操作到協同作業 + submitOp(ops: Op[]): void; + // 注销 + destroy(): void; +} + +export interface SelectionInterface { + /** + * 当前光标路径 + */ + currentRangePath?: { start: RangePath; end: RangePath }; + /** + * 获取所有协作者的光标路径 + */ + getSelections(): Array; + /** + * 设置所有的协作者的光标路径 + * @param data + */ + setSelections(data: Array): void; + /** + * 移除一个协作者的光标 + * @param uuid + */ + remove(uuid: string): void; + /** + * 更新协作者选区 + * @param currentMember + * @param members + */ + updateSelections( + currentMember: Member, + members: Array, + ): { data: Array; range: RangeInterface }; +} + +export type CursorRect = { + top: string; + left: string; + height: string | number; + elementHeight: number; +}; + +export interface RangeColoringInterface { + /** + * 获取节点相对于光标的位置 + * @param node + * @param range + */ + getRectWithRange(node: NodeInterface, range: RangeInterface): DOMRect; + /** + * 光标开始节点和结束节点是否在一条水平线上 + * @param range + */ + isWrapByRange(range: RangeInterface): boolean; + /** + * 绘制子光标 + * @param node 节点 + * @param canvas + * @param range + * @param style + */ + drawSubRang( + node: NodeInterface, + canvas: TinyCanvasInterface, + range: RangeInterface, + style: DrawStyle, + ): void; + /** + * 绘制背景 + * @param range + * @param options + */ + drawBackground( + range: RangeInterface, + options: { uuid: string; color: string }, + ): Array; + /** + * 获取节点 rect + * @param node + * @param rect + */ + getNodeRect(node: NodeInterface, rect: DOMRect): DOMRect; + /** + * 获取光标 rect + * @param selector + * @param leftSpace + */ + getCursorRect( + selector: RangeInterface | NodeInterface, + leftSpace?: number, + ): CursorRect; + /** + * 设置光标 rect + * @param node + * @param rect + */ + setCursorRect(node: NodeInterface, rect: CursorRect): void; + /** + * 展示协作者信息 + * @param node + * @param member + */ + showCursorInfo(node: NodeInterface, member: Member): void; + /** + * 隐藏协作者信息 + * @param node + */ + hideCursorInfo(node: NodeInterface): void; + /** + * 绘制协作者光标 + * @param selector + * @param member + */ + drawCursor( + selector: RangeInterface | NodeInterface, + member: Member, + ): NodeInterface | undefined; + /** + * 绘制卡片光标 + * @param node + * @param cursor + * @param member + */ + drawCard(node: NodeInterface, cursor: NodeInterface, member: Member): void; + /** + * 设置卡片被协作者选中 + * @param card + * @param member + */ + setCardSelectedByOther(card: CardInterface, member?: Member): void; + /** + * 设置卡片被协作者激活 + * @param card + * @param member + */ + setCardActivatedByOther(card: CardInterface, member?: Member): void; + /** + * 绘制 + * @param range + * @param member + */ + drawRange(range: RangeInterface, member: Member): void; + /** + * 更新绘制的背景位置 + */ + updateBackgroundPosition(): void; + /** + * 更新绘制的协作者光标位置 + */ + updateCursorPosition(): void; + /** + * 更新绘制的卡片背景位置 + */ + updateCardPosition(): void; + /** + * 更新绘制的光标位置 + */ + updatePosition(): void; + /** + * 更新背景的透明度 + * @param range + */ + updateBackgroundAlpha(range: RangeInterface): void; + /** + * 渲染 + * @param data + * @param members + * @param idDraw + */ + render( + data: Array, + members: Array, + idDraw: boolean, + ): void; + /** + * 销毁 + */ + destroy(): void; +} + +export type RemotePath = { + start?: RemoteAttr; + end?: RemoteAttr; +}; + +export type RemoteAttr = { + id: string; + leftText: string; + rightText: string; +}; + +export type TargetOp = Op & { + id?: string; + bi?: number; +}; + +export type RepairOp = TargetOp & { + oldPath?: Path; + newPath: Path; +}; + +export interface MutationInterface extends EventEmitter2 { + /** + * 设置文档对象 OT 文档对象,或自定义文档对象 + * @param doc 文档对象 + */ + setDoc(doc: DocInterface | Doc): void; + /** + * 开始监听DOM树改变 + */ + start(): void; + /** + * 停止监听DOM树改变 + */ + stop(): void; + /** + * 开始缓存操作,开启后将拦截监听并缓存起来 + */ + startCache(): void; + /** + * 将缓存提交处理,最后停止缓存 + */ + submitCache(): void; + /** + * 将缓存遗弃,并停止缓存 + */ + destroyCache(): void; + /** + * 获取缓存的记录 + * @returns + */ + getCaches(): MutationRecord[]; + /** + * 操作改变 + * @param ops 操作 + */ + onChange(ops: Op[]): void; +} + +export interface ConsumerInterface { + /** + * 根据路径还原目标节点 + * @param node + * @param path [开始节点,开始offset,结束节点,结束offset] + */ + getElementFromPath( + node: Node | NodeInterface, + path: Path, + ): { + startNode: Node; + startOffset: number; + endNode: Node; + endOffset: number; + }; + /** + * 设置属性操作 + * @param root 根节点 + * @param path 路径 + * @param attr 属性名称 + * @param value 属性值 + */ + setAttribute( + root: NodeInterface, + path: Path, + attr: string, + value: string, + ): void; + /** + * 删除属性操作 + * @param root 根节点 + * @param path 路径 + * @param attr 属性名称 + */ + removeAttribute(root: NodeInterface, path: Path, attr: string): void; + /** + * 插入一个节点 + * @param root 根节点 + * @param path 路径 + * @param value 操作值 + */ + insertNode( + root: NodeInterface, + path: Path, + value: string | Op[] | Op[][], + ): void; + /** + * 删除一个节点 + * @param root 根节点 + * @param path 路径 + */ + deleteNode(root: NodeInterface, path: Path): void; + /** + * 插入文本 + * @param root 根节点 + * @param path 路径 + * @param offset 文本节点的偏移量 + * @param text 插入的文本 + */ + insertText( + root: NodeInterface, + path: Path, + offset: number, + text: string, + ): void; + /** + * 删除文本 + * @param root 根节点 + * @param path 路径 + * @param offset 文本节点的偏移量 + * @param text 删除的文本 + */ + deleteText( + root: NodeInterface, + path: Path, + offset: number, + text: string, + ): void; + /** + * 处理操作 + * @param op + */ + handleOperation(op: TargetOp): void; + /** + * 处理远程操作 + * @param ops + */ + handleRemoteOperations(ops: TargetOp[]): NodeInterface[]; + /** + * 处理本地操作 + * @param ops + */ + handleSelfOperations(ops: TargetOp[]): NodeInterface[]; + /** + * 处理完操作后设置光标位置 + * @param op + */ + setRangeAfterOp(op: Op): void; + /** + * 获取远程光标位置路径 + */ + getRangeRemotePath(): RemotePath | undefined; + /** + * 设置远程光标位置路径 + * @param path + */ + setRangeByRemotePath(path: RemotePath): void; + /** + * 设置光标路径 + * @param path + */ + setRangeByPath(path: { start: RangePath; end: RangePath }): void; + /** + * 处理完操作后更新节点的 __index + * @param ops + * @param applyNodes + */ + handleIndex(ops: Op[], applyNodes: NodeInterface[]): void; +} + +export interface OTInterface extends EventEmitter2 { + // 操作消费者 + consumer: ConsumerInterface; + selection: SelectionInterface; + getColors(): string[]; + setColors(colors: string[]): void; + /** + * 初始化本地操作监听ops,用于记录历史记录 + */ + initLocal(): void; + /** + * 初始化协同服务 + * @param doc 文档对象 + * @param defaultValue 如果文档不存在,则使用 defaultValue 初始化默认值 + */ + initRemote(doc: Doc, defaultValue?: string): void; + /** + * 处理操作改变 + * @param ops 操作集合 + */ + handleChange(ops: Op[]): void; + /** + * 提交操作到协同服务 + * @param ops + */ + submitOps(ops: Op[]): void; + /** + * 应用操作 + * @param ops + */ + apply(ops: Op[]): void; + /** + * 同步数据 + */ + syncValue(): void; + /** + * 开始监听DOM树改变 + */ + startMutation(): void; + /** + * 停止监听DOM树改变 + */ + stopMutation(): void; + /** + * 开始缓存操作,开启后将拦截监听并缓存起来 + */ + startMutationCache(): void; + /** + * 将缓存提交处理,最后停止缓存 + */ + submitMutationCache(): void; + /** + * 将缓存遗弃,并停止缓存 + */ + destroyMutationCache(): void; + /** + * 获取缓存的记录 + * @returns + */ + getCaches(): MutationRecord[]; + /** + * 设置用户颜色 + * @param member + */ + setMemberColor(member: Member): void; + /** + * 获取所有用户 + */ + getMembers(): Array; + /** + * 设置用户 + * @param members + */ + setMembers(members: Array): void; + /** + * 增加一个用户 + * @param member + */ + addMember(member: Member): void; + /** + * 移除一个用户 + * @param member + */ + removeMember(member: Member): void; + /** + * 设置当前用户 + * @param member + */ + setCurrentMember(member: Member): void; + /** + * 获取当前用户 + */ + getCurrentMember(): Member | undefined; + /** + * 渲染用戶選區 + */ + renderSelection(attributes: Array, isDraw?: boolean): void; + /** + * 更新用户选区 + */ + updateSelection(): void; + /** + * 实例化选区 + */ + initSelection(): void; + /** + * 销毁 + */ + destroy(): void; +} diff --git a/packages/engine/src/types/parser.ts b/packages/engine/src/types/parser.ts new file mode 100644 index 00000000..1b917644 --- /dev/null +++ b/packages/engine/src/types/parser.ts @@ -0,0 +1,87 @@ +import { ConversionInterface } from './conversion'; +import { NodeInterface } from './node'; +import { SchemaInterface } from './schema'; + +export type Callbacks = { + /** + * 遍历节点开始 + */ + onOpen?: ( + node: NodeInterface, + name: string, + attributes: { [k: string]: string }, + styles: { [k: string]: string }, + ) => boolean | void; + /** + * 遍历节点结束 + */ + onClose?: ( + node: NodeInterface, + name: string, + attributes: { [k: string]: string }, + styles: { [k: string]: string }, + ) => void; + /** + * 遍历节点文本 + */ + onText?: (node: NodeInterface, test: string) => void; +}; + +export interface ParserInterface { + /** + * 遍历节点 + * @param node 根节点 + * @param conversionRules 标签名称转换器 + * @param callbacks 回调 + * @param isCardNode 是否是卡片 + * @param includeCard 是否包含卡片 + */ + traverse( + node: NodeInterface, + schema: SchemaInterface | null, + conversion: ConversionInterface | null, + callbacks: Callbacks, + includeCard?: boolean, + ): void; + + /** + * 遍历 DOM 树,生成符合标准的 XML 代码 + * @param schemaRules 标签保留规则 + * @param conversionRules 标签转换规则 + * @param replaceSpaces 是否替换空格 + * @param customTags 是否将光标、卡片节点转换为标准代码 + */ + toValue( + schema?: SchemaInterface | null, + conversion?: ConversionInterface | null, + replaceSpaces?: boolean, + customTags?: boolean, + ): string; + + /** + * 转换为HTML代码 + * @param inner 内包裹节点 + * @param outter 外包裹节点 + */ + toHTML(inner?: Node, outter?: Node): { html: string; text: string }; + + /** + * 返回DOM树 + */ + toDOM( + schema?: SchemaInterface | null, + conversion?: ConversionInterface | null, + ): DocumentFragment; + + /** + * 转换为文本 + * @param schema Schema 规则 + * @param includeCard 是否遍历卡片内部,默认不遍历 + * @param formatOL 是否格式化有序列表,
      1. a
      2. b
      -> 1. a 2. b 默认转换 + */ + toText( + schema?: SchemaInterface, + includeCard?: boolean, + formatOL?: boolean, + ): string; +} diff --git a/packages/engine/src/types/plugin.ts b/packages/engine/src/types/plugin.ts new file mode 100644 index 00000000..4a59312d --- /dev/null +++ b/packages/engine/src/types/plugin.ts @@ -0,0 +1,174 @@ +import { CardInterface } from './card'; +import { ConversionData } from './conversion'; +import { EditorInterface } from './engine'; +import { NodeInterface } from './node'; +import { SchemaGlobal, SchemaRule, SchemaValue } from './schema'; + +export type PluginOptions = { + /** + * 是否禁用,默认不禁用。在默认不指定的情况下,编辑器为 readonly 的时候全部禁用 + */ + disabled?: boolean; + [key: string]: any; + /** + * TODO:在当前插件状态下,禁用的插件名称集合,如果禁用全部卡片插件,指定名称为 card 即可,但不包扩可编辑器卡片,如果还需要包括可编辑器卡片,指定名称 card-editable + */ + // disabledPlugins?: Array; +}; + +export interface PluginEntry { + prototype: PluginInterface; + new (editor: EditorInterface, options: PluginOptions): PluginInterface; + readonly pluginName: string; +} + +export interface PluginInterface { + readonly kind: string; + /** + * 是否禁用,默认不禁用。在默认不指定的情况下,编辑器为 readonly 的时候全部禁用 + */ + disabled?: boolean; + /** + * TODO:在当前插件状态下,禁用的插件名称集合,如果禁用全部卡片插件,指定名称为 card 即可,但不包扩可编辑器卡片,如果还需要包括可编辑器卡片,指定名称 card-editable + */ + //disabledPlugins: Array; + /** + * 初始化 + */ + init?(): void; + /** + * 查询插件状态 + * @param args 插件需要的参数 + */ + queryState?(...args: any): any; + /** + * 执行插件 + * @param args 插件需要的参数 + */ + execute(...args: any): void; + /** + * 插件热键绑定,返回需要匹配的组合键字符,如 mod+b,匹配成功即执行插件,还可以带上插件执行所需要的参数,多个参数以数组形式返回{key:"mod+b",args:[]} + * @param event 键盘事件 + */ + hotkey?( + event?: KeyboardEvent, + ): + | string + | { key: string; args: any } + | Array<{ key: string; args: any }> + | Array; + /** + * 插件是否在等待处理中 + * @param callback 插件有等待动作时回调 + */ + waiting?( + callback?: ( + name: string, + card?: CardInterface, + ...args: any + ) => boolean | number | void, + ): Promise; +} + +export interface ElementPluginInterface extends PluginInterface { + /** + * 标签名称 + */ + readonly tagName?: string | Array; + /** + * 标签样式,可选 + * 使用变量表示值时,固定规则:@var0 @var1 @var2 ... 分别表示执行 command.execute 时传入的 参数1 参数2 参数3 ... + * { value:string,format:(value:string) => string } 可以在获取节点属性值时,对值进行自定义格式化处理 + */ + readonly style?: { + [key: string]: + | string + | { value: string; format: (value: string) => string }; + }; + /** + * 标签属性,可选 + * 使用变量表示值时,固定规则:@var0 @var1 @var2 ... 分别表示执行 command.execute 时传入的 参数1 参数2 参数3 ... + * { value:string,format:(value:string) => string } 可以在获取节点属性值时,对值进行自定义格式化处理 + */ + readonly attributes?: { + [key: string]: + | string + | { value: string; format: (value: string) => string }; + }; + /** + * 在 style 或者 attributes 使用变量表示的值规则 + * key 为如上所诉的变量名称 @var0 @var1 @var2 ... + */ + readonly variable?: { [key: string]: SchemaValue }; + /** + * 初始化 + */ + init(): void; + /** + * 将当前插件style属性应用到节点 + * @param node 节点 + * @param args style 对应 variable 中的变量参数 + */ + setStyle(node: NodeInterface | Node, ...args: Array): void; + /** + * 将当前插件attributes属性应用到节点 + * @param node 节点 + * @param args attributes 对应 variable 中的变量参数 + */ + setAttributes(node: NodeInterface | Node, ...args: Array): void; + /** + * 获取节点符合当前插件规则的样式 + * @param node 节点 + * @returns 样式名称和样式值键值对 + */ + getStyle(node: NodeInterface | Node): { [key: string]: string }; + /** + * 获取节点符合当前插件规则的属性 + * @param node 节点 + * @returns 属性名称和属性值键值对 + */ + getAttributes(node: NodeInterface | Node): { [key: string]: string }; + /** + * 检测当前节点是否符合当前插件设置的规则 + * @param node 节点 + * @returns true | false + */ + isSelf(node: NodeInterface | Node): boolean; + /** + * 获取插件设置的属性和样式所生成的规则 + */ + schema(): SchemaRule | SchemaGlobal | Array; + /** + * 在粘贴时的标签转换,例如:b > strong + */ + conversion?(): ConversionData; +} + +export interface PluginModelInterface { + /** + * 实例化的插件集合 + */ + components: { [k: string]: PluginInterface }; + /** + * 实例化插件 + * @param plugins 插件集合 + * @param config 插件配置 + */ + init(plugins: Array, config: { [k: string]: any }): void; + /** + * 新增插件 + * @param clazz 插件类 + */ + add(clazz: PluginEntry, options?: PluginOptions): void; + /** + * 遍历插件 + * @param callback 回调 + */ + each( + callback: ( + name: string, + clazz: PluginEntry, + index?: number, + ) => boolean | void, + ): void; +} diff --git a/packages/engine/src/types/range.ts b/packages/engine/src/types/range.ts new file mode 100644 index 00000000..76b36738 --- /dev/null +++ b/packages/engine/src/types/range.ts @@ -0,0 +1,308 @@ +import { Path } from 'sharedb'; +import { EditorInterface } from './engine'; +import { NodeInterface } from './node'; +import { SelectionInterface } from './selection'; + +export interface Range { + prototype: RangeInterface; + new (): RangeInterface; + /** + * 从一个 Point 位置获取 RangeInterface 对象 + */ + create: ( + editor: EditorInterface, + doc?: Document, + point?: { x: number; y: number }, + ) => RangeInterface; + /** + * 从 Window 、Selection、Range 中创建 RangeInterface 对象 + */ + from: ( + editor: EditorInterface, + win?: Window | globalThis.Selection | globalThis.Range, + ) => RangeInterface | null; + /** + * 从路径转换为光标 + * @param path + * @param context 上下文,默认编辑器节点 + * @param includeCardCursor 是否还原到卡片两侧光标处,必须保证 参数 path 中包含光标位置信息 + */ + fromPath( + path: Path[], + context?: NodeInterface, + includeCardCursor?: boolean, + ): RangeInterface; +} + +export type RangePath = { + path: number[]; + id: string; + bi: number; +}; + +export interface RangeInterface { + /** + * 原生range对象 + */ + readonly base: globalThis.Range; + /** + * 开始节点 + */ + readonly startNode: NodeInterface; + /** + * 结束节点 + */ + readonly endNode: NodeInterface; + /** + * 开始节点和结束节点的共同父节点 + */ + readonly commonAncestorNode: NodeInterface; + /** + * 开始节点 + */ + readonly startContainer: Node; + /** + * 结束节点 + */ + readonly endContainer: Node; + /** + * 开始节点和结束节点的共同父节点 + */ + readonly commonAncestorContainer: Node; + /** + * 光标的开始节点位置和结束节点位置是否重合 + */ + readonly collapsed: boolean; + /** + * 结束节点的偏移量 + */ + readonly endOffset: number; + /** + * 开始节点的偏移量 + */ + readonly startOffset: number; + /** + * 复制选区中的内容 + */ + cloneContents(): DocumentFragment; + /** + * 删除选区中的内容 + */ + deleteContents(): void; + /** + * 提取选区中的内容 + */ + extractContents(): DocumentFragment; + /** + * 获取选区中的 rect + */ + getBoundingClientRect(): DOMRect; + /** + * 获取选区中的 rect + */ + getClientRects(): DOMRectList; + /** + * 在光标位置插入一个节点 + * @param node + */ + insertNode(node: Node | NodeInterface): void; + /** + * 判断一个节点的偏移量是否在选区中 + * @param node + * @param offset + */ + isPointInRange(node: Node | NodeInterface, offset: number): boolean; + /** + * 如果点在范围之前,则返回 -1,如果点在范围内,则返回 0,如果点在范围之后,则返回 1。 + * @param node + * @param offset + */ + comparePoint(node: Node | NodeInterface, offset: number): number; + /** + * 设置range的结束节点和偏移量 + * @param node + * @param offset + */ + setEnd(node: Node | NodeInterface, offset: number): void; + /** + * 让range的结束节点选择在节点的后面 + * @param node + */ + setEndAfter(node: Node | NodeInterface): void; + /** + * 让range的结束节点选择在节点的前面 + * @param node + */ + setEndBefore(node: Node | NodeInterface): void; + /** + * 设置range的开始节点和偏移量 + * @param node + * @param offset + */ + setStart(node: Node | NodeInterface, offset: number): void; + /** + * 让range的开始节点选择在节点的后面 + * @param node + */ + setStartAfter(node: Node | NodeInterface): void; + /** + * 让range的开始节点选择在节点的前面 + * @param node + */ + setStartBefore(node: Node | NodeInterface): void; + /** + * 转换为字符串 + */ + toString(): string; + /** + * 转换为原生range对象 + */ + toRange(): globalThis.Range; + /** + * 设置range选择在开始节点或者结束节点位置重合 + * @param toStart + */ + collapse(toStart?: boolean): RangeInterface; + /** + * 复制range对象 + */ + cloneRange(): RangeInterface; + /** + * 选中一个节点 + * @param node 节点 + * @param contents 是否只选中内容 + */ + select(node: NodeInterface | Node, contents?: boolean): RangeInterface; + /** + * 获取光标选中的文本 + */ + getText(): string | null; + /** + * 获取光标所占的区域 + */ + getClientRect(): DOMRect; + /** + * 将选择标记从 TextNode 扩大到最近非TextNode节点 + * range 实质所选择的内容不变 + */ + enlargeFromTextNode(): RangeInterface; + /** + * 将选择标记从非 TextNode 缩小到TextNode节点上,与 enlargeFromTextNode 相反 + * range 实质所选择的内容不变 + */ + shrinkToTextNode(): RangeInterface; + /** + * 扩大边界 + *

      [123abc]def

      + * to + *

      [123abc]def

      + * @param range 选区 + * @param toBlock 是否扩大到块级节点 + */ + enlargeToElementNode(toBlock?: boolean): RangeInterface; + /** + * 缩小边界 + * [

      123

      ] + * to + *

      [123]

      + */ + shrinkToElementNode(): RangeInterface; + /** + * 创建 selection,通过插入 span 节点标记位置 + * @param key 可选唯一标识 + */ + createSelection(key?: string): SelectionInterface; + /** + * 获取子选区集合 + * @param includeCard 是否包含卡片 + */ + getSubRanges(includeCard?: boolean): Array; + /** + * 设置一个节点为开始节点和结束节点到range + * @param node + * @param start + * @param end + */ + setOffset( + node: Node | NodeInterface, + start: number, + end: number, + ): RangeInterface; + /** + * 在选区中获取所有的节点 + */ + findElements(): Array; + /** + * 选区是否在卡片中 + */ + inCard(): boolean; + /** + * 获取选区开始位置的节点 + */ + getStartOffsetNode(): Node; + /** + * 获取选区结束位置的节点 + */ + getEndOffsetNode(): Node; + /** + * 让光标滚动到光标结束节点位置 + */ + scrollIntoView(): void; + /** + * 在视图内,让光标滚动到光标结束节点位置 + */ + scrollRangeIntoView(): void; + /** + * 光标未重合或者在视图内,让光标滚动到光标结束节点位置 + * @param node + * @param view + */ + scrollIntoViewIfNeeded(node: NodeInterface, view: NodeInterface): void; + /** + * 是否包含卡片 + */ + containsCard(): boolean; + /** + * 在光标位置对blcok添加或者删除br标签 + * @param isLeft + */ + handleBr(isLeft?: boolean): RangeInterface; + + /** + * 获取开始位置前的节点 + * foo|bar + */ + getPrevNode(): NodeInterface | undefined; + + /** + * 获取结束位置后的节点 + * foo|bar + */ + getNextNode(): NodeInterface | undefined; + /** + * 深度剪切 + */ + deepCut(): void; + + /** + * 对比两个范围是否相等 + *范围 + */ + equal(range: RangeInterface | globalThis.Range): boolean; + /** + * 获取当前选区最近的根节点 + */ + getRootBlock(): NodeInterface | undefined; + /** + * 过滤路径 + * @param includeCardCursor + */ + filterPath(includeCardCursor?: boolean): (node: Node) => boolean; + /** + * 获取光标路径 + * @param includeCardCursor 是否包含卡片两侧光标 + */ + toPath( + includeCardCursor?: boolean, + ): { start: RangePath; end: RangePath } | undefined; +} diff --git a/packages/engine/src/types/request.ts b/packages/engine/src/types/request.ts new file mode 100644 index 00000000..5deaa260 --- /dev/null +++ b/packages/engine/src/types/request.ts @@ -0,0 +1,186 @@ +export type AjaxOptions = { + /** + * 請求地址 + */ + url: string; + /** + * 请求方法 + */ + method?: 'POST' | 'GET' | 'PUT' | 'DELETE' | 'OPTIONS' | 'HEAD'; + /** + * 数据 + */ + data?: any; + /** + * Window上下文 + */ + context?: Window & typeof globalThis; + /** + * Document上下文 + */ + doc?: Document; + crossOrigin?: boolean; + type?: string; + headers?: { [key: string]: string }; + withCredentials?: boolean; + jsonpCallback?: string; + jsonpCallbackName?: string; + processData?: boolean; + xhr?: XMLHttpRequest | ((options: AjaxOptions) => XMLHttpRequest); + async?: boolean; + before?: (request: XMLHttpRequest) => void; + timeout?: number; + success?: (data: any) => void; + error?: (error: Error) => void; + complete?: (request: XMLHttpRequest | Error) => void; +} & SetupOptions; + +export type Accept = '*' | 'xml' | 'html' | 'text' | 'json' | 'js'; + +export type SetupOptions = { + traditional?: boolean; + contentType?: string; + requestedWith?: string; + accept?: { [key in Accept]: string }; + dataFilter?: (data: any, type?: string) => any; +}; + +export interface AjaxInterface { + /** + * 获取请求示例 + * @param success + * @param error + */ + getRequest( + success: (data: any) => void, + error: (errorMsg: string, request?: XMLHttpRequest) => void, + ): XMLHttpRequest | undefined; + /** + * 中断当前请求 + */ + abort: () => void; +} + +export type UploaderOptions = { + /** + * 上传地址 + */ + url: string; + /** + * 数据类型 + */ + type?: string; + /** + * 内容类型 + */ + contentType?: string; + /** + * 数据 + */ + data?: {}; + /** + * 跨域 + */ + crossOrigin?: boolean; + /** + * 请求头 + */ + headers?: { [key: string]: string }; + /** + * 上传前处理 + */ + onBefore?: (file: File) => Promise | boolean | void; + /** + * 读取文件处理 + */ + onReady?: (fileInfo: FileInfo, file: File) => Promise | void; + /** + * 上传中 + */ + onUploading?: (file: File, progress: { percent: number }) => void; + /** + * 上传错误 + */ + onError?: (error: Error, file: File) => void; + /** + * 上传成功 + */ + onSuccess?: (response: any, file: File) => void; +}; + +export type File = globalThis.File & { uid?: string; data?: {} }; + +export type FileInfo = { + /** + * 文件uuid + */ + uid: string; + /** + * 文件地址或内容 + */ + src: string | ArrayBuffer | null; + /** + * 文件名称 + */ + name: string; + /** + * 文件大小 + */ + size: number; + /** + * 文件类型 + */ + type: string; + /** + * 文件后缀名 + */ + ext: string; +}; + +export interface UploaderInterface { + /** + * 请求上传 + * @param files 文件集合 + * @param name 文件名称 + */ + request(files: Array, name?: string): Promise; +} + +export type OpenDialogOptions = { + /** + * 单击事件 + */ + event?: MouseEvent; + /** + * 可选取的文件类型 + */ + accept?: string; + /** + * 最多选取多少个文件 + */ + multiple?: boolean | number; +}; + +export interface RequestInterface { + /** + * ajax 请求 + * @param options + */ + ajax(options: AjaxOptions | string): AjaxInterface; + /** + * 文件上传 + * @param options + * @param files + * @param name + */ + upload( + options: UploaderOptions, + files: Array, + name?: string, + ): Promise; + /** + * 打开文件选择框 + * @param options + */ + getFiles(options?: OpenDialogOptions): Promise>; +} diff --git a/packages/engine/src/types/schema.ts b/packages/engine/src/types/schema.ts new file mode 100644 index 00000000..625337e1 --- /dev/null +++ b/packages/engine/src/types/schema.ts @@ -0,0 +1,206 @@ +import { NodeInterface } from './node'; + +/** + * 规则值类型 + */ +export type SchemaValueBase = + | RegExp + | Array + | string + | ((propValue: string) => boolean) + | '@number' + | '@length' + | '@color' + | '@url' + | '*'; + +/** + * 规则对象值类型 + */ +export type SchemaValueObject = { + required: boolean; + value: SchemaValueBase; +}; +/** + * 规则值类型 + */ +export type SchemaValue = SchemaValueObject | SchemaValueBase; +/** + * 属性规则 + */ +export type SchemaAttributes = { + [key: string]: SchemaValue; +}; +/** + * 样式规则 + */ +export type SchemaStyle = { + style: { [key: string]: SchemaValue }; +}; +/** + * 全局规则 + */ +export type SchemaGlobal = { + /** + * 节点类型 + */ + type: 'block' | 'mark' | 'inline'; + /** + * 属性规则 + */ + attributes: SchemaAttributes | SchemaStyle; +}; +/** + * 规则 + */ +export type SchemaRule = { + name: string; + type: 'block' | 'mark' | 'inline'; + attributes?: SchemaAttributes | SchemaStyle; + isVoid?: boolean; +}; +/** + * block 规则 + */ +export type SchemaBlock = SchemaRule & { + type: 'block'; + allowIn?: Array; + canMerge?: boolean; +}; +/** + * mark 规则 + */ +export type SchemaMark = SchemaRule & { + type: 'mark'; +}; + +export interface SchemaInterface { + /** + * 规则集合 + */ + data: { + blocks: Array; + inlines: Array; + marks: Array; + globals: { [key: string]: SchemaAttributes | SchemaStyle }; + }; + /** + * 增加规则,不允许设置div标签,div将用作card使用 + * 只有 type 和 attributes 时,将作为此类型全局属性,与其它所有同类型标签属性将合并 + * @param rules 规则 + */ + add( + rules: SchemaRule | SchemaGlobal | Array, + ): void; + /** + * 移除一个规则 + * @param rule + */ + remove(rule: SchemaRule): void; + /** + * 查找规则 + * @param callback 查找条件 + */ + find(callback: (rule: SchemaRule) => boolean): Array; + /** + * 获取节点类型 + * @param node 节点 + * @param filter 过滤 + */ + getType( + node: NodeInterface, + filter?: (rule: SchemaRule) => boolean, + ): 'block' | 'mark' | 'inline' | undefined; + /** + * 根据节点获取符合的规则 + * @param node 节点 + * @param filter 过滤 + * @returns + */ + getRule( + node: NodeInterface, + filter?: (rule: SchemaRule) => boolean, + ): SchemaRule | undefined; + /** + * 检测节点是否符合某一属性规则 + * @param node 节点 + * @param attributes 属性规则 + */ + checkNode( + node: NodeInterface, + attributes?: SchemaAttributes | SchemaStyle, + ): boolean; + /** + * 检测值是否符合规则 + * @param rule 规则 + * @param attributesName 属性名称 + * @param attributesValue 属性值 + * @param force 是否强制比较值 + */ + checkValue( + rule: SchemaAttributes | SchemaStyle, + attributesName: string, + attributesValue: string, + force?: boolean, + ): boolean; + /** + * 过滤节点样式 + * @param styles 样式 + * @param rule 规则 + */ + filterStyles(styles: { [k: string]: string }, rule: SchemaRule): void; + /** + * 过滤节点属性 + * @param attributes 属性 + * @param rule 规则 + */ + filterAttributes( + attributes: { [k: string]: string }, + rule: SchemaRule, + ): void; + /** + * 过滤满足node节点规则的属性和样式 + * @param node 节点,用于获取规则 + * @param attributes 属性 + * @param styles 样式 + * @returns + */ + filter( + node: NodeInterface, + attributes: { [k: string]: string }, + styles: { [k: string]: string }, + ): void; + /** + * 克隆当前schema对象 + */ + clone(): SchemaInterface; + /** + * 查找节点符合规则的最顶层的节点名称 + * @param name 节点名称 + * @returns 最顶级的block节点名称 + */ + closest(name: string): string; + /** + * 判断子节点名称是否允许放入指定的父节点中 + * @param source 父节点名称 + * @param target 子节点名称 + * @returns true | false + */ + isAllowIn(source: string, target: string): boolean; + /** + * 给一个block节点添加允许放入的block子节点 + * @param parent 允许放入的父节点 + * @param child 允许放入的节点,默认 p + */ + addAllowIn(parent: string, child?: string): void; + /** + * 获取允许有子block节点的标签集合 + * @returns + */ + getAllowInTags(): Array; + /** + * 获取能够合并的block节点的标签集合 + * @returns + */ + getCanMergeTags(): Array; +} diff --git a/packages/engine/src/types/selection.ts b/packages/engine/src/types/selection.ts new file mode 100644 index 00000000..23639a97 --- /dev/null +++ b/packages/engine/src/types/selection.ts @@ -0,0 +1,37 @@ +import { NodeInterface } from './node'; + +export interface SelectionInterface { + /** + * 开始标记 + */ + anchor: NodeInterface | null; + /** + * 结束标记 + */ + focus: NodeInterface | null; + /** + * 是否有创建好标记 + */ + has(): boolean; + /** + * 创建标记 + */ + create(): void; + /** + * 让Range选择标记位置,并删除标记 + */ + move(): void; + /** + * 获取节点相对于标记位置的节点,获取后会移除标记 + * @param node 节点 + * @param position 位置 + * @param isClone 是否复制一个副本 + * @param callback 删除节点时回调,返回一个 boolean 来表示当前节点是否删除 + */ + getNode( + node: NodeInterface, + position?: 'left' | 'center' | 'right', + isClone?: boolean, + callback?: (node: NodeInterface) => boolean, + ): NodeInterface; +} diff --git a/packages/engine/src/types/tiny-canvas.ts b/packages/engine/src/types/tiny-canvas.ts new file mode 100644 index 00000000..891e4133 --- /dev/null +++ b/packages/engine/src/types/tiny-canvas.ts @@ -0,0 +1,38 @@ +export type DrawStyle = { + fill?: string; + stroke?: string; +}; + +export type DrawOptions = DOMRect & DrawStyle; + +export interface TinyCanvasInterface { + /** + * 重置大小 + * @param width + * @param height + */ + resize(width: number, height: number): void; + /** + * 获取图片数据 + * @param options + */ + getImageData(options: DOMRect): ImageData | undefined; + /** + * 绘制区域 + * @param options + */ + drawRect(options: DrawOptions): void; + /** + * 清除绘制额区域 + * @param options + */ + clearRect(options: DOMRect): void; + /** + * 清除 + */ + clear(): void; + /** + * 销毁 + */ + destroy(): void; +} diff --git a/packages/engine/src/types/toolbar.ts b/packages/engine/src/types/toolbar.ts new file mode 100644 index 00000000..ffb2e36d --- /dev/null +++ b/packages/engine/src/types/toolbar.ts @@ -0,0 +1,312 @@ +import { NodeInterface } from './node'; + +/** + * 按钮 + */ +export type ButtonOptions = { + /** + * 类型 + */ + type: 'button'; + /** + * 是否禁用 + */ + disabled?: boolean; + /** + * 链接 + */ + link?: string; + /** + * 样式 + */ + style?: string; + /** + * 样式名称 + */ + class?: string; + /** + * 按钮内容 + */ + content: string; + /** + * 按钮提示内容 + */ + title?: string | (() => string); + /** + * 单击事件 + */ + onClick?: (event: MouseEvent, node: NodeInterface) => void; + /** + * 按钮渲染成功后的回调 + */ + didMount?: (node: NodeInterface) => void; +}; + +/** + * 输入框 + */ +export type InputOptions = { + /** + * 类型 + */ + type: 'input'; + /** + * 输入框占位符 + */ + placeholder: string; + /** + * 输入框值 + */ + value: string | number; + /** + * 输入框前缀内容 + */ + prefix?: string; + /** + * 输入框后缀内容 + */ + suffix?: string; + /** + * 回车后的回调 + */ + onEnter?: (value: string) => void; + /** + * 输入后的回调 + */ + onInput?: (value: string) => void; + /** + * 值改变后回调 + */ + onChange?: (value: string) => void; + /** + * 渲染成功后回调 + */ + didMount?: (node: NodeInterface) => void; +}; +/** + * 单选按钮 + */ +export type DropdownSwitchOptions = { + /** + * 类型 + */ + type: 'switch'; + /** + * 是否禁用 + */ + disabled?: boolean; + /** + * 按钮内容 + */ + content: string; + /** + * 是否选中 + */ + checked?: boolean; + /** + * 获取当前状态 + */ + getState?: () => boolean; + /** + * 单击事件 + */ + onClick?: (event: MouseEvent, node: NodeInterface) => void; +}; +/** + * 下拉项按钮 + */ +export type DropdownButtonOptions = { + /** + * 类型 + */ + type: 'button'; + /** + * 是否禁用 + */ + disabled?: boolean; + /** + * 按钮内容 + */ + content: string; + /** + * 单击回调 + */ + onClick?: (event: MouseEvent, node: NodeInterface) => void; +}; +/** + * 下拉框 + */ +export type DropdownOptions = { + /** + * 类型 + */ + type: 'dropdown'; + /** + * 是否禁用 + */ + disabled?: boolean; + /** + * 内容 + */ + content: string; + /** + * 提示 + */ + title?: string | (() => string); + /** + * 下拉项 + */ + items: Array; + /** + * 渲染成功后的回调 + */ + didMount?: (node: NodeInterface) => void; +}; +/** + * 自定义节点 + */ +export type NodeOptions = { + /** + * 类型 + */ + type: 'node'; + /** + * 节点 + */ + node: NodeInterface; + /** + * 提示 + */ + title?: string | (() => string); + /** + * 渲染成功后的回调 + */ + didMount?: (node: NodeInterface) => void; +}; +/** + * 工具栏项 + */ +export type ToolbarItemOptions = + | ButtonOptions + | InputOptions + | DropdownOptions + | NodeOptions; + +/** + * 工具栏 + */ +export type ToolbarOptions = { + /** + * 工具栏项 + */ + items: Array; +}; + +export interface ButtonInterface { + /** + * 渲染到容器 + * @param container + */ + render(container: NodeInterface): void; +} + +export interface InputInterface { + /** + * 回车 + */ + onEnter: (value: string) => void; + /** + * 输入 + */ + onInput: (value: string) => void; + /** + * 值改变 + */ + onChange: (value: string) => void; + /** + * 查找节点 + * @param role + */ + find(role: string): NodeInterface; + /** + * 渲染到容器 + * @param container + */ + render(container: NodeInterface): void; +} + +export interface DropdownInterface { + /** + * document 单击事件 + * @param e + */ + documentMouseDown(e: MouseEvent): void; + /** + * 初始化事件 + */ + initToggleEvent(): void; + /** + * 触发下拉框展开 + */ + toggleDropdown(): void; + /** + * 显示下拉框 + */ + showDropdown(): void; + /** + * 隐藏下拉框 + */ + hideDropdown(): void; + /** + * 渲染提示 + */ + renderTooltip(): void; + /** + * 渲染下拉框到容器 + * @param container + */ + renderDropdown(container: NodeInterface): void; + /** + * 渲染到容器 + * @param container + */ + render(container: NodeInterface): void; + /** + * 销毁 + */ + destroy(): void; +} + +export interface ToolbarInterface { + /** + * 根节点 + */ + root: NodeInterface; + /** + * 增加工具栏项 + * @param node + */ + addItems(node: NodeInterface): void; + /** + * 查找节点 + * @param role + */ + find(role: string): NodeInterface; + /** + * 隐藏 + */ + hide(): void; + /** + * 展示 + */ + show(): void; + /** + * 渲染 + * @param container + */ + render(container?: NodeInterface): NodeInterface; + /** + * 销毁 + */ + destroy(): void; +} diff --git a/packages/engine/src/types/typing.ts b/packages/engine/src/types/typing.ts new file mode 100644 index 00000000..2b43e3f1 --- /dev/null +++ b/packages/engine/src/types/typing.ts @@ -0,0 +1,91 @@ +import { EngineInterface } from './engine'; +import { EventListener } from './node'; + +export interface TypingHandle { + prototype: TypingHandleInterface; + new (engine: EngineInterface): TypingHandleInterface; +} +/** + * 按键处理接口 + */ +export interface TypingHandleInterface { + /** + * 事件集合 + */ + listeners: Array; + /** + * 按键类型 键盘按下 | 键盘弹起 + */ + type: 'keydown' | 'keyup'; + /** + * 处理的热键 + */ + hotkey: Array | string | ((event: KeyboardEvent) => boolean); + /** + * 绑定事件 + * @param listener 事件方法 + */ + on(listener: EventListener): void; + /** + * 移除事件 + * @param listener 事件方法 + */ + off(listener: EventListener): void; + /** + * 触发事件 + * @param event 键盘事件 + */ + trigger(event: KeyboardEvent): void; + /** + * 销毁 + */ + destroy(): void; +} + +/** + * 按键接口 + */ +export interface TypingInterface { + /** + * 增加一个按键监听处理 + * @param name 监听名称 + * @param handle 监听处理类 + * @param triggerName 触发名称 + */ + addHandleListener( + name: string, + handle: TypingHandle, + triggerName?: string, + ): void; + /** + * 移除一个按键监听处理 + * @param handle 监听处理实例 + */ + removeHandleListener(name: string, type: 'keydown' | 'keyup'): void; + /** + * 获取一个事件监听 + * @param name 监听名称 + */ + getHandleListener( + name: string, + type: 'keydown' | 'keyup', + ): TypingHandleInterface | undefined; + /** + * 绑定键盘按下事件 + * @param type 按键类型 + * @param listener 监听方法 + */ + bindKeydown(event: KeyboardEvent): void; + /** + * 绑定键盘弹起事件 + * @param type 按键类型 + * @param listener 监听方法 + */ + bindKeyup(event: KeyboardEvent): void; + /** + * 触发事件 + * @param type 类型 + * @param event 键盘事件 + */ + trigger(type: 'keydown' | 'keyup', event: KeyboardEvent): void; +} diff --git a/packages/engine/src/types/view.ts b/packages/engine/src/types/view.ts new file mode 100644 index 00000000..3fc57544 --- /dev/null +++ b/packages/engine/src/types/view.ts @@ -0,0 +1,55 @@ +import { CardEntry } from './card'; +import { EditorInterface } from './engine'; +import { NodeInterface } from './node'; +import { PluginEntry } from './plugin'; + +/** + * 阅读器接口 + */ +export interface ViewInterface extends EditorInterface { + /** + * 渲染内容 + * @param content 渲染的内容 + * @param trigger 是否触发渲染完成事件,用来展示插件的特俗效果。例如在heading插件中,展示锚点显示功能。默认为 true + */ + render(content: string, trigger?: boolean): void; + /** + * 触发事件 + * @param eventType 事件名称 + * @param args 参数 + */ + trigger(eventType: string, ...args: any): any; + /** + * 触发render事件 + * @param eventType render + * @param value 渲染根节点 + */ + trigger(eventType: 'render', value: NodeInterface): void; +} + +export type ContentViewOptions = { + /** + * 语言,默认zh-CN + */ + lang?: string; + /** + * 本地化 + */ + locale?: { [key: string]: {} }; + /** + * 插件配置 + */ + plugins?: Array; + /** + * 卡片配置 + */ + cards?: Array; + /** + * 插件选项,每个插件具体选项请在插件查看 + */ + config?: { [k: string]: {} }; + /** + * 阅读器根节点,默认为阅读器所在节点的父节点 + */ + root?: Node; +}; diff --git a/packages/engine/src/typing/index.ts b/packages/engine/src/typing/index.ts new file mode 100644 index 00000000..74a6724f --- /dev/null +++ b/packages/engine/src/typing/index.ts @@ -0,0 +1,128 @@ +import isHotkey from 'is-hotkey'; +import { + EngineInterface, + TypingHandle, + TypingHandleInterface, + TypingInterface, +} from '../types'; +import keydownDefaultHandles from './keydown'; +import keyupDefaultHandles from './keyup'; +import { $ } from '../node'; + +class Typing implements TypingInterface { + private engine: EngineInterface; + private handleListeners: Array<{ + name: string; + triggerName?: string; + triggerParams?: + | any + | ((engine: EngineInterface, event: KeyboardEvent) => any); + handle: TypingHandleInterface; + }> = []; + constructor(engine: EngineInterface) { + this.engine = engine; + keydownDefaultHandles.concat(keyupDefaultHandles).forEach((handle) => { + this.addHandleListener( + handle.name, + handle.handle, + handle.triggerName, + ); + }); + const { container } = engine; + container.on('keydown', (e: KeyboardEvent) => this.bindKeydown(e)); + container.on('keyup', (e: KeyboardEvent) => this.bindKeyup(e)); + } + + addHandleListener( + name: string, + handle: TypingHandle, + triggerName?: string, + triggerParams?: + | any + | ((engine: EngineInterface, event: KeyboardEvent) => any), + ): void { + this.handleListeners.push({ + name, + handle: new handle(this.engine), + triggerName, + triggerParams, + }); + } + + getHandleListener( + name: string, + type: 'keydown' | 'keyup', + ): TypingHandleInterface | undefined { + return this.handleListeners.find( + (listener) => + listener.name === name && listener.handle.type === type, + )?.handle; + } + + removeHandleListener(name: string, type: 'keydown' | 'keyup'): void { + for (let i = 0; i < this.handleListeners.length; i++) { + if ( + this.handleListeners[i].name === name && + this.handleListeners[i].handle.type === type + ) { + this.handleListeners[i].handle.destroy(); + this.handleListeners.splice(i, 1); + break; + } + } + } + + bindKeydown(event: KeyboardEvent) { + const { readonly, card } = this.engine; + //只读状态 + if (readonly) { + //全选禁止默认事件触发 + if (isHotkey('mod+a', event)) event.preventDefault(); + return; + } + //跳过卡片 + if (event.target && card.find($(event.target))) return; + this.trigger('keydown', event); + } + + bindKeyup(event: KeyboardEvent) { + const { readonly, card } = this.engine; + //只读状态 + if (readonly) return; + //跳过卡片 + if (event.target && card.find($(event.target))) return; + this.trigger('keyup', event); + } + + trigger(type: 'keydown' | 'keyup', event: KeyboardEvent) { + //循环事件 + const result = this.handleListeners + .filter(({ handle }) => handle.type === type) + .some((listener) => { + const { name, handle, triggerName, triggerParams } = listener; + if (name === 'default' || !!!handle.hotkey) return false; + if ( + typeof handle.hotkey === 'function' + ? handle.hotkey(event) + : isHotkey(handle.hotkey, event) + ) { + let params = [event]; + if (typeof triggerParams === 'function') + params = triggerParams(this.engine, event); + if ( + !triggerName || + this.engine.trigger(triggerName, ...params) !== false + ) { + handle.trigger(event); + } + return true; + } + return false; + }); + //触发默认事件 + if (result === false) { + this.getHandleListener('default', type)?.trigger(event); + } + } +} +export default Typing; diff --git a/packages/engine/src/typing/keydown/all.ts b/packages/engine/src/typing/keydown/all.ts new file mode 100644 index 00000000..be6d702d --- /dev/null +++ b/packages/engine/src/typing/keydown/all.ts @@ -0,0 +1,6 @@ +import Default from './default'; + +class All extends Default { + hotkey = 'mod+a'; +} +export default All; diff --git a/packages/engine/src/typing/keydown/at.ts b/packages/engine/src/typing/keydown/at.ts new file mode 100644 index 00000000..f61ba1d4 --- /dev/null +++ b/packages/engine/src/typing/keydown/at.ts @@ -0,0 +1,8 @@ +import Default from './default'; + +class At extends Default { + hotkey = (event: KeyboardEvent) => + event.key === '@' || + (event.shiftKey && event.keyCode === 229 && event.code === 'Digit2'); +} +export default At; diff --git a/packages/engine/src/typing/keydown/backspace.ts b/packages/engine/src/typing/keydown/backspace.ts new file mode 100644 index 00000000..9c27cefb --- /dev/null +++ b/packages/engine/src/typing/keydown/backspace.ts @@ -0,0 +1,133 @@ +import { + EngineInterface, + EventListener, + NodeInterface, + TypingHandleInterface, +} from '../../types'; +import { $ } from '../../node'; + +class Backspace implements TypingHandleInterface { + type: 'keydown' | 'keyup' = 'keydown'; + hotkey: Array | string = 'backspace'; + private engine: EngineInterface; + listeners: Array = []; + + constructor(engine: EngineInterface) { + this.engine = engine; + } + + on(listener: EventListener) { + this.listeners.push(listener); + } + + off(listener: EventListener) { + for (let i = 0; i < this.listeners.length; i++) { + if (this.listeners[i] === listener) { + this.listeners.splice(i, 1); + break; + } + } + } + + trigger(event: KeyboardEvent) { + const { change } = this.engine; + const range = change.range.get(); + change.cacheRangeBeforeCommand(); + // 编辑器没有内容 + if (change.isEmpty()) { + event.preventDefault(); + change.initValue(); + return; + } + // 可编辑卡片多选时清空内容 + const { commonAncestorNode } = range; + const cardComponent = this.engine.card.find(commonAncestorNode, true); + const selectionNodes = cardComponent?.isEditable + ? cardComponent?.getSelectionNodes + ? cardComponent.getSelectionNodes() + : [] + : []; + if (selectionNodes.length > 0) { + selectionNodes.forEach((selectionNode) => { + selectionNode.html('


      '); + }); + change.apply( + range + .cloneRange() + .select(selectionNodes[0], true) + .collapse(true), + ); + return; + } + // 处理 BR + const { startNode, startOffset } = range; + if (startNode.isEditable()) { + const child = startNode[0].childNodes[startOffset - 1]; + const lastNode = $(child); + if (lastNode.name === 'br') { + event.preventDefault(); + lastNode.remove(); + change.apply(range); + return; + } + } + let result: boolean | void = true; + for (let i = 0; i < this.listeners.length; i++) { + const listener = this.listeners[i]; + result = listener(event); + if (result === false) break; + } + if (result === false) return; + // 范围为展开状态 + if (!range.collapsed) { + event.preventDefault(); + const prev = startNode.prev(); + if (prev?.name === 'br') { + prev.remove(); + } + change.delete(range); + change.apply(range); + return; + } else { + let brNode: NodeInterface | undefined = undefined; + if (this.engine.node.isBlock(startNode)) { + const child = startNode[0].childNodes[startOffset - 1]; + brNode = $(child); + } else if (startNode.name === 'br') { + brNode = startNode; + } + if (brNode?.name === 'br') { + const prev = brNode.prev(); + const next = brNode.next(); + const n = next?.next(); + const p = prev?.prev(); + // abc

      + if ( + prev?.name !== 'br' && + next?.name === 'br' && + n?.name !== 'br' + ) { + event.preventDefault(); + brNode.remove(); + next.remove(); + change.apply(range.shrinkToTextNode()); + } else if ( + next?.name !== 'br' && + prev?.name === 'br' && + p?.name !== 'br' + ) { + event.preventDefault(); + brNode.remove(); + prev.remove(); + change.apply(range.shrinkToTextNode()); + } + } + } + } + + destroy() { + this.listeners = []; + } +} + +export default Backspace; diff --git a/packages/engine/src/typing/keydown/default.ts b/packages/engine/src/typing/keydown/default.ts new file mode 100644 index 00000000..39bd3e75 --- /dev/null +++ b/packages/engine/src/typing/keydown/default.ts @@ -0,0 +1,43 @@ +import { + EngineInterface, + EventListener, + TypingHandleInterface, +} from '../../types'; + +class DefaultKeydown implements TypingHandleInterface { + type: 'keydown' | 'keyup' = 'keydown'; + hotkey: string | string[] | ((event: KeyboardEvent) => boolean) = ''; + listeners: Array = []; + private engine: EngineInterface; + + constructor(engine: EngineInterface) { + this.engine = engine; + } + + on(listener: EventListener) { + this.listeners.push(listener); + } + + off(listener: EventListener) { + for (let i = 0; i < this.listeners.length; i++) { + if (this.listeners[i] === listener) { + this.listeners.splice(i, 1); + break; + } + } + } + + trigger(event: KeyboardEvent) { + for (let i = 0; i < this.listeners.length; i++) { + const listener = this.listeners[i]; + const result = listener(event); + if (result === false) break; + } + } + + destroy() { + this.listeners = []; + } +} + +export default DefaultKeydown; diff --git a/packages/engine/src/typing/keydown/delete.ts b/packages/engine/src/typing/keydown/delete.ts new file mode 100644 index 00000000..c34f1c0b --- /dev/null +++ b/packages/engine/src/typing/keydown/delete.ts @@ -0,0 +1,162 @@ +import { + EngineInterface, + EventListener, + RangeInterface, + TypingHandleInterface, +} from '../../types'; +import { getWindow } from '../../utils'; +import { CARD_KEY } from '../../constants'; +import Range from '../../range'; +import { $ } from '../../node'; + +class Delete implements TypingHandleInterface { + private engine: EngineInterface; + type: 'keydown' | 'keyup' = 'keydown'; + hotkey: string | string[] | ((event: KeyboardEvent) => boolean) = 'delete'; + listeners: Array = []; + + constructor(engine: EngineInterface) { + this.engine = engine; + } + + on(listener: EventListener) { + this.listeners.push(listener); + } + + off(listener: EventListener) { + for (let i = 0; i < this.listeners.length; i++) { + if (this.listeners[i] === listener) { + this.listeners.splice(i, 1); + break; + } + } + } + + getNext(node: Node): Node | null { + return $(node).isEditable() + ? null + : node.nextSibling + ? node.nextSibling + : node.parentNode === null + ? null + : this.getNext(node.parentNode); + } + + getRange(node: Node, hasNext: boolean = false): RangeInterface | null { + if ($(node).isEditable()) return null; + if (!hasNext) { + const next = this.getNext(node); + if (!next) return null; + node = next; + } + while (node) { + const nodeDom = $(node); + if (nodeDom.attributes(CARD_KEY)) { + if (!node.ownerDocument) return null; + const range = Range.create(this.engine, node.ownerDocument); + range.setStartAfter(node); + range.collapse(true); + return range; + } + if (this.engine.node.isBlock(nodeDom)) { + if (!node.ownerDocument) return null; + const range = Range.create(this.engine, node.ownerDocument); + range.select(nodeDom, true).collapse(true); + return range; + } + if (nodeDom.name === 'br') { + if (node.parentNode?.childNodes.length === 1) return null; + if (!node.ownerDocument) return null; + const range = Range.create(this.engine, node.ownerDocument); + range.setStartAfter(node); + range.collapse(true); + return range; + } + if (node.nodeType === getWindow().Node.TEXT_NODE) { + if (node['data'].length === 0) return this.getRange(node); + if (!node.ownerDocument) return null; + const range = Range.create(this.engine, node.ownerDocument); + range.setStart(node, 1); + range.collapse(true); + return range; + } + if (node.childNodes.length === 0) return this.getRange(node); + node = node.childNodes[0]; + } + return null; + } + + trigger(event: KeyboardEvent) { + const { change } = this.engine; + change.cacheRangeBeforeCommand(); + const range = change.range.get(); + if (!range.collapsed) { + event.preventDefault(); + change.delete(); + return; + } + const card = this.engine.card.find(range.startNode); + let hasNext = false; + let nextNode: Node; + if (card) { + if (card.isLeftCursor(range.startNode)) { + event.preventDefault(); + this.engine.card.select(card); + change.delete(); + return; + } + if (!card.isRightCursor(range.startNode)) return; + nextNode = card.root[0]; + } else if (range.endContainer.nodeType === getWindow().Node.TEXT_NODE) { + if (range.endContainer['data'].length > range.endOffset) { + event.preventDefault(); + const cloneRange = range.cloneRange(); + cloneRange.setEnd(range.endContainer, range.endOffset + 1); + change.range.select(cloneRange); + change.delete(); + change.range.select(change.range.get().shrinkToTextNode()); + return; + } + nextNode = range.endContainer; + } else { + if (range.endContainer.nodeType !== getWindow().Node.ELEMENT_NODE) + return; + if (range.endContainer.childNodes.length === 0) { + nextNode = range.endContainer; + } else if (range.endOffset === 0) { + if ( + range.endContainer.childNodes.length !== 1 || + range.endContainer.firstChild?.nodeName !== 'BR' + ) { + hasNext = true; + } + nextNode = range.endContainer.childNodes[range.endOffset]; + } else { + nextNode = range.endContainer.childNodes[range.endOffset - 1]; + } + } + const nodeRange = this.getRange(nextNode, hasNext); + if (nodeRange) { + event.preventDefault(); + let { startOffset } = range; + if ( + startOffset === 1 && + range.startContainer.childNodes.length === 1 && + range.startContainer.childNodes[0].nodeName === 'BR' + ) + startOffset = 0; + nodeRange.setStart(range.startContainer, startOffset); + change.range.select(nodeRange); + change.delete(); + } + for (let i = 0; i < this.listeners.length; i++) { + const listener = this.listeners[i]; + const result = listener(event); + if (result === false) break; + } + } + destroy(): void { + this.listeners = []; + } +} +export default Delete; diff --git a/packages/engine/src/typing/keydown/down.ts b/packages/engine/src/typing/keydown/down.ts new file mode 100644 index 00000000..1bfa4d7c --- /dev/null +++ b/packages/engine/src/typing/keydown/down.ts @@ -0,0 +1,8 @@ +import isHotkey from 'is-hotkey'; +import Default from './default'; + +class Down extends Default { + hotkey = (event: KeyboardEvent) => + isHotkey('down', event) || isHotkey('ctrl+n', event); +} +export default Down; diff --git a/packages/engine/src/typing/keydown/enter.ts b/packages/engine/src/typing/keydown/enter.ts new file mode 100644 index 00000000..751b2967 --- /dev/null +++ b/packages/engine/src/typing/keydown/enter.ts @@ -0,0 +1,60 @@ +import { + EngineInterface, + EventListener, + TypingHandleInterface, +} from '../../types'; + +class Enter implements TypingHandleInterface { + type: 'keydown' | 'keyup' = 'keydown'; + hotkey: Array | string = 'enter'; + listeners: Array = []; + private engine: EngineInterface; + + constructor(engine: EngineInterface) { + this.engine = engine; + } + + on(listener: EventListener) { + this.listeners.push(listener); + } + + off(listener: EventListener) { + for (let i = 0; i < this.listeners.length; i++) { + if (this.listeners[i] === listener) { + this.listeners.splice(i, 1); + break; + } + } + } + + trigger(event: KeyboardEvent) { + const { change } = this.engine; + change.cacheRangeBeforeCommand(); + const range = change.range.get(); + // 选区选中最后的节点 + const block = this.engine.block.closest(range.endNode); + // 无段落 + if (block.isEditable()) { + this.engine.block.wrap('

      '); + } + for (let i = 0; i < this.listeners.length; i++) { + const listener = this.listeners[i]; + const result = listener(event); + if (result === false) break; + } + if (this.engine.scrollNode) + this.engine.change.range + .get() + .scrollIntoViewIfNeeded( + this.engine.container, + this.engine.scrollNode, + ); + this.engine.trigger('select'); + } + + destroy() { + this.listeners = []; + } +} + +export default Enter; diff --git a/packages/engine/src/typing/keydown/index.ts b/packages/engine/src/typing/keydown/index.ts new file mode 100644 index 00000000..b908538c --- /dev/null +++ b/packages/engine/src/typing/keydown/index.ts @@ -0,0 +1,102 @@ +import { EngineInterface, TypingHandle } from '../../types'; +import Backspace from './backspace'; +import Default from './default'; +import Delete from './delete'; +import Enter from './enter'; +import Tab from './tab'; +import ShiftTab from './shift-tab'; +import ShiftEnter from './shift-enter'; +import At from './at'; +import Space from './space'; +import Slash from './slash'; +import All from './all'; +import Left from './left'; +import Right from './right'; +import Up from './up'; +import Down from './down'; + +const defaultHandles: Array<{ + name: string; + triggerName?: string; + handle: TypingHandle; + triggerParams?: + | any + | ((engine: EngineInterface, event: KeyboardEvent) => any); +}> = [ + { + name: 'default', + handle: Default, + }, + { + name: 'enter', + handle: Enter, + triggerName: 'keydown:enter', + }, + { + name: 'backspace', + handle: Backspace, + triggerName: 'keydown:backspace', + }, + { + name: 'delete', + handle: Delete, + triggerName: 'keydown:backspace', + }, + { + name: 'tab', + handle: Tab, + triggerName: 'keydown:tab', + }, + { + name: 'shift-tab', + handle: ShiftTab, + triggerName: 'keydown:shift-tab', + }, + { + name: 'shift-enter', + handle: ShiftEnter, + triggerName: 'keydown:shift-enter', + }, + { + name: 'at', + handle: At, + triggerName: 'keydown:at', + }, + { + name: 'space', + handle: Space, + triggerName: 'keydown:space', + }, + { + name: 'slash', + handle: Slash, + triggerName: 'keydown:slash', + }, + { + name: 'all', + handle: All, + triggerName: 'keydown:all', + }, + { + name: 'left', + handle: Left, + triggerName: 'keydown:left', + }, + { + name: 'right', + handle: Right, + triggerName: 'keydown:right', + }, + { + name: 'up', + handle: Up, + triggerName: 'keydown:up', + }, + { + name: 'down', + handle: Down, + triggerName: 'keydown:down', + }, +]; + +export default defaultHandles; diff --git a/packages/engine/src/typing/keydown/left.ts b/packages/engine/src/typing/keydown/left.ts new file mode 100644 index 00000000..04a28d15 --- /dev/null +++ b/packages/engine/src/typing/keydown/left.ts @@ -0,0 +1,39 @@ +import isHotkey from 'is-hotkey'; +import { EventListener, TypingHandleInterface } from '../../types'; + +class Left implements TypingHandleInterface { + type: 'keydown' | 'keyup' = 'keydown'; + hotkey = (event: KeyboardEvent) => + isHotkey('left', event) || + isHotkey('shift+left', event) || + isHotkey('ctrl+a', event) || + isHotkey('ctrl+b', event); + + listeners: Array = []; + + on(listener: EventListener) { + this.listeners.push(listener); + } + + off(listener: EventListener) { + for (let i = 0; i < this.listeners.length; i++) { + if (this.listeners[i] === listener) { + this.listeners.splice(i, 1); + break; + } + } + } + + trigger(event: KeyboardEvent) { + for (let i = 0; i < this.listeners.length; i++) { + const listener = this.listeners[i]; + const result = listener(event); + if (result === false) break; + } + } + + destroy() { + this.listeners = []; + } +} +export default Left; diff --git a/packages/engine/src/typing/keydown/right.ts b/packages/engine/src/typing/keydown/right.ts new file mode 100644 index 00000000..308dff85 --- /dev/null +++ b/packages/engine/src/typing/keydown/right.ts @@ -0,0 +1,48 @@ +import isHotkey from 'is-hotkey'; +import { + EngineInterface, + EventListener, + TypingHandleInterface, +} from '../../types'; + +class Right implements TypingHandleInterface { + type: 'keydown' | 'keyup' = 'keydown'; + hotkey = (event: KeyboardEvent) => + isHotkey('right', event) || + isHotkey('shift+right', event) || + isHotkey('ctrl+e', event) || + isHotkey('ctrl+f', event); + + private engine: EngineInterface; + listeners: Array = []; + + constructor(engine: EngineInterface) { + this.engine = engine; + } + + on(listener: EventListener) { + this.listeners.push(listener); + } + + off(listener: EventListener) { + for (let i = 0; i < this.listeners.length; i++) { + if (this.listeners[i] === listener) { + this.listeners.splice(i, 1); + break; + } + } + } + + trigger(event: KeyboardEvent) { + for (let i = 0; i < this.listeners.length; i++) { + const listener = this.listeners[i]; + const result = listener(event); + if (result === false) break; + } + } + + destroy() { + this.listeners = []; + } +} +export default Right; diff --git a/packages/engine/src/typing/keydown/shift-enter.ts b/packages/engine/src/typing/keydown/shift-enter.ts new file mode 100644 index 00000000..2db27f32 --- /dev/null +++ b/packages/engine/src/typing/keydown/shift-enter.ts @@ -0,0 +1,66 @@ +import { + EngineInterface, + EventListener, + TypingHandleInterface, +} from '../../types'; +import { $ } from '../../node'; + +class ShitEnter implements TypingHandleInterface { + private engine: EngineInterface; + type: 'keydown' | 'keyup' = 'keydown'; + hotkey: string | string[] | ((event: KeyboardEvent) => boolean) = + 'shift+enter'; + listeners: Array = []; + + constructor(engine: EngineInterface) { + this.engine = engine; + } + + on(listener: EventListener) { + this.listeners.push(listener); + } + + off(listener: EventListener) { + for (let i = 0; i < this.listeners.length; i++) { + if (this.listeners[i] === listener) { + this.listeners.splice(i, 1); + break; + } + } + } + + trigger(event: KeyboardEvent): void { + const { change, inline, block } = this.engine; + event.preventDefault(); + change.cacheRangeBeforeCommand(); + const range = change.range.get(); + const br = $('
      '); + inline.insert(br, range); + // Chrome 问题:

      foo

      时候需要再插入一个 br,否则没有换行效果 + if (block.isLastOffset(range, 'end')) { + if (!br.next() || br.next()?.name !== 'br') { + const cloneBr = br.clone(); + br.after(cloneBr); + range.select(cloneBr).collapse(false); + } + } + for (let i = 0; i < this.listeners.length; i++) { + const listener = this.listeners[i]; + const result = listener(event); + if (result === false) break; + } + change.apply(range); + if (this.engine.scrollNode) + this.engine.change.range + .get() + .scrollIntoViewIfNeeded( + this.engine.container, + this.engine.scrollNode, + ); + } + destroy(): void { + this.listeners = []; + } +} + +export default ShitEnter; diff --git a/packages/engine/src/typing/keydown/shift-tab.ts b/packages/engine/src/typing/keydown/shift-tab.ts new file mode 100644 index 00000000..372632fa --- /dev/null +++ b/packages/engine/src/typing/keydown/shift-tab.ts @@ -0,0 +1,6 @@ +import Default from './default'; + +class ShiftTab extends Default { + hotkey = 'shift+tab'; +} +export default ShiftTab; diff --git a/packages/engine/src/typing/keydown/slash.ts b/packages/engine/src/typing/keydown/slash.ts new file mode 100644 index 00000000..70ea3428 --- /dev/null +++ b/packages/engine/src/typing/keydown/slash.ts @@ -0,0 +1,10 @@ +import isHotkey from 'is-hotkey'; +import Default from './default'; + +class Slash extends Default { + hotkey = (event: KeyboardEvent) => + event.key === '/' || + isHotkey('/', event) || + (event.keyCode === 229 && event.code === 'Slash'); +} +export default Slash; diff --git a/packages/engine/src/typing/keydown/space.ts b/packages/engine/src/typing/keydown/space.ts new file mode 100644 index 00000000..7a71c002 --- /dev/null +++ b/packages/engine/src/typing/keydown/space.ts @@ -0,0 +1,8 @@ +import Default from './default'; + +class Space extends Default { + hotkey = (event: KeyboardEvent) => { + return event.key === ' '; + }; +} +export default Space; diff --git a/packages/engine/src/typing/keydown/tab.ts b/packages/engine/src/typing/keydown/tab.ts new file mode 100644 index 00000000..18576370 --- /dev/null +++ b/packages/engine/src/typing/keydown/tab.ts @@ -0,0 +1,36 @@ +import { EngineInterface, TypingHandleInterface } from '../../types'; + +class Tab implements TypingHandleInterface { + private engine: EngineInterface; + type: 'keydown' | 'keyup' = 'keydown'; + hotkey: string | string[] | ((event: KeyboardEvent) => boolean) = 'tab'; + listeners: Array = []; + + constructor(engine: EngineInterface) { + this.engine = engine; + } + + on(listener: EventListener) { + this.listeners.push(listener); + } + + off(listener: EventListener) { + for (let i = 0; i < this.listeners.length; i++) { + if (this.listeners[i] === listener) { + this.listeners.splice(i, 1); + break; + } + } + } + + trigger(event: KeyboardEvent): void { + const { node } = this.engine; + event.preventDefault(); + node.insertText(' '); + } + + destroy(): void { + this.listeners = []; + } +} +export default Tab; diff --git a/packages/engine/src/typing/keydown/up.ts b/packages/engine/src/typing/keydown/up.ts new file mode 100644 index 00000000..7bf89013 --- /dev/null +++ b/packages/engine/src/typing/keydown/up.ts @@ -0,0 +1,8 @@ +import isHotkey from 'is-hotkey'; +import Default from './default'; + +class Up extends Default { + hotkey = (event: KeyboardEvent) => + isHotkey('up', event) || isHotkey('ctrl+p', event); +} +export default Up; diff --git a/packages/engine/src/typing/keyup/backspace.ts b/packages/engine/src/typing/keyup/backspace.ts new file mode 100644 index 00000000..3c815b1d --- /dev/null +++ b/packages/engine/src/typing/keyup/backspace.ts @@ -0,0 +1,50 @@ +import { + EngineInterface, + EventListener, + TypingHandleInterface, +} from '../../types'; + +class Backspace implements TypingHandleInterface { + type: 'keydown' | 'keyup' = 'keyup'; + hotkey: Array | string = 'backspace'; + private engine: EngineInterface; + listeners: Array = []; + constructor(engine: EngineInterface) { + this.engine = engine; + } + + on(listener: EventListener) { + this.listeners.push(listener); + } + + off(listener: EventListener) { + for (let i = 0; i < this.listeners.length; i++) { + if (this.listeners[i] === listener) { + this.listeners.splice(i, 1); + break; + } + } + } + + trigger(event: KeyboardEvent) { + const { change } = this.engine; + // 编辑器没有内容 + if (change.isEmpty()) { + event.preventDefault(); + return; + } + + let result: boolean | void = true; + for (let i = 0; i < this.listeners.length; i++) { + const listener = this.listeners[i]; + result = listener(event); + if (result === false) break; + } + } + + destroy() { + this.listeners = []; + } +} + +export default Backspace; diff --git a/packages/engine/src/typing/keyup/default.ts b/packages/engine/src/typing/keyup/default.ts new file mode 100644 index 00000000..2a96ec3f --- /dev/null +++ b/packages/engine/src/typing/keyup/default.ts @@ -0,0 +1,7 @@ +import DefaultKeydown from '../keydown/default'; + +class DefaultKeyup extends DefaultKeydown { + type: 'keydown' | 'keyup' = 'keyup'; +} + +export default DefaultKeyup; diff --git a/packages/engine/src/typing/keyup/enter.ts b/packages/engine/src/typing/keyup/enter.ts new file mode 100644 index 00000000..ec33eda0 --- /dev/null +++ b/packages/engine/src/typing/keyup/enter.ts @@ -0,0 +1,6 @@ +import Default from './default'; + +class Enter extends Default { + hotkey = 'enter'; +} +export default Enter; diff --git a/packages/engine/src/typing/keyup/index.ts b/packages/engine/src/typing/keyup/index.ts new file mode 100644 index 00000000..456cdc67 --- /dev/null +++ b/packages/engine/src/typing/keyup/index.ts @@ -0,0 +1,39 @@ +import { TypingHandle } from '../../types'; +import Enter from './enter'; +import Default from './default'; +import Backspace from './backspace'; +import Tab from './tab'; +import Space from './space'; + +const defaultHandles: Array<{ + name: string; + triggerName?: string; + handle: TypingHandle; +}> = [ + { + name: 'default', + handle: Default, + }, + { + name: 'enter', + handle: Enter, + triggerName: 'keyup:enter', + }, + { + name: 'backspace', + handle: Backspace, + triggerName: 'keyup:backspace', + }, + { + name: 'tab', + handle: Tab, + triggerName: 'keyup:tab', + }, + { + name: 'space', + handle: Space, + triggerName: 'keyup:space', + }, +]; + +export default defaultHandles; diff --git a/packages/engine/src/typing/keyup/space.ts b/packages/engine/src/typing/keyup/space.ts new file mode 100644 index 00000000..7979f27e --- /dev/null +++ b/packages/engine/src/typing/keyup/space.ts @@ -0,0 +1,6 @@ +import Default from './default'; + +class Space extends Default { + hotkey = (event: KeyboardEvent) => event.key === ' '; +} +export default Space; diff --git a/packages/engine/src/typing/keyup/tab.ts b/packages/engine/src/typing/keyup/tab.ts new file mode 100644 index 00000000..4880cbca --- /dev/null +++ b/packages/engine/src/typing/keyup/tab.ts @@ -0,0 +1,6 @@ +import Default from './default'; + +class Tab extends Default { + hotkey = 'tab'; +} +export default Tab; diff --git a/packages/engine/src/utils/index.ts b/packages/engine/src/utils/index.ts new file mode 100644 index 00000000..6c5af09b --- /dev/null +++ b/packages/engine/src/utils/index.ts @@ -0,0 +1,17 @@ +import { EditorInterface, EngineInterface } from '../types'; +import TinyCanvas from './tiny-canvas'; +export * from './string'; +export * from './user-agent'; +export * from './list'; +export * from './node'; +export { TinyCanvas }; + +/** + * 是否是引擎 + * @param editor 编辑器 + */ +export const isEngine = ( + editor: EditorInterface, +): editor is EngineInterface => { + return editor.kind === 'engine'; +}; diff --git a/packages/engine/src/utils/list.ts b/packages/engine/src/utils/list.ts new file mode 100644 index 00000000..84aae1e3 --- /dev/null +++ b/packages/engine/src/utils/list.ts @@ -0,0 +1,33 @@ +/** + * 获取列表样式 + * @param type 类型 + * @param code + */ +export const getListStyle = ( + type?: + | 'disc' + | 'circle' + | 'square' + | 'lower-alpha' + | 'lower-roman' + | 'decimal' + | string, + code: string | number = 0, +) => { + if (!(code = +code)) return '•'; + switch (type?.toLowerCase()) { + case 'disc': + return '•'; + case 'circle': + return '◦'; + case 'square': + return '◼'; + case 'lower-alpha': + return String.fromCharCode('a'.charCodeAt(0) + code); + case 'lower-roman': + return String.fromCharCode(8559 + code); + case 'decimal': + default: + return code; + } +}; diff --git a/packages/engine/src/utils/node.ts b/packages/engine/src/utils/node.ts new file mode 100644 index 00000000..662111c9 --- /dev/null +++ b/packages/engine/src/utils/node.ts @@ -0,0 +1,74 @@ +import { isNodeEntry } from '../node/utils'; +import { DATA_ELEMENT, ROOT } from '../constants/root'; +import { NodeInterface } from '../types/node'; + +export const getDocument = (node?: Node): Document => { + if ( + typeof document === 'undefined' && + typeof global['__amWindow'] === 'undefined' + ) + throw 'document is not defined,If you are using ssr, you can assign a value to the `__amWindow` global variable.'; + + return node + ? node.ownerDocument || node['document'] || node + : typeof document === 'undefined' + ? global['__amWindow'].document + : document; +}; + +export const getWindow = (node?: Node): Window & typeof globalThis => { + if ( + typeof window === 'undefined' && + typeof global['__amWindow'] === 'undefined' + ) + throw 'window is not defined,If you are using ssr, you can assign a value to the `__amWindow` global variable.'; + const win = typeof window === 'undefined' ? global['__amWindow'] : window; + if (!node) return win; + const document = getDocument(node); + return document['parentWindow'] || document.defaultView || win; +}; + +/** + * 移除空的文本节点,并连接相邻的文本节点 + * @param node 节点 + */ +export const combinText = (node: NodeInterface | Node) => { + if (isNodeEntry(node)) node = node[0]; + node.normalize(); +}; + +/** + * 获取一个 dom 元素内所有的 textnode 类型的元素 + * @param {Node} node - dom节点 + * @param {Function} filter - 过滤器 + * @return {Array} 获取的文本节点 + */ +export const getTextNodes = ( + node: Node, + filter?: (node: Node) => boolean, +): Array => { + let textNodes: Array = []; + if (filter && !filter(node)) { + return textNodes; + } + + const nodes = node.childNodes; + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const nodeType = node.nodeType; + if (nodeType === 3) { + textNodes.push(node); + } else if (nodeType === 1 || nodeType === 9 || nodeType === 11) { + textNodes = textNodes.concat(getTextNodes(node, filter)); + } + } + return textNodes; +}; + +export const getParentInRoot = (node: Node) => { + return node.nodeType === getDocument().ELEMENT_NODE && + (node as Element).getAttribute(DATA_ELEMENT) === ROOT + ? undefined + : node.parentNode || undefined; +}; diff --git a/packages/engine/src/utils/string.ts b/packages/engine/src/utils/string.ts new file mode 100644 index 00000000..aecd2a96 --- /dev/null +++ b/packages/engine/src/utils/string.ts @@ -0,0 +1,337 @@ +import { ANCHOR, CURSOR, FOCUS } from '../constants/selection'; +import { + CARD_TYPE_KEY, + CARD_VALUE_KEY, + READY_CARD_KEY, +} from '../constants/card'; +import { DATA_ELEMENT } from '../constants/root'; + +import { getWindow } from './node'; +import { isMacos } from './user-agent'; + +/** + * 随机字符串 + * @param length 长度 + */ +export const random = (length: number = 5) => { + if (length < 5) length = 5; + const str = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let word = ''; + for (let index = 0; index < length; index++) { + word += str.charAt(Math.floor(Math.random() * str.length)); + } + return word; +}; + +/** + * 驼峰命名转换枚举 + */ +export enum CamelCaseType { + UPPER = 'upper', + LOWER = 'lower', +} + +/** + * 转换为驼峰命名法 + * @param {string} value 需要转换的字符串 + * @param {upper,lower} type 转换类型,upper 大驼峰命名法,lower,小驼峰命名法(默认) + */ +export const toCamelCase = ( + value: string, + type: CamelCaseType = CamelCaseType.LOWER, +): string => { + return value + .split('-') + .map((str, index) => { + if (type === 'upper' || (type === 'lower' && index > 0)) { + return str.charAt(0).toUpperCase() + str.substr(1); + } + if (type === 'lower' && index === 0) { + return str.charAt(0).toLowerCase() + str.substr(1); + } + return str; + }) + .join(''); +}; + +/** + * RGB 颜色转换为16进制颜色代码 + * @param {string} rgb + */ +export const toHex = (rgb: string): string => { + const hex = (num: string) => { + const char = parseInt(num, 10).toString(16).toUpperCase(); + return char.length > 1 ? char : '0' + char; + }; + + const reg = /rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/gi; + return rgb.replace(reg, ($0, $1, $2, $3) => { + return '#' + hex($1) + hex($2) + hex($3); + }); +}; + +/** + * 将节点属性转换为 map 数据类型 + * @param {string} value + */ +export const getAttrMap = (value: string): { [k: string]: string } => { + const map: { [k: string]: string } = {}; + const reg = + /\s+(?:([\w\-:]+)|(?:([\w\-:]+)=([^\s"'<>]+))|(?:([\w\-:"]+)="([^"]*)")|(?:([\w\-:"]+)='([^']*)'))(?=(?:\s|\/|>)+)/g; + let match; + + while ((match = reg.exec(value))) { + const key: string = ( + match[1] || + match[2] || + match[4] || + match[6] + ).toLowerCase(); + const val: string = + (match[2] ? match[3] : match[4] ? match[5] : match[7]) || ''; + map[key] = val; + } + + return map; +}; + +/** + * 将 style 样式转换为 map 数据类型 + * @param {string} style + */ +export const getStyleMap = (style: string): { [k: string]: string } => { + style = style.replace(/"/g, '"'); + const map: { [k: string]: string } = {}; + const reg = /\s*([\w\-]+)\s*:([^;]*)(;|$)/g; + let match; + + while ((match = reg.exec(style))) { + const key = match[1].toLowerCase().trim(); + const val = toHex(match[2]).trim(); + map[key] = val; + } + + return map; +}; + +/** + * 使用window内置函数getComputedStyle获取节点style + * @param {Node} node + * @param {string} attrName + */ +export const getComputedStyle = (element: Element, attrName: string) => { + const win = getWindow(element); + const camelKey = toCamelCase(attrName); + const style = win?.getComputedStyle(element, null); + return style ? style[camelKey] : ''; +}; + +/** + * 字符串编码 + * @param value 需要编码的字符串 + */ +export const escape = (value: string) => { + return (value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +}; + +/** + * 字符串解码 + * @param value 需要解码的字符串 + */ +export const unescape = (value: string) => { + return (value || '') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/&/g, '&'); +}; + +/** + * 字符 `.` 编码 + * @param value 需要编码的字符串 + */ +export const escapeDots = (value: string) => { + return value.replace(/\./g, '˙'); +}; + +/** + * 字符 `.` 解码 + * @param value 需要解码的字符串 + */ +export const unescapeDots = (value: string) => { + return value.replace(/˙/g, '.'); +}; + +/** + * 给值增加单位 + * @param value 值 + * @param unit 单位 + */ +export const addUnit = (value: string | number, unit: string = 'px') => { + return value && /^-?\d+(?:\.\d+)?$/.test(value.toString()) + ? value + unit + : value; +}; + +/** + * 移除值的单位 + * @param value 值 + */ +export const removeUnit = (value: string) => { + let match; + return value && (match = /^(-?\d+)/.exec(value)) + ? parseInt(match[1], 10) + : 0; +}; + +/** + * Card组件值编码 + * @param value 需要编码的字符串 + */ +export const encodeCardValue = (value: any): string => { + try { + value = encodeURIComponent(JSON.stringify(value || '')); + } catch (e) { + value = ''; + } + + return 'data:'.concat(value); +}; + +/** + * Card组件值解码 + * @param value 需要解码的字符串 + */ +export const decodeCardValue = (value: string): any => { + try { + value = value.substr(5); + return JSON.parse(decodeURIComponent(value)); + } catch (e) { + return {}; + } +}; + +/** + * 转换光标以及Card组件标签为html + * @param value 需要转换的字符串 + */ +export const transformCustomTags = (value: string) => { + return value + .replace( + //gi, + ''), + ) + .replace( + //gi, + ''), + ) + .replace( + //gi, + ''), + ) + .replace(/(]+>).*?<\/card>/gi, (_, tag) => { + //获取Card属性 + const attributes = getAttrMap(tag); + const { type, name, value } = attributes; + const isInline = type === 'inline'; + const tagName = isInline ? 'span' : 'div'; + const list = ['<'.concat(tagName)]; + list.push(' '.concat(CARD_TYPE_KEY, '="').concat(type || '', '"')); + list.push(' '.concat(READY_CARD_KEY, '="').concat(name || '', '"')); + Object.keys(attributes).forEach((attrsName) => { + if ( + attrsName.indexOf('data-') === 0 && + attrsName.indexOf('data-card') !== 0 + ) { + list.push( + ' ' + .concat(attrsName, '="') + .concat(attributes[attrsName] || '', '"'), + ); + } + }); + if (value !== undefined) { + list.push(' '.concat(CARD_VALUE_KEY, '="').concat(value, '"')); + } + + list.push('>')); + return list.join(''); + }); +}; + +/** + * 验证是否是合法的url地址 + * @param url URL地址 + */ +export const validUrl = (url: string) => { + if (typeof url !== 'string') { + return false; + } + + url = url.toLowerCase(); // https://developer.mozilla.org/en-US-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs + + if (url.startsWith('data:text/html')) { + return false; + } + + if (!!!url.match(/^\S*$/)) { + return false; + } + + if ( + !!['http:', 'https:', 'data:', 'ftp:'].some((protocol) => { + return url.startsWith(protocol); + }) + ) { + return true; + } + + if (url.startsWith('./') || url.startsWith('/')) { + return true; + } + + if (url.indexOf(':') < 0) { + return true; + } + return false; +}; + +export const sanitizeUrl = (url: string) => { + return validUrl(url) ? url : ''; +}; +/** + * 格式化编辑器值,移除光标标记标签,以及标签无效属性 + * @param value + * @returns + */ +export const formatEngineValue = (value: string) => { + if (!value) return value; + const newValue = value.replace(/<(anchor|focus|cursor)[^>]*?\/>/gi, ''); + return /^]*?)>
      <\/p>$/i.test(newValue) + ? value.replace(RegExp.$1, '') + : value; +}; + +/** + * 格式化热键 + * @param key 热键 + */ +export const formatHotkey = (key: string) => { + let keys = key.toLowerCase().split('+'); + keys = keys.map((key) => { + if (key === 'mod') { + return isMacos ? '⌘' : 'Ctrl'; + } else if (key === 'opt') { + return isMacos ? 'Option' : 'Alt'; + } else if (key.length > 1) { + return key.substr(0, 1).toUpperCase() + key.substr(1).toLowerCase(); + } + return key.toUpperCase(); + }); + return keys.join('+'); +}; diff --git a/packages/engine/src/utils/tiny-canvas.ts b/packages/engine/src/utils/tiny-canvas.ts new file mode 100644 index 00000000..16bec2fd --- /dev/null +++ b/packages/engine/src/utils/tiny-canvas.ts @@ -0,0 +1,176 @@ +import { DrawOptions, TinyCanvasInterface } from '../types/tiny-canvas'; + +type Options = { + container?: HTMLElement; + limitHeight?: number; + canvasCache?: Array; + canvasCount?: number; +}; + +type CallbackOptions = DOMRect & { + context: CanvasRenderingContext2D | null; +}; + +type HandleOptions = DOMRect & { + callback: (options: CallbackOptions) => void; +}; + +class TinyCanvas implements TinyCanvasInterface { + private options: Options = { + limitHeight: 5000, + canvasCache: [], + canvasCount: 0, + }; + + constructor(options: Options) { + if (!options.container) throw new Error('need a cantainer!'); + this.options = { ...this.options, ...options }; + options.container.style['line-height'] = '0px'; + } + + private removeCanvas() { + const { canvasCache } = this.options; + canvasCache?.forEach((canvas) => { + canvas?.parentElement?.removeChild(canvas); + }); + this.options.canvasCache = []; + } + + private getCanvas(key: number) { + const { canvasCache } = this.options; + key = key > 0 ? key - 1 : key; + return canvasCache ? canvasCache[key] : undefined; + } + + resize(width: number, height: number) { + const { limitHeight, canvasCount, container } = this.options; + let { canvasCache } = this.options; + const index = Math.ceil(height / (limitHeight || 0)); + if (index !== canvasCount) { + this.removeCanvas(); + canvasCache = []; + for (let i = 0; i < index; i++) { + const canvas = document.createElement('canvas'); + canvas.style['vertical-align'] = 'bottom'; + canvas.setAttribute('width', width.toString()); + if (i === index - 1) { + canvas.setAttribute( + 'height', + (height % (limitHeight || 0)).toString(), + ); + } else { + canvas.setAttribute( + 'height', + (limitHeight || 0).toString(), + ); + } + container?.appendChild(canvas); + canvasCache.push(canvas); + } + this.options.canvasCache = canvasCache; + } else { + const canvas = this.getCanvas(index); + canvasCache?.forEach((can) => { + can.setAttribute('width', width.toString()); + }); + if (canvas) { + canvas.setAttribute( + 'height', + (height % (limitHeight || 0)).toString(), + ); + } + } + } + + private handleSingleRect(options: HandleOptions & { index: number }) { + const { x, y, index, width, height, callback } = options; + const { limitHeight } = this.options; + const canvas = this.getCanvas(index); + if (canvas) { + const context = canvas.getContext('2d'); + const rect = new DOMRect( + x, + y - (limitHeight || 0) * (index - 1), + width, + height, + ); + callback({ + ...rect.toJSON(), + context, + }); + } + } + + drawRect(options: DrawOptions) { + const { x, y, width, height, fill, stroke } = options; + const callback = (opts: CallbackOptions) => { + const { context } = opts; + if (!context) return; + context.fillStyle = fill === undefined ? '#FFEC3D' : fill; + context.strokeStyle = stroke === undefined ? '#FFEC3D' : stroke; + context.fillRect(opts.x, opts.y, opts.width, opts.height); + }; + const rect = new DOMRect(x, y, width, height); + this.handleRect({ + ...rect.toJSON(), + callback, + }); + } + + private handleRect(options: HandleOptions) { + const { x, y, width, height, callback } = options; + const { limitHeight } = this.options; + const last = { + x: x + width, + y: y + height, + }; + const dftIndex = Math.ceil(y / (limitHeight || 0)); + const lastIndex = Math.ceil(last.y / (limitHeight || 0)); + const rect = new DOMRect(x, y, width, height); + this.handleSingleRect({ ...rect.toJSON(), index: dftIndex, callback }); + if (dftIndex !== lastIndex) { + this.handleSingleRect({ + ...rect.toJSON(), + index: lastIndex, + callback, + }); + } + } + + getImageData(options: DOMRect) { + const { x, y, width, height } = options; + const { limitHeight } = this.options; + const index = Math.ceil(y / (limitHeight || 0)); + const canvas = this.getCanvas(index); + const context = canvas?.getContext('2d'); + return context?.getImageData(x, y, width, height); + } + + clearRect(options: DOMRect) { + const { x, y, width, height } = options; + const callback = (opts: CallbackOptions) => { + const { context, x, y, width, height } = opts; + context?.clearRect(x, y, width, height); + }; + const rect = new DOMRect(x, y, width, height); + this.handleRect({ + ...rect.toJSON(), + callback, + }); + } + + clear() { + const { canvasCache } = this.options; + canvasCache?.forEach((canvas) => { + const context = canvas.getContext('2d'); + const width = Number(canvas.getAttribute('width')); + const height = Number(canvas.getAttribute('height')); + context?.clearRect(0, 0, width, height); + }); + } + + destroy() { + this.removeCanvas(); + } +} +export default TinyCanvas; diff --git a/packages/engine/src/utils/user-agent.ts b/packages/engine/src/utils/user-agent.ts new file mode 100644 index 00000000..7a01d916 --- /dev/null +++ b/packages/engine/src/utils/user-agent.ts @@ -0,0 +1,48 @@ +import { getWindow } from './node'; + +const userAgent = ( + typeof navigator !== 'undefined' ? navigator : getWindow().navigator +).userAgent.toLowerCase(); +export const isServer = typeof navigator === 'undefined'; +/** + * 是否是 Edge 浏览器 + * Mozilla/5.0 (Windows NT 10.0 Win64 x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/17.17134 + */ +export const isEdge = /edge/i.test(userAgent); +/** + * 是否是 Chrome 浏览器 + * Mozilla/5.0 (Macintosh Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36 + */ + +export const isChrome = !isEdge && /chrome/i.test(userAgent); +/** + * 是否是 Firefox 浏览器 + * Mozilla/5.0 (Macintosh Intel Mac OS X 10.13 rv:62.0) Gecko/20100101 Firefox/62.0 + */ +export const isFirefox = /firefox/i.test(userAgent); +/** + * 是否是 Safari 浏览器 + * Mozilla/5.0 (Macintosh Intel Mac OS X 10_13_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1 Safari/605.1.15 + */ +export const isSafari = !isEdge && !isChrome && /safari/i.test(userAgent); +/** + * 是否是 手机浏览器 + */ +export const isMobile = /mobile/i.test(userAgent); +/** + * 是否是iOS系统 + */ + +export const isIos = /os [\.\_\d]+ like mac os/i.test(userAgent); +/** + * 是否是 安卓系统 + */ +export const isAndroid = /android/i.test(userAgent); +/** + * 是否是 Mac OS X 系统 + */ +export const isMacos = !isIos && /mac os x/i.test(userAgent); +/** + * 是否是 Windows 系统 + */ +export const isWindows = /windows\s*(?:nt)?\s*[\.\_\d]+/i.test(userAgent); diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts new file mode 100644 index 00000000..6e8aff02 --- /dev/null +++ b/packages/engine/src/view.ts @@ -0,0 +1,135 @@ +import NodeModel, { Event, $ } from './node'; +import language from './locales'; +import { + EventInterface, + NodeInterface, + Selector, + EventListener, + NodeModelInterface, +} from './types/node'; +import schemaDefaultData from './constants/schema'; +import Schema from './schema'; +import Conversion from './parser/conversion'; +import { ViewInterface, ContentViewOptions } from './types/view'; +import { CardModelInterface } from './types/card'; +import { PluginModelInterface } from './types/plugin'; +import { SchemaInterface } from './types/schema'; +import { ConversionInterface } from './types/conversion'; +import CardModel from './card'; +import PluginModel from './plugin'; +import { ClipboardInterface } from './types/clipboard'; +import Clipboard from './clipboard'; +import { LanguageInterface } from './types/language'; +import Language from './language'; +import Parser from './parser'; +import { + CommandInterface, + MarkModelInterface, + NodeIdInterface, + RequestInterface, +} from './types'; +import { BlockModelInterface } from './types/block'; +import { InlineModelInterface } from './types/inline'; +import { ListModelInterface } from './types/list'; +import List from './list'; +import Mark from './mark'; +import Inline from './inline'; +import Block from './block'; +import Command from './command'; +import Request from './request'; +import NodeId from './node/id'; + +class View implements ViewInterface { + private options: ContentViewOptions = { + lang: 'zh-CN', + plugins: [], + cards: [], + }; + readonly kind = 'view'; + root: NodeInterface; + language: LanguageInterface; + container: NodeInterface; + card: CardModelInterface; + plugin: PluginModelInterface; + node: NodeModelInterface; + list: ListModelInterface; + mark: MarkModelInterface; + inline: InlineModelInterface; + block: BlockModelInterface; + clipboard: ClipboardInterface; + event: EventInterface; + schema: SchemaInterface; + conversion: ConversionInterface; + command: CommandInterface; + request: RequestInterface; + nodeId: NodeIdInterface; + + constructor(selector: Selector, options?: ContentViewOptions) { + this.options = { ...this.options, ...options }; + this.language = new Language(this.options.lang || 'zh-CN', language); + this.event = new Event(); + this.command = new Command(this); + this.schema = new Schema(); + this.schema.add(schemaDefaultData); + this.conversion = new Conversion(this); + this.card = new CardModel(this); + this.clipboard = new Clipboard(this); + this.plugin = new PluginModel(this); + this.node = new NodeModel(this); + this.nodeId = new NodeId(this); + this.list = new List(this); + this.mark = new Mark(this); + this.inline = new Inline(this); + this.block = new Block(this); + this.clipboard = new Clipboard(this); + this.request = new Request(); + this.container = $(selector); + this.root = $( + this.options.root || this.container.parent() || document.body, + ); + this.container.addClass('am-engine-view'); + this.mark.init(); + this.inline.init(); + this.block.init(); + this.list.init(); + this.card.init(this.options.cards || []); + this.plugin.init(this.options.plugins || [], this.options.config || {}); + } + + on(eventType: string, listener: EventListener, rewrite?: boolean) { + this.event.on(eventType, listener, rewrite); + return this; + } + + off(eventType: string, listener: EventListener) { + this.event.off(eventType, listener); + return this; + } + + trigger(eventType: string, ...args: any) { + return this.event.trigger(eventType, ...args); + } + + render(content: string, trigger: boolean = true) { + const parser = new Parser(content, this); + const value = parser.toValue(this.schema, this.conversion, false, true); + this.container.html(value); + this.card.render(); + if (trigger) this.trigger('render', this.container); + } + + messageSuccess(message: string) { + console.log(`success:${message}`); + } + + messageError(error: string) { + console.log(`error:${error}`); + } + + messageConfirm(message: string): Promise { + console.log(`confirm:${message}`); + return Promise.reject(false); + } +} + +export default View; diff --git a/packages/engine/tsconfig.json b/packages/engine/tsconfig.json new file mode 100644 index 00000000..bd7a1fb2 --- /dev/null +++ b/packages/engine/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/packages/toolbar-vue/.browserslistrc b/packages/toolbar-vue/.browserslistrc new file mode 100644 index 00000000..214388fe --- /dev/null +++ b/packages/toolbar-vue/.browserslistrc @@ -0,0 +1,3 @@ +> 1% +last 2 versions +not dead diff --git a/packages/toolbar-vue/.fatherrc.ts b/packages/toolbar-vue/.fatherrc.ts new file mode 100644 index 00000000..3a7401ed --- /dev/null +++ b/packages/toolbar-vue/.fatherrc.ts @@ -0,0 +1,5 @@ +import commonjs from '@rollup/plugin-commonjs'; + +export default { + extraRollupPlugins: [commonjs()], +}; diff --git a/packages/toolbar-vue/.gitignore b/packages/toolbar-vue/.gitignore new file mode 100644 index 00000000..403adbc1 --- /dev/null +++ b/packages/toolbar-vue/.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/packages/toolbar-vue/package.json b/packages/toolbar-vue/package.json new file mode 100644 index 00000000..f1dfdb97 --- /dev/null +++ b/packages/toolbar-vue/package.json @@ -0,0 +1,34 @@ +{ + "name": "@aomao/toolbar-vue", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10", + "ant-design-vue": "^2.2.6", + "keymaster": "^1.6.2", + "lodash-es": "^4.17.21", + "tinycolor2": "^1.4.2", + "vue": "^3.2.9" + }, + "devDependencies": { + "@vue/compiler-sfc": "^3.2.9" + } +} diff --git a/packages/toolbar-vue/src/components/button.vue b/packages/toolbar-vue/src/components/button.vue new file mode 100644 index 00000000..bc0bbde1 --- /dev/null +++ b/packages/toolbar-vue/src/components/button.vue @@ -0,0 +1,142 @@ + + + \ No newline at end of file diff --git a/packages/toolbar-vue/src/components/collapse/collapse.vue b/packages/toolbar-vue/src/components/collapse/collapse.vue new file mode 100644 index 00000000..14d9aafd --- /dev/null +++ b/packages/toolbar-vue/src/components/collapse/collapse.vue @@ -0,0 +1,174 @@ + + + \ No newline at end of file diff --git a/packages/toolbar-vue/src/components/collapse/group.vue b/packages/toolbar-vue/src/components/collapse/group.vue new file mode 100644 index 00000000..ee7f02f1 --- /dev/null +++ b/packages/toolbar-vue/src/components/collapse/group.vue @@ -0,0 +1,40 @@ + + \ No newline at end of file diff --git a/packages/toolbar-vue/src/components/collapse/item.vue b/packages/toolbar-vue/src/components/collapse/item.vue new file mode 100644 index 00000000..7e86e5fe --- /dev/null +++ b/packages/toolbar-vue/src/components/collapse/item.vue @@ -0,0 +1,97 @@ + + + \ No newline at end of file diff --git a/packages/toolbar-vue/src/components/color/color.vue b/packages/toolbar-vue/src/components/color/color.vue new file mode 100644 index 00000000..12142821 --- /dev/null +++ b/packages/toolbar-vue/src/components/color/color.vue @@ -0,0 +1,189 @@ + + + \ No newline at end of file diff --git a/packages/toolbar-vue/src/components/color/picker/group.vue b/packages/toolbar-vue/src/components/color/picker/group.vue new file mode 100644 index 00000000..9026297d --- /dev/null +++ b/packages/toolbar-vue/src/components/color/picker/group.vue @@ -0,0 +1,25 @@ + + \ No newline at end of file diff --git a/packages/toolbar-vue/src/components/color/picker/item.vue b/packages/toolbar-vue/src/components/color/picker/item.vue new file mode 100644 index 00000000..ddb83351 --- /dev/null +++ b/packages/toolbar-vue/src/components/color/picker/item.vue @@ -0,0 +1,116 @@ + + \ No newline at end of file diff --git a/packages/toolbar-vue/src/components/color/picker/palette.ts b/packages/toolbar-vue/src/components/color/picker/palette.ts new file mode 100644 index 00000000..9ae8b693 --- /dev/null +++ b/packages/toolbar-vue/src/components/color/picker/palette.ts @@ -0,0 +1,140 @@ +class Palette { + static colors: Array>; + static _map: { [k: string]: { x: number; y: number } }; + /** + * 获取描边颜色 + * 默认为当前 color,浅色不明显区域:第 3 组、第 4 组的第 3、4 个用第 5 组的颜色描边 + * + * @param {string} color 颜色 + * @return {string} 描边颜色 + */ + static getStroke: (color: string) => string; + static getColors: () => Array>; +} + +Palette.colors = [ + [ + '#000000', + '#262626', + '#595959', + '#8C8C8C', + '#BFBFBF', + '#D9D9D9', + '#E9E9E9', + '#F5F5F5', + '#FAFAFA', + '#FFFFFF', + ], + [ + '#F5222D', + '#FA541C', + '#FA8C16', + '#FADB14', + '#52C41A', + '#13C2C2', + '#1890FF', + '#2F54EB', + '#722ED1', + '#EB2F96', + ], + [ + '#FFE8E6', + '#FFECE0', + '#FFEFD1', + '#FCFCCA', + '#E4F7D2', + '#D3F5F0', + '#D4EEFC', + '#DEE8FC', + '#EFE1FA', + '#FAE1EB', + ], + [ + '#FFA39E', + '#FFBB96', + '#FFD591', + '#FFFB8F', + '#B7EB8F', + '#87E8DE', + '#91D5FF', + '#ADC6FF', + '#D3ADF7', + '#FFADD2', + ], + [ + '#FF4D4F', + '#FF7A45', + '#FFA940', + '#FFEC3D', + '#73D13D', + '#36CFC9', + '#40A9FF', + '#597EF7', + '#9254DE', + '#F759AB', + ], + [ + '#CF1322', + '#D4380D', + '#D46B08', + '#D4B106', + '#389E0D', + '#08979C', + '#096DD9', + '#1D39C4', + '#531DAB', + '#C41D7F', + ], + [ + '#820014', + '#871400', + '#873800', + '#614700', + '#135200', + '#00474F', + '#003A8C', + '#061178', + '#22075E', + '#780650', + ], +]; + +Palette._map = (function () { + let map: { [k: string]: { x: number; y: number } } = {}; + const colors = Palette.colors; + for (let i = 0, l1 = colors.length; i < l1; i++) { + const group = colors[i]; + for (let k = 0, l2 = group.length; k < l2; k++) { + const color = colors[i][k]; + map[color] = { + y: i, + x: k, + }; + } + } + return map; +})(); + +/** + * 获取描边颜色 + * 默认为当前 color,浅色不明显区域:第 3 组、第 4 组的第 3、4 个用第 5 组的颜色描边 + * + * @param {string} color 颜色 + * @return {string} 描边颜色 + */ +Palette.getStroke = function (color: string): string { + const pos = Palette._map[color]; + if (!pos) return color; + + if (pos.y === 2 || (pos.y === 3 && pos.x > 2 && pos.x < 5)) { + return this.colors[4][pos.x]; + } + + return color; +}; + +Palette.getColors = function () { + return this.colors; +}; + +export default Palette; diff --git a/packages/toolbar-vue/src/components/color/picker/picker.vue b/packages/toolbar-vue/src/components/color/picker/picker.vue new file mode 100644 index 00000000..7de45a18 --- /dev/null +++ b/packages/toolbar-vue/src/components/color/picker/picker.vue @@ -0,0 +1,161 @@ + + + \ No newline at end of file diff --git a/packages/toolbar-vue/src/components/dropdown-list.vue b/packages/toolbar-vue/src/components/dropdown-list.vue new file mode 100644 index 00000000..8f5065c8 --- /dev/null +++ b/packages/toolbar-vue/src/components/dropdown-list.vue @@ -0,0 +1,88 @@ + + \ No newline at end of file diff --git a/packages/toolbar-vue/src/components/dropdown.vue b/packages/toolbar-vue/src/components/dropdown.vue new file mode 100644 index 00000000..136c9bec --- /dev/null +++ b/packages/toolbar-vue/src/components/dropdown.vue @@ -0,0 +1,251 @@ + + + \ No newline at end of file diff --git a/packages/toolbar-vue/src/components/group.vue b/packages/toolbar-vue/src/components/group.vue new file mode 100644 index 00000000..104cd322 --- /dev/null +++ b/packages/toolbar-vue/src/components/group.vue @@ -0,0 +1,74 @@ + + + + \ No newline at end of file diff --git a/packages/toolbar-vue/src/components/table.vue b/packages/toolbar-vue/src/components/table.vue new file mode 100644 index 00000000..fdf955d4 --- /dev/null +++ b/packages/toolbar-vue/src/components/table.vue @@ -0,0 +1,92 @@ + + + + \ No newline at end of file diff --git a/packages/toolbar-vue/src/components/toolbar.vue b/packages/toolbar-vue/src/components/toolbar.vue new file mode 100644 index 00000000..ba8b09cb --- /dev/null +++ b/packages/toolbar-vue/src/components/toolbar.vue @@ -0,0 +1,304 @@ + + + \ No newline at end of file diff --git a/packages/toolbar-vue/src/config/fontfamily.ts b/packages/toolbar-vue/src/config/fontfamily.ts new file mode 100644 index 00000000..dbb0bb5f --- /dev/null +++ b/packages/toolbar-vue/src/config/fontfamily.ts @@ -0,0 +1,126 @@ +import { DropdownListItem } from '../types'; +import { isSupportFontFamily } from '../utils'; + +export const defaultData = [ + { + key: 'default', + value: '', + }, + { + key: 'arial', + value: 'Arial', + }, + { + key: 'comicSansMS', + value: '"Comic Sans MS"', + }, + { + key: 'courierNew', + value: '"Courier New"', + }, + { + key: 'georgia', + value: 'Georgia', + }, + { + key: 'helvetica', + value: 'Helvetica', + }, + { + key: 'impact', + value: 'Impact', + }, + { + key: 'timesNewRoman', + value: '"Times New Roman"', + }, + { + key: 'trebuchetMS', + value: '"Trebuchet MS"', + }, + { + key: 'verdana', + value: 'Verdana', + }, + { + key: 'fangSong', + value: 'FangSong, 仿宋, FZFangSong-Z02S, STFangsong, fangsong', + }, + { + key: 'stFangsong', + value: 'STFangsong, 华文仿宋, FangSong, FZFangSong-Z02S, fangsong', + }, + { + key: 'stSong', + value: 'STSong, 华文宋体, SimSun, "Songti SC", NSimSun, serif', + }, + { + key: 'stKaiti', + value: 'STKaiti, 华文楷体, KaiTi, "Kaiti SC", cursive', + }, + { + key: 'simSun', + value: 'SimSun, 宋体, "Songti SC", NSimSun, STSong, serif', + }, + { + key: 'microsoftYaHei', + value: '"Microsoft YaHei", 微软雅黑, "PingFang SC", SimHei, STHeiti, sans-serif', + }, + { + key: 'kaiTi', + value: 'KaiTi, 楷体, STKaiti, "Kaiti SC", cursive', + }, + { + key: 'kaitiSC', + value: '"Kaiti SC"', + }, + { + key: 'simHei', + value: 'SimHei, 黑体, "Microsoft YaHei", "PingFang SC", STHeiti, sans-serif', + }, + { + key: 'heitiSC', + value: '"Heiti SC"', + }, + { + key: 'fzHei', + value: 'FZHei-B01S', + }, + { + key: 'fzKai', + value: 'FZKai-Z03S', + }, + { + key: 'fzFangSong', + value: 'FZFangSong-Z02S', + }, +]; +/** + * 生成字体下拉列表项 + * @param data key-value 键值对数据,key 名称,如果有传语言则是语言键值对的key否则就直接显示 + * @param language 语言,可选 + */ +export default ( + data: Array<{ key: string; value: string }>, + language?: { [key: string]: string }, +): Array => { + return data.map(({ key, value }) => { + const disabled = + key !== 'default' + ? !value.split(',').some((v) => isSupportFontFamily(v.trim())) + : false; + return { + key: value, + faimlyName: language ? language[key] : key, + content: `${ + language ? language[key] : key + }`, + hotkey: false, + disabled, + title: disabled + ? (language && language['notInstalled']) || + 'The font may not be installed' + : undefined, + }; + }); +}; diff --git a/packages/toolbar-vue/src/config/index.css b/packages/toolbar-vue/src/config/index.css new file mode 100644 index 00000000..d89cc991 --- /dev/null +++ b/packages/toolbar-vue/src/config/index.css @@ -0,0 +1,49 @@ +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-heading .toolbar-button { + font-weight: bold; + min-width: 73px; +} + +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-heading .heading-item-h1, +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-heading .heading-item-h2, +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-heading .heading-item-h3, +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-heading .heading-item-h4, +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-heading .heading-item-h5, +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-heading .heading-item-h6 { + line-height: 1.6; + font-weight: bold; + color: #262626; +} + +.heading-item-h1 { + font-size: 28px; +} + +.heading-item-h2 { + font-size: 24px; +} + +.heading-item-h3 { + font-size: 20px; +} + +.heading-item-h4 { + font-size: 16px; +} + +.heading-item-h5 { + font-size: 14px; +} + +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-heading .heading-item-h6 { + font-size: 14px; + font-weight: normal; +} + +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-fontsize .toolbar-button { + font-weight: bold; + min-width: 58px; +} + +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-fontfamily .toolbar-button { + font-size: 12px; +} \ No newline at end of file diff --git a/packages/toolbar-vue/src/config/index.ts b/packages/toolbar-vue/src/config/index.ts new file mode 100644 index 00000000..076c46c9 --- /dev/null +++ b/packages/toolbar-vue/src/config/index.ts @@ -0,0 +1,742 @@ +import { h } from 'vue'; +import { CARD_SELECTOR, EngineInterface } from '@aomao/engine'; +import { ToolbarItemProps } from '../types'; +import TableSelector from '../components/table.vue'; +import fontfamily, { defaultData as fontFamilyDefaultData } from './fontfamily'; +import './index.css'; + +export { fontfamily, fontFamilyDefaultData }; + +export const getToolbarDefaultConfig = ( + engine: EngineInterface, +): Array => { + const language = engine.language.get<{ + [key: string]: { [key: string]: string }; + }>('toolbar'); + return [ + { + type: 'collapse', + header: language['collapse']['title'], + icon: 'collapse', + groups: [ + { + items: [ + { + name: 'image-uploader', + icon: ` + + + + + + + + + + + `, + title: language['image']['title'], + search: '图片,tupian,image,img', + }, + { + name: 'codeblock', + icon: ` + + + + + + + + + + `, + title: language['codeblock']['title'], + search: '代码块,daimakuai,code', + }, + { + name: 'table', + command: { name: 'table', args: [3, 3] }, + placement: 'rightTop', + onDisabled: () => { + // 有激活卡片 或者没有启用插件 + return ( + !!engine.card.active || + !engine.command.queryEnabled('table') + ); + }, + prompt: + !!engine.card.active || + !engine.command.queryEnabled('table') + ? undefined + : h(TableSelector, { + onSelect: ( + event: MouseEvent, + rows: number, + cols: number, + ) => { + engine.command.execute( + 'table', + rows, + cols, + ); + }, + }), + icon: ` + + + + + + + + + + + + + + + + + + + + + + + + + `, + title: language['table']['title'], + search: 'biaoge,table', + }, + { + name: 'file-uploader', + icon: ` + + + + + + + + `, + title: language['file']['title'], + search: '附件,文件,fujian,wenjian,file', + }, + { + name: 'video-uploader', + icon: ` + + + + + + + + + + + + + + + + + + + `, + title: language['video']['title'], + search: '视频,MP4,shipin,video', + }, + { + name: 'math', + icon: ` + + `, + title: language['math']['title'], + search: '公式,数学公式,gongshi,formula,math,latex', + }, + { + name: 'status', + icon: ` + + `, + title: language['status']['title'], + search: 'status,label,状态', + }, + ], + }, + ], + }, + { + type: 'button', + name: 'undo', + icon: 'undo', + title: language['undo']['title'], + onDisabled: () => { + return ( + !engine.command.queryState('undo') || + !engine.command.queryEnabled('undo') + ); + }, + onActive: () => false, + }, + { + type: 'button', + name: 'redo', + icon: 'redo', + title: language['redo']['title'], + onDisabled: () => { + return ( + !engine.command.queryState('redo') || + !engine.command.queryEnabled('redo') + ); + }, + onActive: () => false, + }, + { + type: 'button', + name: 'paintformat', + icon: 'paintformat', + title: language['paintformat']['title'], + }, + { + type: 'button', + name: 'removeformat', + icon: 'clean', + title: language['removeformat']['title'], + }, + { + type: 'dropdown', + name: 'heading', + className: 'toolbar-dropdown-heading', + title: language['heading']['title'], + items: [ + { + key: 'p', + className: 'heading-item-p', + content: language['heading']['p'], + }, + { + key: 'h1', + className: 'heading-item-h1', + content: language['heading']['h1'], + }, + { + key: 'h2', + className: 'heading-item-h2', + content: language['heading']['h2'], + }, + { + key: 'h3', + className: 'heading-item-h3', + content: language['heading']['h3'], + }, + { + key: 'h4', + className: 'heading-item-h4', + content: language['heading']['h4'], + }, + { + key: 'h5', + className: 'heading-item-h5', + content: language['heading']['h5'], + }, + { + key: 'h6', + className: 'heading-item-h6', + content: language['heading']['h6'], + }, + ], + }, + { + type: 'dropdown', + name: 'fontsize', + className: 'toolbar-dropdown-fontsize', + title: language['fontsize']['title'], + items: [ + { key: '12px', content: '12px', hotkey: false }, + { key: '13px', content: '13px', hotkey: false }, + { key: '14px', content: '14px', hotkey: false }, + { key: '15px', content: '15px', hotkey: false }, + { key: '16px', content: '16px', hotkey: false }, + { key: '19px', content: '19px', hotkey: false }, + { key: '22px', content: '22px', hotkey: false }, + { key: '24px', content: '24px', hotkey: false }, + { key: '29px', content: '29px', hotkey: false }, + { key: '32px', content: '32px', hotkey: false }, + { key: '40px', content: '40px', hotkey: false }, + { key: '48px', content: '48px', hotkey: false }, + ].map((item) => + item.key === engine.container.css('font-size') + ? { ...item, isDefault: true } + : item, + ), + onDisabled: () => { + const tag = engine.command.queryState('heading') || 'p'; + return ( + /^h\d$/.test(tag) || + !engine.command.queryEnabled('fontsize') + ); + }, + }, + { + type: 'dropdown', + name: 'fontfamily', + className: 'toolbar-dropdown-fontfamily', + title: language['fontfamily']['title'], + items: fontfamily(fontFamilyDefaultData, { + ...(language['fontfamily']['items'] as {}), + notInstalled: language['fontfamily']['notInstalled'], + }), + onActive: (items) => { + const values = engine.command.queryState('fontfamily'); + if (!values || !Array.isArray(values) || values.length === 0) + return ''; + const familys: Array = values[0] + .split(',') + .map((name: string) => + name.replace(/"/g, '').trim().toLowerCase(), + ); + return ( + items.find( + (item) => + familys.indexOf( + (item as any)['faimlyName'] + .trim() + .toLowerCase(), + ) > -1, + )?.key || '' + ); + }, + }, + { + type: 'button', + name: 'bold', + icon: 'bold', + title: language['bold']['title'], + onDisabled: () => { + const tag = engine.command.queryState('heading') || 'p'; + return ( + /^h\d$/.test(tag) || !engine.command.queryEnabled('bold') + ); + }, + }, + { + type: 'button', + name: 'italic', + icon: 'italic', + title: language['italic']['title'], + }, + { + type: 'button', + name: 'strikethrough', + icon: 'strikethrough', + title: language['strikethrough']['title'], + }, + { + type: 'button', + name: 'underline', + icon: 'underline', + title: language['underline']['title'], + }, + { + type: 'dropdown', + name: 'moremark', + icon: 'moremark', + single: false, + title: language['moremark']['title'], + items: [ + { + key: 'sup', + icon: 'sup', + content: language['moremark']['sup'], + disabled: !engine.command.queryEnabled('sup'), + command: { name: 'sup', args: [] }, + }, + { + key: 'sub', + icon: 'sub', + disabled: !engine.command.queryEnabled('sub'), + content: language['moremark']['sub'], + command: { name: 'sub', args: [] }, + }, + { + key: 'code', + icon: 'code', + disabled: !engine.command.queryEnabled('code'), + content: language['moremark']['code'], + command: { name: 'code', args: [] }, + }, + ], + onDisabled: () => { + const plugins = []; + if (engine.command.queryEnabled('sup') === true) + plugins.push('sup'); + if (engine.command.queryEnabled('sub') === true) + plugins.push('sub'); + if (engine.command.queryEnabled('code') === true) + plugins.push('code'); + return plugins.length === 0; + }, + onActive: () => { + const plugins = []; + if (engine.command.queryState('sup') === true) + plugins.push('sup'); + if (engine.command.queryState('sub') === true) + plugins.push('sub'); + if (engine.command.queryState('code') === true) + plugins.push('code'); + return plugins; + }, + }, + { + type: 'color', + name: 'fontcolor', + defaultColor: '#262626', + defaultActiveColor: '#F5222D', + buttonTitle: language['fontcolor']['title'], + dropdownTitle: language['fontcolor']['more'], + content: (color: string, stroke: string, disabled?: boolean) => { + if (disabled === true) { + color = '#BFBFBF'; + stroke = '#BFBFBF'; + } + return ` + color-font + Created with Sketch. + + + + + `; + }, + }, + { + type: 'color', + name: 'backcolor', + defaultColor: 'transparent', + defaultActiveColor: '#FADB14', + buttonTitle: language['backcolor']['title'], + dropdownTitle: language['backcolor']['more'], + content: (color: string, stroke: string, disabled?: boolean) => { + if (disabled === true) { + color = '#BFBFBF'; + stroke = '#BFBFBF'; + } + return ` + + + + `; + }, + }, + { + type: 'dropdown', + name: 'alignment', + title: language['alignment']['title'], + items: [ + { + key: 'left', + icon: 'align-left', + content: language['alignment']['left'], + }, + { + key: 'center', + icon: 'align-center', + content: language['alignment']['center'], + }, + { + key: 'right', + icon: 'align-right', + content: language['alignment']['right'], + }, + { + key: 'justify', + icon: 'align-justify', + content: language['alignment']['justify'], + }, + ], + }, + { + type: 'button', + name: 'unorderedlist', + icon: 'unordered-list', + title: language['unorderedlist']['title'], + }, + { + type: 'button', + name: 'orderedlist', + icon: 'ordered-list', + title: language['orderedlist']['title'], + }, + { + type: 'button', + name: 'tasklist', + icon: 'task-list', + title: language['tasklist']['title'], + }, + { + type: 'dropdown', + name: 'indent', + icon: 'indent', + hasDot: false, + title: language['indent']['title'], + items: [ + { + key: 'in', + icon: 'indent', + content: language['indent']['in'], + }, + { + key: 'out', + icon: 'outdent', + content: language['indent']['out'], + }, + ], + }, + { + type: 'dropdown', + name: 'line-height', + content: () => + ``, + title: language['line-height']['title'], + items: [ + { + key: 'default', + content: language['line-height']['default'], + }, + { + key: '1', + content: '1', + }, + { + key: '1.15', + content: '1.15', + }, + { + key: '1.5', + content: '1.5', + }, + { + key: '2', + content: '2', + }, + { + key: '2.5', + content: '2.5', + }, + { + key: '3', + content: '3', + }, + ], + }, + { + type: 'button', + name: 'link', + icon: 'link', + command: { name: 'link', args: ['_blank'] }, + title: language['link']['title'], + onDisabled: () => { + const { change, card } = engine; + const range = change.range.get(); + const cardComponent = card.find(range.startNode); + return ( + (!!cardComponent && + !cardComponent.isCursor(range.startNode)) || + range.commonAncestorNode.find(CARD_SELECTOR).length > 0 || + !engine.command.queryEnabled('link') + ); + }, + }, + { + type: 'button', + name: 'quote', + icon: 'quote', + title: language['quote']['title'], + }, + { + type: 'button', + name: 'hr', + icon: 'hr', + title: language['hr']['title'], + }, + ]; +}; diff --git a/packages/toolbar-vue/src/hooks/index.ts b/packages/toolbar-vue/src/hooks/index.ts new file mode 100644 index 00000000..8d0d8623 --- /dev/null +++ b/packages/toolbar-vue/src/hooks/index.ts @@ -0,0 +1,3 @@ +import useRight from './useRight'; + +export { useRight }; diff --git a/packages/toolbar-vue/src/hooks/useRight.ts b/packages/toolbar-vue/src/hooks/useRight.ts new file mode 100644 index 00000000..f4245e34 --- /dev/null +++ b/packages/toolbar-vue/src/hooks/useRight.ts @@ -0,0 +1,17 @@ +import { ref, onMounted, Ref } from 'vue'; +import { isMobile } from '@aomao/engine'; + +const useRight = (button: Ref) => { + const isRight = ref(false); + + onMounted(() => { + if (button.value && isMobile) { + const rect = button.value.getBoundingClientRect(); + isRight.value = rect.left > window.visualViewport.width / 2; + } + }); + + return isRight; +}; + +export default useRight; diff --git a/packages/toolbar-vue/src/index.ts b/packages/toolbar-vue/src/index.ts new file mode 100644 index 00000000..669f8129 --- /dev/null +++ b/packages/toolbar-vue/src/index.ts @@ -0,0 +1,21 @@ +import { App } from 'vue'; +import Toolbar from './components/toolbar.vue'; +import { + getToolbarDefaultConfig, + fontFamilyDefaultData, + fontfamily, +} from './config'; +import ToolbarPlugin, { ToolbarComponent } from './plugin'; + +Toolbar.install = (app: App) => { + app.component(Toolbar.name, Toolbar); +}; + +export default Toolbar; +export { + ToolbarPlugin, + ToolbarComponent, + getToolbarDefaultConfig, + fontFamilyDefaultData, + fontfamily, +}; diff --git a/packages/toolbar-vue/src/locales/en-US.ts b/packages/toolbar-vue/src/locales/en-US.ts new file mode 100644 index 00000000..4678a8e0 --- /dev/null +++ b/packages/toolbar-vue/src/locales/en-US.ts @@ -0,0 +1,232 @@ +import { isMacos } from '@aomao/engine'; + +export default { + toolbar: { + collapse: { + title: `Type ${ + isMacos ? '⌘' : 'Ctrl' + } + / to quickly insert a card`, + }, + undo: { + title: 'Undo', + }, + redo: { + title: 'Redo', + }, + paintformat: { + title: 'Format brush', + }, + removeformat: { + title: 'Clear format', + }, + heading: { + title: 'Text and title', + p: 'Text', + h1: 'Heading 1', + h2: 'Heading 2', + h3: 'Heading 3', + h4: 'Heading 4', + h5: 'Heading 5', + h6: 'Heading 6', + }, + fontfamily: { + title: 'Font family', + notInstalled: 'The font may not be installed', + items: { + default: 'Default', + arial: 'Arial', + comicSansMS: 'Comic Sans MS', + courierNew: 'Courier New', + georgia: 'Georgia', + helvetica: 'Helvetica', + impact: 'Impact', + timesNewRoman: 'Times New Roman', + trebuchetMS: 'Trebuchet MS', + verdana: 'Verdana', + fangSong: 'FangSong', + stFangsong: 'STFangsong', + stSong: 'STSong', + stKaiti: 'STKaiti', + simSun: 'SimSum', + microsoftYaHei: 'Microsoft YaHei', + kaiTi: 'KaiTi', + kaitiSC: 'KaiTi SC', + simHei: 'SimHei', + heitiSC: 'Heiti SC', + fzHei: 'FZHeiTi', + fzKai: 'FZKaiTi', + fzFangSong: 'FZFangSong', + }, + }, + fontsize: { + title: 'Font size', + }, + fontcolor: { + title: 'Font color', + more: 'More colors', + }, + backcolor: { + title: 'Background color', + more: 'More colors', + }, + bold: { + title: 'Bold', + }, + italic: { + title: 'Italic', + }, + strikethrough: { + title: 'Strikethrough', + }, + underline: { + title: 'Underline', + }, + moremark: { + title: 'More text styles', + sup: 'Sup', + sub: 'Sub', + code: 'Inline code', + }, + alignment: { + title: 'Alignment', + left: 'Align left', + center: 'Align center', + right: 'Align right', + justify: 'Align justify', + }, + unorderedlist: { + title: 'Unordered list', + }, + orderedlist: { + title: 'Ordered list', + }, + tasklist: { + title: 'Task list', + }, + indent: { + title: 'Ident', + in: 'Increase indent', + out: 'Reduce indent', + }, + 'line-height': { + title: 'Line height', + default: 'Default', + }, + link: { + title: 'Insert Link', + }, + quote: { + title: 'Insert reference', + }, + hr: { + title: 'Insert dividing line', + }, + colorPicker: { + defaultText: 'Default Color', + nonFillText: 'No fill color', + '#000000': 'Black', + '#262626': 'Dark Gray 3', + '#595959': 'Dark Gray 2', + '#8C8C8C': 'Dark Gray 1', + '#BFBFBF': 'Gray', + '#D9D9D9': 'Light Gray 4', + '#E9E9E9': 'Light Gray 3', + '#F5F5F5': 'Light Gray 2', + '#FAFAFA': 'Light Gray 1', + '#FFFFFF': 'White', + '#F5222D': 'Red', + '#FA541C': 'Chinese Red', + '#FA8C16': 'Orange', + '#FADB14': 'Yellow', + '#52C41A': 'Green', + '#13C2C2': 'Cyan', + '#1890FF': 'Light Blue', + '#2F54EB': 'Blue', + '#722ED1': 'Purple', + '#EB2F96': 'Magenta', + '#FFE8E6': 'Red 1', + '#FFECE0': 'Chinese Red 1', + '#FFEFD1': 'Orange 1', + '#FCFCCA': 'Yellow 1', + '#E4F7D2': 'Green 1', + '#D3F5F0': 'Cyan 1', + '#D4EEFC': 'Light Blue 1', + '#DEE8FC': 'Blue 1', + '#EFE1FA': 'Purple 1', + '#FAE1EB': 'Magenta 1', + '#FFA39E': 'Red 2', + '#FFBB96': 'Chinese Red 2', + '#FFD591': 'Orange 2', + '#FFFB8F': 'Yellow 2', + '#B7EB8F': 'Green 2', + '#87E8DE': 'Cyan 2', + '#91D5FF': 'Light Blue 2', + '#ADC6FF': 'Blue 2', + '#D3ADF7': 'Purple 2', + '#FFADD2': 'Magenta 2', + '#FF4D4F': 'Red 3', + '#FF7A45': 'Chinese Red 3', + '#FFA940': 'Orange 3', + '#FFEC3D': 'Yellow 3', + '#73D13D': 'Green 3', + '#36CFC9': 'Cyan 3', + '#40A9FF': 'Light Blue 3', + '#597EF7': 'Blue 3', + '#9254DE': 'Purple 3', + '#F759AB': 'Magenta 3', + '#CF1322': 'Red 4', + '#D4380D': 'Chinese Red 4', + '#D46B08': 'Orange 4', + '#D4B106': 'Yellow 4', + '#389E0D': 'Green 4', + '#08979C': 'Cyan 4', + '#096DD9': 'Light Blue 4', + '#1D39C4': 'Blue 4', + '#531DAB': 'Purple 4', + '#C41D7F': 'Magenta 4', + '#820014': 'Red 5', + '#871400': 'Chinese Red 5', + '#873800': 'Orange 5', + '#614700': 'Yellow 5', + '#135200': 'Green 5', + '#00474F': 'Cyan 5', + '#003A8C': 'Light Blue 5', + '#061178': 'Blue 5', + '#22075E': 'Purple 5', + '#780650': 'Magenta 5', + }, + component: { + placeholder: 'Card name', + }, + image: { + title: 'Image', + }, + codeblock: { + title: 'Codeblock', + }, + table: { + title: 'Table', + }, + file: { + title: 'File', + }, + video: { + title: 'Video', + }, + math: { + title: 'Formula', + }, + status: { + title: 'Status', + }, + mind: { + title: 'Mind Map', + }, + commonlyUsed: { + title: 'Commonly used', + }, + searchEmtpy: { + title: 'No matching card', + }, + }, +}; diff --git a/packages/toolbar-vue/src/locales/index.ts b/packages/toolbar-vue/src/locales/index.ts new file mode 100644 index 00000000..6266072c --- /dev/null +++ b/packages/toolbar-vue/src/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/packages/toolbar-vue/src/locales/zh-cn.ts b/packages/toolbar-vue/src/locales/zh-cn.ts new file mode 100644 index 00000000..44383625 --- /dev/null +++ b/packages/toolbar-vue/src/locales/zh-cn.ts @@ -0,0 +1,232 @@ +import { isMacos } from '@aomao/engine'; + +export default { + toolbar: { + collapse: { + title: `输入 ${ + isMacos ? '⌘' : 'Ctrl' + } + / 快速插入卡片`, + }, + undo: { + title: '撤销', + }, + redo: { + title: '重做', + }, + paintformat: { + title: '格式刷', + }, + removeformat: { + title: '清除格式', + }, + heading: { + title: '正文与标题', + p: '正文', + h1: '标题 1', + h2: '标题 2', + h3: '标题 3', + h4: '标题 4', + h5: '标题 5', + h6: '标题 6', + }, + fontfamily: { + title: '字体', + notInstalled: '可能未安装该字体', + items: { + default: '默认', + arial: 'Arial', + comicSansMS: 'Comic Sans MS', + courierNew: 'Courier New', + georgia: 'Georgia', + helvetica: 'Helvetica', + impact: 'Impact', + timesNewRoman: 'Times New Roman', + trebuchetMS: 'Trebuchet MS', + verdana: 'Verdana', + fangSong: '仿宋', + stFangsong: '华文仿宋', + stSong: '华文宋体', + stKaiti: '华文楷体', + simSun: '宋体', + microsoftYaHei: '微软雅黑', + kaiTi: '楷体', + kaitiSC: '楷体-简', + simHei: '黑体', + heitiSC: '黑体-简', + fzHei: '方正黑体', + fzKai: '方正楷体', + fzFangSong: '方正仿宋', + }, + }, + fontsize: { + title: '字号', + }, + fontcolor: { + title: '字体颜色', + more: '更多颜色', + }, + backcolor: { + title: '背景颜色', + more: '更多颜色', + }, + bold: { + title: '粗体', + }, + italic: { + title: '斜体', + }, + strikethrough: { + title: '删除线', + }, + underline: { + title: '下划线', + }, + moremark: { + title: '更多文本样式', + sup: '上标', + sub: '下标', + code: '行内代码', + }, + alignment: { + title: '对齐方式', + left: '左对齐', + center: '居中对齐', + right: '右对齐', + justify: '两端对齐', + }, + unorderedlist: { + title: '无序列表', + }, + orderedlist: { + title: '有序列表', + }, + tasklist: { + title: '任务列表', + }, + indent: { + title: '缩进', + in: '增加缩进', + out: '减少缩进', + }, + 'line-height': { + title: '行高', + default: '默认', + }, + link: { + title: '链接', + }, + quote: { + title: '插入引用', + }, + hr: { + title: '插入分割线', + }, + colorPicker: { + defaultText: '默认', + nonFillText: '无填充色', + '#000000': '黑色', + '#262626': '深灰 3', + '#595959': '深灰 2', + '#8C8C8C': '深灰 1', + '#BFBFBF': '灰色', + '#D9D9D9': '浅灰 4', + '#E9E9E9': '浅灰 3', + '#F5F5F5': '浅灰 2', + '#FAFAFA': '浅灰 1', + '#FFFFFF': '白色', + '#F5222D': '红色', + '#FA541C': '朱红', + '#FA8C16': '橙色', + '#FADB14': '黄色', + '#52C41A': '绿色', + '#13C2C2': '青色', + '#1890FF': '浅蓝', + '#2F54EB': '蓝色', + '#722ED1': '紫色', + '#EB2F96': '玫红', + '#FFE8E6': '红色 1', + '#FFECE0': '朱红 1', + '#FFEFD1': '橙色 1', + '#FCFCCA': '黄色 1', + '#E4F7D2': '绿色 1', + '#D3F5F0': '青色 1', + '#D4EEFC': '浅蓝 1', + '#DEE8FC': '蓝色 1', + '#EFE1FA': '紫色 1', + '#FAE1EB': '玫红 1', + '#FFA39E': '红色 2', + '#FFBB96': '朱红 2', + '#FFD591': '橙色 2', + '#FFFB8F': '黄色 2', + '#B7EB8F': '绿色 2', + '#87E8DE': '青色 2', + '#91D5FF': '浅蓝 2', + '#ADC6FF': '蓝色 2', + '#D3ADF7': '紫色 2', + '#FFADD2': '玫红 2', + '#FF4D4F': '红色 3', + '#FF7A45': '朱红 3', + '#FFA940': '橙色 3', + '#FFEC3D': '黄色 3', + '#73D13D': '绿色 3', + '#36CFC9': '青色 3', + '#40A9FF': '浅蓝 3', + '#597EF7': '蓝色 3', + '#9254DE': '紫色 3', + '#F759AB': '玫红 3', + '#CF1322': '红色 4', + '#D4380D': '朱红 4', + '#D46B08': '橙色 4', + '#D4B106': '黄色 4', + '#389E0D': '绿色 4', + '#08979C': '青色 4', + '#096DD9': '浅蓝 4', + '#1D39C4': '蓝色 4', + '#531DAB': '紫色 4', + '#C41D7F': '玫红 4', + '#820014': '红色 5', + '#871400': '朱红 5', + '#873800': '橙色 5', + '#614700': '黄色 5', + '#135200': '绿色 5', + '#00474F': '青色 5', + '#003A8C': '浅蓝 5', + '#061178': '蓝色 5', + '#22075E': '紫色 5', + '#780650': '玫红 5', + }, + component: { + placeholder: '卡片名称', + }, + image: { + title: '图片', + }, + codeblock: { + title: '代码块', + }, + table: { + title: '表格', + }, + file: { + title: '附件', + }, + video: { + title: '视频', + }, + math: { + title: '公式', + }, + status: { + title: '状态', + }, + mind: { + title: '脑图', + }, + commonlyUsed: { + title: '常用', + }, + searchEmtpy: { + title: '无匹配卡片', + }, + }, +}; diff --git a/packages/toolbar-vue/src/plugin/component/collapse.ts b/packages/toolbar-vue/src/plugin/component/collapse.ts new file mode 100644 index 00000000..55aff68e --- /dev/null +++ b/packages/toolbar-vue/src/plugin/component/collapse.ts @@ -0,0 +1,175 @@ +import { createApp, App } from 'vue'; +import Keymaster, { deleteScope, setScope, unbind } from 'keymaster'; +import { $, EngineInterface, NodeInterface, Position } from '@aomao/engine'; +import Collapse from '../../components/collapse/collapse.vue'; +import { CollapseGroupProps } from '../../types'; + +export type Options = { + onCancel?: () => void; + onSelect?: (event: MouseEvent, name: string) => void; +}; + +export interface CollapseComponentInterface { + select(index: number): void; + scroll(direction: 'up' | 'down'): void; + unbindEvents(): void; + bindEvents(): void; + remove(): void; + render( + container: NodeInterface, + target: NodeInterface, + data: Array, + ): void; +} + +class CollapseComponent implements CollapseComponentInterface { + private engine: EngineInterface; + private root?: NodeInterface; + private otpions: Options; + private vm?: App; + #position?: Position; + private readonly SCOPE_NAME = 'data-toolbar-component'; + + constructor(engine: EngineInterface, options: Options) { + this.otpions = options; + this.engine = engine; + this.#position = new Position(engine); + } + + handlePreventDefault = (event: Event) => { + // Card已被删除 + if (this.root?.closest('body').length !== 0) { + event.preventDefault(); + return false; + } + return; + }; + + select(index: number) { + this.root + ?.find('.toolbar-collapse-item-active') + .removeClass('toolbar-collapse-item-active'); + this.root + ?.find('.toolbar-collapse-item') + .eq(index) + ?.addClass('toolbar-collapse-item-active'); + } + + scroll(direction: 'up' | 'down') { + if (!this.root) return; + const items = this.root.find('.toolbar-collapse-item').toArray(); + let activeNode = this.root.find('.toolbar-collapse-item-active'); + const activeIndex = items.findIndex((item) => item.equal(activeNode)); + + let index = direction === 'up' ? activeIndex - 1 : activeIndex + 1; + if (index < 0) { + index = items.length - 1; + } + if (index >= items.length) index = 0; + activeNode = items[index]; + this.select(index); + let offset = 0; + this.root + .find('.toolbar-collapse-group-title,.toolbar-collapse-item') + .each((node) => { + if (activeNode.equal(node)) return false; + offset += (node as Element).clientHeight; + return; + }); + const rootElement = this.root.get()!; + rootElement.scrollTop = offset - rootElement.clientHeight / 2; + } + + unbindEvents() { + deleteScope(this.SCOPE_NAME); + unbind('enter', this.SCOPE_NAME); + unbind('up', this.SCOPE_NAME); + unbind('down', this.SCOPE_NAME); + unbind('esc', this.SCOPE_NAME); + this.engine.off('keydown:enter', this.handlePreventDefault); + } + + bindEvents() { + this.unbindEvents(); + setScope(this.SCOPE_NAME); + //回车 + Keymaster('enter', this.SCOPE_NAME, (event) => { + // Card 已被删除 + if (this.root?.closest('body').length === 0) { + return; + } + event.preventDefault(); + const active = this.root?.find('.toolbar-collapse-item-active'); + active?.get()?.click(); + }); + + Keymaster('up', this.SCOPE_NAME, (event) => { + // Card 已被删除 + if (this.root?.closest('body').length === 0) { + return; + } + event.preventDefault(); + this.scroll('up'); + }); + Keymaster('down', this.SCOPE_NAME, (e) => { + // Card 已被删除 + if (this.root?.closest('body').length === 0) { + return; + } + e.preventDefault(); + this.scroll('down'); + }); + Keymaster('esc', this.SCOPE_NAME, (event) => { + event.preventDefault(); + this.unbindEvents(); + const { onCancel } = this.otpions; + if (onCancel) onCancel(); + }); + this.engine.on('keydown:enter', this.handlePreventDefault); + } + + remove() { + if (!this.root || this.root.length === 0) return; + this.#position?.destroy(); + if (this.vm) this.vm.unmount(); + this.root.remove(); + this.root = undefined; + } + + render( + container: NodeInterface, + target: NodeInterface, + data: Array, + ) { + this.unbindEvents(); + this.remove(); + this.root = $('
      '); + container.append(this.root); + + const rootElement = this.root.get()!; + + const { onSelect } = this.otpions; + if (data.length > 0) { + this.vm = createApp(Collapse, { + engine: this.engine, + groups: data, + onSelect, + }); + this.vm.mount(rootElement); + } else { + this.root.append( + `
      ${this.engine.language.get( + 'toolbar', + 'searchEmtpy', + 'title', + )}
      `, + ); + } + + this.select(0); + this.bindEvents(); + this.#position?.bind(this.root, target); + } +} + +export default CollapseComponent; diff --git a/packages/toolbar-vue/src/plugin/component/index.css b/packages/toolbar-vue/src/plugin/component/index.css new file mode 100644 index 00000000..f4d643ca --- /dev/null +++ b/packages/toolbar-vue/src/plugin/component/index.css @@ -0,0 +1,34 @@ +.data-toolbar-component-list { + position: absolute; + min-height: 0px; + top: 10px; + left: 0; +} + +.data-toolbar-component-list .toolbar-dropdown-list { + top:0px; + position: relative; +} + +.data-toolbar-component-placeholder { + color: rgba(0,0,0,0.25); + pointer-events: none; + width: 76px; +} + +.data-toolbar-component-list-empty { + position: relative; + font-size: 14px; + background: #ffffff; + border: 1px solid #e8e8e8; + border-radius: 3px 3px; + box-shadow: 0 2px 10px rgb(0 0 0 / 12%); + padding: 5px 16px; + line-height: 32px; + min-width: 200px; + height: auto; + transition: all 0.25s cubic-bezier(0.3, 1.2, 0.2, 1); + z-index: 999; + max-height: calc(80vh); + overflow: auto; +} \ No newline at end of file diff --git a/packages/toolbar-vue/src/plugin/component/index.ts b/packages/toolbar-vue/src/plugin/component/index.ts new file mode 100644 index 00000000..960a630f --- /dev/null +++ b/packages/toolbar-vue/src/plugin/component/index.ts @@ -0,0 +1,256 @@ +import { + $, + Card, + isEngine, + NodeInterface, + isHotkey, + CardType, + isServer, +} from '@aomao/engine'; +import { + CollapseGroupProps, + CollapseItemProps, + CollapseProps, +} from '../../types'; +import { getToolbarDefaultConfig } from '../../config'; +import CollapseComponent, { CollapseComponentInterface } from './collapse'; +import './index.css'; + +export type Data = Array; + +class ToolbarComponent extends Card<{ data: Data }> { + private keyword?: NodeInterface; + private placeholder?: NodeInterface; + private component?: CollapseComponentInterface; + #collapseData?: Data; + + static get cardName() { + return 'toolbar'; + } + + static get cardType() { + return CardType.INLINE; + } + + static get singleSelectable() { + return false; + } + + static get autoSelected() { + return false; + } + + init() { + if (!isEngine(this.editor) || isServer) { + return; + } + + this.component = new CollapseComponent(this.editor, { + onCancel: () => { + this.changeToText(); + }, + onSelect: () => { + this.remove(); + }, + }); + } + + getData(): Data { + if (!isEngine(this.editor)) { + return []; + } + const data: + | Data + | { title: any; items: Omit[] }[] = []; + const defaultConfig = getToolbarDefaultConfig(this.editor); + const collapseConfig = defaultConfig.find( + ({ type }) => type === 'collapse', + ); + let collapseGroups: Array = []; + if (collapseConfig) + collapseGroups = (collapseConfig as CollapseProps).groups; + const collapseItems: Array> = []; + collapseGroups.forEach((group) => { + collapseItems.push(...group.items); + }); + const value = this.getValue(); + if (!value || !value.data) return []; + + value.data.forEach((group: any) => { + const title = group.title; + const items: Array> = []; + group.items.forEach((item: any) => { + let name = item; + if (typeof item !== 'string') name = item.name; + const collapseItem = collapseItems.find( + (item) => item.name === name, + ); + if (collapseItem) { + items.push({ + ...collapseItem, + ...(typeof item !== 'string' ? item : {}), + disabled: collapseItem.onDisabled + ? collapseItem.onDisabled() + : !this.editor.command.queryEnabled(name), + }); + } + }); + data.push({ + title, + items, + }); + }); + return data; + } + + /** + * 查询 + * @param keyword 关键字 + * @returns + */ + search(keyword: string) { + const items: Array> = []; + // search with case insensitive + if (typeof keyword === 'string') keyword = keyword.toLowerCase(); + // 已经有值了就不用赋值了,不然会影响禁用状态 + + if (!this.#collapseData) this.#collapseData = []; + this.#collapseData.forEach((group) => { + group.items.forEach((item) => { + if ( + item.search && + item.search.toLowerCase().indexOf(keyword) >= 0 + ) { + if (!items.find(({ name }) => name === item.name)) { + items.push({ ...item }); + } + } + }); + }); + const data = []; + if (items.length > 0) { + data.push({ + title: '', + items: items, + }); + } + return data; + } + + remove() { + if (!isEngine(this.editor)) return; + this.component?.remove(); + this.editor.card.remove(this.id); + } + + changeToText() { + if (!this.root.inEditor() || !isEngine(this.editor)) { + return; + } + + const content = this.keyword?.get()?.innerText || ''; + this.remove(); + this.editor.node.insertText(content); + } + + destroy() { + this.component?.unbindEvents(); + this.component?.remove(); + } + + activate(activated: boolean) { + super.activate(activated); + if (!activated) { + this.component?.unbindEvents(); + this.changeToText(); + } + } + + handleInput() { + if (!isEngine(this.editor)) return; + const { change, card } = this.editor; + if (change.isComposing()) { + return; + } + const content = + this.keyword + ?.get() + ?.innerText.replace(/[\r\n]/g, '') || ''; + // 内容为空 + if (content === '') { + this.component?.remove(); + card.remove(this.id); + return; + } + + const keyword = content.substr(1); + // 搜索关键词为空 + if (keyword === '') { + this.component?.render( + this.editor.root, + this.root, + this.#collapseData || [], + ); + return; + } + const data = this.search(keyword); + this.component?.render(this.editor.root, this.root, data); + } + + resetPlaceHolder() { + if ('/' === this.keyword?.get()?.innerText) + this.placeholder?.show(); + else this.placeholder?.hide(); + } + + render(): string | void | NodeInterface { + const editor = this.editor; + if (!isEngine(editor) || isServer) return; + const language = editor.language.get<{ placeholder: string }>( + 'toolbar', + 'component', + ); + this.root.attributes('data-transient', 'true'); + this.root.attributes('contenteditable', 'false'); + // 编辑模式 + const container = $( + `/${language['placeholder']}`, + ); + const center = this.getCenter(); + center.empty().append(container); + this.keyword = center.find('.data-toolbar-component-keyword'); + this.placeholder = center.find('.data-toolbar-component-placeholder'); + // 监听输入事件 + this.keyword?.on('keydown', (e) => { + if (isHotkey('enter', e)) { + e.preventDefault(); + } + }); + const renderTime = Date.now(); + this.keyword?.on('input', () => { + this.resetPlaceHolder(); + // 在 Windows 上使用中文输入法,在 keydown 事件里无法阻止用户的输入,所以在这里删除用户的输入 + if (Date.now() - renderTime < 200) { + const textNode = this.keyword?.first(); + if ( + (textNode && + textNode.isText() && + textNode[0].nodeValue === '/、') || + textNode?.get()?.nodeValue === '//' + ) { + const text = textNode.get()?.splitText(1); + text?.remove(); + } + } + + setTimeout(() => { + this.handleInput(); + }, 10); + }); + if (!this.#collapseData) this.#collapseData = this.getData(); + // 显示下拉列表 + this.component?.render(editor.root, this.root, this.#collapseData); + } +} + +export default ToolbarComponent; diff --git a/packages/toolbar-vue/src/plugin/index.ts b/packages/toolbar-vue/src/plugin/index.ts new file mode 100644 index 00000000..cf4328bb --- /dev/null +++ b/packages/toolbar-vue/src/plugin/index.ts @@ -0,0 +1,107 @@ +import { + EditorInterface, + isEngine, + isSafari, + NodeInterface, + Plugin, + PluginOptions, +} from '@aomao/engine'; +import { CollapseItemProps } from '../types'; +import locales from '../locales'; +import ToolbarComponent from './component'; + +type Config = Array<{ + title: string; + items: Array | string>; +}>; +export interface Options extends PluginOptions { + config: Config; +} + +const defaultConfig = (editor: EditorInterface): Config => { + return [ + { + title: editor.language.get( + 'toolbar', + 'commonlyUsed', + 'title', + ), + items: [ + 'image-uploader', + 'codeblock', + 'table', + 'file-uploader', + 'video-uploader', + 'math', + 'status', + ], + }, + ]; +}; + +class ToolbarPlugin extends Plugin { + static get pluginName() { + return 'toolbar'; + } + + init() { + if (isEngine(this.editor)) { + this.editor.on('keydown:slash', (event) => this.onSlash(event)); + this.editor.on('parse:value', (node) => this.paserValue(node)); + } + this.editor.language.add(locales); + } + + paserValue(node: NodeInterface) { + if ( + node.isCard() && + node.attributes('name') === ToolbarComponent.cardName + ) { + return false; + } + return true; + } + + onSlash(event: KeyboardEvent) { + if (!isEngine(this.editor)) return; + const { change } = this.editor; + let range = change.range.get(); + const block = this.editor.block.closest(range.startNode); + const text = block.text().trim(); + if (text === '/' && isSafari) { + block.empty(); + } + + if ( + '' === text || + ('/' === text && isSafari) || + event.ctrlKey || + event.metaKey + ) { + range = change.range.get(); + if (range.collapsed) { + event.preventDefault(); + const data = this.options.config || defaultConfig(this.editor); + const card = this.editor.card.insert( + ToolbarComponent.cardName, + { + data, + }, + ); + this.editor.card.activate(card.root); + range = change.range.get(); + //选中关键词输入节点 + const keyword = card.find('.data-toolbar-component-keyword'); + range.select(keyword, true); + range.collapse(false); + change.range.select(range); + } + } + } + + execute(...args: any): void { + throw new Error('Method not implemented.'); + } +} +export { ToolbarComponent }; +export default ToolbarPlugin; diff --git a/packages/toolbar-vue/src/shims-vue.d.ts b/packages/toolbar-vue/src/shims-vue.d.ts new file mode 100644 index 00000000..ea85c268 --- /dev/null +++ b/packages/toolbar-vue/src/shims-vue.d.ts @@ -0,0 +1,6 @@ +/* eslint-disable */ +declare module '*.vue' { + import { DefineComponent } from 'vue'; + const component: DefineComponent<{}, {}, any>; + export default component; +} diff --git a/packages/toolbar-vue/src/types.ts b/packages/toolbar-vue/src/types.ts new file mode 100644 index 00000000..25b0dbc4 --- /dev/null +++ b/packages/toolbar-vue/src/types.ts @@ -0,0 +1,361 @@ +import { EngineInterface } from '@aomao/engine'; +import { ExtractPropTypes, PropType, VNode } from 'vue'; +import { omit } from 'lodash-es'; + +//命令 +export type Command = + | { name: string; args: Array } + | Array + | undefined; +//tooltip 位置 +export type Placement = + | 'top' + | 'left' + | 'right' + | 'bottom' + | 'topLeft' + | 'topRight' + | 'bottomLeft' + | 'bottomRight' + | 'leftTop' + | 'leftBottom' + | 'rightTop' + | 'rightBottom'; +//按钮 +export const buttonProps = { + engine: Object as PropType, + name: { + type: String, + required: true, + } as const, + icon: String, + content: [String, Function] as PropType string) | VNode>, + title: String, + placement: String as PropType, + hotkey: [String, Object] as PropType, + command: Object as PropType, + autoExecute: { + type: [Boolean, undefined] as PropType, + default: undefined, + }, + className: String, + active: { + type: [Boolean, undefined] as PropType, + default: undefined, + }, + disabled: { + type: [Boolean, undefined] as PropType, + default: undefined, + }, + onClick: Function as PropType<(event: MouseEvent) => void | boolean>, + onMouseDown: Function as PropType<(event: MouseEvent) => void | boolean>, + onMouseEnter: Function as PropType<(event: MouseEvent) => void | boolean>, + onMouseLevel: Function as PropType<(event: MouseEvent) => void | boolean>, +}; + +export type ButtonProps = ExtractPropTypes; +//增加type +export type GroupButtonProps = { + type: 'button'; + values?: any; +} & Omit; +//下拉项 +export type DropdownListItem = { + key: string; + icon?: string; + content?: string | (() => string); + hotkey?: boolean | string; + isDefault?: boolean; + disabled?: boolean; + title?: string; + placement?: Placement; + className?: string; + command?: { name: string; args: Array } | Array; + autoExecute?: boolean; +}; +//下拉列表 +export const dropdownListProps = { + engine: Object as PropType, + name: { + type: String, + required: true, + } as const, + direction: String as PropType<'vertical' | 'horizontal'>, + items: { + type: Array as PropType>, + required: true, + } as const, + values: { + type: [String, Array, Number] as PropType< + string | number | Array + >, + required: true, + } as const, + className: String, + onSelect: Function as PropType< + (event: MouseEvent, key: string) => void | boolean + >, + hasDot: { + type: [Boolean, undefined] as PropType, + default: undefined, + }, +}; +export type DropdownListProps = ExtractPropTypes; +//下拉 +export const dropdownProps = { + engine: Object as PropType, + name: { + type: String, + required: true, + } as const, + values: [String, Array, Number] as PropType< + string | number | Array + >, + items: { + type: Array as PropType>, + default: [], + } as const, + icon: String, + content: [String, Function] as PropType string)>, + title: String, + disabled: { + type: [Boolean, undefined] as PropType, + default: undefined, + }, + single: { + type: [Boolean, undefined] as PropType, + default: undefined, + }, + className: String, + direction: String as PropType<'vertical' | 'horizontal'>, + onSelect: Function as PropType< + (event: MouseEvent, key: string) => void | boolean + >, + hasArrow: { + type: [Boolean, undefined] as PropType, + default: undefined, + }, + hasDot: { + type: [Boolean, undefined] as PropType, + default: undefined, + }, +}; +export type DropdownProps = ExtractPropTypes; + +export type GroupDropdownProps = { + type: 'dropdown'; +} & Omit; + +//颜色 +export const colorPickerItemProps = { + engine: { + type: Object as PropType, + required: true, + } as const, + color: { + type: String, + required: true, + } as const, + active: Boolean, + setStroke: { + type: [Boolean, undefined] as PropType, + default: undefined, + }, + onSelect: Function as PropType<(color: string, event: MouseEvent) => void>, +}; +export type ColorPickerItemProps = ExtractPropTypes< + typeof colorPickerItemProps +>; +//颜色分组 +export const colorPickerGroupProps = { + engine: colorPickerItemProps.engine, + colors: { + type: Array as PropType>, + required: true, + } as const, + setStroke: colorPickerItemProps.setStroke, + onSelect: colorPickerItemProps.onSelect, +}; + +export type ColorPickerGroupProps = ExtractPropTypes< + typeof colorPickerGroupProps +>; + +//picker +export const colorPickerProps = { + engine: colorPickerGroupProps.engine, + colors: Array as PropType>>, + defaultColor: { + type: String, + required: true, + } as const, + defaultActiveColor: { + type: String, + required: true, + } as const, + setStroke: colorPickerGroupProps.setStroke, + onSelect: colorPickerItemProps.onSelect, +}; +export type ColorPickerProps = ExtractPropTypes; +//color +export const colorProps = { + engine: buttonProps.engine, + name: buttonProps.name, + content: { + type: [String, Function] as PropType< + | string + | ((color: string, stroke: string, disabled?: boolean) => string) + >, + required: true, + } as const, + buttonTitle: String, + dropdownTitle: String, + command: buttonProps.command, + autoExecute: buttonProps.autoExecute, + disabled: buttonProps.disabled, + ...omit(colorPickerProps, 'engine'), +}; +export type ColorProps = ExtractPropTypes; + +export type GroupColorProps = { + type: 'color'; +} & Omit; + +//collapse item +export const collapseItemProps = { + name: buttonProps.name, + engine: buttonProps.engine, + icon: buttonProps.icon, + title: buttonProps.title, + search: String, + description: buttonProps.content, + disabled: buttonProps.disabled, + prompt: [String, Function, Object] as PropType< + string | (() => string) | VNode + >, + command: buttonProps.command, + autoExecute: buttonProps.autoExecute, + className: buttonProps.className, + placement: buttonProps.placement, + onClick: Function as PropType< + (event: MouseEvent, name: string) => boolean | void + >, + onMouseDown: Function as PropType<(event: MouseEvent) => void>, +}; +export type CollapseItemProps = ExtractPropTypes & { + onDisabled?: () => boolean; +}; + +//collapse group +export const collapseGroupProps = { + engine: buttonProps.engine, + title: String, + items: { + type: Array as PropType>>, + required: true, + } as const, + onSelect: collapseItemProps.onClick, +}; +export type CollapseGroupProps = ExtractPropTypes; +//collapse +export const collapseProps = { + engine: collapseGroupProps.engine, + header: String, + groups: { + type: Array as PropType>, + required: true, + } as const, + disabled: buttonProps.disabled, + className: collapseItemProps.className, + icon: collapseItemProps.icon, + content: buttonProps.content, + onSelect: collapseGroupProps.onSelect, +}; +export type CollapseProps = ExtractPropTypes; + +export type ToolbarCollapseGroupProps = { + type: 'collapse'; +} & Omit; + +export const groupProps = { + engine: { + type: Object as PropType, + required: true, + } as const, + items: { + type: Array as PropType< + Array< + | GroupButtonProps + | GroupDropdownProps + | GroupColorProps + | ToolbarCollapseGroupProps + > + >, + default: [], + }, + icon: collapseItemProps.icon, + content: buttonProps.content, +}; + +export type GroupProps = ExtractPropTypes; + +export type ToolbarButtonProps = { + onActive?: () => boolean; + onDisabled?: () => boolean; +} & GroupButtonProps; + +export type ToolbarDropdownProps = { + onActive?: (items: Array) => string | Array; + onDisabled?: () => boolean; +} & GroupDropdownProps; + +export type ToolbarColorProps = { + onActive?: () => string | Array; + onDisabled?: () => boolean; +} & GroupColorProps; + +export type ToolbarItemProps = + | ToolbarButtonProps + | ToolbarDropdownProps + | ToolbarColorProps + | ToolbarCollapseGroupProps; + +export type GroupItemDataProps = { + icon?: string; + content?: string | (() => string) | VNode; + items: Array; +}; + +type GroupItemProps = + | Array< + | ToolbarItemProps + | string + | (Omit & { + groups: Array< + Omit & { + items: Array< + Omit | 'string' + >; + } + >; + }) + > + | GroupItemDataProps; + +export type GroupDataProps = Omit & { + items: Array; +}; + +export const toolbarProps = { + engine: { + type: Object as PropType, + required: true, + } as const, + items: { + type: Array as PropType>, + default: [], + }, + className: String, +}; + +export type ToolbarProps = ExtractPropTypes; diff --git a/packages/toolbar-vue/src/utils.ts b/packages/toolbar-vue/src/utils.ts new file mode 100644 index 00000000..e0b2e1ad --- /dev/null +++ b/packages/toolbar-vue/src/utils.ts @@ -0,0 +1,72 @@ +import { EngineInterface } from '@aomao/engine'; + +export const autoGetHotkey = ( + engine: EngineInterface, + name: string, + itemKey?: string, +) => { + const plugin = engine?.plugin.components[name]; + if (plugin && plugin.hotkey) { + let key = plugin.hotkey(); + if (key) { + if (Array.isArray(key)) { + if (itemKey) { + const index = key.findIndex( + (k: any) => typeof k === 'object' && k.args === itemKey, + ); + key = key[index > -1 ? index : 0]; + } else { + key = key[0]; + } + } + if (typeof key === 'object') { + key = key.key; + } + return key; + } + } + return; +}; + +/** + * 是否支持字体 + * @param font 字体名称 + * @returns + */ +export const isSupportFontFamily = (font: string) => { + if (typeof font !== 'string') { + console.log('Font name is not legal !'); + return false; + } + + let width; + const body = document.body; + + const container = document.createElement('span'); + container.innerHTML = Array(10).join('wi'); + container.style.cssText = [ + 'position:absolute', + 'width:auto', + 'font-size:128px', + 'left:-99999px', + ].join(' !important;'); + + const getWidth = (fontFamily: string) => { + container.style.fontFamily = fontFamily; + body.appendChild(container); + width = container.clientWidth; + body.removeChild(container); + + return width; + }; + + const monoWidth = getWidth('monospace'); + const serifWidth = getWidth('serif'); + const sansWidth = getWidth('sans-serif'); + + return ( + monoWidth !== getWidth(font + ',monospace') || + sansWidth !== getWidth(font + ',sans-serif') || + serifWidth !== getWidth(font + ',serif') + ); +}; diff --git a/packages/toolbar-vue/tsconfig.json b/packages/toolbar-vue/tsconfig.json new file mode 100644 index 00000000..e5339aee --- /dev/null +++ b/packages/toolbar-vue/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "preserve", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true, + "lib": ["esnext", "dom", "dom.iterable", "scripthost"] + }, + "include": ["src/*.ts", "src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/packages/toolbar-vue/tslint.json b/packages/toolbar-vue/tslint.json new file mode 100644 index 00000000..12f8560f --- /dev/null +++ b/packages/toolbar-vue/tslint.json @@ -0,0 +1,15 @@ +{ + "defaultSeverity": "warning", + "extends": ["tslint:recommended"], + "linterOptions": { + "exclude": ["node_modules/**"] + }, + "rules": { + "indent": [true, "spaces", 4], + "interface-name": false, + "no-consecutive-blank-lines": false, + "object-literal-sort-keys": false, + "ordered-imports": false, + "quotemark": [true, "single"] + } +} diff --git a/packages/toolbar/package.json b/packages/toolbar/package.json new file mode 100644 index 00000000..60d84c32 --- /dev/null +++ b/packages/toolbar/package.json @@ -0,0 +1,37 @@ +{ + "name": "@aomao/toolbar", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10", + "antd": "^4.12.3", + "classnames-es-ts": "^2.2.7", + "keymaster": "^1.6.2", + "lodash-es": "^4.17.21", + "react": "^17.0.1", + "react-dom": "^17.0.1", + "tinycolor2": "^1.4.2" + }, + "devDependencies": { + "@types/keymaster": "^1.6.28", + "@types/lodash-es": "^4.17.4" + } +} diff --git a/packages/toolbar/src/button/index.css b/packages/toolbar/src/button/index.css new file mode 100644 index 00000000..9bb880ce --- /dev/null +++ b/packages/toolbar/src/button/index.css @@ -0,0 +1,41 @@ +.editor-toolbar .toolbar-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: auto; + min-width: 32px; + margin: 0; + text-align: center; + padding: 0 7px; + background-color: transparent; + border: 1px solid transparent; + border-radius: 3px 3px; + font-size: 16px; + cursor: pointer; + color: #595959; + outline: none; + line-height: 32px; +} + +.editor-toolbar:not(.editor-toolbar-mobile) .toolbar-button { + padding: 0 4px; +} + +.editor-toolbar:not(.editor-toolbar-mobile) .toolbar-button:hover { + border: 1px solid transparent; + background-color: #f5f5f5; +} + +.editor-toolbar:not(.editor-toolbar-mobile) .toolbar-button:active,.editor-toolbar .toolbar-button-active,.editor-toolbar:not(.editor-toolbar-mobile) .toolbar-button-active:hover { + background-color: #e8e8e8; + border: 1px solid transparent; +} + +.editor-toolbar .toolbar-button-disabled,.editor-toolbar:not(.editor-toolbar-mobile) .toolbar-button-disabled:hover { + background-color: transparent; + border: 1px solid transparent; + box-shadow: none; + color: #000000; + opacity: 0.25; + cursor: not-allowed; +} \ No newline at end of file diff --git a/packages/toolbar/src/button/index.tsx b/packages/toolbar/src/button/index.tsx new file mode 100644 index 00000000..a191ca55 --- /dev/null +++ b/packages/toolbar/src/button/index.tsx @@ -0,0 +1,151 @@ +import React, { useState } from 'react'; +import Tooltip from 'antd/es/tooltip'; +import classnames from 'classnames-es-ts'; +import { EngineInterface, formatHotkey, isMobile } from '@aomao/engine'; +import { autoGetHotkey } from '../utils'; +import 'antd/es/tooltip/style'; +import './index.css'; + +export type ButtonProps = { + engine?: EngineInterface; + name: string; + icon?: React.ReactNode; + content?: React.ReactNode | (() => React.ReactNode); + title?: string; + placement?: + | 'right' + | 'top' + | 'left' + | 'bottom' + | 'topLeft' + | 'topRight' + | 'bottomLeft' + | 'bottomRight' + | 'leftTop' + | 'leftBottom' + | 'rightTop' + | 'rightBottom'; + hotkey?: boolean | string; + command?: { name: string; args: Array } | Array; + autoExecute?: boolean; + className?: string; + active?: boolean; + disabled?: boolean; + onClick?: (event: React.MouseEvent) => void | boolean; + onMouseDown?: (event: React.MouseEvent) => void; + onMouseEnter?: (event: React.MouseEvent) => void; + onMouseLeave?: (event: React.MouseEvent) => void; +}; + +const ToolbarButton: React.FC = (props) => { + const { name, engine, command } = props; + + const [tooltipVisible, setTooltipVisible] = useState(false); + + const onClick = (event: React.MouseEvent) => { + const { command, onClick, disabled, autoExecute } = props; + + const nodeName = (event.target as Node).nodeName; + if (nodeName !== 'INPUT' && nodeName !== 'TEXTAREA') + event.preventDefault(); + + if (disabled) return; + if (onClick && onClick(event) === false) return; + if (autoExecute !== false) { + let commandName = name; + let commandArgs = []; + if (command) { + if (!Array.isArray(command)) { + commandName = command.name; + commandArgs = command.args; + } else { + commandArgs = command; + } + } + engine?.command.execute(commandName, ...commandArgs); + } + }; + + const onMouseDown = (event: React.MouseEvent) => { + event.preventDefault(); + const { onMouseDown, disabled } = props; + if (disabled) return; + if (onMouseDown) onMouseDown(event); + + setTooltipVisible(false); + }; + + const onMouseEnter = (event: React.MouseEvent) => { + const { onMouseEnter } = props; + if (onMouseEnter) { + onMouseEnter(event); + } + setTooltipVisible(true); + }; + + const onMouseLeave = (event: React.MouseEvent) => { + const { onMouseLeave } = props; + if (onMouseLeave) { + onMouseLeave(event); + } + setTooltipVisible(false); + }; + + const renderButton = () => { + const { icon, content, className, active, disabled } = props; + return ( + + ); + }; + + let title = props.title ? ( +
      {props.title}
      + ) : null; + let hotkey = props.hotkey; + //默认获取插件的热键 + if (engine && (hotkey === true || hotkey === undefined)) { + hotkey = autoGetHotkey( + engine, + command && !Array.isArray(command) ? command.name : name, + ); + } + if (typeof hotkey === 'string' && hotkey !== '') { + title = ( + <> + {title} +
      + {formatHotkey(hotkey)} +
      + + ); + } + return title && !isMobile ? ( + + {renderButton()} + + ) : ( + renderButton() + ); +}; + +export default ToolbarButton; diff --git a/packages/toolbar/src/collapse/group.tsx b/packages/toolbar/src/collapse/group.tsx new file mode 100644 index 00000000..cc0f687d --- /dev/null +++ b/packages/toolbar/src/collapse/group.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { EngineInterface } from '@aomao/engine'; +import CollapseItem, { CollapseItemProps } from './item'; + +export type CollapseGroupProps = { + engine?: EngineInterface; + title?: React.ReactNode; + items: Array>; + onSelect?: (event: React.MouseEvent, name: string) => void | boolean; +}; + +const CollapseGroup: React.FC = ({ + engine, + title, + items, + onSelect, +}) => { + return ( +
      + {title && ( +
      {title}
      + )} + {items.map((item) => { + return ( + { + let result; + if (item.onClick) + result = item.onClick(event, name); + if (onSelect) onSelect(event, name); + return result; + }} + /> + ); + })} +
      + ); +}; + +export default CollapseGroup; diff --git a/packages/toolbar/src/collapse/index.css b/packages/toolbar/src/collapse/index.css new file mode 100644 index 00000000..68937905 --- /dev/null +++ b/packages/toolbar/src/collapse/index.css @@ -0,0 +1,71 @@ +.toolbar-collapse-header { + color: #8c8c8c; + margin: 4px 16px 0; + font-size: 12px; + line-height: 20px; + text-align: left; + padding-bottom: 8px; + margin-bottom: 6px; + border-bottom: 1px solid #e8e8e8; +} + +.toolbar-collapse-header code{ + background-color: #f5f5f5; + border-radius: 4px; + padding: 2px; + border: 1px solid #d9d9d9; +} + +.toolbar-collapse-content { + min-width: 200px +} + +.toolbar-collapse-group-title { + padding: 2px 16px; + text-align: left; + color: #8c8c8c; + font-weight: 700; + font-size: 12px; + line-height: 24px; +} + +.toolbar-collapse-item { + display: flex; + cursor: pointer; + padding: 4px 16px 0; +} + +.toolbar-collapse-item-active { + background-color: #f4f4f4; +} + +.editor-toolbar .toolbar-collapse-item-disabled, .data-toolbar-component-list .toolbar-collapse-item-disabled, .editor-toolbar:not(.editor-toolbar-mobile) .toolbar-collapse-item-disabled:hover, .data-toolbar-component-list .toolbar-collapse-item-disabled:hover { + background-color: transparent; + border: 1px solid transparent; + box-shadow: none; + color: #000000; + opacity: 0.25; + cursor: not-allowed; +} + +.toolbar-collapse-item .toolbar-collapse-item-text +{ + display: block; + text-align: left; + margin-left: 8px; +} + +.toolbar-collapse-item .toolbar-collapse-item-title{ + display: block; + color: #595959; + line-height: 24px; + font-size: 14px; + font-weight: normal; +} + +.toolbar-collapse-item .toolbar-collapse-item-description +{ + display: block; + font-size: 12px; + color: rgba(0,0,0,.45); +} \ No newline at end of file diff --git a/packages/toolbar/src/collapse/index.tsx b/packages/toolbar/src/collapse/index.tsx new file mode 100644 index 00000000..f802e7e5 --- /dev/null +++ b/packages/toolbar/src/collapse/index.tsx @@ -0,0 +1,124 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { EngineInterface } from '@aomao/engine'; +import classnames from 'classnames-es-ts'; +import Button from '../button'; +import { useRight } from '../hooks'; +import CollapseGroup, { CollapseGroupProps } from './group'; +import './index.css'; + +export type CollapseProps = { + header?: React.ReactNode; + groups: Array; + engine?: EngineInterface; + className?: string; + icon?: React.ReactNode; + content?: React.ReactNode | (() => React.ReactNode); + onSelect?: (event: React.MouseEvent, name: string) => void | boolean; +}; + +const Collapse: React.FC = ({ + icon, + content, + header, + groups, + engine, + className, + onSelect, +}) => { + const isCustomize = !!!(icon || content); + const [visible, setVisible] = useState(isCustomize); + + const collapseRef = useRef(null); + const isRight = useRight(collapseRef); + + useEffect(() => { + if (!isCustomize) + return () => document.removeEventListener('click', hide); + return; + }, [isCustomize]); + + const show = () => { + setVisible(true); + setTimeout(() => { + document.addEventListener('click', hide); + }, 10); + }; + + const hide = (event?: MouseEvent) => { + if (event) { + let node = event.target; + // while (node) { + // if (node === collapseRef.current) { + // return; + // } + // node = (node as Element).parentNode; + // } + } + document.removeEventListener('click', hide); + setVisible(false); + }; + + const toggle = () => { + if (visible) { + hide(); + } else { + show(); + } + }; + + return ( +
      + {!isCustomize && ( +
      + {pickerVisible && ( +
      + +
      + )} +
      + ); +}; + +export default ColorButton; diff --git a/packages/toolbar/src/color/picker/group.tsx b/packages/toolbar/src/color/picker/group.tsx new file mode 100644 index 00000000..30102290 --- /dev/null +++ b/packages/toolbar/src/color/picker/group.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { EngineInterface } from '@aomao/engine'; +import ColorPickerItem from './item'; + +export type ColorPickerGroupProps = { + engine: EngineInterface; + colors: Array; + activeColors: Array; + setStroke?: boolean; + onSelect?: (color: string, event: React.MouseEvent) => void; +}; + +const ColorPickerGroup: React.FC = ({ + engine, + colors, + activeColors, + onSelect, + setStroke, +}) => { + return ( + + {colors.map((color) => { + return ( + + ); + })} + + ); +}; + +export default ColorPickerGroup; diff --git a/packages/toolbar/src/color/picker/index.css b/packages/toolbar/src/color/picker/index.css new file mode 100644 index 00000000..a984e0b9 --- /dev/null +++ b/packages/toolbar/src/color/picker/index.css @@ -0,0 +1,80 @@ +.colorpicker-default { + display: flex; + align-items: center; + padding: 4px 8px; + margin: 4px 0 8px; + border-radius: 2px; + cursor: pointer; +} + +.colorpicker-default:hover { + background-color: #f5f5f5; +} + +.colorpicker-default-text { + margin-left: 8px; +} + +.colorpicker-group { + display: flex; + width: 100%; + height: auto; + position: relative; + padding: 0 8px; +} + +.colorpicker-group:nth-child(2){ + margin-bottom: 6px; +} + +.colorpicker-group:last-child { + margin-bottom: 0px; +} + +.colorpicker-group-item { + width: 24px; + height: 24px; + padding: 2px 2px; + display: inline-block; + border-radius: 3px 3px; + border: 1px solid transparent; + flex: 0 0 auto; + cursor: pointer; + background-color: #fff; +} +.colorpicker-group-item > span { + position: relative; + width: 18px; + height: 18px; + display: block; + border-radius: 2px 2px; + border: 1px solid transparent; +} +.colorpicker-group-item > span svg { + position: absolute; + top: -1px; + left: 1px; + width: 12px; + height: 12px; +} +.colorpicker-group-item-border > span { + border: 1px solid #e8e8e8; +} +.colorpicker-group-item-special { + position: relative; +} +.colorpicker-group-item-special:after { + content: ""; + display: block; + position: absolute; + top: 10px; + left: 0px; + width: 22px; + height: 0; + border-bottom: 2px solid #ff5151; + transform: rotate(45deg); +} +.colorpicker-group-item:hover { + border: 1px solid #d9d9d9; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); +} \ No newline at end of file diff --git a/packages/toolbar/src/color/picker/index.tsx b/packages/toolbar/src/color/picker/index.tsx new file mode 100644 index 00000000..0a5fc8d2 --- /dev/null +++ b/packages/toolbar/src/color/picker/index.tsx @@ -0,0 +1,78 @@ +import React, { useState } from 'react'; +import { EngineInterface } from '@aomao/engine'; +import ColorPickerGroup, { ColorPickerGroupProps } from './group'; +import Palette from './palette'; +import ColorPickerItem from './item'; +import './index.css'; + +export type ColorPickerProps = { + engine: EngineInterface; + colors?: Array>; + defaultColor: string; + defaultActiveColor: string; +} & Omit; + +const ColorPicker: React.FC = ({ + engine, + colors, + defaultActiveColor, + defaultColor, + onSelect, + setStroke, +}) => { + const [active, setActive] = useState([defaultActiveColor]); + if (!colors) colors = Palette.getColors(); + + const triggerSelect = (color: string, event: React.MouseEvent) => { + setActive([color]); + if (onSelect) onSelect(color, event); + }; + + return ( +
      { + if ('INPUT' !== (event.target as Element).tagName) { + event.preventDefault(); + } + }} + > +
      triggerSelect(defaultColor, event)} + > + + + {engine.language.get( + 'toolbar', + 'colorPicker', + defaultColor === 'transparent' + ? 'nonFillText' + : 'defaultText', + )} + +
      + {colors.map((data, index) => { + return ( + + ); + })} +
      + ); +}; + +export default ColorPicker; + +export { Palette }; diff --git a/packages/toolbar/src/color/picker/item.tsx b/packages/toolbar/src/color/picker/item.tsx new file mode 100644 index 00000000..3227bd71 --- /dev/null +++ b/packages/toolbar/src/color/picker/item.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import classnames from 'classnames-es-ts'; +import tinycolor2, { ColorInput } from 'tinycolor2'; +import { EngineInterface } from '@aomao/engine'; +import Palette from './palette'; + +export type ColorPickerItemProps = { + engine: EngineInterface; + color: string; + activeColors: Array; + setStroke?: boolean; + onSelect?: (color: string, event: React.MouseEvent) => void; +}; + +const ColorPickerItem: React.FC = ({ + engine, + color, + activeColors, + setStroke, + onSelect, +}) => { + const toState = (color: ColorInput, oldHue?: number) => { + const tinyColor = color['hex'] + ? tinycolor2(color['hex']) + : tinycolor2(color); + const hsl = tinyColor.toHsl(); + const hsv = tinyColor.toHsv(); + const rgb = tinyColor.toRgb(); + const hex = tinyColor.toHex(); + + if (hsl.s === 0) { + hsl.h = oldHue || 0; + hsv.h = oldHue || 0; + } + + const transparent = hex === '000000' && rgb.a === 0; + return { + hsl: hsl, + hex: transparent ? 'transparent' : '#'.concat(hex), + rgb: rgb, + hsv: hsv, + oldHue: color['h'] || oldHue || hsl.h, + source: color['source'], + }; + }; + + const getContrastingColor = (color: { + hsl: tinycolor2.ColorFormats.HSLA; + hex: string; + rgb: tinycolor2.ColorFormats.RGBA; + hsv: tinycolor2.ColorFormats.HSVA; + oldHue: any; + source: any; + }) => { + if (color.hex === 'transparent') { + return 'rgba(0,0,0,0.4)'; + } + + const yiq = + (color.rgb.r * 299 + color.rgb.g * 587 + color.rgb.b * 114) / 1000; + return yiq >= 210 ? '#8C8C8C' : '#FFFFFF'; + }; + + const triggerSelect = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (onSelect) onSelect(color, event); + }; + const state = toState(color || '#FFFFFF'); + //接近白色的颜色,需要添加一个边框。不然看不见 + const needBorder = + ['#ffffff', '#fafafa', 'transparent'].indexOf(state.hex) >= 0; + //是否激活 + const active = activeColors.indexOf(color) >= 0; + const special = 'transparent' === color; + const styles: any = { + check: { + fill: getContrastingColor(state), + display: active ? 'block' : 'none', + }, + block: { + backgroundColor: color, + }, + }; + if (setStroke) { + styles.block.border = '1px solid '.concat(Palette.getStroke(color)); + } + return ( + + + + + + + + ); +}; + +export default ColorPickerItem; diff --git a/packages/toolbar/src/color/picker/palette.ts b/packages/toolbar/src/color/picker/palette.ts new file mode 100644 index 00000000..165e9340 --- /dev/null +++ b/packages/toolbar/src/color/picker/palette.ts @@ -0,0 +1,140 @@ +class Palette { + static colors: Array>; + static _map: { [k: string]: { x: number; y: number } }; + /** + * 获取描边颜色 + * 默认为当前 color,浅色不明显区域:第 3 组、第 4 组的第 3、4 个用第 5 组的颜色描边 + * + * @param {string} color 颜色 + * @return {string} 描边颜色 + */ + static getStroke: (color: string) => string; + static getColors: () => Array>; +} + +Palette.colors = [ + [ + '#000000', + '#262626', + '#595959', + '#8C8C8C', + '#BFBFBF', + '#D9D9D9', + '#E9E9E9', + '#F5F5F5', + '#FAFAFA', + '#FFFFFF', + ], + [ + '#F5222D', + '#FA541C', + '#FA8C16', + '#FADB14', + '#52C41A', + '#13C2C2', + '#1890FF', + '#2F54EB', + '#722ED1', + '#EB2F96', + ], + [ + '#FFE8E6', + '#FFECE0', + '#FFEFD1', + '#FCFCCA', + '#E4F7D2', + '#D3F5F0', + '#D4EEFC', + '#DEE8FC', + '#EFE1FA', + '#FAE1EB', + ], + [ + '#FFA39E', + '#FFBB96', + '#FFD591', + '#FFFB8F', + '#B7EB8F', + '#87E8DE', + '#91D5FF', + '#ADC6FF', + '#D3ADF7', + '#FFADD2', + ], + [ + '#FF4D4F', + '#FF7A45', + '#FFA940', + '#FFEC3D', + '#73D13D', + '#36CFC9', + '#40A9FF', + '#597EF7', + '#9254DE', + '#F759AB', + ], + [ + '#CF1322', + '#D4380D', + '#D46B08', + '#D4B106', + '#389E0D', + '#08979C', + '#096DD9', + '#1D39C4', + '#531DAB', + '#C41D7F', + ], + [ + '#820014', + '#871400', + '#873800', + '#614700', + '#135200', + '#00474F', + '#003A8C', + '#061178', + '#22075E', + '#780650', + ], +]; + +Palette._map = (function () { + let map = {}; + const colors = Palette.colors; + for (let i = 0, l1 = colors.length; i < l1; i++) { + const group = colors[i]; + for (let k = 0, l2 = group.length; k < l2; k++) { + const color = colors[i][k]; + map[color] = { + y: i, + x: k, + }; + } + } + return map; +})(); + +/** + * 获取描边颜色 + * 默认为当前 color,浅色不明显区域:第 3 组、第 4 组的第 3、4 个用第 5 组的颜色描边 + * + * @param {string} color 颜色 + * @return {string} 描边颜色 + */ +Palette.getStroke = function (color: string): string { + const pos = Palette._map[color]; + if (!pos) return color; + + if (pos.y === 2 || (pos.y === 3 && pos.x > 2 && pos.x < 5)) { + return this.colors[4][pos.x]; + } + + return color; +}; + +Palette.getColors = function () { + return this.colors; +}; + +export default Palette; diff --git a/packages/toolbar/src/config/toolbar/fontfamily.tsx b/packages/toolbar/src/config/toolbar/fontfamily.tsx new file mode 100644 index 00000000..9ff5934b --- /dev/null +++ b/packages/toolbar/src/config/toolbar/fontfamily.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { DropdownListItem } from '../../dropdown/list'; +import { isSupportFontFamily } from '../../utils'; + +export const defaultData = [ + { + key: 'default', + value: '', + }, + { + key: 'arial', + value: 'Arial', + }, + { + key: 'comicSansMS', + value: '"Comic Sans MS"', + }, + { + key: 'courierNew', + value: '"Courier New"', + }, + { + key: 'georgia', + value: 'Georgia', + }, + { + key: 'helvetica', + value: 'Helvetica', + }, + { + key: 'impact', + value: 'Impact', + }, + { + key: 'timesNewRoman', + value: '"Times New Roman"', + }, + { + key: 'trebuchetMS', + value: '"Trebuchet MS"', + }, + { + key: 'verdana', + value: 'Verdana', + }, + { + key: 'fangSong', + value: 'FangSong, 仿宋, FZFangSong-Z02S, STFangsong, fangsong', + }, + { + key: 'stFangsong', + value: 'STFangsong, 华文仿宋, FangSong, FZFangSong-Z02S, fangsong', + }, + { + key: 'stSong', + value: 'STSong, 华文宋体, SimSun, "Songti SC", NSimSun, serif', + }, + { + key: 'stKaiti', + value: 'STKaiti, 华文楷体, KaiTi, "Kaiti SC", cursive', + }, + { + key: 'simSun', + value: 'SimSun, 宋体, "Songti SC", NSimSun, STSong, serif', + }, + { + key: 'microsoftYaHei', + value: '"Microsoft YaHei", 微软雅黑, "PingFang SC", SimHei, STHeiti, sans-serif', + }, + { + key: 'kaiTi', + value: 'KaiTi, 楷体, STKaiti, "Kaiti SC", cursive', + }, + { + key: 'kaitiSC', + value: '"Kaiti SC"', + }, + { + key: 'simHei', + value: 'SimHei, 黑体, "Microsoft YaHei", "PingFang SC", STHeiti, sans-serif', + }, + { + key: 'heitiSC', + value: '"Heiti SC"', + }, + { + key: 'fzHei', + value: 'FZHei-B01S', + }, + { + key: 'fzKai', + value: 'FZKai-Z03S', + }, + { + key: 'fzFangSong', + value: 'FZFangSong-Z02S', + }, +]; +/** + * 生成字体下拉列表项 + * @param data key-value 键值对数据,key 名称,如果有传语言则是语言键值对的key否则就直接显示 + * @param language 语言,可选 + */ +export default ( + data: Array<{ key: string; value: string }>, + language?: { [key: string]: string }, +): Array => { + return data.map(({ key, value }) => { + const disabled = + key !== 'default' + ? !value.split(',').some((v) => isSupportFontFamily(v.trim())) + : false; + return { + key: value, + faimlyName: language ? language[key] : key, + content: () => ( + + {language ? language[key] : key} + + ), + hotkey: false, + disabled, + title: disabled + ? (language && language['notInstalled']) || + 'The font may not be installed' + : undefined, + }; + }); +}; diff --git a/packages/toolbar/src/config/toolbar/index.css b/packages/toolbar/src/config/toolbar/index.css new file mode 100644 index 00000000..d89cc991 --- /dev/null +++ b/packages/toolbar/src/config/toolbar/index.css @@ -0,0 +1,49 @@ +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-heading .toolbar-button { + font-weight: bold; + min-width: 73px; +} + +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-heading .heading-item-h1, +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-heading .heading-item-h2, +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-heading .heading-item-h3, +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-heading .heading-item-h4, +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-heading .heading-item-h5, +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-heading .heading-item-h6 { + line-height: 1.6; + font-weight: bold; + color: #262626; +} + +.heading-item-h1 { + font-size: 28px; +} + +.heading-item-h2 { + font-size: 24px; +} + +.heading-item-h3 { + font-size: 20px; +} + +.heading-item-h4 { + font-size: 16px; +} + +.heading-item-h5 { + font-size: 14px; +} + +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-heading .heading-item-h6 { + font-size: 14px; + font-weight: normal; +} + +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-fontsize .toolbar-button { + font-weight: bold; + min-width: 58px; +} + +.editor-toolbar .toolbar-dropdown.toolbar-dropdown-fontfamily .toolbar-button { + font-size: 12px; +} \ No newline at end of file diff --git a/packages/toolbar/src/config/toolbar/index.tsx b/packages/toolbar/src/config/toolbar/index.tsx new file mode 100644 index 00000000..63c1dbbd --- /dev/null +++ b/packages/toolbar/src/config/toolbar/index.tsx @@ -0,0 +1,893 @@ +import React from 'react'; +import { CARD_SELECTOR, EngineInterface } from '@aomao/engine'; +import { + ButtonProps, + DropdownProps, + ColorProps, + CollapseProps, +} from '../../types'; +import TableSelector from '../../table'; +import './index.css'; +import fontfamily, { defaultData as fontFamilyDefaultData } from './fontfamily'; + +export { fontfamily, fontFamilyDefaultData }; + +export const getToolbarDefaultConfig = ( + engine: EngineInterface, +): Array => { + const language = engine.language.get('toolbar'); + const headingLanguage = language['heading']; + return [ + { + type: 'collapse', + header: language['collapse']['title'], + icon: 'collapse', + groups: [ + { + items: [ + { + name: 'image-uploader', + icon: ( + + + + + + + + + + + + + ), + title: language['image']['title'], + search: '图片,tupian,image,img', + }, + { + name: 'codeblock', + icon: ( + + + + + + + + + + + + ), + title: language['codeblock']['title'], + search: '代码块,daimakuai,code', + }, + { + name: 'table', + command: { name: 'table', args: [3, 3] }, + placement: 'rightTop', + onDisabled: () => { + // 有激活卡片 或者没有启用插件 + return ( + !!engine.card.active || + !engine.command.queryEnabled('table') + ); + }, + prompt: + !!engine.card.active || + !engine.command.queryEnabled( + 'table', + ) ? undefined : ( + { + engine.command.execute( + 'table', + rows, + cols, + ); + }} + /> + ), + icon: ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + title: language['table']['title'], + search: 'biaoge,table', + }, + { + name: 'file-uploader', + icon: ( + + + + + + + + + + ), + title: language['file']['title'], + search: '附件,文件,fujian,wenjian,file', + }, + { + name: 'video-uploader', + icon: ( + + + + + + + + + + + + + + + + + + + + + ), + title: language['video']['title'], + search: '视频,MP4,shipin,video', + }, + { + name: 'math', + icon: ( + + + + + + + + + ), + title: language['math']['title'], + search: '公式,数学公式,gongshi,formula,math,latex', + }, + { + name: 'status', + icon: ( + + + + + + + + + + + + + + ), + title: language['status']['title'], + search: 'status,label,状态', + }, + /**{ + name: 'mind', + icon: ( + + + + ), + title: language['mind']['title'], + search: '脑图,思维导图,mind,naotu,shiweidaotu', + },**/ + ], + }, + ], + }, + { + type: 'button', + name: 'undo', + icon: 'undo', + title: language['undo']['title'], + onDisabled: () => { + return ( + !engine.command.queryState('undo') || + !engine.command.queryEnabled('undo') + ); + }, + onActive: () => false, + }, + { + type: 'button', + name: 'redo', + icon: 'redo', + title: language['redo']['title'], + onDisabled: () => { + return ( + !engine.command.queryState('redo') || + !engine.command.queryEnabled('redo') + ); + }, + onActive: () => false, + }, + { + type: 'button', + name: 'paintformat', + icon: 'paintformat', + title: language['paintformat']['title'], + }, + { + type: 'button', + name: 'removeformat', + icon: 'clean', + title: language['removeformat']['title'], + }, + { + type: 'dropdown', + name: 'heading', + className: 'toolbar-dropdown-heading', + title: headingLanguage['title'], + items: [ + { + key: 'p', + className: 'heading-item-p', + content: headingLanguage['p'], + }, + { + key: 'h1', + className: 'heading-item-h1', + content: headingLanguage['h1'], + }, + { + key: 'h2', + className: 'heading-item-h2', + content: headingLanguage['h2'], + }, + { + key: 'h3', + className: 'heading-item-h3', + content: headingLanguage['h3'], + }, + { + key: 'h4', + className: 'heading-item-h4', + content: headingLanguage['h4'], + }, + { + key: 'h5', + className: 'heading-item-h5', + content: headingLanguage['h5'], + }, + { + key: 'h6', + className: 'heading-item-h6', + content: headingLanguage['h6'], + }, + ], + }, + { + type: 'dropdown', + name: 'fontsize', + className: 'toolbar-dropdown-fontsize', + title: language['fontsize']['title'], + items: [ + { key: '12px', content: '12px', hotkey: false }, + { key: '13px', content: '13px', hotkey: false }, + { key: '14px', content: '14px', hotkey: false }, + { key: '15px', content: '15px', hotkey: false }, + { key: '16px', content: '16px', hotkey: false }, + { key: '19px', content: '19px', hotkey: false }, + { key: '22px', content: '22px', hotkey: false }, + { key: '24px', content: '24px', hotkey: false }, + { key: '29px', content: '29px', hotkey: false }, + { key: '32px', content: '32px', hotkey: false }, + { key: '40px', content: '40px', hotkey: false }, + { key: '48px', content: '48px', hotkey: false }, + ].map((item) => + item.key === engine.container.css('font-size') + ? { ...item, isDefault: true } + : item, + ), + onDisabled: () => { + const tag = engine.command.queryState('heading') || 'p'; + return ( + /^h\d$/.test(tag) || + !engine.command.queryEnabled('fontsize') + ); + }, + }, + { + type: 'dropdown', + name: 'fontfamily', + className: 'toolbar-dropdown-fontfamily', + title: language['fontfamily']['title'], + items: fontfamily(fontFamilyDefaultData, { + ...language['fontfamily']['items'], + notInstalled: language['fontfamily']['notInstalled'], + }), + onActive: (items) => { + const values = engine.command.queryState('fontfamily'); + if (!values || !Array.isArray(values) || values.length === 0) + return ''; + const familys: Array = values[0] + .split(',') + .map((name: string) => + name.replace(/"/g, '').trim().toLowerCase(), + ); + return ( + items.find( + (item) => + familys.indexOf( + item['faimlyName'].trim().toLowerCase(), + ) > -1, + )?.key || '' + ); + }, + }, + { + type: 'button', + name: 'bold', + icon: 'bold', + title: language['bold']['title'], + onDisabled: () => { + const tag = engine.command.queryState('heading') || 'p'; + return ( + /^h\d$/.test(tag) || !engine.command.queryEnabled('bold') + ); + }, + }, + { + type: 'button', + name: 'italic', + icon: 'italic', + title: language['italic']['title'], + }, + { + type: 'button', + name: 'strikethrough', + icon: 'strikethrough', + title: language['strikethrough']['title'], + }, + { + type: 'button', + name: 'underline', + icon: 'underline', + title: language['underline']['title'], + }, + { + type: 'dropdown', + name: 'moremark', + icon: 'moremark', + single: false, + title: language['moremark']['title'], + items: [ + { + key: 'sup', + icon: 'sup', + content: language['moremark']['sup'], + disabled: !engine.command.queryEnabled('sup'), + command: { name: 'sup', args: [] }, + }, + { + key: 'sub', + icon: 'sub', + disabled: !engine.command.queryEnabled('sub'), + content: language['moremark']['sub'], + command: { name: 'sub', args: [] }, + }, + { + key: 'code', + icon: 'code', + disabled: !engine.command.queryEnabled('code'), + content: language['moremark']['code'], + command: { name: 'code', args: [] }, + }, + ], + onDisabled: () => { + const plugins = []; + if (engine.command.queryEnabled('sup') === true) + plugins.push('sup'); + if (engine.command.queryEnabled('sub') === true) + plugins.push('sub'); + if (engine.command.queryEnabled('code') === true) + plugins.push('code'); + return plugins.length === 0; + }, + onActive: () => { + const plugins = []; + if (engine.command.queryState('sup') === true) + plugins.push('sup'); + if (engine.command.queryState('sub') === true) + plugins.push('sub'); + if (engine.command.queryState('code') === true) + plugins.push('code'); + return plugins; + }, + }, + { + type: 'color', + name: 'fontcolor', + defaultColor: '#262626', + defaultActiveColor: '#F5222D', + buttonTitle: language['fontcolor']['title'], + dropdownTitle: language['fontcolor']['more'], + content: (color: string, stroke: string, disabled?: boolean) => { + if (disabled === true) { + color = '#BFBFBF'; + stroke = '#BFBFBF'; + } + return ( + + color-font + Created with Sketch. + + + + + + ); + }, + }, + { + type: 'color', + name: 'backcolor', + defaultColor: 'transparent', + defaultActiveColor: '#FADB14', + buttonTitle: language['backcolor']['title'], + dropdownTitle: language['backcolor']['more'], + content: (color: string, stroke: string, disabled?: boolean) => { + if (disabled === true) { + color = '#BFBFBF'; + stroke = '#BFBFBF'; + } + return ( + + + + + + + + + ); + }, + }, + { + type: 'dropdown', + name: 'alignment', + title: language['alignment']['title'], + items: [ + { + key: 'left', + icon: 'align-left', + content: language['alignment']['left'], + }, + { + key: 'center', + icon: 'align-center', + content: language['alignment']['center'], + }, + { + key: 'right', + icon: 'align-right', + content: language['alignment']['right'], + }, + { + key: 'justify', + icon: 'align-justify', + content: language['alignment']['justify'], + }, + ], + }, + { + type: 'button', + name: 'unorderedlist', + icon: 'unordered-list', + title: language['unorderedlist']['title'], + }, + { + type: 'button', + name: 'orderedlist', + icon: 'ordered-list', + title: language['orderedlist']['title'], + }, + { + type: 'button', + name: 'tasklist', + icon: 'task-list', + title: language['tasklist']['title'], + }, + { + type: 'dropdown', + name: 'indent', + icon: 'indent', + hasDot: false, + title: language['indent']['title'], + items: [ + { + key: 'in', + icon: 'indent', + content: language['indent']['in'], + }, + { + key: 'out', + icon: 'outdent', + content: language['indent']['out'], + }, + ], + }, + { + type: 'dropdown', + name: 'line-height', + renderContent: () => ( + + ), + title: language['line-height']['title'], + items: [ + { + key: 'default', + content: language['line-height']['default'], + }, + { + key: '1', + content: '1', + }, + { + key: '1.15', + content: '1.15', + }, + { + key: '1.5', + content: '1.5', + }, + { + key: '2', + content: '2', + }, + { + key: '2.5', + content: '2.5', + }, + { + key: '3', + content: '3', + }, + ], + }, + { + type: 'button', + name: 'link', + icon: 'link', + command: { name: 'link', args: ['_blank'] }, + title: language['link']['title'], + onDisabled: () => { + const { change, card } = engine; + const range = change.range.get(); + const cardComponent = card.find(range.startNode); + return ( + (!!cardComponent && + !cardComponent.isCursor(range.startNode)) || + range.commonAncestorNode.find(CARD_SELECTOR).length > 0 || + !engine.command.queryEnabled('link') + ); + }, + }, + { + type: 'button', + name: 'quote', + icon: 'quote', + title: language['quote']['title'], + }, + { + type: 'button', + name: 'hr', + icon: 'hr', + title: language['hr']['title'], + }, + ]; +}; diff --git a/packages/toolbar/src/dropdown/index.css b/packages/toolbar/src/dropdown/index.css new file mode 100644 index 00000000..0d6b2e5d --- /dev/null +++ b/packages/toolbar/src/dropdown/index.css @@ -0,0 +1,106 @@ +.toolbar-dropdown { + position: relative; + display: inline-flex; +} + +.toolbar-dropdown .toolbar-dropdown-trigger { + display: inline-flex; + align-items: center; +} + +.toolbar-dropdown .toolbar-dropdown-trigger .toolbar-dropdown-button-text { + font-size: 12px; +} + +.toolbar-dropdown .toolbar-dropdown-trigger-arrow .toolbar-button{ + padding-right: 20px; +} + +.toolbar-dropdown .toolbar-dropdown-trigger-arrow .data-icon-arrow { + position: absolute; + right: 6px; + top: 15px; + width: 8px; + height: 8px; + background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4NCjxzdmcgd2lkdGg9IjhweCIgaGVpZ2h0PSI1cHgiIHZpZXdCb3g9IjAgMCA4IDUiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+DQogICAgPCEtLSBHZW5lcmF0b3I6IFNrZXRjaCA1Mi41ICg2NzQ2OSkgLSBodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2ggLS0+DQogICAgPHRpdGxlPkdyb3VwIENvcHkgNjwvdGl0bGU+DQogICAgPGRlc2M+Q3JlYXRlZCB3aXRoIFNrZXRjaC48L2Rlc2M+DQogICAgPGcgaWQ9IlN5bWJvbHMiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG9wYWNpdHk9IjAuNDUiPg0KICAgICAgICA8ZyBpZD0idG9vbGJhciIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTMyOC4wMDAwMDAsIC0xOC4wMDAwMDApIj4NCiAgICAgICAgICAgIDxnIGlkPSJwYXJhZ3JhcGgtc3R5bGUiPg0KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDIyNi4wMDAwMDAsIDQuMDAwMDAwKSI+DQogICAgICAgICAgICAgICAgICAgIDxnIGlkPSJHcm91cC1Db3B5LTYiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDEwMi4wMDAwMDAsIDEyLjAwMDAwMCkiPg0KICAgICAgICAgICAgICAgICAgICAgICAgPHJlY3QgaWQ9IlJlY3RhbmdsZSIgeD0iMCIgeT0iMCIgd2lkdGg9IjgiIGhlaWdodD0iOCI+PC9yZWN0Pg0KICAgICAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTAuNTk2MDkzNzUsMi41NTcwMzEyNSBMMy43NDUzMTI1LDYuMzc4MTI1IEMzLjg3NzM0Mzc1LDYuNTI1NzgxMjUgNC4xMDg1OTM3NSw2LjUyNTc4MTI1IDQuMjQwNjI1LDYuMzc4MTI1IEw3LjQwNTQ2ODc1LDIuNTU3MDMxMjUgQzcuNTk2MDkzNzUsMi4zNDI5Njg3NSA3LjQ0NDUzMTI1LDIuMDAzOTA2MjUgNy4xNTc4MTI1LDIuMDAzOTA2MjUgTDAuODQ0NTMxMjUsMi4wMDM5MDYyNSBDMC41NTcwMzEyNSwyLjAwMzkwNjI1IDAuNDA0Njg3NSwyLjM0Mjk2ODc1IDAuNTk2MDkzNzUsMi41NTcwMzEyNSBaIiBpZD0iU2hhcGUiIGZpbGw9IiMwMDAwMDAiIGZpbGwtcnVsZT0ibm9uemVybyI+PC9wYXRoPg0KICAgICAgICAgICAgICAgICAgICA8L2c+DQogICAgICAgICAgICAgICAgPC9nPg0KICAgICAgICAgICAgPC9nPg0KICAgICAgICA8L2c+DQogICAgPC9nPg0KPC9zdmc+); + background-repeat: no-repeat; + transition: all 0.25s cubic-bezier(0.3, 1.2, 0.2, 1); +} + +.toolbar-dropdown .toolbar-dropdown-list { + position: absolute; + top: 32px; + font-size: 12px; + background: #ffffff; + border: 1px solid #e8e8e8; + border-radius: 3px 3px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12); + padding: 5px 0; + height: auto; + transition: all 0.25s cubic-bezier(0.3, 1.2, 0.2, 1); + z-index: 999; + max-height: calc(80vh); + overflow: auto; +} + +.toolbar-dropdown.toolbar-dropdown-right:not(.toolbar-dropdown-right) .toolbar-dropdown-list{ + left: 0px; +} + +.editor-toolbar-mobile .toolbar-dropdown .toolbar-dropdown-list { + bottom: 32px; + top: auto; + max-height: calc(30vh); + overflow: auto; +} + +.editor-toolbar-mobile .toolbar-dropdown.toolbar-dropdown-right .toolbar-dropdown-list { + right: 0px; +} + +.toolbar-dropdown .toolbar-dropdown-list .toolbar-dropdown-list-item { + padding: 2px 10px 2px 16px; + line-height: 30px; + color: #595959; + text-align: left; + position: relative; + display: block; + white-space: nowrap; +} + +.toolbar-dropdown .toolbar-dropdown-list .toolbar-dropdown-list-item-disabled { + color: rgba(17, 31, 44, 0.24); + cursor: not-allowed; +} + +.toolbar-dropdown .toolbar-dropdown-list .toolbar-dropdown-list-item:not(.toolbar-dropdown-list-item-disabled):hover { + color: #262626; + background-color: #f5f5f5; +} + +.toolbar-dropdown .toolbar-dropdown-list .toolbar-dropdown-list-item .data-icon { + margin-right: 8px; +} + +.toolbar-dropdown .toolbar-dropdown-list.toolbar-dropdown-horizontal .toolbar-dropdown-list-item { + display: inline-block; +} + +.toolbar-dropdown .toolbar-dropdown-list.toolbar-dropdown-dot .toolbar-dropdown-list-item { + padding-left: 30px; + padding-right: 16px; + white-space: nowrap; +} + +.toolbar-dropdown .toolbar-dropdown-list .toolbar-dropdown-list-item .data-icon-dot +{ + position: absolute; + top: 50%; + left: 8px; + margin-top: -7px; + width: 14px; + height: 14px; + display: block; + background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTRweCIgaGVpZ2h0PSIxNHB4IiB2aWV3Qm94PSIwIDAgMTQgMTQiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDUyLjQgKDY3Mzc4KSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5VbnRpdGxlZDwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4KICAgIDxnIGlkPSJQYWdlLTEiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG9wYWNpdHk9IjAuNDUiPgogICAgICAgIDxnIGlkPSJjaGVjayIgZmlsbD0iIzAwMDAwMCI+CiAgICAgICAgICAgIDxyZWN0IGlkPSJSZWN0YW5nbGUiIG9wYWNpdHk9IjAiIHg9IjAiIHk9IjAiIHdpZHRoPSIxNCIgaGVpZ2h0PSIxNCI+PC9yZWN0PgogICAgICAgICAgICA8cGF0aCBkPSJNMTIuNDY4NzUsMi41OTc2NTYyNSBMMTEuNTEzMDg1OSwyLjU5NzY1NjI1IEMxMS4zNzkxMDE2LDIuNTk3NjU2MjUgMTEuMjUxOTUzMSwyLjY1OTE3OTY5IDExLjE2OTkyMTksMi43NjQ0NTMxMiBMNS41MzMwMDc4MSw5LjkwNTI3MzQ0IEwyLjgzMDA3ODEyLDYuNDgwNDY4NzUgQzIuNzQ2Njc5NjksNi4zNzUxOTUzMSAyLjYyMDg5ODQ0LDYuMzEzNjcxODcgMi40ODY5MTQwNiw2LjMxMzY3MTg3IEwxLjUzMTI1LDYuMzEzNjcxODcgQzEuNDM5NjQ4NDQsNi4zMTM2NzE4NyAxLjM4OTA2MjUsNi40MTg5NDUzMSAxLjQ0NTExNzE5LDYuNDkwMDM5MDYgTDUuMTg5ODQzNzUsMTEuMjM0MTc5NyBDNS4zNjQ4NDM3NSwxMS40NTU2NjQxIDUuNzAxMTcxODcsMTEuNDU1NjY0MSA1Ljg3NzUzOTA2LDExLjIzNDE3OTcgTDEyLjU1NDg4MjgsMi43NzI2NTYyNSBDMTIuNjEwOTM3NSwyLjcwMjkyOTY5IDEyLjU2MDM1MTYsMi41OTc2NTYyNSAxMi40Njg3NSwyLjU5NzY1NjI1IFoiIGlkPSJQYXRoIj48L3BhdGg+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4=); + background-repeat: no-repeat; +} \ No newline at end of file diff --git a/packages/toolbar/src/dropdown/index.tsx b/packages/toolbar/src/dropdown/index.tsx new file mode 100644 index 00000000..e9d3a6de --- /dev/null +++ b/packages/toolbar/src/dropdown/index.tsx @@ -0,0 +1,167 @@ +import React, { useState, useRef } from 'react'; +import classnames from 'classnames-es-ts'; +import { EngineInterface } from '@aomao/engine'; +import Button from '../button'; +import DropdownList, { DropdownListItem } from './list'; +import { useRight } from '../hooks'; +import './index.css'; + +export type DropdownProps = { + name: string; + items: Array; + values?: string | Array; + engine?: EngineInterface; + icon?: React.ReactNode; + content?: React.ReactNode | (() => React.ReactNode); + title?: string; + disabled?: boolean; + single?: boolean; + className?: string; + direction?: 'vertical' | 'horizontal'; + onSelect?: (event: React.MouseEvent, key: string) => void | boolean; + hasArrow?: boolean; + hasDot?: boolean; + renderContent?: (item: DropdownListItem) => React.ReactNode; +}; + +const Dropdown: React.FC = ({ + engine, + direction, + name, + icon, + content, + title, + className, + items, + disabled, + single, + values, + onSelect, + hasArrow, + renderContent, + hasDot, +}) => { + const [visible, setVisible] = useState(false); + + const buttonRef = useRef(null); + const isRight = useRight(buttonRef); + + const toggle = (event: React.MouseEvent) => { + event.preventDefault(); + if (disabled) { + return; + } + + if (visible) { + hide(); + } else { + show(); + } + }; + + const show = () => { + setTimeout(() => { + document.addEventListener('click', hide); + }, 10); + setVisible(true); + }; + + const hide = () => { + document.removeEventListener('click', hide); + setVisible(false); + }; + + const triggerSelect = (event: React.MouseEvent, key: string) => { + if (onSelect) onSelect(event, key); + }; + + const renderCustomeContent = ( + icon?: React.ReactNode, + content?: React.ReactNode, + ) => { + return icon ? ( + + ) : typeof content === 'string' ? ( + {content} + ) : typeof content === 'function' ? ( + content() + ) : ( + content + ); + }; + if (single !== false) + values = + Array.isArray(values) && values.length > 0 ? values[0] : values; + const item = items.find( + (item) => + (typeof values === 'string' && item.key === values) || + (Array.isArray(values) && values.indexOf(item.key) > -1), + ); + const defaultItem = + items.length > 0 + ? items.find((item) => item.isDefault === true) || items[0] + : null; + let buttonContent = item + ? renderContent + ? renderContent(item) + : Array.isArray(values) && values.length > 1 + ? renderCustomeContent(icon, content) + : renderCustomeContent(item.icon, item.content) + : icon || content + ? Array.isArray(values) && values.length > 0 + ? '' + : renderCustomeContent(icon, content) + : defaultItem + ? renderCustomeContent(defaultItem.icon, defaultItem.content) + : ''; + if (hasArrow !== false) + buttonContent = ( + <> + {buttonContent} + + + ); + + return ( +
      +
      +
      + {visible && ( + + )} +
      + ); +}; + +export default Dropdown; diff --git a/packages/toolbar/src/dropdown/list.tsx b/packages/toolbar/src/dropdown/list.tsx new file mode 100644 index 00000000..dee873ea --- /dev/null +++ b/packages/toolbar/src/dropdown/list.tsx @@ -0,0 +1,164 @@ +import React from 'react'; +import classnames from 'classnames-es-ts'; +import Tooltip from 'antd/es/tooltip'; +import { EngineInterface, formatHotkey, isMobile } from '@aomao/engine'; +import { autoGetHotkey } from '../utils'; +import 'antd/es/tooltip/style'; + +export type DropdownListItem = { + key: string; + icon?: React.ReactNode; + content?: React.ReactNode | (() => React.ReactNode); + hotkey?: boolean | string; + isDefault?: boolean; + title?: string; + placement?: + | 'right' + | 'top' + | 'left' + | 'bottom' + | 'topLeft' + | 'topRight' + | 'bottomLeft' + | 'bottomRight' + | 'leftTop' + | 'leftBottom' + | 'rightTop' + | 'rightBottom'; + className?: string; + disabled?: boolean; + command?: { name: string; args: Array } | Array; + autoExecute?: boolean; +}; + +export type DropdownListProps = { + engine?: EngineInterface; + direction?: 'vertical' | 'horizontal'; + name: string; + items: Array; + values: string | Array; + className?: string; + onSelect?: (event: React.MouseEvent, key: string) => void | boolean; + hasDot?: boolean; +}; + +const DropdownList: React.FC = ({ + engine, + name, + direction, + items, + className, + onSelect, + values, + hasDot, +}) => { + if (!direction) direction = 'vertical'; + + const triggerSelect = (event: React.MouseEvent, key: string) => { + event.preventDefault(); + event.stopPropagation(); + const item = items.find((item) => item.key === key); + if (!item) return; + const { autoExecute, command } = item; + if (onSelect && onSelect(event, key) === false) return; + if (autoExecute !== false) { + let commandName = name; + let commandArgs = [key]; + if (command) { + if (!Array.isArray(command)) { + commandName = command.name; + commandArgs = commandArgs.concat(command.args); + } else { + commandArgs = commandArgs.concat(command); + } + } + engine?.command.execute(commandName, ...commandArgs); + } + }; + + const renderItem = ({ + key, + title, + icon, + content, + command, + placement, + className, + hotkey, + disabled, + }: DropdownListItem) => { + const renderContent = () => ( + { + if (disabled) return; + return triggerSelect(event, key); + }} + > + {((typeof values === 'string' && values === key) || + (Array.isArray(values) && values.indexOf(key) > -1)) && + direction !== 'horizontal' && + hasDot !== false && ( + + )} + {typeof icon === 'string' ? ( + + ) : ( + icon + )} + {typeof content === 'function' ? content() : content} + + ); + let titleElement = title ? ( +
      {title}
      + ) : null; + //默认获取插件的热键 + if (engine && hotkey !== false) { + hotkey = autoGetHotkey( + engine, + command && !Array.isArray(command) ? command.name : name, + key, + ); + } + if (typeof hotkey === 'string' && hotkey !== '') { + titleElement = ( + <> + {title} +
      + {formatHotkey(hotkey)} +
      + + ); + } + + return titleElement && !isMobile ? ( + + {renderContent()} + + ) : ( + renderContent() + ); + }; + + return ( +
      + {items.map((item) => renderItem(item))} +
      + ); +}; + +export default DropdownList; diff --git a/packages/toolbar/src/group/index.css b/packages/toolbar/src/group/index.css new file mode 100644 index 00000000..04d3e994 --- /dev/null +++ b/packages/toolbar/src/group/index.css @@ -0,0 +1,9 @@ +.editor-toolbar-group { + padding: 4px 8px; + width: auto; + border-left: 1px solid #e8e8e8; +} + +.editor-toolbar .editor-toolbar-group:nth-child(1) { + border-left: none; +} \ No newline at end of file diff --git a/packages/toolbar/src/group/index.tsx b/packages/toolbar/src/group/index.tsx new file mode 100644 index 00000000..3ea8da5b --- /dev/null +++ b/packages/toolbar/src/group/index.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import classNames from 'classnames-es-ts'; +import { EngineInterface, isMobile } from '@aomao/engine'; +import Popover from 'antd/es/popover'; +import Button, { ButtonProps } from '../button'; +import Dropdown, { DropdownProps } from '../dropdown'; +import ColorButton, { ColorButtonProps } from '../color'; +import Collapse, { CollapseProps as CollapseButtonProps } from '../collapse'; +import 'antd/es/popover/style'; +import './index.css'; + +export type GroupButtonProps = { + type: 'button'; +} & Omit; + +export type GroupDropdownProps = { + type: 'dropdown'; +} & Omit; + +export type GroupColorProps = { + type: 'color'; +} & Omit; + +export type CollapseProps = { + type: 'collapse'; +} & Omit; + +export type GroupProps = { + engine: EngineInterface; + items: Array< + GroupButtonProps | GroupDropdownProps | GroupColorProps | CollapseProps + >; + icon?: React.ReactNode; + content?: React.ReactNode | (() => React.ReactNode); +}; + +const ToolbarGroup: React.FC = ({ + engine, + items, + icon, + content, +}) => { + const renderItems = () => { + return items.map((item, index) => { + switch (item.type) { + case 'button': + return +
      +
      +
      +
      `); + root.addClass(isMobile ? 'data-pswp-mobile' : 'data-pswp-pc'); + return root; + } + + hoverControllerFadeInAndOut() { + this.barUI.on('mouseenter', () => { + this.removeFadeOut(this.barUI, 'barFadeInAndOut'); + this.removeFadeOut(this.closeUI, 'closeFadeInAndOut'); + }); + + this.barUI.on('mouseleave', () => { + this.fadeOut(this.barUI, 'barFadeInAndOut'); + this.fadeOut(this.closeUI, 'closeFadeInAndOut'); + }); + + this.closeUI.on('mouseenter', () => { + this.removeFadeOut(this.barUI, 'barFadeInAndOut'); + this.removeFadeOut(this.closeUI, 'closeFadeInAndOut'); + }); + + this.closeUI.on('mouseleave', () => { + this.fadeOut(this.barUI, 'barFadeInAndOut'); + this.fadeOut(this.closeUI, 'closeFadeInAndOut'); + }); + } + + 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 = + getWindow().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); + 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); + } + } + + bindKeyboardEvnet() { + this.root.on('keydown', (event) => { + if ((event.metaKey || event.ctrlKey) && 187 === event.keyCode) { + event.preventDefault(); + this.zoomIn(); + } + if (isHotkey('mod+-', event)) { + event.preventDefault(); + this.zoomOut(); + } + }); + } + + 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['box-shadow'] = '0 0 10px rgba(0, 0, 0, 0.5)'; + } else { + node.onload = () => { + node.style.background = 'white'; + node.style['box-shadow'] = '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() { + this.close(); + } +} + +export default Pswp; diff --git a/plugins/image/src/component/pswp/zoom.ts b/plugins/image/src/component/pswp/zoom.ts new file mode 100644 index 00000000..b6c4255b --- /dev/null +++ b/plugins/image/src/component/pswp/zoom.ts @@ -0,0 +1,161 @@ +import { PswpInterface } from '@/types'; +import { $, EditorInterface, Tooltip } from '@aomao/engine'; + +class Zoom { + private pswp: PswpInterface; + private editor: EditorInterface; + prevStatus: string = 'default'; + nextStatus: string = 'default'; + zoomInStatus: string = 'default'; + zoomOutStatus: string = 'default'; + originSizeStatus: string = 'default'; + bestSizeStatus: string = '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 = 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/plugins/image/src/component/resizer/index.css b/plugins/image/src/component/resizer/index.css new file mode 100644 index 00000000..e2560064 --- /dev/null +++ b/plugins/image/src/component/resizer/index.css @@ -0,0 +1,98 @@ +.data-image-resizer { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + bottom: 0px; + right: 0; + z-index: 1; +} +.data-image-resizer-holder { + position: absolute; + width: 12px; + height: 12px; + border: 2px solid #fff; + background: #1890FF; + display: inline-block; +} +.data-image-resizer-holder-right-top { + top: -6px; + right: -6px; + cursor: nesw-resize; +} +.data-image-resizer-holder-right-bottom { + bottom: -6px; + right: -6px; + cursor: nwse-resize; +} +.data-image-resizer-holder-left-bottom { + bottom: -6px; + left: -6px; + cursor: nesw-resize; +} +.data-image-resizer-holder-left-top { + left: -6px; + top: -6px; + cursor: nwse-resize; +} + +.data-image-resizer-bg { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + cursor: pointer; + width: 100%; + height: 100%; + opacity: 0; +} +.data-image-resizer-bg-active { + opacity: 0.3; +} + +.data-image-resizer-number { + position: absolute; + display: inline-block; + line-height: 24px; + padding: 0 4px; + font-size: 12px; + border-radius: 3px 3px; + background: rgba(0, 0, 0, 0.86); + color: rgba(255, 255, 255, 0.96); + font-family: 'Lucida Console', Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease-in-out; + transform: scale(0.8); +} + +.data-image-resizer-number-right-top { + top: 0px; + right: -6px; + transform: translateX(100%) scale(0.8); +} + +.data-image-resizer-number-right-bottom { + right: -6px; + bottom: 0px; + transform: translateX(100%) scale(0.8); +} + +.data-image-resizer-number-left-bottom { + left: -6px; + bottom: 0px; + transform: translateX(-100%) scale(0.8); +} + +.data-image-resizer-number-left-top { + left: -6px; + top: 0px; + transform: translateX(-100%) scale(0.8); +} + +.data-image-resizer-number-active { + opacity: 1; + visibility: visible; +} \ No newline at end of file diff --git a/plugins/image/src/component/resizer/index.ts b/plugins/image/src/component/resizer/index.ts new file mode 100644 index 00000000..6182c244 --- /dev/null +++ b/plugins/image/src/component/resizer/index.ts @@ -0,0 +1,246 @@ +import { + $, + NodeInterface, + EventListener, + isMobile, + getWindow, +} from '@aomao/engine'; +import './index.css'; + +export type Options = { + src: string; + width: number; + height: number; + maxWidth: number; + rate: number; + onChange?: (size: Size) => void; +}; + +export type Position = + | 'right-top' + | 'left-top' + | 'right-bottom' + | 'left-bottom'; + +export type Point = { + x: number; + y: number; +}; + +export type Size = { + width: number; + height: number; +}; + +class Resizer { + private options: Options; + private root: NodeInterface; + private image: NodeInterface; + private resizerNumber: NodeInterface; + private point: Point = { x: 0, y: 0 }; + private position?: Position; + private size: Size; + maxWidth: number; + /** + * 是否改变大小中 + */ + private resizing: boolean = false; + + constructor(options: Options) { + this.options = options; + this.root = $(this.renderTemplate(options.src)); + this.image = this.root.find('img'); + this.resizerNumber = this.root.find('.data-image-resizer-number'); + const { width, height } = this.options; + this.size = { + width, + height, + }; + this.maxWidth = this.options.maxWidth; + } + + renderTemplate(src: string) { + return ` +
      + +
      +
      +
      +
      + +
      `; + } + + onMouseDown(event: MouseEvent | TouchEvent, position: Position) { + if (this.resizing) return; + event.preventDefault(); + event.stopPropagation(); + this.root.css( + 'top', + ['right-top', 'left-top'].indexOf(position) > -1 ? 'auto' : 0, + ); + this.root.css( + 'left', + ['left-top', 'left-bottom'].indexOf(position) > -1 ? 'auto' : 0, + ); + this.root.css( + 'bottom', + ['right-bottom', 'left-bottom'].indexOf(position) > -1 ? 'auto' : 0, + ); + this.root.css( + 'right', + ['right-top', 'right-bottom'].indexOf(position) > -1 ? 'auto' : 0, + ); + this.point = { + x: + getWindow().TouchEvent && event instanceof TouchEvent + ? event.touches[0].clientX + : (event as MouseEvent).clientX, + y: + getWindow().TouchEvent && event instanceof TouchEvent + ? event.touches[0].clientY + : (event as MouseEvent).clientY, + }; + this.position = position; + this.resizing = true; + this.resizerNumber.addClass( + `data-image-resizer-number-${this.position}`, + ); + this.resizerNumber.addClass('data-image-resizer-number-active'); + this.image.show(); + document.addEventListener( + isMobile ? 'touchmove' : 'mousemove', + this.onMouseMove, + ); + document.addEventListener( + isMobile ? 'touchend' : 'mouseup', + this.onMouseUp, + ); + } + + onMouseMove = (event: MouseEvent | TouchEvent) => { + event.preventDefault(); + event.stopPropagation(); + const { clientX, clientY } = + getWindow().TouchEvent && event instanceof TouchEvent + ? event.touches[0] + : (event as MouseEvent); + + if (clientX !== this.point.x || clientY !== this.point.y) { + //移动后的宽度 + const width = this.point.x - clientX; + //移动后的高度 + const height = this.point.y - clientY; + this.updateSize(width, height); + } + this.resizing = true; + }; + + onMouseUp = (event: MouseEvent | TouchEvent) => { + event.preventDefault(); + event.stopPropagation(); + const root = this.root.get(); + if (!root) return; + const { clientWidth, clientHeight } = root; + this.size = { + width: clientWidth, + height: clientHeight, + }; + this.resizerNumber.removeClass( + `data-image-resizer-number-${this.position}`, + ); + this.resizerNumber.removeClass('data-image-resizer-number-active'); + this.position = undefined; + this.resizing = false; + + document.removeEventListener( + isMobile ? 'touchmove' : 'mousemove', + this.onMouseMove, + ); + document.removeEventListener( + isMobile ? 'touchend' : 'mouseup', + this.onMouseUp, + ); + const { onChange } = this.options; + if (onChange) onChange(this.size); + this.image.hide(); + }; + + updateSize(width: number, height: number) { + if (['right-top', 'right-bottom'].indexOf(this.position || '') > -1) { + width = this.size.width - width; + } else { + width = this.size.width + width; + } + if (width < 24) { + width = 24; + } + const { rate } = this.options; + if (width > this.maxWidth) { + width = this.maxWidth; + } + + height = width * rate; + if (height < 24) { + height = 24; + width = height / rate; + } + width = Math.round(width); + height = Math.round(height); + this.setSize(width, height); + } + + setSize(width: number, height: number) { + this.root.css({ + width: width + 'px', + height: height + 'px', + }); + this.resizerNumber.html(`${width}\xB7${height}`); + } + + on(eventType: string, listener: EventListener) { + this.image.on(eventType, listener); + } + + off(eventType: string, listener: EventListener) { + this.image.off(eventType, listener); + } + + render() { + const { width, height } = this.options; + this.root.css({ + width: `${width}px`, + height: `${height}px`, + }); + + this.root + .find('.data-image-resizer-holder-right-top') + .on(isMobile ? 'touchstart' : 'mousedown', (event) => { + return this.onMouseDown(event, 'right-top'); + }); + this.root + .find('.data-image-resizer-holder-right-bottom') + .on(isMobile ? 'touchstart' : 'mousedown', (event) => { + return this.onMouseDown(event, 'right-bottom'); + }); + this.root + .find('.data-image-resizer-holder-left-bottom') + .on(isMobile ? 'touchstart' : 'mousedown', (event) => { + return this.onMouseDown(event, 'left-bottom'); + }); + this.root + .find('.data-image-resizer-holder-left-top') + .on(isMobile ? 'touchstart' : 'mousedown', (event) => { + return this.onMouseDown(event, 'left-top'); + }); + return this.root; + } + + destroy() { + this.root.remove(); + document.removeEventListener('mousemove', this.onMouseMove); + document.removeEventListener('mouseup', this.onMouseUp); + } +} + +export default Resizer; diff --git a/plugins/image/src/index.ts b/plugins/image/src/index.ts new file mode 100644 index 00000000..0e394c54 --- /dev/null +++ b/plugins/image/src/index.ts @@ -0,0 +1,148 @@ +import { + $, + CardEntry, + CardInterface, + CardType, + CARD_KEY, + NodeInterface, + Plugin, + PluginEntry, +} from '@aomao/engine'; +import ImageComponent, { ImageValue } from './component'; +import ImageUploader from './uploader'; +import locales from './locales'; + +export default class extends Plugin<{ + onBeforeRender?: (status: 'uploading' | 'done', src: string) => string; +}> { + static get pluginName() { + return 'image'; + } + + init() { + this.editor.language.add(locales); + this.editor.on('parse:html', (node) => this.parseHtml(node)); + } + + 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.constructor as CardEntry).cardName === + 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(async (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) { + root.find(`[${CARD_KEY}=${ImageComponent.cardName}`).each( + (cardNode) => { + const node = $(cardNode); + const card = this.editor.card.find(node) as ImageComponent; + const value = card?.getValue(); + if (value?.src && value.status === 'done') { + const img = node.find('.data-image-meta > img'); + node.empty(); + let src = value.src; + const { onBeforeRender } = this.options; + if (onBeforeRender) { + src = onBeforeRender(value.status, value.src); + } + img.attributes('src', src); + img.css('visibility', 'visible'); + img.css('background', ''); + img.css('background-color', ''); + img.css('background-repeat', ''); + img.css('background-position', ''); + img.css('background-image', ''); + img.removeAttributes('class'); + + if (img.length > 0) { + node.replaceWith(img); + if (card.type === CardType.BLOCK) { + this.editor.node.wrap( + img, + $(`

      `), + ); + } + } + } else node.remove(); + }, + ); + } +} + +export { ImageComponent, ImageUploader }; diff --git a/plugins/image/src/locales/en-US.ts b/plugins/image/src/locales/en-US.ts new file mode 100644 index 00000000..c59fb396 --- /dev/null +++ b/plugins/image/src/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/plugins/image/src/locales/index.ts b/plugins/image/src/locales/index.ts new file mode 100644 index 00000000..6266072c --- /dev/null +++ b/plugins/image/src/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/plugins/image/src/locales/zh-cn.ts b/plugins/image/src/locales/zh-cn.ts new file mode 100644 index 00000000..c2903474 --- /dev/null +++ b/plugins/image/src/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/plugins/image/src/types.ts b/plugins/image/src/types.ts new file mode 100644 index 00000000..932dd1d4 --- /dev/null +++ b/plugins/image/src/types.ts @@ -0,0 +1,29 @@ +import { NodeInterface } from '@aomao/engine'; +import { EventEmitter2 } from 'eventemitter2'; + +export interface PswpInterface extends EventEmitter2 { + root: NodeInterface; + barUI: NodeInterface; + closeUI: NodeInterface; + hoverControllerFadeInAndOut(): 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: Array, index: number): void; + close(): void; + destroy(): void; +} diff --git a/plugins/image/src/uploader.ts b/plugins/image/src/uploader.ts new file mode 100644 index 00000000..c442be1c --- /dev/null +++ b/plugins/image/src/uploader.ts @@ -0,0 +1,716 @@ +import { + $, + File, + isAndroid, + isEngine, + NodeInterface, + Plugin, + random, + READY_CARD_KEY, + getExtensionName, + SchemaInterface, + PluginOptions, + CARD_VALUE_KEY, + decodeCardValue, + encodeCardValue, + removeUnit, +} from '@aomao/engine'; +import ImageComponent, { ImageValue } from './component'; + +export interface Options extends PluginOptions { + /** + * 文件上传配置 + */ + file: { + /** + * 文件上传地址 + */ + action: string; + /** + * 数据返回类型,默认 json + */ + type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js'; + /** + * 图片文件上传时 FormData 的名称,默认 file + */ + name?: string; + /** + * 额外携带数据上传 + */ + data?: {}; + /** + * 请求类型,默认 multipart/form-data; + */ + contentType?: string; + /** + * 图片接收的格式,默认 "svg","png","bmp","jpg","jpeg","gif","tif","tiff","emf","webp" + */ + accept?: string | Array; + /** + * 是否跨域 + */ + crossOrigin?: boolean; + /** + * 请求头 + */ + headers?: + | { [key: string]: string } + | (() => { [key: string]: string }) + | (() => { [key: string]: string }); + /** + * 文件选择限制数量 + */ + multiple?: boolean | number; + /** + * 上传大小限制,默认 1024 * 1024 * 5 就是5M + */ + limitSize?: number; + }; + remote: { + /** + * 是否跨域 + */ + crossOrigin?: boolean; + /** + * 请求头 + */ + headers?: { [key: string]: string } | (() => { [key: string]: string }); + /** + * 上传地址 + */ + action: string; + /** + * 数据返回类型,默认 json + */ + type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js'; + /** + * 额外携带数据上传 + */ + data?: {}; + /** + * 图片地址上传时请求参数的名称,默认 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; +} + +export default class extends Plugin { + private cardComponents: { [key: string]: ImageComponent } = {}; + private loadCounts: { [key: string]: number } = {}; + + static get pluginName() { + return 'image-uploader'; + } + + extensionNames = [ + 'svg', + 'png', + 'bmp', + 'jpg', + 'jpeg', + 'gif', + 'tif', + 'tiff', + 'emf', + 'webp', + ]; + + init() { + if (isEngine(this.editor)) { + this.editor.on('keydown:enter', (event) => this.markdown(event)); + this.editor.on('drop:files', (files) => this.dropFiles(files)); + this.editor.on('paste:event', ({ files }) => + this.pasteFiles(files), + ); + this.editor.on('paste:schema', (schema) => + this.pasteSchema(schema), + ); + this.editor.on('paste:each', (node) => this.pasteEach(node)); + this.editor.on('paste:after', () => this.pasteAfter()); + this.editor.on( + 'paste:markdown-check', + (child) => !this.checkMarkdown(child)?.match, + ); + this.editor.on('paste:markdown', (child) => + this.pasteMarkdown(child), + ); + } + let { accept } = this.options.file || {}; + 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; + } + + isImage(file: File) { + const name = getExtensionName(file); + return this.extensionNames.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'] || {}; + if (onBeforeRender) return onBeforeRender(value.status, value.src); + } + return value.src; + } + + loadImage(id: string, value: ImageValue) { + if (!this.loadCounts[id]) this.loadCounts[id] = 1; + const image = new Image(); + + image.src = this.getUrl(value); + image.onload = () => { + delete this.loadCounts[id]; + this.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'; + this.editor.card.update(id, value); + } + }; + } + + async execute(files?: Array | string | MouseEvent) { + if (!isEngine(this.editor)) return; + const { request, card, language } = this.editor; + const { + action, + data, + type, + contentType, + multiple, + crossOrigin, + 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') { + files = await request.getFiles({ + event: files, + accept: isAndroid + ? 'image/*' + : this.extensionNames.length > 0 + ? '.' + this.extensionNames.join(',.') + : '', + multiple, + }); + } else if (typeof files === 'string') { + this.insertRemote(files); + return; + } + if (files.length === 0) return; + request.upload( + { + url: action, + crossOrigin, + headers: typeof headers === 'function' ? headers() : headers, + data, + type, + contentType, + onBefore: (file) => { + if (file.size > limitSize) { + this.editor.messageError( + language + .get('image', '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 src = fileInfo.src || ''; + const base64String = + typeof src !== 'string' + ? window.btoa( + String.fromCharCode(...new Uint8Array(src)), + ) + : src; + const insertCard = (value: Partial) => { + const component = card.insert( + 'image', + { + ...value, + status: 'uploading', + //fileInfo.src, 再协作中,如果大图片使用base64加载图片预览会造成很大资源浪费 + }, + base64String, + ) as ImageComponent; + this.cardComponents[fileInfo.uid] = component; + }; + return new Promise((resolve) => { + const image = new Image(); + image.src = base64String; + image.onload = () => { + insertCard({ + src: '', + size: { + width: image.width, + height: image.height, + naturalHeight: image.naturalHeight, + naturalWidth: image.naturalHeight, + }, + }); + resolve(); + }; + image.onerror = () => { + insertCard({ src: '' }); + 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 || + this.editor.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 || + this.editor.language.get('image', 'uploadError'), + }); + delete this.cardComponents[file.uid || '']; + }, + }, + files, + name, + ); + return; + } + + dropFiles(files: Array) { + if (!isEngine(this.editor)) return; + files = files.filter((file) => this.isImage(file)); + if (files.length === 0) return; + this.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-size': '@number', + 'data-width': '@number', + 'data-height': '@number', + }, + }); + } + + pasteFiles(files: Array) { + if (!isEngine(this.editor)) return; + files = files.filter((file) => this.isImage(file)); + if (files.length === 0) return; + this.editor.command.execute('image-uploader', files); + return false; + } + + pasteEach(node: NodeInterface) { + 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; + this.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 src = node.attributes('src') || node.attributes('data-src'); + const alt = node.attributes('alt'); + if (!src) { + node.remove(); + return; + } + const width = node.css('width'); + const height = node.css('height'); + this.editor.card.replaceNode(node, 'image', { + src, + status: + (isRemote && isRemote(src)) || /^data:image\//i.test(src) + ? 'uploading' + : 'done', + alt, + percent: 0, + size: { + width: removeUnit(width), + height: removeUnit(height), + }, + }); + node.remove(); + } + } + + uploadAddress(src: string, component: ImageComponent) { + if (!isEngine(this.editor)) return; + const { action, type, data, contentType, crossOrigin, headers, name } = + this.options.remote; + const { parse } = this.options; + const addressName = name || 'url'; + this.editor.request.ajax({ + url: action, + method: 'POST', + contentType: contentType || 'application/json', + type: type === undefined ? 'json' : type, + crossOrigin, + headers: typeof headers === 'function' ? headers() : headers, + data: { + ...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) { + this.editor.card.update(component.id, { + status: 'error', + message: + result.data || + this.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) => { + this.editor.card.update(component.id, { + status: 'error', + message: + error.message || + this.editor.language.get('image', 'uploadError'), + }); + }, + }); + } + + insertRemote(src: string, alt?: string) { + const value = { + src, + alt, + status: 'uploading', + }; + const { isRemote } = this.options; + //上传第三方图片 + if (isRemote && isRemote(src)) { + const component = this.editor.card.insert( + 'image', + value, + ) as ImageComponent; + this.uploadAddress(src, component); + return; + } + //当前图片 + value.status = 'done'; + this.editor.card.insert('image', value); + } + + pasteAfter() { + this.editor.container + .find('[data-card-key=image]') + .each((node, key) => { + const component = this.editor.card.find(node) as ImageComponent; + if (!component || !isEngine(this.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(); + this.editor.command.execute('image-uploader', [file]); + this.cardComponents[file.uid] = component; + return; + } + const { isRemote } = this.options; + if (isRemote && isRemote(src)) { + this.uploadAddress(src, component); + } + }); + } + + markdown(event: KeyboardEvent) { + if (!isEngine(this.editor) || this.options.markdown === false) return; + const { change, node } = this.editor; + const range = change.range.get(); + + if (!range.collapsed || change.isComposing() || !this.markdown) return; + const blockApi = this.editor.block; + const block = blockApi.closest(range.startNode); + + if (!node.isRootBlock(block)) { + return; + } + + const chars = blockApi.getLeftText(block); + const match = /!\[([^\]]{0,})\]\((https?:\/\/[^\)]{5,})\)/.exec(chars); + if (match) { + event.preventDefault(); + const splits = match[1].split('|'); + const src = match[2]; + blockApi.removeLeftText(block); + const alignment = splits[1]; + if (alignment === 'center' || alignment === 'right') { + this.editor.command.execute('alignment', alignment); + } + this.insertRemote(src, splits[0]); + } + } + + checkMarkdown(node: NodeInterface) { + if (!isEngine(this.editor) || !this.markdown || !node.isText()) return; + + const text = node.text(); + if (!text) return; + // 带跳转链接的图片 + let reg = + /(\[!\[([^\]]{0,})\]\((https?:\/\/[^\)]{5,})\)\]\(([\S]+?)\))|(!\[([^\]]{0,})\]\((https?:\/\/[^\)]{5,})\))/; + let match = reg.exec(text); + + if (!match) { + // 无跳转链接的图片 + //![aomao-preview](https://user-images.githubusercontent.com/55792257/125074830-62d79300-e0f0-11eb-8d0f-bb96a7775568.png) + reg = /(!\[([^\]]{0,})\]\((https?:\/\/[^\)]{5,})\))/; + match = reg.exec(text); + } + return { + reg, + match, + }; + } + + pasteMarkdown(node: NodeInterface) { + const result = this.checkMarkdown(node); + if (!result) return; + + const { isRemote } = this.options; + let { reg, match } = result; + if (!match) return; + let newText = ''; + let textNode = node.clone(true).get()!; + const { card } = this.editor; + while ( + textNode.textContent && + (match = reg.exec(textNode.textContent)) + ) { + //从匹配到的位置切断 + let regNode = textNode.splitText(match.index); + newText += textNode.textContent; + //从匹配结束位置分割 + textNode = regNode.splitText(match[0].length); + const isLink = match[0].startsWith('['); + const alt = isLink ? match[2] : match[6]; + const src = isLink ? match[3] : match[7]; + const link = isLink ? match[4] : ''; + + const cardNode = card.replaceNode($(regNode), 'image', { + src, + status: + (isRemote && isRemote(src)) || /^data:image\//i.test(src) + ? 'uploading' + : 'done', + alt, + percent: 0, + link: !!link + ? { + href: link, + target: isRemote && isRemote(link) ? '_blank' : '', + } + : undefined, + }); + regNode.remove(); + + newText += cardNode.get()?.outerHTML; + } + newText += textNode.textContent; + node.text(newText); + } +} diff --git a/plugins/image/tsconfig.json b/plugins/image/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/image/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/indent/README.md b/plugins/indent/README.md new file mode 100644 index 00000000..6b5b005d --- /dev/null +++ b/plugins/indent/README.md @@ -0,0 +1,67 @@ +# @aomao/plugin-indent + +缩进插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-indent +``` + +添加到引擎 + +此插件建议放在第一个增加,以免其它插件拦截了事件,使其无法生效 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Indent from '@aomao/plugin-indent'; + +new Engine(...,{ plugins:[Indent] }) +``` + +## 可选项 + +### 快捷键 + +默认缩进快捷键 `mod+]` + +默认删除缩进快捷键 `mod+[` + +```ts +//快捷键, +hotkey?: { + in?:string //缩进快捷键,默认 mod+] + out?:string //删除缩进快捷键,默认 mod+[ +}; + +//使用配置 +new Engine(...,{ + config:{ + "indent":{ + //修改快捷键 + hotkey:{ + "in":"快捷键", + "out":"快捷键" + } + } + } + }) +``` + +### 最大 padding + +最大 padding,每次缩进为 2 + +```ts +maxPadding?:number +``` + +## 命令 + +有一个参数 默认为 `in` ,可选值为 `in` 增加缩进,`out` 减少缩进 + +```ts +engine.command.execute('indent'); +//使用 command 执行查询当前状态,返回 numbber,当前缩进值 +engine.command.queryState('indent'); +``` diff --git a/plugins/indent/package.json b/plugins/indent/package.json new file mode 100644 index 00000000..1fbdaf22 --- /dev/null +++ b/plugins/indent/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-indent", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/indent/src/index.ts b/plugins/indent/src/index.ts new file mode 100644 index 00000000..2fd36490 --- /dev/null +++ b/plugins/indent/src/index.ts @@ -0,0 +1,327 @@ +import { + $, + addUnit, + isEngine, + NodeInterface, + Plugin, + PluginEntry, + removeUnit, + SchemaGlobal, + PluginOptions, + ConversionFromValue, + ConversionToValue, +} from '@aomao/engine'; + +export interface Options extends PluginOptions { + hotkey?: { + in?: string; + out?: string; + }; + maxPadding?: number; +} + +const TEXT_INENT_KEY = 'text-indent'; + +export default class extends Plugin { + static get pluginName() { + return 'indent'; + } + + init() { + this.editor.schema.add(this.schema()); + this.conversion().forEach(({ from, to }) => { + this.editor.conversion.add(from, to); + }); + this.editor.on('keydown:backspace', (event) => this.onBackspace(event)); + this.editor.on('keydown:tab', (event) => this.onTab(event)); + this.editor.on('keydown:shift-tab', (event) => this.onShiftTab(event)); + if (isEngine(this.editor)) { + this.editor.on('paste:each', (node) => this.pasteEach(node)); + } + } + + execute(type: 'in' | 'out' = 'in') { + if (!isEngine(this.editor)) return; + const { change, list, block } = this.editor; + list.split(); + const range = change.range.get(); + const blocks = block.findBlocks(range); + // 没找到目标 block + if (!blocks) { + return; + } + const maxPadding = this.options.maxPadding || 50; + // 其它情况 + blocks.forEach((block) => { + this.addPadding(block, type === 'in' ? 2 : -2, maxPadding); + }); + list.merge(); + } + + queryState() { + if (!isEngine(this.editor)) return; + const { change, list, node } = this.editor; + const range = change.range.get(); + if (!range.startNode.inEditor()) return 0; + const block = this.editor.block.closest(range.startNode); + if (block.name === 'li') { + return list.getIndent(block.closest('ul,ol')); + } + + if (node.isRootBlock(block) || node.isNestedBlock(block)) { + return removeUnit(block.css(TEXT_INENT_KEY)); + } + return 0; + } + + addPadding(block: NodeInterface, padding: number, maxPadding: number) { + const { list, node } = this.editor; + if (block.name === 'li') return; + if (node.isList(block)) { + list.addIndent(block, padding, maxPadding); + } else if (node.isRootBlock(block) || node.isNestedBlock(block)) { + if (padding > 0) { + if (removeUnit(block.css(TEXT_INENT_KEY))) { + const currentValue = block.css(TEXT_INENT_KEY); + let newValue = removeUnit(currentValue) + padding; + // 获取自身宽度计算最大的indent + let width = block.width(); + width = width === 0 ? this.editor.root.width() : width; + // 获取字体大小用作计算em + let fontSize = block.css('font-size'); + // 如果本身没有字体大小就,获取编辑器根节点默认字体大小 + fontSize = + !fontSize || fontSize.endsWith('em') + ? this.editor.root.css('font-size') + : fontSize; + if (fontSize.endsWith('em')) { + fontSize = $(document.body).css('font-size'); + } + if (!fontSize.endsWith('px')) fontSize = '16px'; + const widthMax = + width > 0 ? width / removeUnit(fontSize) : maxPadding; + + newValue = Math.min( + currentValue.endsWith('px') + ? newValue / removeUnit(fontSize) + : newValue, + maxPadding, + widthMax, + ); + if (newValue <= 0) block.css(TEXT_INENT_KEY, ''); + else { + block.css( + TEXT_INENT_KEY, + addUnit(newValue > 0 ? newValue : 0, 'em'), + ); + } + } else { + block.css(TEXT_INENT_KEY, `${padding}em`); + } + } else { + const currentValue = block.css(TEXT_INENT_KEY); + const newValue = removeUnit(currentValue) + padding; + if (newValue <= 0) block.css(TEXT_INENT_KEY, ''); + else { + block.css( + TEXT_INENT_KEY, + addUnit(newValue > 0 ? newValue : 0, 'em'), + ); + } + } + } + } + + hotkey() { + const inHotkey = this.options.hotkey?.in || 'mod+]'; + const outHotkey = this.options.hotkey?.out || 'mod+['; + return [ + { key: inHotkey, args: 'in' }, + { key: outHotkey, args: 'out' }, + ]; + } + + schema(): SchemaGlobal { + return { + type: 'block', + attributes: { + style: { + [TEXT_INENT_KEY]: '@length', + }, + }, + }; + } + + conversion(): Array<{ from: ConversionFromValue; to: ConversionToValue }> { + return [ + { + from: (_, styles, attributes) => { + return ( + !!styles['padding-left'] || !!attributes[TEXT_INENT_KEY] + ); + }, + to: (name, styles, attributes) => { + const node = $(`<${name} />`); + let valueStr = + styles['padding-left'] || attributes[TEXT_INENT_KEY]; + + // 转换为px + if (valueStr.endsWith('pt')) { + valueStr = this.convertToPX(valueStr); + } + // 转换为em + if (valueStr.endsWith('px')) { + // 获取自身的字体大小 + let fontSize = styles['font-size']; + // 如果本身没有字体大小就,获取编辑器根节点默认字体大小 + fontSize = + !fontSize || fontSize.endsWith('em') + ? this.editor.root.css('font-size') + : fontSize; + if (fontSize.endsWith('em')) { + fontSize = $(document.body).css('font-size'); + } + if (!fontSize.endsWith('px')) fontSize = '16px'; + const value = + removeUnit(valueStr) / removeUnit(fontSize); + styles[TEXT_INENT_KEY] = `${value}em`; + } else if (valueStr.endsWith('em')) { + styles[TEXT_INENT_KEY] = valueStr; + } + delete styles['padding-left']; + delete attributes[TEXT_INENT_KEY]; + node.css(styles); + Object.keys(attributes).forEach((name) => { + node.attributes(name, attributes[name]); + }); + return node; + }, + }, + ]; + } + + onBackspace(event: KeyboardEvent) { + if (!isEngine(this.editor)) return; + const { change, list, node } = this.editor; + const blockApi = this.editor.block; + let range = change.range.get(); + const block = this.editor.block.closest(range.startNode); + if (!node.isBlock(block)) return; + const parent = block.parent(); + let blocks = change.blocks; + if (parent && node.isBlock(parent)) { + blocks = blocks.filter((b) => !b.equal(parent)); + } + if ('li' === block.name) { + if (range.collapsed && !list.isFirst(range)) { + return; + } else if (!range.collapsed) return; + } else if ( + (range.collapsed && blockApi.isLastOffset(range, 'end')) || + !blockApi.isFirstOffset(range, 'start') || + blocks.length > 1 + ) + return; + else if (!range.collapsed) return; + if (this.queryState()) { + event.preventDefault(); + this.editor.command.execute( + (this.constructor as PluginEntry).pluginName, + 'out', + ); + return false; + } + return; + } + + onTab(event: KeyboardEvent) { + if (!isEngine(this.editor)) return; + const { change, list, block } = this.editor; + const range = change.range.get(); + //列表 + if (range.collapsed && list.isFirst(range)) { + event.preventDefault(); + this.editor.command.execute( + (this.constructor as PluginEntry).pluginName, + 'in', + ); + return false; + } + //

      foo

      + if (!range.collapsed || block.isFirstOffset(range, 'start')) { + event.preventDefault(); + this.editor.command.execute( + (this.constructor as PluginEntry).pluginName, + 'in', + true, + ); + return false; + } + return; + } + + onShiftTab(event: KeyboardEvent) { + if (!isEngine(this.editor)) return; + event.preventDefault(); + this.editor.command.execute( + (this.constructor as PluginEntry).pluginName, + 'out', + ); + return false; + } + + convertToPX(value: string) { + const match = /([\d\.]+)(pt|px)$/i.exec(value); + if (match && match[2] === 'pt') { + return ( + String(Math.round((parseInt(match[1], 10) * 96) / 72)) + 'px' + ); + } + return value; + } + + pasteEach(node: NodeInterface) { + //pt 转为px + if (!node.isCard() && this.editor.node.isBlock(node)) { + const textIndentSource = node.css(TEXT_INENT_KEY); + if (!!textIndentSource && textIndentSource.endsWith('pt')) { + const textIndent = this.convertToPX(textIndentSource); + if (!!textIndent) { + const maxPadding = this.options.maxPadding || 50; + let newValue = removeUnit(textIndent); + // 获取自身宽度计算最大的indent + let width = node.width(); + width = width === 0 ? this.editor.root.width() : width; + // 获取字体大小用作计算em + let fontSize = node.css('font-size'); + // 如果本身没有字体大小就,获取编辑器根节点默认字体大小 + fontSize = + !fontSize || fontSize.endsWith('em') + ? this.editor.root.css('font-size') + : fontSize; + if (fontSize.endsWith('em')) { + fontSize = $(document.body).css('font-size'); + } + if (!fontSize.endsWith('px')) fontSize = '16px'; + const widthMax = + width > 0 ? width / removeUnit(fontSize) : maxPadding; + + newValue = Math.min( + textIndent.endsWith('px') + ? newValue / removeUnit(fontSize) + : newValue, + maxPadding, + widthMax, + ); + if (newValue <= 0) node.css(TEXT_INENT_KEY, ''); + else { + node.css( + TEXT_INENT_KEY, + addUnit(newValue > 0 ? newValue : 0, 'em'), + ); + } + } + } + } + } +} diff --git a/plugins/indent/tsconfig.json b/plugins/indent/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/indent/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/italic/README.md b/plugins/italic/README.md new file mode 100644 index 00000000..0ee8d2df --- /dev/null +++ b/plugins/italic/README.md @@ -0,0 +1,66 @@ +# @aomao/plugin-italic + +斜体样式插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-italic +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Italic from '@aomao/plugin-italic'; + +new Engine(...,{ plugins:[Italic] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+i`,以数组形式传入多个快捷键 + +```ts +//快捷键, +hotkey?: string | Array; + +//使用配置 +new Engine(...,{ + config:{ + "italic":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Italic 插件 markdown 语法为`_` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "italic":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +```ts +engine.command.execute('italic'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('italic'); +``` diff --git a/plugins/italic/package.json b/plugins/italic/package.json new file mode 100644 index 00000000..3ce7316f --- /dev/null +++ b/plugins/italic/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-italic", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/italic/src/index.ts b/plugins/italic/src/index.ts new file mode 100644 index 00000000..09ce3ff2 --- /dev/null +++ b/plugins/italic/src/index.ts @@ -0,0 +1,39 @@ +import { MarkPlugin, PluginOptions } from '@aomao/engine'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; + markdown?: string; +} +export default class extends MarkPlugin { + static get pluginName() { + return 'italic'; + } + + tagName = 'em'; + + markdown = + this.options.markdown === undefined ? '_' : this.options.markdown; + + hotkey() { + return this.options.hotkey || 'mod+i'; + } + + conversion() { + return [ + { + from: { + span: { + style: { + 'font-style': 'italic', + }, + }, + }, + to: this.tagName, + }, + { + from: 'i', + to: this.tagName, + }, + ]; + } +} diff --git a/plugins/italic/tsconfig.json b/plugins/italic/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/italic/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/line-height/README.md b/plugins/line-height/README.md new file mode 100644 index 00000000..e7f1e1e5 --- /dev/null +++ b/plugins/line-height/README.md @@ -0,0 +1,80 @@ +# @aomao/plugin-line-height + +行高插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-line-height +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Lineheight from '@aomao/plugin-line-height'; + +new Engine(...,{ plugins:[Lineheight] }) +``` + +## 可选项 + +### 粘贴过滤自定义行高 + +支持过滤不符合自定义的行高 + +```ts +/** + * @param lineHeight 当前行高 + * @returns 返回 string 修改当前值,false 移除,true 保留 + * */ +filter?: (lineHeight: string) => string | boolean +//配置 +new Engine(...,{ + config:{ + [LineHeihgt.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 + } + } + } +} +``` + +### 快捷键 + +默认无快捷键 + +```ts +//快捷键,key 组合键,args,执行参数,[lineHeight] , lineHeight 可选,不传值删除当前光标位置的行高 +hotkey?:{key:string,args:Array};//默认无 + +//使用配置 +new Engine(...,{ + config:{ + "line-height":{ + //修改快捷键 + hotkey:{ + key:"mod+b", + args:["2"] + } + } + } + }) +``` + +## 命令 + +```ts +//lineHeight:更改的行高 +engine.command.execute('line-height', lineHeight); +//使用 command 执行查询当前状态,返回 Array | undefined,当前光标所在处行高值集合 +engine.command.queryState('line-height'); +``` diff --git a/plugins/line-height/package.json b/plugins/line-height/package.json new file mode 100644 index 00000000..c0e1795f --- /dev/null +++ b/plugins/line-height/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-line-height", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/line-height/src/index.ts b/plugins/line-height/src/index.ts new file mode 100644 index 00000000..27d2c415 --- /dev/null +++ b/plugins/line-height/src/index.ts @@ -0,0 +1,127 @@ +import { + isEngine, + NodeInterface, + Plugin, + SchemaGlobal, + PluginOptions, +} from '@aomao/engine'; + +export interface Options extends PluginOptions { + hotkey?: string; + filter?: (lineHeight: string) => string | boolean; +} + +export default class extends Plugin { + static get pluginName() { + return 'line-height'; + } + + #styleName = 'line-height'; + + init() { + this.editor.schema.add(this.schema()); + if (isEngine(this.editor)) { + this.editor.on('paste:each', (node) => this.pasteEach(node)); + } + } + + execute(lineHeight?: string) { + if (!isEngine(this.editor)) return; + if (lineHeight === 'default') lineHeight = ''; + const { change, block } = this.editor; + const range = change.range.get(); + const blocks = block.findBlocks(range); + // 没找到目标 block + if (!blocks) { + return; + } + // 其它情况 + blocks.forEach((block) => { + this.addLineHeight(block, lineHeight); + }); + } + + queryState() { + if (!isEngine(this.editor)) return; + const { change, node } = this.editor; + const range = change.range.get(); + if (!range.startNode.inEditor()) return ['default']; + const { blocks } = change; + + const values: Array = []; + blocks.forEach((block) => { + if (node.isNestedBlock(block)) { + const lineHeightSource = + block.get()?.style.lineHeight || ''; + if (!lineHeightSource) return; + const lineHeight = this.convertToPX(lineHeightSource); + + const { filter } = this.options; + if (filter) { + const result = filter(lineHeight); + if (result === false) { + return; + } else if (typeof result === 'string') { + values.push(result); + } else values.push(lineHeight); + } + } + }); + return values.length === 0 ? ['default'] : values; + } + /** + * 给 Block 节点增加行高 + * @param block block 节点 + * @param lineHeight 行高 + * @returns + */ + addLineHeight(block: NodeInterface, lineHeight?: string) { + const { node } = this.editor; + if (!node.isNestedBlock(block)) return; + block.css(this.#styleName, lineHeight || ''); + } + + schema(): SchemaGlobal { + return { + type: 'block', + attributes: { + style: { + [this.#styleName]: '@length', + }, + }, + }; + } + + convertToPX(value: string) { + const match = /([\d\.]+)(pt|px)$/i.exec(value); + if (match && match[2] === 'pt') { + return ( + String(Math.round((parseInt(match[1], 10) * 96) / 72)) + 'px' + ); + } + return value; + } + + pasteEach(node: NodeInterface) { + //pt 转为px + if (!node.isCard() && this.editor.node.isBlock(node)) { + const lineHeightSource = node.css(this.#styleName); + if (!lineHeightSource) return; + const lineHeight = this.convertToPX(lineHeightSource); + if (lineHeightSource.endsWith('pt')) { + node.css(this.#styleName, lineHeight); + } + const { filter } = this.options; + if (filter) { + const result = filter(lineHeight); + if (result === false) { + node.css(this.#styleName, ''); + } else if (typeof result === 'string') { + node.css(this.#styleName, result); + } + } else node.css(this.#styleName, ''); + const nodeApi = this.editor.node; + if (!nodeApi.isBlock(node)) nodeApi.unwrap(node); + } + } +} diff --git a/plugins/line-height/tsconfig.json b/plugins/line-height/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/line-height/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/link-vue/.browserslistrc b/plugins/link-vue/.browserslistrc new file mode 100644 index 00000000..214388fe --- /dev/null +++ b/plugins/link-vue/.browserslistrc @@ -0,0 +1,3 @@ +> 1% +last 2 versions +not dead diff --git a/plugins/link-vue/.fatherrc.ts b/plugins/link-vue/.fatherrc.ts new file mode 100644 index 00000000..3a7401ed --- /dev/null +++ b/plugins/link-vue/.fatherrc.ts @@ -0,0 +1,5 @@ +import commonjs from '@rollup/plugin-commonjs'; + +export default { + extraRollupPlugins: [commonjs()], +}; diff --git a/plugins/link-vue/README.md b/plugins/link-vue/README.md new file mode 100644 index 00000000..1e4e9214 --- /dev/null +++ b/plugins/link-vue/README.md @@ -0,0 +1,72 @@ +# @aomao/plugin-link-vue + +链接插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-link-vue +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Link from '@aomao/plugin-link'; + +new Engine(...,{ plugins:[Link] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+k`,默认参数为 ["_blank"] + +```ts +//快捷键,key 组合键,args,执行参数,[target?:string,href?:string,text?:string] 打开模式:可选,默认链接:可选,默认文本:可选 +hotkey?:string | {key:string,args:Array}; + +//使用配置 +new Engine(...,{ + config:{ + "link":{ + //修改快捷键 + hotkey:{ + key:"mod+k", + args:["_balnk_","https://www.yanmao.cc","ITELLYOU"] + } + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Link 插件 markdown 语法为`[文本](链接地址)` 回车后触发 + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "link":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +可传入三个参数[target?:string,href?:string,text?:string] 打开模式:可选,默认链接:可选,默认文本:可选 + +```ts +//target:'_blank', '_parent', '_top', '_self',href:链接,text:文字 +engine.command.execute('link', '_blank', 'https://www.yanmao.cc', 'ITELLYOU'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('link'); +``` diff --git a/plugins/link-vue/package.json b/plugins/link-vue/package.json new file mode 100644 index 00000000..82a6c199 --- /dev/null +++ b/plugins/link-vue/package.json @@ -0,0 +1,35 @@ +{ + "name": "@aomao/plugin-link-vue", + "version": "2.5.3", + "description": "> TODO: description", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "scripts": { + "test": "echo \"Error: run tests from root\" && exit 1" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10", + "ant-design-vue": "^2.2.6", + "vue": "^3.2.9" + }, + "devDependencies": { + "@vue/compiler-sfc": "^3.2.9" + } +} diff --git a/plugins/link-vue/src/index.css b/plugins/link-vue/src/index.css new file mode 100644 index 00000000..d430438f --- /dev/null +++ b/plugins/link-vue/src/index.css @@ -0,0 +1,79 @@ +.data-link-container { + max-width: 398px; + display: inline-block; + border: 1px solid #E8E8E8; + border-radius: 4px; + box-shadow: rgba(221, 221, 221, 0.5) 0px 1px 3px; + background: white; +} + +.data-link-container-mobile { + max-width: calc(100vw - 20px);; +} + +.data-link-container .data-link-editor { + min-width: 365px; + padding: 16px 12px; + padding-bottom: 4px; +} + +.data-link-container-mobile .data-link-editor { + min-width: calc(100vw - 40px); + padding: 8px 6px; +} + +.data-link-container p { + margin-top: 0; + margin-bottom: 14px; +} + +.data-link-container .itellyou-icon { + color: #8590A6; + font-size: 16px; +} +.data-link-preview { + line-height: 16px; + padding: 6px 8px; + vertical-align: middle; + white-space: nowrap; + display: flex; + justify-content:space-between; +} +.data-link-preview > * { + display: block; +} + +.data-link-preview a { + display: inline-block; + color: #595959; + margin: 0px 0px 0px 8px; + padding: 4px; +} +.data-link-preview a:hover { + background: #F4F4F4; + cursor: pointer; +} +.data-link-preview a.data-link-preview-open { + color: #1890FF; + max-width: 292px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; + font-size: 14px; + letter-spacing: 1.2px; + vertical-align: middle; + margin: 0; +} +.data-link-container-mobile .data-link-preview a.data-link-preview-open { + max-width: 70%; +} +.data-link-preview a.data-link-preview-open::before +{ + vertical-align: middle; + margin-right: 2px; +} + +.data-link-preview a.data-link-preview-open:hover{ + background: transparent; +} \ No newline at end of file diff --git a/plugins/link-vue/src/index.ts b/plugins/link-vue/src/index.ts new file mode 100644 index 00000000..356f2e8c --- /dev/null +++ b/plugins/link-vue/src/index.ts @@ -0,0 +1,195 @@ +import { + $, + NodeInterface, + InlinePlugin, + isEngine, + PluginEntry, + PluginOptions, +} from '@aomao/engine'; +import Toolbar from './toolbar'; +import locales from './locales'; + +import './index.css'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; + markdown?: string; +} +export default class extends InlinePlugin { + private toolbar?: Toolbar; + + static get pluginName() { + return 'link'; + } + + attributes = { + target: '@var0', + href: '@var1', + }; + + variable = { + '@var0': ['_blank', '_parent', '_top', '_self'], + '@var1': { + required: true, + value: '*', + }, + }; + + tagName = 'a'; + + markdown = + this.options.markdown === undefined + ? '\\[(.+?)\\]\\(([\\S]+?)\\)$' + : this.options.markdown; + + init() { + super.init(); + const editor = this.editor; + if (isEngine(editor)) { + this.toolbar = new Toolbar(editor, { + onConfirm: this.options.onConfirm, + }); + } + editor.on('parse:html', (node) => this.parseHtml(node)); + editor.on('select', () => { + this.query(); + }); + editor.language.add(locales); + } + + hotkey() { + return this.options.hotkey || { key: 'mod+k', args: ['_blank'] }; + } + + execute() { + if (!isEngine(this.editor)) return; + const { inline, change } = this.editor; + if (!this.queryState()) { + const inlineNode = $(`<${this.tagName} />`); + this.setStyle(inlineNode, ...arguments); + this.setAttributes(inlineNode, ...arguments); + const text = arguments.length > 2 ? arguments[2] : ''; + + if (!!text) { + inlineNode.text(text); + inline.insert(inlineNode); + } else { + inline.wrap(inlineNode); + } + const range = change.range.get(); + if (!range.collapsed && change.inlines.length > 0) { + this.toolbar?.show(change.inlines[0]); + } + } else { + const inlineNode = change.inlines.find((node) => this.isSelf(node)); + if (inlineNode && inlineNode.length > 0) { + inline.unwrap(inlineNode); + } + } + } + + query() { + if (!isEngine(this.editor)) return; + const { change, inline } = this.editor; + const range = change.range.get(); + const inlineNode = inline + .findInlines(range) + .find((node) => this.isSelf(node)); + this.toolbar?.hide(inlineNode); + if (inlineNode && !inlineNode.isCard()) { + if (range.collapsed) this.toolbar?.show(inlineNode); + return true; + } + return false; + } + + queryState() { + return this.query(); + } + + triggerMarkdown(event: KeyboardEvent, text: string, node: NodeInterface) { + const editor = this.editor; + if (!isEngine(editor) || !this.markdown) return; + const match = new RegExp(this.markdown).exec(text); + if (match) { + const { command } = editor; + event.preventDefault(); + const text = match[1]; + const url = match[2]; + // 移除 markdown 语法 + const markdownTextNode = node + .get()! + .splitText(node.text().length - match[0].length); + markdownTextNode.splitText(match[0].length); + $(markdownTextNode).remove(); + command.execute( + (this.constructor as PluginEntry).pluginName, + '_blank', + url, + text, + ); + editor.node.insertText('\xA0'); + return false; + } + return; + } + + checkMarkdown(node: NodeInterface) { + if (!isEngine(this.editor) || !this.markdown || !node.isText()) return; + + const text = node.text(); + if (!text) return; + + const reg = /(\[(.+?)\]\(([\S]+?)\))/; + const match = reg.exec(text); + return { + reg, + match, + }; + } + + pasteMarkdown(node: NodeInterface) { + const result = this.checkMarkdown(node); + if (!result) return; + let { reg, match } = result; + if (!match) return; + + let newText = ''; + let textNode = node.clone(true).get()!; + while ( + textNode.textContent && + (match = reg.exec(textNode.textContent)) + ) { + //从匹配到的位置切断 + let regNode = textNode.splitText(match.index + 1); + newText += textNode.textContent; + //从匹配结束位置分割 + textNode = regNode.splitText(match[0].length - 1); + + const text = match[2]; + const url = match[3]; + + const inlineNode = $(`<${this.tagName} />`); + this.setAttributes(inlineNode, '_blank', url); + inlineNode.text(!!text ? text : url); + + newText += inlineNode.get()?.outerHTML; + } + newText += textNode.textContent; + node.text(newText); + } + + parseHtml(root: NodeInterface) { + root.find(this.tagName).css({ + 'font-family': 'monospace', + 'font-size': 'inherit', + 'background-color': 'rgba(0,0,0,.06)', + padding: '0 2px', + border: '1px solid rgba(0,0,0,.08)', + 'border-radius': '2px 2px', + 'line-height': 'inherit', + 'overflow-wrap': 'break-word', + 'text-indent': '0', + }); + } +} diff --git a/plugins/link-vue/src/locales/en-US.ts b/plugins/link-vue/src/locales/en-US.ts new file mode 100644 index 00000000..0f451344 --- /dev/null +++ b/plugins/link-vue/src/locales/en-US.ts @@ -0,0 +1,12 @@ +export default { + link: { + text: 'Text', + link: 'Link', + text_placeholder: 'Description text', + link_placeholder: 'Link address', + link_open: 'Open link', + link_edit: 'Edit link', + link_remove: 'Remove link', + ok_button: 'OK', + }, +}; diff --git a/plugins/link-vue/src/locales/index.ts b/plugins/link-vue/src/locales/index.ts new file mode 100644 index 00000000..6266072c --- /dev/null +++ b/plugins/link-vue/src/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/plugins/link-vue/src/locales/zh-cn.ts b/plugins/link-vue/src/locales/zh-cn.ts new file mode 100644 index 00000000..13ed14ca --- /dev/null +++ b/plugins/link-vue/src/locales/zh-cn.ts @@ -0,0 +1,12 @@ +export default { + link: { + text: '文本', + link: '链接', + text_placeholder: '描述文本', + link_placeholder: '链接地址', + link_open: '打开链接', + link_edit: '编辑链接', + link_remove: '移除链接', + ok_button: '确定', + }, +}; diff --git a/plugins/link-vue/src/shims-vue.d.ts b/plugins/link-vue/src/shims-vue.d.ts new file mode 100644 index 00000000..ea85c268 --- /dev/null +++ b/plugins/link-vue/src/shims-vue.d.ts @@ -0,0 +1,6 @@ +/* eslint-disable */ +declare module '*.vue' { + import { DefineComponent } from 'vue'; + const component: DefineComponent<{}, {}, any>; + export default component; +} diff --git a/plugins/link-vue/src/toolbar/editor.vue b/plugins/link-vue/src/toolbar/editor.vue new file mode 100644 index 00000000..4a6e5941 --- /dev/null +++ b/plugins/link-vue/src/toolbar/editor.vue @@ -0,0 +1,103 @@ + + diff --git a/plugins/link-vue/src/toolbar/index.ts b/plugins/link-vue/src/toolbar/index.ts new file mode 100644 index 00000000..424182ba --- /dev/null +++ b/plugins/link-vue/src/toolbar/index.ts @@ -0,0 +1,224 @@ +import { + $, + EngineInterface, + isMobile, + NodeInterface, + Position, +} from '@aomao/engine'; +import { createApp, App } from 'vue'; +import AmEditor from './editor.vue'; +import AmPreview from './preview.vue'; + +export type ToolbarOptions = { + onConfirm?: ( + text: string, + link: string, + ) => Promise<{ text: string; link: string }>; +}; + +class Toolbar { + private engine: EngineInterface; + private root?: NodeInterface; + private target?: NodeInterface; + private options?: ToolbarOptions; + private mouseInContainer: boolean = false; + private vm?: App; + position: Position; + + constructor(engine: EngineInterface, options?: ToolbarOptions) { + this.engine = engine; + const { change } = this.engine; + this.options = options; + this.position = new Position(this.engine); + change.event.onWindow('mousedown', (event: MouseEvent) => { + if (!event.target) return; + const target = $(event.target); + const container = target.closest('.data-link-container'); + this.mouseInContainer = container && container.length > 0; + if (!target.inEditor() && !this.mouseInContainer) this.hide(); + }); + } + + private create() { + if (!this.target) return; + let root = $('.data-link-container'); + if (root.length === 0) { + root = $( + ``, + ); + } + this.root = root; + const rect = this.target.get()?.getBoundingClientRect(); + if (!rect) return; + this.root.css({ + top: `${window.pageYOffset + rect.bottom + 4}px`, + left: `${window.pageXOffset}px`, + position: 'absolute', + 'z-index': 1, + }); + } + + private async onOk(text: string, link: string) { + if (!this.target) return; + const { change } = this.engine; + const range = change.range.get(); + if (!change.rangePathBeforeCommand) { + if (!range.startNode.inEditor()) { + range.select(this.target, true); + change.range.select(range); + } + change.cacheRangeBeforeCommand(); + } + const { onConfirm } = this.options || {}; + if (onConfirm) { + const result = await onConfirm(text, link); + text = result.text; + link = result.link; + } + this.target.attributes('href', link); + text = text.trim() === '' ? link : text; + const oldText = this.target.text(); + if (oldText !== text) { + const children = this.target.children(); + // 左右两侧有零宽字符 + if (children.length < 3) { + this.target.text(text); + } + // 中间节点是文本字符 + else if (children.length === 3 && children.eq(1)?.isText()) { + this.target.text(text); + } + // 中间节点是非文本节点 + else if (children.length === 3) { + let element = children.eq(1); + while (element) { + const child = element.children(); + // 有多个子节点就直接设置文本,覆盖里面的mark样式 + if (child.length > 1 || child.length === 0) { + element.text(text); + break; + } + // 里面的子节点是文本就设置文本 + else if (child.eq(0)?.isText()) { + element.text(text); + break; + } + // 里面的子节点非文本节点就继续循环 + else { + element = child; + } + } + } + // 多个其它节点 + else { + this.target.text(text); + } + } + this.engine.inline.repairCursor(this.target); + range.setStart(this.target.next()!, 1); + range.setEnd(this.target.next()!, 1); + change.apply(range); + this.mouseInContainer = false; + this.hide(); + } + + editor(text: string, href: string, callback?: () => void) { + const vm = createApp(AmEditor, { + language: this.engine.language, + defaultText: text, + defaultLink: href, + onLoad: () => { + this.mouseInContainer = true; + if (callback) callback(); + }, + onOk: (text: string, link: string) => this.onOk(text, link), + }); + return vm; + } + + preview(href: string, callback?: () => void) { + const { change, inline, language } = this.engine; + const vm = createApp(AmPreview, { + language, + href, + onLoad: () => { + if (callback) callback(); + }, + onEdit: () => { + if (!this.target) return; + this.mouseInContainer = false; + this.hide(undefined, false); + this.show(this.target, true); + }, + onRemove: () => { + if (!this.target) return; + const range = change.range.get(); + range.select(this.target, true); + inline.repairRange(range); + change.range.select(range); + change.cacheRangeBeforeCommand(); + inline.unwrap(); + this.mouseInContainer = false; + this.target = undefined; + this.hide(); + }, + }); + return vm; + } + + show(target: NodeInterface, forceEdit?: boolean) { + this.target = target; + this.create(); + const text = target.text().replace(/\u200B/g, ''); + const href = target.attributes('href'); + const container = this.root!.get()!; + + const name = !href || forceEdit ? 'am-link-editor' : 'am-link-preview'; + if (this.vm && this.vm._component.name === name) { + if (!this.root || !this.target) return; + this.position?.bind(this.root, this.target); + return; + } else if (this.vm) { + this.vm.unmount(); + this.vm = undefined; + this.position?.destroy(); + } + + setTimeout(() => { + this.position?.bind(this.root!, this.target!); + this.vm = + !href || forceEdit + ? this.editor(text, href, () => { + this.position?.update(); + }) + : this.preview(href, () => { + this.position?.update(); + }); + this.vm.mount(container); + }, 20); + } + + hide(target?: NodeInterface, clearTarget?: boolean) { + if (target && this.target && target.equal(this.target)) return; + const element = this.root?.get(); + if (element && !this.mouseInContainer) { + if (this.vm) { + this.vm.unmount(); + this.vm = undefined; + this.position?.destroy(); + } + this.root = undefined; + if (this.target && !this.target.attributes('href')) { + const { change, inline } = this.engine; + const range = change.range.get(); + range.select(this.target, true); + change.range.select(range); + inline.unwrap(); + } + if (clearTarget !== false) this.target = undefined; + } + } +} +export default Toolbar; diff --git a/plugins/link-vue/src/toolbar/preview.vue b/plugins/link-vue/src/toolbar/preview.vue new file mode 100644 index 00000000..6c906206 --- /dev/null +++ b/plugins/link-vue/src/toolbar/preview.vue @@ -0,0 +1,60 @@ + + \ No newline at end of file diff --git a/plugins/link-vue/tsconfig.json b/plugins/link-vue/tsconfig.json new file mode 100644 index 00000000..e5339aee --- /dev/null +++ b/plugins/link-vue/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "preserve", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true, + "lib": ["esnext", "dom", "dom.iterable", "scripthost"] + }, + "include": ["src/*.ts", "src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/link-vue/tslint.json b/plugins/link-vue/tslint.json new file mode 100644 index 00000000..12f8560f --- /dev/null +++ b/plugins/link-vue/tslint.json @@ -0,0 +1,15 @@ +{ + "defaultSeverity": "warning", + "extends": ["tslint:recommended"], + "linterOptions": { + "exclude": ["node_modules/**"] + }, + "rules": { + "indent": [true, "spaces", 4], + "interface-name": false, + "no-consecutive-blank-lines": false, + "object-literal-sort-keys": false, + "ordered-imports": false, + "quotemark": [true, "single"] + } +} diff --git a/plugins/link/README.md b/plugins/link/README.md new file mode 100644 index 00000000..14d018e9 --- /dev/null +++ b/plugins/link/README.md @@ -0,0 +1,78 @@ +# @aomao/plugin-link + +链接插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-link +``` + +`Vue` 使用 + +```bash +$ yarn add @aomao/plugin-link-vue +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Link from '@aomao/plugin-link'; + +new Engine(...,{ plugins:[Link] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+k`,默认参数为 ["_blank"] + +```ts +//快捷键,key 组合键,args,执行参数,[target?:string,href?:string,text?:string] 打开模式:可选,默认链接:可选,默认文本:可选 +hotkey?:string | {key:string,args:Array}; + +//使用配置 +new Engine(...,{ + config:{ + "link":{ + //修改快捷键 + hotkey:{ + key:"mod+k", + args:["_balnk_","https://www.yanmao.cc","ITELLYOU"] + } + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Link 插件 markdown 语法为`[文本](链接地址)` 回车后触发 + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "link":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +可传入三个参数[target?:string,href?:string,text?:string] 打开模式:可选,默认链接:可选,默认文本:可选 + +```ts +//target:'_blank', '_parent', '_top', '_self',href:链接,text:文字 +engine.command.execute('link', '_blank', 'https://www.yanmao.cc', 'ITELLYOU'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('link'); +``` diff --git a/plugins/link/package.json b/plugins/link/package.json new file mode 100644 index 00000000..939c3072 --- /dev/null +++ b/plugins/link/package.json @@ -0,0 +1,34 @@ +{ + "name": "@aomao/plugin-link", + "version": "2.5.3", + "description": "> TODO: description", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "scripts": { + "test": "echo \"Error: run tests from root\" && exit 1" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10", + "antd": "^4.12.3", + "classnames-es-ts": "^2.2.7", + "react": "^17.0.1", + "react-dom": "^17.0.2" + } +} diff --git a/plugins/link/src/index.css b/plugins/link/src/index.css new file mode 100644 index 00000000..d430438f --- /dev/null +++ b/plugins/link/src/index.css @@ -0,0 +1,79 @@ +.data-link-container { + max-width: 398px; + display: inline-block; + border: 1px solid #E8E8E8; + border-radius: 4px; + box-shadow: rgba(221, 221, 221, 0.5) 0px 1px 3px; + background: white; +} + +.data-link-container-mobile { + max-width: calc(100vw - 20px);; +} + +.data-link-container .data-link-editor { + min-width: 365px; + padding: 16px 12px; + padding-bottom: 4px; +} + +.data-link-container-mobile .data-link-editor { + min-width: calc(100vw - 40px); + padding: 8px 6px; +} + +.data-link-container p { + margin-top: 0; + margin-bottom: 14px; +} + +.data-link-container .itellyou-icon { + color: #8590A6; + font-size: 16px; +} +.data-link-preview { + line-height: 16px; + padding: 6px 8px; + vertical-align: middle; + white-space: nowrap; + display: flex; + justify-content:space-between; +} +.data-link-preview > * { + display: block; +} + +.data-link-preview a { + display: inline-block; + color: #595959; + margin: 0px 0px 0px 8px; + padding: 4px; +} +.data-link-preview a:hover { + background: #F4F4F4; + cursor: pointer; +} +.data-link-preview a.data-link-preview-open { + color: #1890FF; + max-width: 292px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; + font-size: 14px; + letter-spacing: 1.2px; + vertical-align: middle; + margin: 0; +} +.data-link-container-mobile .data-link-preview a.data-link-preview-open { + max-width: 70%; +} +.data-link-preview a.data-link-preview-open::before +{ + vertical-align: middle; + margin-right: 2px; +} + +.data-link-preview a.data-link-preview-open:hover{ + background: transparent; +} \ No newline at end of file diff --git a/plugins/link/src/index.ts b/plugins/link/src/index.ts new file mode 100644 index 00000000..90a816e8 --- /dev/null +++ b/plugins/link/src/index.ts @@ -0,0 +1,204 @@ +import { + $, + NodeInterface, + InlinePlugin, + isEngine, + PluginEntry, + PluginOptions, +} from '@aomao/engine'; +import Toolbar from './toolbar'; +import locales from './locales'; + +import './index.css'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; + markdown?: string; + onConfirm?: ( + text: string, + link: string, + ) => Promise<{ text: string; link: string }>; +} +export default class extends InlinePlugin { + private toolbar?: Toolbar; + + static get pluginName() { + return 'link'; + } + + attributes = { + target: '@var0', + href: '@var1', + }; + + variable = { + '@var0': ['_blank', '_parent', '_top', '_self'], + '@var1': { + required: true, + value: '*', + }, + }; + + tagName = 'a'; + + markdown = + this.options.markdown === undefined + ? '\\[(.+?)\\]\\(([\\S]+?)\\)$' + : this.options.markdown; + + init() { + super.init(); + const editor = this.editor; + if (isEngine(editor)) { + this.toolbar = new Toolbar(editor, { + onConfirm: this.options.onConfirm, + }); + } + editor.on('parse:html', (node) => this.parseHtml(node)); + editor.on('select', () => { + this.query(); + }); + editor.language.add(locales); + } + + hotkey() { + return this.options.hotkey || { key: 'mod+k', args: ['_blank'] }; + } + + execute(...args: any) { + if (!isEngine(this.editor)) return; + const { inline, change } = this.editor; + if (!this.queryState()) { + const inlineNode = $(`<${this.tagName} />`); + this.setStyle(inlineNode, ...args); + this.setAttributes(inlineNode, ...args); + const text = args.length > 2 ? args[2] : ''; + + if (!!text) { + inlineNode.text(text); + inline.insert(inlineNode); + } else { + inline.wrap(inlineNode); + } + const range = change.range.get(); + if (!range.collapsed && change.inlines.length > 0) { + this.toolbar?.show(change.inlines[0]); + } + } else { + const inlineNode = change.inlines.find((node) => this.isSelf(node)); + if (inlineNode && inlineNode.length > 0) { + inline.unwrap(inlineNode); + } + } + } + + query() { + if (!isEngine(this.editor)) return; + const { change, inline } = this.editor; + const range = change.range.get(); + const inlineNode = inline + .findInlines(range) + .find((node) => this.isSelf(node)); + this.toolbar?.hide(inlineNode); + if (inlineNode && !inlineNode.isCard()) { + if (range.collapsed) this.toolbar?.show(inlineNode); + return true; + } + return false; + } + + queryState() { + return this.query(); + } + + triggerMarkdown(event: KeyboardEvent, text: string, node: NodeInterface) { + const editor = this.editor; + if (!isEngine(editor) || !this.markdown) return; + const match = new RegExp(this.markdown).exec(text); + if (match) { + const { command } = editor; + event.preventDefault(); + const text = match[1]; + const url = match[2]; + // 移除 markdown 语法 + const markdownTextNode = node + .get()! + .splitText(node.text().length - match[0].length); + markdownTextNode.splitText(match[0].length); + $(markdownTextNode).remove(); + command.execute( + (this.constructor as PluginEntry).pluginName, + '_blank', + url, + text, + ); + editor.node.insertText('\xA0'); + return false; + } + return; + } + + checkMarkdown(node: NodeInterface) { + if (!isEngine(this.editor) || !this.markdown || !node.isText()) return; + + const text = node.text(); + if (!text) return; + + const reg = /(\[(.+?)\]\(([\S]+?)\))/; + const match = reg.exec(text); + return { + reg, + match, + }; + } + + pasteMarkdown(node: NodeInterface) { + const result = this.checkMarkdown(node); + if (!result) return; + let { reg, match } = result; + if (!match) return; + + let newText = ''; + let textNode = node.clone(true).get()!; + while ( + textNode.textContent && + (match = reg.exec(textNode.textContent)) + ) { + //从匹配到的位置切断 + let regNode = textNode.splitText(match.index); + if ( + textNode.textContent.endsWith('!') || + match[2].startsWith('!') + ) { + newText += textNode.textContent; + textNode = regNode.splitText(match[0].length); + newText += regNode.textContent; + continue; + } + newText += textNode.textContent; + //从匹配结束位置分割 + textNode = regNode.splitText(match[0].length); + + const text = match[2]; + const url = match[3]; + + const inlineNode = $(`<${this.tagName} />`); + this.setAttributes(inlineNode, '_blank', url); + inlineNode.html(!!text ? text : url); + + newText += inlineNode.get()?.outerHTML; + } + newText += textNode.textContent; + node.text(newText); + } + + parseHtml(root: NodeInterface) { + root.find(this.tagName).css({ + 'font-size': 'inherit', + padding: '0 2px', + 'line-height': 'inherit', + 'overflow-wrap': 'break-word', + 'text-indent': '0', + }); + } +} diff --git a/plugins/link/src/locales/en-US.ts b/plugins/link/src/locales/en-US.ts new file mode 100644 index 00000000..0f451344 --- /dev/null +++ b/plugins/link/src/locales/en-US.ts @@ -0,0 +1,12 @@ +export default { + link: { + text: 'Text', + link: 'Link', + text_placeholder: 'Description text', + link_placeholder: 'Link address', + link_open: 'Open link', + link_edit: 'Edit link', + link_remove: 'Remove link', + ok_button: 'OK', + }, +}; diff --git a/plugins/link/src/locales/index.ts b/plugins/link/src/locales/index.ts new file mode 100644 index 00000000..6266072c --- /dev/null +++ b/plugins/link/src/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/plugins/link/src/locales/zh-cn.ts b/plugins/link/src/locales/zh-cn.ts new file mode 100644 index 00000000..13ed14ca --- /dev/null +++ b/plugins/link/src/locales/zh-cn.ts @@ -0,0 +1,12 @@ +export default { + link: { + text: '文本', + link: '链接', + text_placeholder: '描述文本', + link_placeholder: '链接地址', + link_open: '打开链接', + link_edit: '编辑链接', + link_remove: '移除链接', + ok_button: '确定', + }, +}; diff --git a/plugins/link/src/toolbar/editor.tsx b/plugins/link/src/toolbar/editor.tsx new file mode 100644 index 00000000..de386dae --- /dev/null +++ b/plugins/link/src/toolbar/editor.tsx @@ -0,0 +1,83 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { LanguageInterface } from '@aomao/engine'; +import Input from 'antd/es/input'; +import Button from 'antd/es/button'; +import classnames from 'classnames-es-ts'; +import 'antd/es/input/style'; +import 'antd/es/button/style'; + +export type LinkEditorProps = { + language: LanguageInterface; + defaultText?: string; + defaultLink?: string; + className?: string; + onLoad?: (element: Input) => void; + onOk: (text: string, link: string) => void; +}; + +const LinkEditor: React.FC = ({ + language, + defaultLink, + defaultText, + className, + onLoad, + onOk, +}) => { + const [text, setText] = useState(defaultText || ''); + const [link, setLink] = useState(defaultLink || ''); + + const linkRef = useRef(null); + + useEffect(() => { + if (onLoad && linkRef.current) onLoad(linkRef.current); + setTimeout(() => { + linkRef.current?.focus(); + }, 200); + }, []); + + return ( +
      +

      {language.get('link', 'text')}

      +

      + { + setText(event.target.value); + }} + /> +

      +

      {language.get('link', 'link')}

      +

      + { + setLink(event.target.value); + }} + /> +

      +

      + +

      +
      + ); +}; + +export default LinkEditor; diff --git a/plugins/link/src/toolbar/index.tsx b/plugins/link/src/toolbar/index.tsx new file mode 100644 index 00000000..b24e3d3c --- /dev/null +++ b/plugins/link/src/toolbar/index.tsx @@ -0,0 +1,207 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import ConfigProvider from 'antd/es/config-provider'; +import { + $, + EngineInterface, + NodeInterface, + isMobile, + Position, +} from '@aomao/engine'; +import Editor from './editor'; +import Preview from './preview'; + +export type ToolbarOptions = { + onConfirm?: ( + text: string, + link: string, + ) => Promise<{ text: string; link: string }>; +}; + +class Toolbar { + private engine: EngineInterface; + private root?: NodeInterface; + private target?: NodeInterface; + private options?: ToolbarOptions; + private mouseInContainer: boolean = false; + #position: Position; + + constructor(engine: EngineInterface, options?: ToolbarOptions) { + this.engine = engine; + const { change } = this.engine; + this.options = options; + this.#position = new Position(this.engine); + change.event.onWindow('mousedown', (event: MouseEvent) => { + if (!event.target) return; + const target = $(event.target); + const container = target.closest('.data-link-container'); + this.mouseInContainer = container && container.length > 0; + if (!target.inEditor() && !this.mouseInContainer) this.hide(); + }); + } + + private create() { + if (!this.target) return; + let root = $('.data-link-container'); + if (root.length === 0) { + root = $( + ``, + ); + } + this.root = root; + const rect = this.target.get()?.getBoundingClientRect(); + if (!rect) return; + this.root.css({ + top: `${window.pageYOffset + rect.bottom + 4}px`, + left: `${window.pageXOffset}px`, + position: 'absolute', + 'z-index': 1, + }); + } + + private async onOk(text: string, link: string) { + if (!this.target) return; + const { change, history } = this.engine; + const range = change.range.get(); + if (!change.rangePathBeforeCommand) { + if (!range.startNode.inEditor()) { + range.select(this.target, true); + change.range.select(range); + } + change.cacheRangeBeforeCommand(); + } + const { onConfirm } = this.options || {}; + if (onConfirm) { + const result = await onConfirm(text, link); + text = result.text; + link = result.link; + } + this.target.attributes('href', link); + text = text.trim() === '' ? link : text; + const oldText = this.target.text(); + if (oldText !== text) { + const children = this.target.children(); + // 左右两侧有零宽字符 + if (children.length < 3) { + this.target.text(text); + } + // 中间节点是文本字符 + else if (children.length === 3 && children.eq(1)?.isText()) { + this.target.text(text); + } + // 中间节点是非文本节点 + else if (children.length === 3) { + let element = children.eq(1); + while (element) { + const child = element.children(); + // 有多个子节点就直接设置文本,覆盖里面的mark样式 + if (child.length > 1 || child.length === 0) { + element.text(text); + break; + } + // 里面的子节点是文本就设置文本 + else if (child.eq(0)?.isText()) { + element.text(text); + break; + } + // 里面的子节点非文本节点就继续循环 + else { + element = child; + } + } + } + // 多个其它节点 + else { + this.target.text(text); + } + } + + this.engine.inline.repairCursor(this.target); + range.setStart(this.target.next()!, 1); + range.setEnd(this.target.next()!, 1); + change.apply(range); + this.mouseInContainer = false; + this.hide(); + } + + editor(text: string, href: string) { + return ( + (this.mouseInContainer = true)} + onOk={(text: string, link: string) => this.onOk(text, link)} + /> + ); + } + + preview(href: string) { + return ( + { + if (!this.target) return; + this.mouseInContainer = false; + this.hide(undefined, false); + this.show(this.target, true); + }} + onRemove={() => { + if (!this.target) return; + const { change, inline } = this.engine; + const range = change.range.get(); + range.select(this.target, true); + inline.repairRange(range); + change.range.select(range); + change.cacheRangeBeforeCommand(); + inline.unwrap(); + this.mouseInContainer = false; + this.target = undefined; + this.hide(); + }} + href={href} + /> + ); + } + + show(target: NodeInterface, forceEdit?: boolean) { + this.target = target; + this.create(); + const text = target.text().replace(/\u200B/g, ''); + const href = target.attributes('href'); + const container = this.root!.get()!; + ReactDOM.render( + + {!href || forceEdit + ? this.editor(text, href) + : this.preview(href)} + , + container, + () => { + if (!this.root || !this.target) return; + this.#position?.bind(this.root, this.target); + }, + ); + } + + hide(target?: NodeInterface, clearTarget?: boolean) { + if (target && this.target && target.equal(this.target)) return; + const element = this.root?.get(); + if (element && !this.mouseInContainer) { + ReactDOM.unmountComponentAtNode(element); + this.#position?.destroy(); + this.root = undefined; + if (this.target && !this.target.attributes('href')) { + const { change, inline } = this.engine; + const range = change.range.get(); + range.select(this.target, true); + change.range.select(range); + inline.unwrap(); + } + if (clearTarget !== false) this.target = undefined; + } + } +} +export default Toolbar; diff --git a/plugins/link/src/toolbar/preview.tsx b/plugins/link/src/toolbar/preview.tsx new file mode 100644 index 00000000..4d9f9bbc --- /dev/null +++ b/plugins/link/src/toolbar/preview.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import classnames from 'classnames-es-ts'; +import { LanguageInterface } from '@aomao/engine'; +import Tooltip from 'antd/es/tooltip'; +import 'antd/es/tooltip/style'; + +export type LinkPreviewProps = { + language: LanguageInterface; + href?: string; + className?: string; + onEdit: (event: React.MouseEvent) => void; + onRemove: (event: React.MouseEvent) => void; +}; + +const LinkPreview: React.FC = ({ + language, + href, + onEdit, + onRemove, +}) => { + return ( + + ); +}; + +export default LinkPreview; diff --git a/plugins/link/tsconfig.json b/plugins/link/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/link/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/mark-range/README.md b/plugins/mark-range/README.md new file mode 100644 index 00000000..1e78f8fc --- /dev/null +++ b/plugins/mark-range/README.md @@ -0,0 +1,201 @@ +# @aomao/plugin-mark-range + +光标区域标记插件 + +可用来配合开发类似于批注、划线评论 + +## 安装 + +```bash +$ yarn add @aomao/plugin-mark-range +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import MarkRange from '@aomao/plugin-mark-range'; + +new Engine(...,{ plugins:[MarkRange] }) +``` + +## 可选项 + +```ts +//使用配置 +new Engine(...,{ + config:{ + "mark-range":{ + //修改快捷键 + hotkey:..., + //其它可选项 + ... + } + } + }) +``` + +### 标记类型集合 + +必须为标记插件指定至少一个类型。如果有多种标记可指定多个类型 + +```ts +keys: Array + +//例如评论 keys = ["comment"] +``` + +### 标记节点改变回调 + +在协同编辑时,其它作者添加标记后,或者在编辑、删除一些节点中包含标记节点时都会触发此回调 + +在使用 撤销、重做 相关操作时,也会触发此回调 + +addIds: 新增的标记节点编号集合 + +removeIds: 删除的标记节点编号集合 + +ids: 所有有效的标记节点编号集合 + +```ts +onChange?: (addIds: { [key: string]: Array},removeIds: { [key: string]: Array},ids: { [key:string] : Array }) => void +``` + +### 选中标记节时点回调 + +在光标改变时触发,selectInfo 有值的情况下将携带光标所在最近,如果是嵌套关系,那么就返回最里层的标记编号 + +```ts +onSelect? : (range: RangeInterface, selectInfo?: { key: string, id: string}) => void +``` + +### 快捷键 + +默认无快捷键 + +```ts +//快捷键,key 组合键,args,执行参数,[mode?: string, value?: string] 语言模式:可选,代码文本:可选 +hotkey?:string | {key:string,args:Array};//默认无 +``` + +## 插件方法 + +所有命令都需要指定在可选项中 `keys` 中传入的指定 key + +```ts +engine.command.executeMethod('mark-range', 'action', '标记key'); +``` + +### 预览 + +对一个标记或当前做在光标位置进行效果预览 + +如果不传入编辑 id 参数,那么就对当前光标所选进行效果预览 + +此操作不会参与协同同步 + +此操作不会产生历史记录,无法做 撤销 和 重做 操作 + +光标改变时,将自动取消当前预览效果 + +如果是对光标进行效果预览,命令将返回光标选中区域的所有文本拼接。卡片将使用 [card:卡片名称,卡片编号] 这种格式拼接,需要转换则要自行处理 + +```ts +engine.command.executeMethod('mark-range', 'action', key: string, 'preview', id?:string): string | undefined; +``` + +### 将预览效果应用到编辑器 + +将预览效果应用到编辑器,并同步到协同服务器 + +此操作不会产生历史记录,无法做 撤销 和 重做 操作 + +必须传入一个标记编号,可以是字符串。编号相对于 key 应是唯一的 + +```ts +engine.command.executeMethod('mark-range', 'action', key: string, 'apply', id:string); +``` + +### 取消预览效果 + +如果不传入标记编号,则取消所有的当前正在进行的预览项 + +```ts +engine.command.executeMethod('mark-range', 'action', key: string, 'revoke', id?:string); +``` + +### 查找节点 + +根据标记编号找出其在编辑器中所有相对应的 dom 节点对象 + +```ts +engine.command.executeMethod('mark-range', 'action', key: string, 'find', id: string): Array; +``` + +### 移除标记效果 + +移除指定标记编号的标记效果 + +此操作不会产生历史记录,无法做 撤销 和 重做 操作 + +```ts +engine.command.executeMethod('mark-range', 'action', key: string, 'remove', id: string) +``` + +### 过滤标记 + +对编辑器值中的所有标记过滤,并返回过滤后的值和所有标记的编号和对应路径 + +value 默认获取当前编辑器根节点中的 html 作为值 + +在我们需要将标记和编辑器值分开存储或有条件展现标记时很有用 + +```ts +engine.command.executeMethod('mark-range', 'action', key: string, 'filter', value?: string): { value: string, paths: Array<{ id: Array, path: Array}>} +``` + +### 还原标记 + +使用标记路径和过滤后的编辑器值进行标记还原 + +value 默认获取当前编辑器根节点中的 html 作为值 + +```ts +engine.command.executeMethod('mark-range', 'action', key: string, 'wrap', paths: Array<{ id: Array, path: Array}>, value?: string): string +``` + +## 样式定义 + +```css +/** 编辑器中标记样式 -comment- 中的 comment 都是代指标记中配置的 key ---- 开始 **/ +[data-comment-preview], +[data-comment-id] { + position: relative; +} + +span[data-comment-preview], +span[data-comment-id] { + display: inline-block; +} + +[data-comment-preview]::before, +[data-comment-id]::before { + content: ''; + position: absolute; + width: 100%; + bottom: 0px; + left: 0; + height: 2px; + border-bottom: 2px solid #f8e1a1 !important; +} + +[data-comment-preview] { + background: rgb(250, 241, 209) !important; +} + +[data-card-key][data-comment-id]::before, +[data-card-key][data-comment-preview]::before { + bottom: -2px; +} +/** 编辑器中标记样式 ---- 结束 **/ +``` diff --git a/plugins/mark-range/package.json b/plugins/mark-range/package.json new file mode 100644 index 00000000..450828d9 --- /dev/null +++ b/plugins/mark-range/package.json @@ -0,0 +1,31 @@ +{ + "name": "@aomao/plugin-mark-range", + "version": "2.5.3", + "description": "范围标记", + "publishConfig": { + "access": "public" + }, + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10", + "tinycolor2": "^1.4.2" + } +} diff --git a/plugins/mark-range/src/index.ts b/plugins/mark-range/src/index.ts new file mode 100644 index 00000000..94d03b90 --- /dev/null +++ b/plugins/mark-range/src/index.ts @@ -0,0 +1,732 @@ +import { + $, + CardEntry, + DATA_TRANSIENT_ATTRIBUTES, + isEngine, + MarkPlugin, + NodeInterface, + Parser, + Range, + RangeInterface, + SchemaGlobal, + SchemaMark, + Selection, + PluginOptions, +} from '@aomao/engine'; +import { Path } from 'sharedb'; + +export interface Options extends PluginOptions { + keys: Array; + hotkey?: string | Array; + onChange?: ( + addIds: { [key: string]: Array }, + removeIds: { [key: string]: Array }, + ids: { [key: string]: Array }, + ) => void; + onSelect?: ( + range: RangeInterface, + selectInfo?: { key: string; id: string }, + ) => void; +} + +const PLUGIN_NAME = 'mark-range'; + +export default class extends MarkPlugin { + private range?: RangeInterface; + private executeBySelf: boolean = false; + private MARK_KEY = `data-mark-key`; + private ids: { [key: string]: Array } = {}; + + readonly followStyle: boolean = false; + + readonly copyOnEnter: boolean = false; + + #isRevoke: boolean = false; + #isPreview: boolean = false; + #isApply: boolean = false; + #previewAwating?: (value: boolean) => void; + + static get pluginName() { + return PLUGIN_NAME; + } + + tagName = 'span'; + + combineValueByWrap = true; + + getIdName(key: string) { + return `data-${key}-id`; + } + + getPreviewName(key: string) { + return `data-${key}-preview`; + } + + init() { + super.init(); + const globals: Array = []; + this.options.keys.forEach((key) => { + globals.push( + { + type: 'block', + attributes: { + [this.getIdName(key)]: '*', + [this.MARK_KEY]: key, + }, + }, + { + type: 'inline', + attributes: { + [this.getIdName(key)]: '*', + [this.MARK_KEY]: key, + }, + }, + ); + }); + this.editor.schema.add(globals); + this.editor.on('beforeCommandExecute', (name: string) => { + this.executeBySelf = name === PLUGIN_NAME; + }); + this.editor.on('afterCommandExecute', (name: string) => { + this.executeBySelf = false; + }); + + if (isEngine(this.editor)) { + const { change } = this.editor; + this.editor.on('change', () => { + this.triggerChange(); + }); + this.editor.on('select', () => this.onSelectionChange()); + this.editor.on('parse:value', (node, atts) => { + const key = node.attributes(this.MARK_KEY); + if (!!key) { + atts[DATA_TRANSIENT_ATTRIBUTES] = this.getPreviewName(key); + } + }); + this.editor.on('afterSetValue', () => { + this.range = change.range.get(); + this.ids = this.getIds(); + }); + const keys = this.options.keys.map((key) => + this.getPreviewName(key), + ); + this.editor.history.onFilter((op) => { + if ( + ('od' in op || 'oi' in op) && + keys.includes(op.p[op.p.length - 1].toString()) + ) { + return true; + } + return false; + }); + this.editor.history.onSelf(() => { + if (this.#isPreview && !this.#previewAwating) { + return new Promise((resolve) => { + this.#previewAwating = resolve; + this.#isPreview = false; + }); + } else if (this.#isRevoke && this.#previewAwating) { + this.#previewAwating(false); + } else if (this.#isApply && this.#previewAwating) { + this.#previewAwating(true); + } + return; + }); + } else { + this.editor.container.document?.addEventListener( + 'selectionchange', + () => this.onSelectionChange(), + ); + } + } + + schema() { + const rules: Array = this.options.keys.map((key) => { + return { + name: 'span', + type: 'mark', + attributes: { + [this.MARK_KEY]: { + required: true, + value: key, + }, + [this.getIdName(key)]: '*', + }, + }; + }); + return rules; + } + /** + * 获取当前选择的标记id + * @param range 光标 + * @param strict 是否严格模式 + * @returns + */ + getSelectInfo(range: RangeInterface, strict?: boolean) { + const { card } = this.editor; + const cloneRange = range + .cloneRange() + .shrinkToElementNode() + .shrinkToTextNode(); + const { startNode, startOffset, endNode, endOffset, collapsed } = + cloneRange; + let startMark = startNode.closest(`[${this.MARK_KEY}]`); + const startChild = startNode.children().eq(startOffset); + //如果选择是块级卡片就选择在卡片根节点 + if (startNode.type === Node.ELEMENT_NODE && startChild?.isBlockCard()) { + startMark = startChild; + } else { + const cardMark = card.find(startMark); + if ( + cardMark && + !cardMark.isEditable && + cardMark.root.isBlockCard() + ) { + startMark = cardMark.root; + } + } + let key = startMark.attributes(this.MARK_KEY); + //获取节点标记ID + const startId = startMark.attributes(this.getIdName(key)); + //设置当前选中的标记ID + let selectId: string | undefined = !!startId ? startId : undefined; + + //不是重合状态,并且开始节点不是块级卡片 + if (!collapsed && !!startId && !startMark.isBlockCard()) { + let endMark = endNode.closest(`[${this.MARK_KEY}]`); + const endKey = endMark.attributes(this.MARK_KEY); + const endChild = endNode.children().eq(endOffset); + //如果选择是块级卡片就选择在卡片根节点 + if (endNode.type === Node.ELEMENT_NODE && endChild?.isBlockCard()) { + endMark = endChild; + } + const endId = endMark.attributes(this.getIdName(key)); + //需要两端都是同一类型同一个id才需要显示 + if (key === endKey && startId === endId) { + selectId = startId; + //严格模式,开始节点和结束节点需要在节点内的两侧 + if (strict) { + const strictRange = Range.from(this.editor)?.cloneRange(); + strictRange?.setStart(startMark, 0); + strictRange?.setEnd( + endMark, + endMark.isText() + ? endMark.text().length + : endMark.children().length, + ); + if ( + !strictRange + ?.shrinkToElementNode() + .shrinkToTextNode() + ?.equal(cloneRange) + ) + selectId = undefined; + } + } else selectId = undefined; + } + return selectId + ? { + key, + id: selectId.split(',')[0], + } + : undefined; + } + /** + * 根据编号获取所有节点 + * @param key 标记类型 + * @param id 编号 + * @returns + */ + findElements(key: string, id: string) { + const { container } = this.editor; + const elements: Array = []; + container.find(`[${this.getIdName(key)}]`).each((markNode) => { + const mark = $(markNode); + const ids = mark.attributes(this.getIdName(key)).trim().split(','); + if (ids.indexOf(id) > -1) elements.push(mark); + }); + return elements; + } + /** + * 预览 + * @param id 标记id,否则预览当前光标位置 + */ + preview(key: string, id?: string) { + if (id) { + const elements = this.findElements(key, id); + elements.forEach((markNode) => { + markNode.attributes( + DATA_TRANSIENT_ATTRIBUTES, + this.getPreviewName(key), + ); + markNode.attributes(this.getPreviewName(key), 'true'); + }); + } else if (this.range) { + const { onSelect } = this.options; + const { block, node, card } = this.editor; + let range = this.range; + //光标重合时,选择整个block块 + if (range.collapsed) { + const blockNode = block.closest(range.startNode); + if (!node.isBlock(blockNode)) return; + range.select(blockNode, true); + if (isEngine(this.editor)) { + this.editor.change.range.select(range); + } + } + const selectInfo = this.getSelectInfo(range, true); + //当前光标已存在标记 + if (selectInfo && selectInfo.key === key) { + //触发选择 + if (onSelect) onSelect(range, selectInfo); + return; + } + //包裹标记预览样式 + this.editor.mark.wrap( + `<${this.tagName} ${ + this.MARK_KEY + }="${key}" ${DATA_TRANSIENT_ATTRIBUTES}="${this.getPreviewName( + key, + )}" ${this.getPreviewName(key)}="true" />`, + range, + ); + //遍历当前光标选择节点,拼接选择的文本 + let text = ''; + const subRanges = range.getSubRanges(true); + subRanges.forEach((subRange) => { + //如果是卡片,就给卡片加上预览样式 + const cardComponent = card.find(subRange.startNode); + if (cardComponent) { + text += `[card:${ + (cardComponent.constructor as CardEntry).cardName + },${cardComponent.id}]`; + if (cardComponent.root.attributes(this.getIdName(key))) + return; + cardComponent.root.attributes(this.MARK_KEY, key); + cardComponent.root.attributes( + this.getPreviewName(key), + 'true', + ); + } else { + text += subRange.getText(); + } + }); + this.#isPreview = true; + return text; + } + return; + } + + /** + * 应用标记样式到编辑器 + * @param 标记类型 + * @param id + */ + apply(key: string, id: string) { + //遍历预览节点 + this.editor.container + .find(`[${this.getPreviewName(key)}]`) + .each((markNode) => { + const mark = $(markNode); + //获取旧id + const oldIds = mark + .attributes(this.getIdName(key)) + .trim() + .split(','); + //组合新的id串 + let ids: Array = []; + if (oldIds[0] === '') oldIds.splice(0, 1); + //范围大的id放在后面 + if (oldIds.length > 0) { + for (let i = 0; i < oldIds.length; i++) { + const oldId = oldIds[i]; + //判断之前旧的id对应光标位置是否包含当前节点 + const parent = markNode.parentElement; + if ( + parent && + oldIds.indexOf(id) < 0 && + ids.indexOf(id) < 0 + ) { + const elements = this.findElements(key, oldId); + const oldRange = Range.from( + this.editor, + )?.cloneRange(); + if (!oldRange || elements.length === 0) continue; + const oldBegin = oldRange + .select(elements[0], true) + .collapse(true) + .cloneRange(); + const oldEnd = oldRange + .select(elements[elements.length - 1], true) + .collapse(false) + .cloneRange(); + oldRange.setStart( + oldBegin.startContainer, + oldBegin.startOffset, + ); + oldRange.setEnd( + oldEnd.endContainer, + oldEnd.endOffset, + ); + const reuslt = oldRange.comparePoint( + parent, + mark.index(), + ); + if (reuslt >= 0) { + ids.push(id); + ids = ids.concat(oldIds.slice(i)); + break; + } + } + ids.push(oldId); + } + //未增加就驾到末尾 + if ( + ids.length === oldIds.length && + oldIds.indexOf(id) < 0 + ) { + ids.push(id); + } + } else { + ids.push(id); + } + mark.attributes( + DATA_TRANSIENT_ATTRIBUTES, + this.getPreviewName(key), + ); + //设置新的id串 + mark.attributes(this.getIdName(key), ids.join(',')); + mark.removeAttributes(this.getPreviewName(key)); + }); + this.#isApply = true; + } + /** + * 遗弃预览项 + * @param key 标记类型 + * @param id 编号,不传编号则遗弃所有预览项 + */ + revoke(key: string, id?: string) { + const { node } = this.editor; + let elements: Array = []; + if (id) elements = this.findElements(key, id); + else + elements = this.editor.container + .find(`[${this.getPreviewName(key)}]`) + .toArray(); + //遍历预览节点 + elements.forEach((markNode) => { + const mark = $(markNode); + //获取旧id传 + const oldIds = mark + .attributes(this.getIdName(key)) + .trim() + .split(','); + if (oldIds[0] === '') oldIds.splice(0, 1); + //如果没有id,移除标记样式包裹 + if (oldIds.length === 0) { + if (mark.isCard()) { + mark.removeAttributes(this.MARK_KEY); + mark.removeAttributes(this.getPreviewName(key)); + } else { + node.unwrap(mark); + } + } else { + //移除预览样式 + mark.removeAttributes(this.getPreviewName(key)); + } + }); + if (!id && elements.length > 0 && isEngine(this.editor)) { + this.#isRevoke = true; + } + } + /** + * 移除标识 + * @param key 标记类型 + * @param id 编号 + */ + remove(key: string, id: string) { + const { node } = this.editor; + + const elements: Array = this.findElements( + key, + id, + ); + + //遍历节点 + elements.forEach((markNode) => { + const mark = $(markNode); + //获取旧id传 + const oldIds = mark + .attributes(this.getIdName(key)) + .trim() + .split(','); + if (oldIds[0] === '') oldIds.splice(0, 1); + //移除标记样式包裹 + if (oldIds.length === 1 && !!oldIds.find((i) => i === id)) { + if (mark.isCard()) { + mark.removeAttributes(this.MARK_KEY); + mark.removeAttributes(this.getIdName(key)); + mark.removeAttributes(this.getPreviewName(key)); + } else { + node.unwrap(mark); + } + } else { + //移除预览样式 + mark.removeAttributes(this.getPreviewName(key)); + //移除id + const index = oldIds.findIndex((i) => i === id); + oldIds.splice(index, 1); + mark.attributes(this.getIdName(key), oldIds.join(',')); + } + }); + } + + hotkey() { + return this.options.hotkey || ''; + } + + execute() {} + + action(key: string, action: string, ...args: any): any { + const history = isEngine(this.editor) ? this.editor.history : undefined; + const id = args[0]; + switch (action) { + case 'preview': + const reuslt = this.preview(key, id); + return reuslt; + case 'apply': + if (!id) return; + this.apply(key, id); + break; + case 'revoke': + this.revoke(key, id); + break; + case 'find': + if (!id) return []; + return this.findElements(key, id); + case 'remove': + if (!id) return; + this.remove(key, id); + break; + case 'filter': + return this.filterValue(key, id); + case 'wrap': + const value = args[1]; + return this.wrapFromPath(key, id, value); + } + } + + getIds() { + const ids: { [key: string]: Array } = {}; + this.editor.container.find(`[${this.MARK_KEY}]`).each((markNode) => { + const mark = $(markNode); + const key = mark.attributes(this.MARK_KEY); + const idArray = mark.attributes(this.getIdName(key)).split(','); + idArray.forEach((id) => { + if (!!id) { + if (!ids[key]) ids[key] = []; + if (ids[key].indexOf(id) < 0) ids[key].push(id); + } + }); + }); + return ids; + } + + /** + * 光标选择改变触发 + * @returns + */ + onSelectionChange() { + if (this.executeBySelf) return; + const { window } = this.editor.container; + const selection = window?.getSelection(); + + if (!selection) return; + const range = Range.from(this.editor, selection); + if (!range) return; + const { onSelect } = this.options; + + //不在编辑器内 + if ( + !$(range.getStartOffsetNode()).inEditor() || + !$(range.getEndOffsetNode()).inEditor() + ) { + if (onSelect) onSelect(range); + this.range = undefined; + return; + } + + this.triggerChange(); + + const selectInfo = this.getSelectInfo(range, true); + if (onSelect) onSelect(range, selectInfo); + this.range = range; + } + + triggerChange() { + const { onChange } = this.options; + const addIds: { [key: string]: Array } = {}; + const removeIds: { [key: string]: Array } = {}; + const ids = this.getIds(); + this.options.keys.forEach((key) => { + const prevIds = this.ids[key] || []; + const curIds = ids[key] || []; + curIds.forEach((id) => { + if (prevIds.indexOf(id) < 0) { + if (!addIds[key]) addIds[key] = []; + addIds[key].push(id); + } + }); + prevIds.forEach((id) => { + if (curIds.indexOf(id) < 0) { + if (!removeIds[key]) removeIds[key] = []; + removeIds[key].push(id); + } + }); + }); + this.ids = ids; + if (onChange) onChange(addIds, removeIds, ids); + } + + /** + * 过滤标记样式,并返回每个样式的路径 + * @param value 编辑器值 + * @returns 过滤后的值和路径 + */ + filterValue( + key: string, + value?: string, + ): { + value: string; + paths: Array<{ id: Array; path: Array }>; + } { + const { node, card } = this.editor; + const container = this.editor.container.clone(value ? false : true); + container.css({ + position: 'fixed', + top: 0, + clip: 'rect(0, 0, 0, 0)', + }); + $(document.body).append(container); + if (value) container.html(value); + + card.render(container); + + const selection = container.window?.getSelection(); + const range = ( + selection + ? Range.from(this.editor, selection) || + Range.create(this.editor) + : Range.create(this.editor) + ).cloneRange(); + + const parser = new Parser(container, this.editor); + const { schema, conversion } = this.editor; + if (!range) { + container.remove(); + return { + value: value ? value : parser.toValue(schema, conversion), + paths: [], + }; + } + range.select(container, true).collapse(true); + + const paths: Array<{ id: Array; path: Array }> = []; + container.traverse((childNode) => { + const id = childNode.attributes(this.getIdName(key)); + if (!!id) { + const rangeClone = range.cloneRange(); + + if (childNode.isCard()) { + rangeClone.select(childNode); + childNode.removeAttributes(this.getIdName(key)); + } else { + rangeClone.select(childNode, true); + const selection = rangeClone.createSelection(); + node.unwrap(childNode); + selection.move(); + } + const rangePath = rangeClone + .shrinkToElementNode() + .shrinkToTextNode() + .toPath(); + paths.push({ + id: id.split(','), + path: rangePath + ? [rangePath.start.path, rangePath.end.path] + : [], + }); + } + }, false); + + value = parser.toValue(schema, conversion); + container.remove(); + return { + value, + paths, + }; + } + /** + * 从标记样式路径还原包裹编辑器值 + * @param paths 标记样式路径 + * @param value 编辑器值 + * @returns + */ + wrapFromPath( + key: string, + paths: Array<{ id: Array; path: Array }>, + value?: string, + ): string { + const { card } = this.editor; + const container = this.editor.container.clone(value ? false : true); + if (value) value = Selection.removeTags(value); + container.css({ + position: 'fixed', + top: 0, + clip: 'rect(0, 0, 0, 0)', + }); + $(document.body).append(container); + if (value) container.html(value); + + card.render(container); + const selection = container.window?.getSelection(); + const range = ( + selection + ? Range.from(this.editor, selection) || + Range.create(this.editor) + : Range.create(this.editor) + ).cloneRange(); + + const parser = new Parser(container, this.editor); + const { schema, conversion } = this.editor; + if (!range) { + container.remove(); + return value ? value : parser.toValue(schema, conversion); + } + + range.select(container, true).collapse(true); + + (paths || []).forEach(({ id, path }) => { + const pathRange = Range.fromPath(this.editor, { + start: { path: path[0] as number[], id: '', bi: -1 }, + end: { path: path[1] as number[], id: '', bi: -1 }, + }); + const elements = pathRange.findElements(); + elements.forEach((element) => { + const node = $(element); + if (node.isCard()) { + node.attributes(this.getIdName(key), id.join(',')); + } + }); + this.editor.mark.wrap( + `<${this.tagName} ${this.MARK_KEY}="${key}" ${this.getIdName( + key, + )}="${id.join(',')}" />`, + pathRange, + ); + }); + value = parser.toValue(schema, conversion); + container.remove(); + return value; + } +} diff --git a/plugins/mark-range/tsconfig.json b/plugins/mark-range/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/mark-range/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/mark/README.md b/plugins/mark/README.md new file mode 100644 index 00000000..41adc14d --- /dev/null +++ b/plugins/mark/README.md @@ -0,0 +1,66 @@ +# @aomao/plugin-mark + +标记样式插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-mark +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Mark from '@aomao/plugin-mark'; + +new Engine(...,{ plugins:[Mark] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键无,以数组形式传入多个快捷键 + +```ts +//快捷键, +hotkey?: string | Array; + +//使用配置 +new Engine(...,{ + config:{ + "mark":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Mark 插件 markdown 语法为`==` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "mark":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +```ts +engine.command.execute('mark'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('orderedlist'); +``` diff --git a/plugins/mark/package.json b/plugins/mark/package.json new file mode 100644 index 00000000..61e13381 --- /dev/null +++ b/plugins/mark/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-mark", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/mark/src/index.css b/plugins/mark/src/index.css new file mode 100644 index 00000000..70324050 --- /dev/null +++ b/plugins/mark/src/index.css @@ -0,0 +1,5 @@ +.am-engine mark, +.am-engine-view mark { + padding: 0; + background: #ff0; +} \ No newline at end of file diff --git a/plugins/mark/src/index.ts b/plugins/mark/src/index.ts new file mode 100644 index 00000000..3e4ffb3f --- /dev/null +++ b/plugins/mark/src/index.ts @@ -0,0 +1,21 @@ +import { MarkPlugin, PluginOptions } from '@aomao/engine'; +import './index.css'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; + markdown?: string; +} +export default class extends MarkPlugin { + tagName = 'mark'; + + static get pluginName() { + return 'mark'; + } + + hotkey() { + return this.options.hotkey || ''; + } + + markdown = + this.options.markdown === undefined ? '==' : this.options.markdown; +} diff --git a/plugins/mark/tsconfig.json b/plugins/mark/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/mark/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/math/README.md b/plugins/math/README.md new file mode 100644 index 00000000..78efd6d4 --- /dev/null +++ b/plugins/math/README.md @@ -0,0 +1,126 @@ +# @aomao/plugin-math + +数学公式 + +## 安装 + +```bash +$ yarn add @aomao/plugin-math +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Math , { MathComponent } from '@aomao/plugin-math'; + +new Engine(...,{ plugins:[ Math ] , cards:[ MathComponent ]}) +``` + +## `Math` 可选项 + +```ts +//使用配置 +new Engine(...,{ + config:{ + [Math.pluginName]:{ + //...相关配置 + } + } + }) +``` + +### 请求生成公式代码为图片或 SVG + +`action`: 请求地址,始终使用 `POST` 请求 + +`type`: 默认为 `json` + +`contentType`: 默认以 `application/json` 类型发起请求 + +`data`: 请求时将这些数据一起`POST`到服务端 + +```ts +/** + * 请求生成公式svg地址 + */ +action: string; +/** + * 数据返回类型,默认 json + */ +type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js'; +/** + * 额外携带数据上传 + */ +data?: {}; +/** + * 请求类型,默认 application/json; + */ +contentType?: string; +``` + +配置后,插件会使用 `content` 字段 POST 到指定的 `action` 地址,里面包含了公式代码 + +### 解析服务端响应数据 + +默认会查找 + +公式对应图片地址或`SVG`代码:response.url || response.data && response.data.url + +`result`: true 生成成功,data 为公式对应图片地址或`SVG`代码。false 生成失败,data 为错误消息 + +```ts +/** + * 解析生成后的Respone,返回 result:是否成功,data:成功:公式对应图片地址或`SVG`代码,失败:错误信息 + */ +parse?: ( + response: any, +) => { + result: boolean; + data: string; +}; +``` + +### 画图接口 + +可以使用 `https://g.yanmao.cc/latex` 地址生成公式对应的 `SVG` 代码。该项目使用[mathjax](https://www.mathjax.org/) 生成 `SVG` 代码 + +演示站点:[https://drawing.yanmao.cc/](https://drawing.yanmao.cc/) + +配置: + +```ts +[Math.pluginName]: { + action: `https://g.yanmao.cc/latex`, + parse: (res: any) => { + if(res.success) return { result: true, data: res.svg} + return { result: false} + } +} +``` + +## 命令 + +### 插入公式代码 + +参数 1:公式代码 + +参数 2:公式对应图片地址或`SVG`代码 + +```ts +engine.command.execute( + Math.pluginName, + '公式代码', //可选 + '公式对应图片地址或`SVG`代码', //可选 +); +``` + +### 请求生成公式代码图片或 SVG + +参数 1:固定为 `query` +参数 2:成功后的回调 +参数 3:失败后的回调。可选 + +```ts +engine.command.execute(Math.pluginName, "query", success:(url: string) => void, failed: (message: string) => void); +``` diff --git a/plugins/math/package.json b/plugins/math/package.json new file mode 100644 index 00000000..179d24c5 --- /dev/null +++ b/plugins/math/package.json @@ -0,0 +1,30 @@ +{ + "name": "@aomao/plugin-math", + "version": "2.5.3", + "description": "Math(数学公式)", + "keywords": [ + "Math" + ], + "author": "ITELLYOU ", + "homepage": "https://github.com/yanmao-cc/am-editor/tree/main/plugins/plugin-math#readme", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/math/src/component/editor.ts b/plugins/math/src/component/editor.ts new file mode 100644 index 00000000..98365231 --- /dev/null +++ b/plugins/math/src/component/editor.ts @@ -0,0 +1,104 @@ +import { + $, + DATA_ELEMENT, + EditorInterface, + NodeInterface, + Tooltip, + UI, + TRIGGER_CARD_ID, + isMobile, + isHotkey, +} from '@aomao/engine'; +import { getLocales } from '../utils'; + +export type Options = { + tips?: string; + onFocus?: () => void; + onBlur?: () => void; + onChange?: (value: string) => void; + onOk?: (event: Event) => void; + onDestroy?: () => void; +}; + +class MathEditor { + private editor: EditorInterface; + private options: Options; + private container?: NodeInterface; + + constructor(editor: EditorInterface, options: Options) { + this.editor = editor; + this.options = options; + } + + focus() { + this.container?.find('textarea').get()?.focus(); + } + + render(cardId: string, defaultValue?: string) { + this.destroy(); + + this.container = $( + `
      `, + ); + + const locales = getLocales(this.editor); + const { onBlur, onFocus, onChange, onOk, tips } = this.options; + const textarea = $(``); + + textarea.on('focus', () => { + if (onFocus) onFocus(); + }); + + textarea.on('blur', () => { + if (onBlur) onBlur(); + }); + + textarea.on('input', (event: KeyboardEvent) => { + if (onChange) onChange((event.target as HTMLTextAreaElement).value); + }); + + textarea.on('mousedown', () => { + textarea.get()?.focus(); + }); + + textarea.on('keydown', (event: KeyboardEvent) => { + if (onOk && isHotkey('mod+enter', event)) { + onOk(event); + } + }); + + this.container.append(textarea); + const toolbar = $(`
      `); + if (tips) + toolbar.append( + $(`
      ${tips}
      `), + ); + const buttonContainer = $( + `
      `, + ); + const button = buttonContainer.find('a'); + button.on('mouseenter', () => { + Tooltip.show(button, locales.buttonTips); + }); + button.on('mouseleave', () => { + Tooltip.hide(); + }); + button.on('mousedown', () => { + Tooltip.hide(); + }); + if (onOk) button.on('click', onOk); + toolbar.append(buttonContainer); + this.container.append(toolbar); + return this.container; + } + + destroy() { + this.container?.remove(); + const { onDestroy } = this.options; + if (onDestroy) onDestroy(); + } +} + +export default MathEditor; diff --git a/plugins/math/src/component/index.css b/plugins/math/src/component/index.css new file mode 100644 index 00000000..ca1f26bc --- /dev/null +++ b/plugins/math/src/component/index.css @@ -0,0 +1,98 @@ + +.am-engine .data-math-content-tmp, +.am-engine-view .data-math-content-tmp { + padding: 0 6px; + border-radius: 3px 3px; + height: 100%; + color: rgba(0, 0, 0, 0.25); + display: inline-block; + white-space:nowrap; + } + .am-engine span[data-card-key="math"]:not(.card-selected) .data-math-content-tmp { + background: #f5f5f5; + } + .am-engine-view span[data-card-key="math"] .data-math-content-tmp { + background: #f5f5f5; + } + +.am-engine span[data-card-key="math"] [data-card-element="center"] { + max-width: calc(100% - 2px); +} + .am-engine span[data-card-key="math"] .data-math-container { + display: inline-block; + cursor: pointer; + border: 2px solid transparent; + } + .am-engine span[data-card-key="math"] ::selection { + background: transparent !important; +} + .data-card-math-editor { + outline: none; + width: 420px; + border-radius: 3px 3px; + position: absolute; + border: 1px solid #e8e8e8; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + z-index: 125; + text-indent: 0; + } +.data-card-math-editor-mobile { + width: calc(100vw - 40px); +} + .data-card-math-editor textarea { + width: 100%; + border: none; + min-width: 400px; + min-height: 88px; + padding: 6px 6px; + line-height: 24px; + color: #595959; + outline: none; + font-family: 'Lucida Console', Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + vertical-align: bottom; + font-size: 14px; + font-weight: 400; + } + + .data-card-math-editor-mobile textarea{ + min-width:auto; + } + .data-card-math-editor .data-math-editor-toolbar { + position: relative; + background: #f9f9f9; + border-top: 1px solid #e8e8e8; + padding: 2px 8px; + text-align: left; + text-indent: 0; + display: flex; + justify-content: space-between; + } + + .data-card-math-editor .data-math-editor-toolbar .data-math-editor-toolbar-tips a { + font-size: 12px; + color: #8c8c8c; + vertical-align: middle; + } + + .data-card-math-editor .data-math-editor-toolbar .data-math-editor-toolbar-tips a:hover { + color: #595959; + } + + .data-card-math-editor .data-math-editor-toolbar .data-math-editor-toolbar-tips .data-icon { + vertical-align: middle; + margin-right: 4px; + color: #8c8c8c; + } + .data-card-math-editor .data-math-editor-toolbar .data-math-editor-toolbar-button .data-embed-toolbar-btn { + font-size: 14px; + display: block; + } + + .am-engine span[data-card-key="math"].card-selected [data-card-element="center"].data-card-border-selected { + outline: none; + } + + .am-engine span[data-card-key="math"] .data-card-border-selected .data-math-container { + border: 2px solid #1890FF; + border-radius: 4px; +} \ No newline at end of file diff --git a/plugins/math/src/component/index.ts b/plugins/math/src/component/index.ts new file mode 100644 index 00000000..b8226101 --- /dev/null +++ b/plugins/math/src/component/index.ts @@ -0,0 +1,213 @@ +import debounce from 'lodash-es/debounce'; +import { + $, + Card, + CardType, + isEngine, + NodeInterface, + Position, +} from '@aomao/engine'; +import { getLocales } from '../utils'; +import MathEditor from './editor'; +import './index.css'; + +export type MathValue = { + code: string; + url: string; +}; + +export default class MathCard extends Card { + #position?: Position; + + static get cardName() { + return 'math'; + } + + static get cardType() { + return CardType.INLINE; + } + + static get selectStyleType(): 'background' | 'border' { + return 'border'; + } + + static get autoSelected() { + return false; + } + + private container?: NodeInterface; + private editorContainer?: NodeInterface; + private mathEditor?: MathEditor; + + isSaving: boolean = false; + + init() { + super.init(); + const tips = getLocales<{ text: string; href: string }>( + this.editor, + ).tips; + const { card } = this.editor; + if (!this.#position) this.#position = new Position(this.editor); + if (this.mathEditor) return; + this.mathEditor = new MathEditor(this.editor, { + tips: `${tips.text}`, + onFocus: () => { + this.editorContainer?.addClass('textarea-focus'); + }, + onBlur: () => { + this.editorContainer?.removeClass('textarea-focus'); + }, + onChange: this.queryMath, + onOk: (event: Event) => { + event.stopPropagation(); + event.preventDefault(); + card.activate($(document.body)); + card.focus(this); + }, + onDestroy: () => { + this.#position?.destroy(); + }, + }); + } + + queryMath = debounce((code: string) => this.renderMath(code), 300); + + query( + code: string, + success: (url: string) => void, + failed?: (message: string) => void, + ) { + const { command } = this.editor; + command.executeMethod('math', 'action', 'query', code, success, failed); + } + + getMaxWidth(node: NodeInterface = this.root) { + const block = this.editor.block.closest(node).get(); + if (!block) return 0; + const style = window.getComputedStyle(block); + const width = + parseInt(style.width) - + parseInt(style['padding-left']) - + parseInt(style['padding-right']); + return !isEngine(this.editor) ? width : width - 2; + } + + focusTextarea() { + this.mathEditor?.focus(); + } + + onActivate(activated: boolean) { + super.onActivate(activated); + if (!isEngine(this.editor) || this.editor.readonly) return; + if (activated) this.renderEditor(); + else this.mathEditor?.destroy(); + } + + renderPureText(text: string) { + const maxWidth = this.getMaxWidth(); + this.container?.html( + `${text}`, + ); + this.#position?.update(); + } + + renderMath(code: string) { + this.isSaving = true; + this.query( + code, + (url: string) => { + const image = new Image(); + image.src = url; + image.onload = () => { + this.renderImage($(image)); + this.container?.empty(); + this.container?.append(image); + }; + this.setValue({ + url, + code, + }); + this.isSaving = false; + }, + () => { + this.renderPureText(code); + this.setValue({ + url: '', + code, + }); + this.isSaving = false; + }, + ); + } + + renderImage(image: NodeInterface) { + const maxWidth = this.getMaxWidth(); + const node = image.get()!; + let { naturalWidth, naturalHeight } = node; + + const width = parseInt(`${(14 / 17.4) * naturalWidth}`); + const height = parseInt(`${(14 / 17.4) * naturalHeight}`); + image.css('width', `${width}px`); + image.css('height', `${height}px`); + image.css('max-width', `${maxWidth}px`); + this.#position?.update(); + } + + renderView() { + const value = this.getValue(); + const locales = getLocales(this.editor); + const { url, code } = value || { url: '', code: '' }; + if (!this.container) { + this.container = $(''); + this.getCenter().empty().append(this.container); + } + if (url) { + let image = this.container.find('img'); + if (image.length === 0) { + image = $(``); + this.container.empty().append(image); + } else { + image.attributes('src', url); + } + + image.on('load', () => { + this.renderImage(image); + }); + + image.on('error', () => { + this.renderMath(code); + }); + } else if (code) { + if (!isEngine(this.editor)) { + this.renderPureText(code); + } else { + this.renderMath(code); + } + } else { + this.renderPureText(locales.placeholder); + } + if (!isEngine(this.editor) || this.editor.readonly) { + this.container.css('cursor', 'pointer'); + this.container.attributes('draggable', 'true'); + this.container.css('user-select', 'none'); + } + } + + renderEditor() { + if (!this.mathEditor) return; + const value = this.getValue(); + if (!value) return; + this.editorContainer = this.mathEditor.render(value.id, value.code); + if (this.container) + this.#position?.bind(this.editorContainer, this.container); + } + + render(): string | void | NodeInterface { + this.renderView(); + } + + destroy() { + super.destroy(); + this.mathEditor?.destroy(); + } +} diff --git a/plugins/math/src/index.ts b/plugins/math/src/index.ts new file mode 100644 index 00000000..d30f00c4 --- /dev/null +++ b/plugins/math/src/index.ts @@ -0,0 +1,284 @@ +import { + $, + CARD_KEY, + NodeInterface, + Plugin, + CardEntry, + PluginOptions, + CardInterface, + PluginEntry, + isEngine, + SchemaInterface, + decodeCardValue, + encodeCardValue, + AjaxInterface, +} from '@aomao/engine'; +import MathComponent from './component'; +import locales from './locales'; + +export interface Options extends PluginOptions { + /** + * 请求生成公式svg地址 + */ + action: string; + /** + * 数据返回类型,默认 json + */ + type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js'; + /** + * 额外携带数据上传 + */ + data?: {}; + /** + * 请求类型,默认 application/json; + */ + contentType?: string; + /** + * 解析上传后的Respone,返回 result:是否成功,data:成功:公式数据,失败:错误信息 + */ + parse?: (response: any) => { + result: boolean; + data: string; + }; +} + +export default class Math extends Plugin { + static get pluginName() { + return 'math'; + } + + #request?: AjaxInterface; + + 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(...args: any): void { + const { card } = this.editor; + const cardComponent = card.insert(MathComponent.cardName, { + code: args[0] || '', + url: args[1] || '', + }); + card.activate(cardComponent.root); + window.setTimeout(() => { + (cardComponent as MathComponent).focusTextarea(); + }, 10); + } + + action(action: string, ...args: any) { + switch (action) { + case 'query': + const [code, success, failed] = args; + return this.query(code, success, failed); + } + } + + query( + code: string, + success: (url: string) => void, + failed: (message: string) => void, + ) { + const { request } = this.editor; + const { action, type, contentType, data, parse } = this.options; + this.#request?.abort(); + this.#request = request.ajax({ + url: action, + method: 'POST', + contentType: contentType || 'application/json', + type: type === undefined ? 'json' : type, + data: { + ...data, + content: code, + }, + success: (response) => { + const url = + response.url || (response.data && response.data.url); + + const result = parse + ? parse(response) + : !!url + ? { result: true, data: url } + : { result: false }; + if (result.result) { + const isUrl = + result.data.indexOf('http') === 0 || + result.data.indexOf('/') === 0; + let url = result.data; + if (!isUrl) { + url = this.exConvertToPx(result.data); + url = + (url.indexOf('data:') < 0 + ? 'data:image/svg+xml,' + : '') + + encodeURIComponent(url) + .replace(/'/g, '%27') + .replace(/"/g, '%22'); + } + success(url); + } else { + failed(result.data); + } + }, + error: (error) => { + failed( + error.message || + this.editor.language.get('image', 'uploadError'), + ); + }, + }); + } + + exConvertToPx(svg: string) { + const regWidth = /width="([\d\.]+ex)"/; + const widthMaths = regWidth.exec(svg); + const exWidth = widthMaths ? widthMaths[1] : null; + + const regHeight = /height="([\d\.]+ex)"/; + const heightMaths = regHeight.exec(svg); + const exHeight = heightMaths ? heightMaths[1] : null; + + if (exWidth) { + const pxWidth = + parseFloat(exWidth.substring(0, exWidth.length - 2)) * 9; + svg = svg.replace(`width="${exWidth}"`, `width="${pxWidth}px"`); + } + + if (exHeight) { + const pxHeight = + parseFloat(exHeight.substring(0, exHeight.length - 2)) * 9; + svg = svg.replace(`height="${exHeight}"`, `height="${pxHeight}px"`); + } + return svg; + } + + 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 === + MathComponent.cardName && + (component as MathComponent).isSaving + ); + }; + // 找到不合格的组件 + 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(async (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: 'mark', + name: 'span', + attributes: { + 'data-type': { + required: true, + value: MathComponent.cardName, + }, + 'data-value': '*', + }, + }); + } + + pasteHtml(node: NodeInterface) { + if (!isEngine(this.editor)) return; + if (node.isElement()) { + const type = node.attributes('data-type'); + if (type === MathComponent.cardName) { + const value = node.attributes('data-value'); + const cardValue = decodeCardValue(value); + if (!cardValue.url) return; + this.editor.card.replaceNode( + node, + MathComponent.cardName, + cardValue, + ); + node.remove(); + return false; + } + } + return true; + } + + parseHtml(root: NodeInterface) { + root.find(`[${CARD_KEY}=${MathComponent.cardName}`).each((cardNode) => { + const node = $(cardNode); + const card = this.editor.card.find(node) as MathComponent; + const value = card?.getValue(); + if (value) { + const img = node.find('img'); + node.empty(); + img.attributes('src', value.url); + img.css('visibility', 'visible'); + img.css('vertical-align', 'middle'); + const span = $( + ``, + ); + span.append(img); + node.replaceWith(span); + } else node.remove(); + }); + } +} + +export { MathComponent }; diff --git a/plugins/math/src/locales/en-US.ts b/plugins/math/src/locales/en-US.ts new file mode 100644 index 00000000..19991986 --- /dev/null +++ b/plugins/math/src/locales/en-US.ts @@ -0,0 +1,15 @@ +import { isMacos } from '@aomao/engine'; + +export default { + math: { + errorMessageCopy: 'Copy error message', + getError: 'Failed to get svg code', + placeholder: 'Add Tex formula', + ok: 'Ok', + buttonTips: `${isMacos ? '⌘' : 'Ctrl'} + Enter`, + tips: { + text: 'Understand LaTeX syntax', + href: 'https://math.meta.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference', + }, + }, +}; diff --git a/plugins/math/src/locales/index.ts b/plugins/math/src/locales/index.ts new file mode 100644 index 00000000..6266072c --- /dev/null +++ b/plugins/math/src/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/plugins/math/src/locales/zh-cn.ts b/plugins/math/src/locales/zh-cn.ts new file mode 100644 index 00000000..10729708 --- /dev/null +++ b/plugins/math/src/locales/zh-cn.ts @@ -0,0 +1,15 @@ +import { isMacos } from '@aomao/engine'; + +export default { + math: { + errorMessageCopy: '复制错误信息', + getError: '获取svg代码失败', + placeholder: '添加 Tex 公式', + ok: '确定', + buttonTips: `${isMacos ? '⌘' : 'Ctrl'} + Enter`, + tips: { + text: '了解 LaTeX 语法', + href: 'https://math.meta.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference', + }, + }, +}; diff --git a/plugins/math/src/utils.ts b/plugins/math/src/utils.ts new file mode 100644 index 00000000..dbda9d01 --- /dev/null +++ b/plugins/math/src/utils.ts @@ -0,0 +1,7 @@ +import { EditorInterface } from '@aomao/engine'; + +export const getLocales = ( + editor: EditorInterface, +) => { + return editor.language.get<{ [key: string]: T }>('math'); +}; diff --git a/plugins/math/tsconfig.json b/plugins/math/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/math/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/mention/README.md b/plugins/mention/README.md new file mode 100644 index 00000000..deed8e70 --- /dev/null +++ b/plugins/mention/README.md @@ -0,0 +1,126 @@ +# @aomao/plugin-mention + +提及插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-mention +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Mention, { MentionComponent } from '@aomao/plugin-mention'; + +new Engine(...,{ plugins:[Mention], cards: [MentionComponent] }) +``` + +## 可选项 + +```ts +//使用配置 +new Engine(...,{ + config:{ + "mention":{ + //其它可选项 + ... + } + } + }) +``` + +`defaultData`: 默认下拉查询列表展示数据 + +`onSearch`: 查询时的方法,或者配置 action,二选其一 + +`onSelect`: 选中列表中的一项后回调,这里可以返回一个自定义值与 key、name 一起组合成新的值存在 cardValue 里面。并且执行 getList 命令后会一起返回来 + +`onClick`: 在“提及”上单击时触发 + +`onMouseEnter`: 鼠标移入“提及”上时触发 + +`onRender`: 自定义渲染列表 + +`onRenderItem`: 自定义渲染列表项 + +`onLoading`: 自定渲染加载状态 + +`onEmpty`: 自定渲染空状态 + +`action`: 查询地址,始终使用 `GET` 请求,参数 `keyword` + +`data`: 查询时同时将这些数据一起传到到服务端 + +```ts +//默认展示的列表数据 +defaultData?: Array<{ key: string, name: string, avatar?: string}> +//查询时的方法,或者配置 action,二选其一 +onSearch?:(keyword: string) => Promise> +//选中列表中的一项后回调,这里可以返回一个自定义值与 key、name 一起组合成新的值存在 cardValue 里面。并且执行 getList 命令后会一起返回来 +onSelect?: (data: {[key:string]: string}) => void | {[key: string]: string} +//在“提及”上单击事件 +onClick?:(data: {[key:string]: string}) => void +//鼠标移入“提及”上时触发 +onMouseEnter?:(node: NodeInterface, data: {[key:string]: string}) => void +//自定义渲染列表,bindItem 可以为列表项绑定需要的属性和事件 +onRender?: (data: MentionItem, root: NodeInterface, bindItem: (node: NodeInterface, data: {[key:string]: string}) => NodeInterface) => Promise; +//自定义渲染列表项 +onRenderItem?: (item: MentionItem, root: NodeInterface) => string | NodeInterface | void +// 自定渲染加载状态 +onLoading?: (root: NodeInterface) => string | NodeInterface | void +// 自定渲染空状态 +onEmpty?: (root: NodeInterface) => string | NodeInterface | void +/** + * 查询地址 + */ +action?: string; +/** + * 数据返回类型,默认 json + */ +type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js'; +/** + * 额外携带数据上传 + */ +data?: {}; +/** + * 请求类型,默认 multipart/form-data; + */ +contentType?: string; +/** + * 解析上传后的Respone,返回 result:是否成功,data:成功:文件地址,失败:错误信息 + */ +parse?: ( + response: any, +) => { + result: boolean; + data: Array<{ key: string, name: string, avatar?: string}>; +}; + +``` + +### 解析服务端响应数据 + +`result`: true 上传成功,data 数据集合。false 上传失败,data 为错误消息 + +```ts +/** + * 解析上传后的Respone,返回 result:是否成功,data:成功:文件地址,失败:错误信息 + */ +parse?: ( + response: any, +) => { + result: boolean; + data: Array<{ key: string, name: string, avatar?: string}>; +}; +``` + +## 插件方法 + +获取文档中所有的提及 + +```ts +//返回 Array<{ key: string, name: string}> +engine.command.executeMethod('mention', 'getList'); +``` diff --git a/plugins/mention/package.json b/plugins/mention/package.json new file mode 100644 index 00000000..ab424185 --- /dev/null +++ b/plugins/mention/package.json @@ -0,0 +1,31 @@ +{ + "name": "@aomao/plugin-mention", + "version": "2.5.3", + "description": "提到 @", + "publishConfig": { + "access": "public" + }, + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10", + "keymaster": "^1.6.2" + } +} diff --git a/plugins/mention/src/component/collapse.ts b/plugins/mention/src/component/collapse.ts new file mode 100644 index 00000000..4c9e3403 --- /dev/null +++ b/plugins/mention/src/component/collapse.ts @@ -0,0 +1,277 @@ +import Keymaster, { setScope, unbind, deleteScope } from 'keymaster'; +import { + $, + DATA_ELEMENT, + EngineInterface, + NodeInterface, + UI, + Position, + Scrollbar, + unescape, + escape, +} from '@aomao/engine'; +import { MentionItem } from '../types'; + +export type Options = { + onCancel?: () => void; + onSelect?: (event: MouseEvent, data: { [key: string]: string }) => void; +}; + +export interface CollapseComponentInterface { + select(index: number): void; + scroll(direction: 'up' | 'down'): void; + unbindEvents(): void; + bindEvents(): void; + remove(): void; + render(target: NodeInterface, data: Array): void; +} + +class CollapseComponent implements CollapseComponentInterface { + private engine: EngineInterface; + private root?: NodeInterface; + private target?: NodeInterface; + private otpions: Options; + private readonly SCOPE_NAME = 'data-mention-component'; + #position?: Position; + #scrollbar?: Scrollbar; + static renderItem?: ( + item: MentionItem, + root: NodeInterface, + ) => string | NodeInterface | void; + + static renderEmpty: (root: NodeInterface) => string | NodeInterface | void = + () => { + return `
      Empty Data
      `; + }; + + static renderLoading?: ( + root: NodeInterface, + ) => string | NodeInterface | void; + + static render?: ( + root: NodeInterface, + data: MentionItem[], + bindItem: ( + node: NodeInterface, + data: { [key: string]: string }, + ) => NodeInterface, + ) => Promise; + + constructor(engine: EngineInterface, options: Options) { + this.otpions = options; + this.engine = engine; + this.#position = new Position(this.engine); + } + + handlePreventDefault = (event: Event) => { + // Card已被删除 + if (this.root?.closest('body').length !== 0) { + event.preventDefault(); + return false; + } + return; + }; + + select(index: number) { + this.root + ?.find('.data-mention-item-active') + .removeClass('data-mention-item-active'); + this.root + ?.find('.data-mention-item') + .eq(index) + ?.addClass('data-mention-item-active'); + } + + scroll(direction: 'up' | 'down') { + if (!this.root) return; + const items = this.root.find('.data-mention-item').toArray(); + if (items.length === 0) return; + let activeNode: NodeInterface | null = + items.find((item) => item.hasClass('data-mention-item-active')) || + items[0]; + let startNode = activeNode; + activeNode = direction === 'up' ? activeNode.prev() : activeNode.next(); + while (true) { + if (!activeNode) + activeNode = + direction === 'up' ? items[items.length - 1] : items[0]; + if ( + !!activeNode.attributes('data-key') || + startNode.equal(activeNode) + ) + break; + else + activeNode = + direction === 'up' ? activeNode.prev() : activeNode.next(); + } + if (!activeNode || !activeNode.attributes('data-key')) { + this.select(-1); + return; + } + this.select(items.findIndex((n) => n.equal(activeNode!))); + let offset = 0; + this.root.find('.data-mention-item').each((node) => { + if (activeNode?.equal(node)) return false; + offset += (node as Element).clientHeight; + return; + }); + const rootElement = this.root.get()!; + rootElement.scrollTop = offset - rootElement.clientHeight / 2; + } + + unbindEvents() { + deleteScope(this.SCOPE_NAME); + unbind('enter', this.SCOPE_NAME); + unbind('up', this.SCOPE_NAME); + unbind('down', this.SCOPE_NAME); + unbind('esc', this.SCOPE_NAME); + this.engine.off('keydown:enter', this.handlePreventDefault); + this.#position?.destroy(); + } + + bindEvents() { + this.unbindEvents(); + setScope(this.SCOPE_NAME); + //回车 + Keymaster('enter', this.SCOPE_NAME, (event) => { + // Card 已被删除 + if (this.root?.closest('body').length === 0) { + return; + } + event.preventDefault(); + const active = this.root?.find('.data-mention-item-active'); + active?.get()?.click(); + }); + + Keymaster('up', this.SCOPE_NAME, (event) => { + // Card 已被删除 + if (this.root?.closest('body').length === 0) { + return; + } + event.preventDefault(); + this.scroll('up'); + }); + Keymaster('down', this.SCOPE_NAME, (e) => { + // Card 已被删除 + if (this.root?.closest('body').length === 0) { + return; + } + e.preventDefault(); + this.scroll('down'); + }); + Keymaster('esc', this.SCOPE_NAME, (event) => { + event.preventDefault(); + this.unbindEvents(); + const { onCancel } = this.otpions; + if (onCancel) onCancel(); + }); + this.engine.on('keydown:enter', this.handlePreventDefault); + if (!this.root || !this.target) return; + this.#position?.bind(this.root, this.target); + } + + remove() { + if (!this.root || this.root.length === 0) return; + this.#scrollbar?.destroy(); + this.unbindEvents(); + this.root.remove(); + this.root = undefined; + } + + renderTemplate({ name, avatar }: MentionItem) { + return `
      + ${ + avatar + ? `` + : '' + } + ${unescape(name)} +
      `; + } + + bindItem = (node: NodeInterface, data: { [key: string]: string }) => { + const { onSelect } = this.otpions; + node.addClass('data-mention-item'); + const { key, name } = data; + if (key) { + node.attributes({ 'data-key': escape(key) }); + } else { + node.removeAttributes('data-key'); + } + node.attributes({ + 'data-name': escape(name), + }); + node.on('click', (event: MouseEvent) => { + if (!key) return; + event.stopPropagation(); + event.preventDefault(); + if (onSelect) onSelect(event, data); + }); + node.on('mouseenter', () => { + if (!key) return; + this.root + ?.find('.data-mention-item-active') + .removeClass('data-mention-item-active'); + node.addClass('data-mention-item-active'); + }); + return node; + }; + + getBody() { + return this.root?.find('.data-mention-component-body'); + } + + render(target: NodeInterface, data: Array) { + this.remove(); + this.root = $( + `
      `, + ); + + this.target = target; + + let body = this.getBody(); + if (CollapseComponent.renderLoading) { + const result = CollapseComponent.renderLoading(this.root); + body = this.getBody(); + if (result) body?.append(result); + } else if (data.filter((item) => !!item.key).length === 0) { + const result = CollapseComponent.renderEmpty(this.root); + body = this.getBody(); + if (result) body?.append(result); + } else if (CollapseComponent.render) { + CollapseComponent.render(this.root, data, this.bindItem).then( + (result) => { + const body = this.getBody(); + if (result) body?.append(result); + this.#scrollbar?.destroy(); + if (body) + this.#scrollbar = new Scrollbar( + body, + false, + true, + false, + ); + this.select(0); + this.bindEvents(); + this.#scrollbar?.refresh(); + }, + ); + return; + } else { + data.forEach((data) => { + const result = CollapseComponent.renderItem + ? CollapseComponent.renderItem(data, this.root!) + : this.renderTemplate(data); + if (!result) return; + + body?.append(this.bindItem($(result), data as any)); + }); + this.select(0); + } + if (body) this.#scrollbar = new Scrollbar(body, false, true, false); + this.bindEvents(); + this.#scrollbar?.refresh(); + } +} + +export default CollapseComponent; diff --git a/plugins/mention/src/component/index.css b/plugins/mention/src/component/index.css new file mode 100644 index 00000000..3eb8cddd --- /dev/null +++ b/plugins/mention/src/component/index.css @@ -0,0 +1,74 @@ +.am-engine .data-mention-component, .am-engine-view .data-mention-component { + color: #1890ff !important; + cursor: pointer; +} + +.am-engine [data-card-key="mention"].card-selected [data-card-element="center"].data-card-border-selected { + outline: 0 none; +} + +.data-mention-component-list { + position: absolute; + font-size: 12px; + background: #ffffff; + border: 1px solid #e8e8e8; + border-radius: 3px 3px; + box-shadow: 0 2px 10px rgb(0 0 0 / 12%); + transition: all 0.25s cubic-bezier(0.3, 1.2, 0.2, 1); + z-index: 999; + overflow: hidden; + padding: 5px 0; + max-width: 250px; +} + +.data-mention-component-body { + max-height: calc(40vh); + overflow: hidden; +} + +.data-mention-component-placeholder { + color: rgba(0,0,0,0.25); + pointer-events: none; + min-width: 78px; +} + +.data-mention-item { + display: flex; + cursor: pointer; + padding: 4px 16px; + min-width: 160px; + align-items: center; +} + +.data-mention-item .data-mention-item-text { + display: block; + text-align: left; + margin-left: 8px; + color: #595959; + line-height: 24px; + font-size: 14px; + font-weight: normal; +} + +.data-mention-item.data-mention-item-active { + background-color: #f4f4f4; +} + +.data-mention-item-avatar img { + max-width: 38px; +} + +.data-mention-component-body.data-scrollable.scroll-y { + padding-right: 0 !important; +} + +.data-mention-hover-layout { + position: absolute; + font-size: 12px; + background: #ffffff; + border: 1px solid #e8e8e8; + border-radius: 3px 3px; + box-shadow: 0 2px 10px rgb(0 0 0 / 12%); + transition: all 0.25s cubic-bezier(0.3, 1.2, 0.2, 1); + z-index: 999; +} \ No newline at end of file diff --git a/plugins/mention/src/component/index.ts b/plugins/mention/src/component/index.ts new file mode 100644 index 00000000..7b51edb2 --- /dev/null +++ b/plugins/mention/src/component/index.ts @@ -0,0 +1,356 @@ +import { + $, + Card, + isEngine, + NodeInterface, + isHotkey, + CardType, + isServer, + Position, + DATA_ELEMENT, + UI, + CardInterface, +} from '@aomao/engine'; +import CollapseComponent, { CollapseComponentInterface } from './collapse'; +import { MentionItem } from '../types'; +import './index.css'; + +export type MentionValue = { + key?: string; + name?: string; +}; + +class Mention extends Card { + private component?: CollapseComponentInterface; + #container?: NodeInterface; + #keyword?: NodeInterface; + #placeholder?: NodeInterface; + #position?: Position; + #showTimeout?: NodeJS.Timeout; + #hideTimeout?: NodeJS.Timeout; + #enterLayout?: NodeInterface; + + static get cardName() { + return 'mention'; + } + + static get cardType() { + return CardType.INLINE; + } + + static get autoSelected() { + return false; + } + + /** + * 默认数据 + */ + static defaultData?: Array; + + /** + * 查询 + * @param keyword 关键字 + * @returns + */ + static search(keyword: string) { + return new Promise>((resolve) => { + resolve([]); + }); + } + + /** + * 单击 + * @param key + * @param name + */ + static itemClick?: ( + node: NodeInterface, + data: { [key: string]: string }, + ) => void; + + /** + * 鼠标移入 + * @param node + * @param key + * @param name + */ + static mouseEnter?: ( + node: NodeInterface, + data: { [key: string]: string }, + ) => void; + + /** + * 自定义渲染列表 + * @param root 根节点 + */ + static set render( + fun: ( + root: NodeInterface, + data: MentionItem[], + bindItem: ( + node: NodeInterface, + data: { [key: string]: string }, + ) => NodeInterface, + ) => Promise, + ) { + CollapseComponent.render = fun; + } + + static onSelect?: (data: { + [key: string]: string; + }) => void | { [key: string]: string }; + + static onInsert?: (card: CardInterface) => void; + + /** + * 自定义渲染列表项 + * @param item 数据项 + */ + static set renderItem( + fun: ( + item: MentionItem, + root: NodeInterface, + ) => string | NodeInterface | void, + ) { + CollapseComponent.renderItem = fun; + } + + static renderLoading?: ( + root: NodeInterface, + ) => string | NodeInterface | void; + + static set renderEmpty( + fun: (root: NodeInterface) => string | NodeInterface | void, + ) { + CollapseComponent.renderEmpty = fun; + } + + init() { + if (!this.#position) this.#position = new Position(this.editor); + if (!isEngine(this.editor) || isServer) { + return; + } + super.init(); + if (this.component) return; + this.component = new CollapseComponent(this.editor, { + onCancel: () => { + this.changeToText(); + }, + onSelect: (_, data: { [key: string]: string }) => { + let newValue = {}; + if (Mention.onSelect) { + newValue = Mention.onSelect(data) || {}; + delete newValue['id']; + } + const { card } = this.editor; + this.component?.remove(); + this.component = undefined; + this.#keyword?.remove(); + card.focus(this, false); + const component = card.insert(Mention.cardName, { + ...data, + ...newValue, + }); + card.removeNode(this); + if (Mention.onInsert) Mention.onInsert(component); + }, + }); + } + + remove() { + if (!isEngine(this.editor)) return; + this.component?.remove(); + this.#keyword?.remove(); + this.editor.card.remove(this.id); + } + + changeToText() { + if (!this.root.inEditor() || !isEngine(this.editor)) { + return; + } + + const content = this.#keyword?.get()?.innerText || ''; + this.remove(); + this.editor.node.insertText(content); + } + + activate(activated: boolean) { + super.activate(activated); + if (!activated && this.#keyword && this.#keyword.length > 0) { + this.component?.unbindEvents(); + this.changeToText(); + } + } + + handleInput() { + if (!isEngine(this.editor)) return; + const { change, card } = this.editor; + if (change.isComposing()) { + return; + } + const content = + this.#keyword + ?.get() + ?.innerText.replace(/[\r\n]/g, '') || ''; + // 内容为空 + if (content === '') { + this.component?.remove(); + card.remove(this.id); + return; + } + + const keyword = content.substr(1); + // 搜索关键词为空 + if (keyword === '' && Mention.defaultData) { + this.component?.render(this.root, Mention.defaultData); + return; + } + if (Mention.renderLoading) { + CollapseComponent.renderLoading = Mention.renderLoading; + this.component?.render(this.root, []); + CollapseComponent.renderLoading = undefined; + } + Mention.search(keyword).then((data) => { + this.component?.render(this.root, data); + }); + } + + resetPlaceHolder() { + if ('@' === this.#keyword?.get()?.innerText) + this.#placeholder?.show(); + else this.#placeholder?.hide(); + } + + hideEnter = () => { + this.#hideTimeout = setTimeout(() => { + this.#position?.destroy(); + this.#enterLayout?.remove(); + }, 200); + }; + + showEnter = () => { + if (!this.#container || !Mention.mouseEnter) return; + const value = this.getValue(); + if (!value?.name) return; + const { id, key, name, ...info } = value; + if (this.#hideTimeout) clearTimeout(this.#hideTimeout); + if (this.#showTimeout) clearTimeout(this.#showTimeout); + if (this.#enterLayout && this.#enterLayout.length > 0) return; + this.#showTimeout = setTimeout(() => { + if (!this.#container) return; + this.#enterLayout = $( + `
      `, + ); + this.#enterLayout.on('mouseenter', () => { + if (this.#hideTimeout) clearTimeout(this.#hideTimeout); + }); + this.#enterLayout.on('mouseleave', this.hideEnter); + + Mention.mouseEnter!(this.#enterLayout, { + key: unescape(key || ''), + name: unescape(name), + ...info, + }); + + setTimeout(() => { + this.#position?.bind(this.#enterLayout!, this.#container!); + }, 10); + }, 200); + }; + + render(): string | void | NodeInterface { + const value = this.getValue(); + // 有值的情况、展示模式 + if (value?.name && !this.#container) { + const { id, key, name, ...info } = value; + this.#container = $( + `@${unescape( + name, + )}`, + ); + + this.#container.on('click', () => { + if (!this.#container || !Mention.itemClick) return; + Mention.itemClick(this.#container, { + key: unescape(key || ''), + name: unescape(name), + ...info, + }); + }); + + this.#container.on('mouseenter', this.showEnter); + this.#container.on('mouseleave', this.hideEnter); + } else if (this.#container) { + return; + } + + // 不是引擎,阅读模式 + if (!isEngine(this.editor)) { + return this.#container; + } + const language = this.editor.language.get('mention'); + let timeout: NodeJS.Timeout | undefined = undefined; + // 没有值的情况下,弹出下拉框编辑模式 + if (!this.#container) { + this.#container = $( + `@${language['placeholder']}`, + ); + this.#keyword = this.#container.eq(0); + this.#placeholder = this.#container.eq(1); + // 监听输入事件 + this.#keyword?.on('keydown', (e) => { + if (isHotkey('enter', e)) { + e.preventDefault(); + } + }); + const renderTime = Date.now(); + this.#keyword?.on('input', () => { + this.resetPlaceHolder(); + // 在 Windows 上使用中文输入法,在 keydown 事件里无法阻止用户的输入,所以在这里删除用户的输入 + if (Date.now() - renderTime < 200) { + const textNode = this.#keyword?.first(); + if ( + textNode && + textNode.isText() && + textNode[0].nodeValue === '@@' + ) { + const text = textNode.get()?.splitText(1); + text?.remove(); + } + } + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => { + this.handleInput(); + }, 100); + }); + this.getCenter().append(this.#container); + setTimeout(() => { + if (isEngine(this.editor)) { + const range = this.editor.change.range.get(); + range.select(this.#keyword!, true).collapse(false); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range.toRange()); + } + }, 10); + this.component?.render(this.root, Mention.defaultData || []); + if (!Mention.defaultData) { + setTimeout(() => { + this.handleInput(); + }, 50); + } + } + // 可编辑下,展示模式 + else { + this.component?.remove(); + return this.#container; + } + } + + destroy() { + this.component?.remove(); + this.#position?.destroy(); + } +} + +export default Mention; diff --git a/plugins/mention/src/index.ts b/plugins/mention/src/index.ts new file mode 100644 index 00000000..ada469a7 --- /dev/null +++ b/plugins/mention/src/index.ts @@ -0,0 +1,271 @@ +import { + $, + DATA_TRANSIENT_ELEMENT, + decodeCardValue, + isEngine, + isSafari, + NodeInterface, + Plugin, + CardEntry, + unescape, + PluginOptions, + SchemaInterface, + CARD_KEY, + encodeCardValue, + CardInterface, + AjaxInterface, +} from '@aomao/engine'; +import MentionComponent from './component'; +import locales from './locales'; +import { MentionItem } from './types'; + +export interface Options extends PluginOptions { + defaultData?: Array; + onSearch?: (keyword: string) => Promise>; + onSelect?: (data: { + [key: string]: string; + }) => void | { [key: string]: string }; + onInsert?: (card: CardInterface) => void; + onClick?: (node: NodeInterface, data: { [key: string]: string }) => void; + onMouseEnter?: ( + node: NodeInterface, + data: { [key: string]: string }, + ) => void; + onRender?: ( + root: NodeInterface, + data: MentionItem[], + bindItem: ( + node: NodeInterface, + data: { [key: string]: string }, + ) => NodeInterface, + ) => Promise; + onRenderItem?: ( + item: MentionItem, + root: NodeInterface, + ) => string | NodeInterface | void; + onLoading?: (root: NodeInterface) => string | NodeInterface | void; + onEmpty?: (root: NodeInterface) => string | NodeInterface | void; + spaceTrigger?: boolean; + /** + * 查询地址 + */ + action?: string; + /** + * 数据返回类型,默认 json + */ + type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js'; + /** + * 额外携带数据上传 + */ + data?: {}; + /** + * 请求类型,默认 multipart/form-data; + */ + contentType?: string; + /** + * 解析上传后的Respone,返回 result:是否成功,data:成功:文件地址,失败:错误信息 + */ + parse?: (response: any) => { + result: boolean; + data: Array; + }; +} + +class MentionPlugin extends Plugin { + #request?: AjaxInterface; + static get pluginName() { + return 'mention'; + } + + init() { + const { + defaultData, + onSearch, + onSelect, + onInsert, + onClick, + onMouseEnter, + onRender, + onRenderItem, + onLoading, + onEmpty, + action, + contentType, + type, + parse, + } = this.options; + const { request } = this.editor; + if (defaultData) MentionComponent.defaultData = defaultData; + if (onClick) MentionComponent.itemClick = onClick; + if (onMouseEnter) MentionComponent.mouseEnter = onMouseEnter; + if (onRender) MentionComponent.render = onRender; + if (onRenderItem) MentionComponent.renderItem = onRenderItem; + if (onLoading) MentionComponent.renderLoading = onLoading; + if (onEmpty) MentionComponent.renderEmpty = onEmpty; + if (onSelect) MentionComponent.onSelect = onSelect; + if (onInsert) MentionComponent.onInsert = onInsert; + MentionComponent.search = (keyword: string) => { + if (onSearch) return onSearch(keyword); + return new Promise((resolve) => { + if (action) { + this.#request?.abort(); + this.#request = request.ajax({ + url: action, + contentType: contentType || '', + type: type === undefined ? 'json' : type, + data: { + keyword, + }, + success: (response: any) => { + const { result, data } = parse + ? parse(response) + : response; + if (!result) return; + resolve(data); + }, + method: 'GET', + }); + } else resolve([]); + }); + }; + if (isEngine(this.editor)) { + this.editor.on('keydown:at', (event) => this.onAt(event)); + this.editor.on('parse:value', (node) => this.paserValue(node)); + 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), + ); + } + this.editor.language.add(locales); + } + + paserValue(node: NodeInterface) { + if ( + node.isCard() && + node.attributes('name') === MentionComponent.cardName + ) { + const value = node.attributes('value'); + const cardValue = decodeCardValue(value); + if (!cardValue || !cardValue['name']) return false; + } + return true; + } + + onAt(event: KeyboardEvent) { + if (!isEngine(this.editor)) return; + const { change } = this.editor; + let range = change.range.get(); + const block = this.editor.block.closest(range.startNode); + const text = block.text().trim(); + if (text === '@' && isSafari) { + block.empty(); + } + + // 空格触发 + if (this.options.spaceTrigger) { + const selection = range.createSelection(); + if (selection.anchor) { + const prevNode = $(selection.anchor).prev(); + const prevText = + prevNode && prevNode.isText() ? prevNode[0].nodeValue : ''; + selection.move(); + // 前面有非空格文本时,应该要输入普通 at 字符 + if (prevText && /[^\s@]$/.test(prevText)) { + return; + } + } + } + + event.preventDefault(); // 插入 @,并弹出选择器 + + range = change.range.get(); + if (range.collapsed) { + event.preventDefault(); + const card = this.editor.card.insert(MentionComponent.cardName); + card.root.attributes(DATA_TRANSIENT_ELEMENT, 'true'); + this.editor.card.activate(card.root); + range = change.range.get(); + //选中关键词输入节点 + const keyword = card.find('.data-mention-component-keyword'); + range.select(keyword, true); + range.collapse(false); + change.range.select(range); + } + } + + getList() { + const values: Array<{ [key: string]: string }> = []; + this.editor.card.each((card) => { + const Component = card.constructor as CardEntry; + if (Component.cardName === MentionComponent.cardName) { + const { key, name, ...value } = + (card as MentionComponent).getValue() || {}; + if (name && key) + values.push({ + key: unescape(key), + name: unescape(name), + ...value, + }); + } + }); + return values; + } + + pasteSchema(schema: SchemaInterface) { + schema.add({ + type: 'mark', + name: 'span', + attributes: { + 'data-type': { + required: true, + value: MentionComponent.cardName, + }, + 'data-value': '*', + }, + }); + } + + pasteHtml(node: NodeInterface) { + if (!isEngine(this.editor)) return; + if (node.isElement()) { + const type = node.attributes('data-type'); + if (type === MentionComponent.cardName) { + const value = node.attributes('data-value'); + const cardValue = decodeCardValue(value); + if (!cardValue.name) return; + this.editor.card.replaceNode( + node, + MentionComponent.cardName, + cardValue, + ); + node.remove(); + return false; + } + } + return true; + } + + parseHtml(root: NodeInterface) { + root.find(`[${CARD_KEY}=${MentionComponent.cardName}`).each( + (cardNode) => { + const node = $(cardNode); + const card = this.editor.card.find(node) as MentionComponent; + const value = card?.getValue(); + if (value?.id && value.name) { + const html = `@${value.name}`; + node.empty(); + node.replaceWith($(html)); + } else node.remove(); + }, + ); + } + + execute() {} +} +export { MentionComponent }; +export default MentionPlugin; diff --git a/plugins/mention/src/locales/en-US.ts b/plugins/mention/src/locales/en-US.ts new file mode 100644 index 00000000..56d1d890 --- /dev/null +++ b/plugins/mention/src/locales/en-US.ts @@ -0,0 +1,5 @@ +export default { + mention: { + placeholder: 'User name', + }, +}; diff --git a/plugins/mention/src/locales/index.ts b/plugins/mention/src/locales/index.ts new file mode 100644 index 00000000..6266072c --- /dev/null +++ b/plugins/mention/src/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/plugins/mention/src/locales/zh-cn.ts b/plugins/mention/src/locales/zh-cn.ts new file mode 100644 index 00000000..ffdf8a1d --- /dev/null +++ b/plugins/mention/src/locales/zh-cn.ts @@ -0,0 +1,5 @@ +export default { + mention: { + placeholder: '用户名', + }, +}; diff --git a/plugins/mention/src/types.ts b/plugins/mention/src/types.ts new file mode 100644 index 00000000..4e618db4 --- /dev/null +++ b/plugins/mention/src/types.ts @@ -0,0 +1 @@ +export type MentionItem = { key?: string; name: string; avatar?: string }; diff --git a/plugins/mention/tsconfig.json b/plugins/mention/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/mention/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/mind/README.md b/plugins/mind/README.md new file mode 100644 index 00000000..7684e5fb --- /dev/null +++ b/plugins/mind/README.md @@ -0,0 +1,11 @@ +# `@aomao/plugin-mind` + +> TODO: description + +## Usage + +``` +const pluginMind = require('@aomao/plugin-mind'); + +// TODO: DEMONSTRATE API +``` diff --git a/plugins/mind/package.json b/plugins/mind/package.json new file mode 100644 index 00000000..9f7cf618 --- /dev/null +++ b/plugins/mind/package.json @@ -0,0 +1,33 @@ +{ + "name": "@aomao/plugin-mind", + "version": "2.5.3", + "description": "脑图 Mind", + "keywords": [ + "Mind", + "脑图" + ], + "author": "ITELLYOU ", + "homepage": "https://github.com/yanmao-cc/am-editor/tree/main/plugins/plugin-mind#readme", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@antv/hierarchy": "^0.6.7", + "@antv/x6": "^1.23.10", + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/mind/src/@types/hierarchy.d.ts b/plugins/mind/src/@types/hierarchy.d.ts new file mode 100644 index 00000000..09bd8d0a --- /dev/null +++ b/plugins/mind/src/@types/hierarchy.d.ts @@ -0,0 +1,8 @@ +declare module '@antv/hierarchy' { + export default { + compactBox: (root, options) => any, + dendrogram: (root, options) => any, + indented: (root, options) => any, + mindmap: (root, options) => any, + }; +} diff --git a/plugins/mind/src/component/editor/hot-areas.ts b/plugins/mind/src/component/editor/hot-areas.ts new file mode 100644 index 00000000..6bae7863 --- /dev/null +++ b/plugins/mind/src/component/editor/hot-areas.ts @@ -0,0 +1,207 @@ +import { Graph, Node } from '@antv/x6'; +import { traverseTree } from './utils'; + +class HotAreas { + #graph: Graph; + + constructor(graph: Graph) { + this.#graph = graph; + } + + /*setHotArea() { + let hotAreas = [] + const roots = this.#graph.getRootNodes() + const postFix = 'placeholder' + //const showHotArea = this.get('showHotArea') + // 设置根节点热区 + const root = roots[0] + const rootBox = root.getBBox() + const rootDx = 90 + const rootDy = 60 + hotAreas.push({ + minX: rootBox.x - rootDx, + minY: rootBox.minY - rootDy, + maxX: (rootBox.minX + rootBox.maxX) / 2, + maxY: rootBox.maxY + rootDy, + parent: root, + current: root, + id: root.id + 'left' + postFix, + nth: root.children?.length || 0, + side: 'left', + color: 'orange' + }) + hotAreas.push({ + minX: (rootBox.x + rootBox.width), + minY: rootBox.y - rootBox.height + 20, + maxX: (rootBox.x + rootBox.width), + maxY: rootBox.y + rootBox.height - 20, + parent: root, + current: root, + id: root.id + 'right' + postFix, + nth: root.children?.length || 0, + side: 'right', + color: 'pink' + }) + + function getNext(initNextIndex: number, child: Node, parent: Node) { + const children = parent.children || [] + // 所在节点 + let nextIndex = initNextIndex + + if (!parent.parent) { + while (children[nextIndex] && children[nextIndex].side !== child.side) { + nextIndex++ + } + } + + while (children[nextIndex] && children[nextIndex].isPlaceholder) { + nextIndex++ + } + + if (children[nextIndex] && children[nextIndex].side === child.side) { + return children[nextIndex] + } + } + + function getLast(initNextIndex, child, parent) { + const children = parent.children + // 所在节点 + let lastIndex = initNextIndex + + if (!parent.parent) { + while (children[lastIndex] && children[lastIndex].side !== child.side) { + lastIndex-- + } + } + + while (children[lastIndex] && children[lastIndex].isPlaceholder) { + lastIndex-- + } + + if (children[lastIndex] && children[lastIndex].side === child.side) { + return children[lastIndex] + } + } + // 设置子节点热区 + traverseTree(root, (child: Node, parent: Node, index: number) => { + const data = + if (child.isPlaceholder || !child.isVisible()) { + return + } + + const next = getNext(index + 1, child, parent) + const last = getLast(index - 1, child, parent) + const childBox = child.getBBox() + const children = parent.children + // 所在节点 + const firstSubDx = 90 + const dy = 16 + const isFirstRight = child.hierarchy === 2 && child.side === 'right' + const isFirstLeft = child.hierarchy === 2 && child.side === 'left' + + if (!last) { + hotAreas.push({ + minX: isFirstRight ? childBox.minX - firstSubDx : childBox.minX, + minY: function () { + let minY = last ? childBox.minY : childBox.minY - dy + + if (children[index - 1] && children[index - 1].isPlaceholder && children[index - 1].side === child.side) { + const placeholderBox = graph.find(children[index - 1].id).getBBox() + minY = placeholderBox.minY + } + + return minY + }(), + maxX: isFirstLeft ? childBox.maxX + firstSubDx : childBox.maxX, + maxY: (childBox.minY + childBox.maxY) / 2, + parent: parent, + id: (last ? last.id : undefined) + child.id + parent.id + postFix, + side: child.side, + color: 'yellow', + nth: index + }) + } + + if (next) { + const nextBox = graph.find(next.id).getBBox() + hotAreas.push({ + minX: function () { + if (child.side === 'left') { + return Math.max(childBox.minX, nextBox.minX) + } + return isFirstRight ? childBox.minX - firstSubDx : childBox.minX + }(), + minY: (childBox.minY + childBox.maxY) / 2, + maxX: function () { + if (child.side === 'right') { + return Math.min(childBox.maxX, nextBox.maxX) + } + return isFirstLeft ? childBox.maxX + firstSubDx : childBox.maxX + }(), + maxY: (nextBox.minY + nextBox.maxY) / 2, + parent: parent, + id: child.id + (next ? next.id : undefined) + parent.id + postFix, + side: child.side, + color: 'blue', + nth: index + 1 + }); + } else { + hotAreas.push({ + minX: isFirstRight ? childBox.minX - firstSubDx : childBox.minX, + minY: (childBox.minY + childBox.maxY) / 2, + maxX: isFirstLeft ? childBox.maxX + firstSubDx : childBox.maxX, + maxY: function () { + let maxY = childBox.maxY + dy + if (children[index + 1] && children[index + 1].isPlaceholder && children[index + 1].side === child.side) { + const placeholderBox = graph.find(children[index + 1].id).getBBox() + maxY = placeholderBox.maxY + } + return maxY + }(), + parent: parent, + id: child.id + undefined + parent.id + postFix, + color: 'red', + nth: index + 1, + addOrder: 'push', + side: child.side + }) + } + + if (!child.children || child.children.length === 0 || child.children.length === 1 && child.children[0].isPlaceholder) { + const dx = 100 + const _dy = 0 + let box + + if (child.x > parent.x) { + box = { + minX: childBox.maxX, + minY: childBox.minY - _dy, + maxX: childBox.maxX + dx, + maxY: childBox.maxY + _dy + } + } else { + box = { + minX: childBox.minX - dx, + minY: childBox.minY - _dy, + maxX: childBox.minX, + maxY: childBox.maxY + _dy + } + } + + hotAreas.push({ + ...box, + parent: child, + id: undefined + undefined + child.id + postFix, + nth: 0, + color: 'green', + side: child.side, + addOrder: 'push' + }) + } + }, parent => { + return parent.children + }) + this.set('hotAreas', hotAreas) + showHotArea && this._drawHotAreaShape() + }*/ +} diff --git a/plugins/mind/src/component/editor/html-node.ts b/plugins/mind/src/component/editor/html-node.ts new file mode 100644 index 00000000..f0523003 --- /dev/null +++ b/plugins/mind/src/component/editor/html-node.ts @@ -0,0 +1,167 @@ +import { NodeData } from '../../types'; +import { Graph, Node, Cell } from '@antv/x6'; +import { $, DATA_ELEMENT, EDITABLE } from '@aomao/engine'; + +const template = `
      +
      +
      +
      +
      + add +
      +
      `; + +export type Options = { + onAdded?: (node: Node) => void; +}; + +class HtmlNode { + graph: Graph; + #options: Options; + + constructor(graph: Graph, options: Options) { + this.graph = graph; + this.#options = options; + } + + /** + * 获取可编辑节点 + * @param node 节点 + * @returns + */ + getEditableElement(node: Cell) { + const container = node.findView(this.graph)?.container; + if (!container) return; + return $(container).find('div.mind-content'); + } + + getNodeConfig( + options: { + x: number; + y: number; + width?: number; + height?: number; + } & NodeData, + ): Node.Metadata { + const { x, y, width, height, ...data } = options; + return { + shape: 'html', + x, // Number,必选,节点位置的 x 值 + y, // Number,必选,节点位置的 y 值 + width: width || 16, // Number,可选,节点大小的 width 值 + height: height || 24, // Number,可选,节点大小的 height 值 + attributes: { + body: { + fill: 'transparent', + strokeWidth: 0, + }, + }, + data: { + ...data, + }, + html: { + render: (node: any) => { + return this.render(node); + }, + shouldComponentUpdate: (node: any) => { + return node.hasChanged('data'); + }, + }, + }; + } + + render(node: Node) { + const data = node.getData(); + + const { value, editable, attributes, styles, classNames } = data; + const base = $(template); + const body = base.find('.mind-body'); + + const editableElement = base.find('.mind-content'); + editableElement.each((element) => { + const node = $(element); + node.attributes('contenteditable', `${!!editable}`); + node.closest('.mind-container')?.attributes( + 'readonly', + `${!editable}`, + ); + editableElement.html(value || '

      Heelo

      '); + if (editable) { + setTimeout(() => { + node.get()?.focus(); + }, 50); + } + }); + + Object.keys(attributes || {}).forEach((name) => { + body.attributes(name, attributes![name]); + }); + Object.keys(styles || {}).forEach((name) => { + body.css(name, styles![name]); + }); + if (Array.isArray(classNames)) { + classNames.forEach((className) => { + body.addClass(className); + }); + } else if (typeof classNames === 'string') { + body.addClass(classNames); + } + const addTool = base.find('.mind-tool-add'); + addTool.on('click', () => { + const bbox = node.getBBox(); + const data = node.getData(); + const edgeBeginX = bbox.x + bbox.width - 5; + const edgeBeginY = bbox.y + bbox.height / 2; + const nodeX = bbox.x + bbox.width + 80; + const nodeY = bbox.y - 80; + + const target = this.graph.addNode( + this.getNodeConfig({ + x: nodeX, + y: nodeY, + width: 60, + height: 25, + hierarchy: (data.hierarchy || 0) + 1, + }), + ); + node.addChild(target); + const { onAdded } = this.#options; + if (onAdded) onAdded(target); + /*const targetBBox = target.getBBox(); + const edgeEndX = nodeX + targetBBox.width; + const edgeEndY = nodeY + targetBBox.height + 4; + const edge = this.graph.addEdge({ + shape: 'edge', // 指定使用何种图形,默认值为 'edge' + source: { x: edgeBeginX, y: edgeBeginY }, + target: { x: edgeEndX, y: edgeEndY }, + vertices: [ + { + x: edgeBeginX + 18, + y: edgeBeginY - 24, + }, + { + x: edgeBeginX + 24 + 18, + y: edgeEndY, + }, + ], + connector: { + name: 'rounded', + args: { + radius: 20, + }, + }, + attributes: { + line: { + stroke: '#ccc', + strokeWidth: 3, + targetMarker: null, + }, + }, + }); + node.addChild(edge);*/ + }); + return base.get()!; + } +} + +export default HtmlNode; diff --git a/plugins/mind/src/component/editor/index.css b/plugins/mind/src/component/editor/index.css new file mode 100644 index 00000000..d6edade0 --- /dev/null +++ b/plugins/mind/src/component/editor/index.css @@ -0,0 +1,41 @@ +.mind-container { + border: 1px solid transparent; + width: 100%; + height: 100%; + padding: 2px; + position: relative; +} +.x6-node-selected .mind-container { + border: 1px solid #096dd9; + background: rgba(52, 155, 249, .15); +} + +.mind-container[readonly="false"] { + cursor: text; +} + +.mind-container[readonly="true"] .mind-content { + cursor: move !important; +} + +.mind-body { + width: 100%; + height: 100%; +} + +.mind-main-node { + background-color: #596086; +} + +.mind-tool-add { + position: absolute; + right: -40px; + top: 10px; + width: 40px; + display: none; + cursor: pointer; +} + +.mind-container:hover .mind-tool-add { + display: block; +} \ No newline at end of file diff --git a/plugins/mind/src/component/editor/index.ts b/plugins/mind/src/component/editor/index.ts new file mode 100644 index 00000000..8b1a8ee7 --- /dev/null +++ b/plugins/mind/src/component/editor/index.ts @@ -0,0 +1,232 @@ +import { NodeData, ShapeData } from '../../types'; +import { Graph, Model, Cell, Node, Edge, Addon } from '@antv/x6'; +import Hierarchy from '@antv/hierarchy'; +import { $, DATA_ELEMENT, NodeInterface, UI } from '@aomao/engine'; +import HtmlNode from './html-node'; +import './index.css'; + +/*let GraphMoudle +if (!isServer) { + import('@antv/x6').then(moudle => { + GraphMoudle = moudle + }); +}*/ + +export type Options = { + width?: number; + height?: number; + onChange?: (data: Array) => void; + onSelectedEditable?: (cell: Cell) => void; +}; + +class GraphEditor { + #options: Options; + #editableCell?: Cell; + #htmlNode: HtmlNode; + #graph: Graph; + #dnd: Addon.Dnd; + + constructor(container: NodeInterface, options: Options) { + this.#options = options; + this.#graph = new Graph({ + container: container.get()!, + width: options.width, + height: options.height || 600, + selecting: { + enabled: true, + }, + getHTMLComponent: (node: Node) => { + return this.#htmlNode.render(node); + }, + interacting: ({ cell }) => { + if (cell.isNode() && cell.id === 'main') return true; + return { nodeMovable: false, edgeMovable: false }; + }, + }); + this.#graph.on('cell:changed', () => { + const { onChange } = this.#options; + if (onChange) onChange(this.getData()); + }); + + this.#htmlNode = new HtmlNode(this.#graph, { + onAdded: () => { + console.log('added'); + const data = this.getData(); + const nodes = this.#graph.getNodes(); + const hierarchy = this.getHierarchy(data); + /*const updatePosition = (data: Array) => { + data.forEach((item) => { + const node = nodes.find((node) => node.id === item.id); + if (node && node.parent) { + const { x, y } = node.getBBox(); + const newX = + item.x + item.data.width / 2 + item.hgap; + const newY = + item.y + item.data.height / 2 + item.vgap; + if (newX !== x || newY !== y) { + node.position(newX, newY, { + relative: true, + deep: true, + }); + } + } + if (item.children) updatePosition(item.children); + }); + }; + updatePosition(hierarchy);*/ + }, + }); + this.#dnd = new Addon.Dnd({ + target: this.#graph, + getDragNode: (sourceNode) => sourceNode, + }); + this.#graph.on('node:mousedown', ({ e, node }) => { + //this.#dnd.start(node, e); + }); + } + + getData() { + const nodes = this.#graph.getNodes(); + const data: Array = []; + nodes.forEach((node) => { + if (!node.isNode()) return; + const getItem = (node: Cell) => { + const bbox = node.getBBox(); + const children: Array = []; + node.children?.forEach((child) => { + if (!child.isNode()) return; + children.push(getItem(child)); + }); + + const data = node.getData(); + return { + id: node.id, + x: bbox.x, + y: bbox.y, + width: bbox.width, + height: bbox.height, + data: node.getData(), + children, + hierarchy: data.hierarchy || 1, + }; + }; + if (node.parent) return; + data.push(getItem(node)); + }); + return data; + } + + getHierarchy(data: Array) { + return data.map((data) => + Hierarchy.mindmap(data, { + direction: 'H', + getSubTreeSep: (node: ShapeData) => { + if (node.children && node.children.length > 0) { + if (node.data?.hierarchy && node.data?.hierarchy <= 2) { + return 8; + } + return 2; + } + return 0; + }, + getHGap: (node: ShapeData) => { + if (node.data?.hierarchy && node.data?.hierarchy === 1) { + return 8; + } + + if (node.data?.hierarchy && node.data?.hierarchy === 2) { + return 24; + } + + return 18; + }, + getVGap: (node: ShapeData) => { + if (node.data?.hierarchy && node.data?.hierarchy === 1) { + return 8; + } + + if (node.data?.hierarchy && node.data?.hierarchy === 2) { + return 12; + } + + return 2; + }, + getSide: (node: ShapeData) => { + /*if (node.data.side) { + return node.data.side + }*/ + + return 'right'; + }, + }), + ); + } + + setEditableNodeValue(value: string) { + if (!this.#editableCell) return; + this.#editableCell.setData({ value }, { silent: true }); + } + + render(data: Array) { + const getNode = (item: any): Node.Metadata => { + const newX = item.x + item.data.width / 2 + item.hgap; + const newY = item.y + item.data.height / 2 + item.vgap; + return { + ...item, + x: newX, + y: newY, + data: item.data.data, + shape: 'html', + attributes: { + body: { + fill: 'transparent', + strokeWidth: 0, + }, + }, + html: { + render: (node: Node) => { + return this.#htmlNode.render(node); + }, + shouldComponentUpdate: (node: Node) => { + return node.hasChanged('data'); + }, + }, + }; + }; + const hierarchyData = this.getHierarchy(data); + hierarchyData.forEach((hierarchy) => { + const node = this.#graph.addNode(getNode(hierarchy)); + + const appendChild = (root: Node, item: any) => { + item.children?.forEach((child: any) => { + const node = this.#graph.addNode(getNode(child)); + root.addChild(node); + appendChild(node, child); + }); + }; + appendChild(node, hierarchy); + }); + } + + didRender() { + this.#graph.on('node:dblclick', ({ cell }) => { + if (cell.shape !== 'html') return; + cell.setData({ editable: true }); + console.log('node:dblclick'); + this.#editableCell = cell; + const { onSelectedEditable } = this.#options; + if (onSelectedEditable) onSelectedEditable(cell); + }); + this.#graph.on('node:unselected', () => { + console.log('node:unselected'); + this.#editableCell?.setData({ editable: false }); + this.#editableCell = undefined; + }); + } + + destroy() { + this.#graph.dispose(); + } +} + +export default GraphEditor; diff --git a/plugins/mind/src/component/editor/shape/edges/base.ts b/plugins/mind/src/component/editor/shape/edges/base.ts new file mode 100644 index 00000000..6741ed08 --- /dev/null +++ b/plugins/mind/src/component/editor/shape/edges/base.ts @@ -0,0 +1,3 @@ +import { Edge } from '@antv/x6'; + +class MindBaseEdge extends Edge {} diff --git a/plugins/mind/src/component/editor/utils.ts b/plugins/mind/src/component/editor/utils.ts new file mode 100644 index 00000000..dac10657 --- /dev/null +++ b/plugins/mind/src/component/editor/utils.ts @@ -0,0 +1,33 @@ +/** + * depth first traverse, from root to leaves, children in inverse order + * if the fn returns false, terminate the traverse + */ +const traverse = ( + data: T, + fn: (param: T) => boolean, +) => { + if (fn(data) === false) { + return false; + } + + if (data && data.children) { + for (let i = data.children.length - 1; i >= 0; i--) { + if (!traverse(data.children[i], fn)) return false; + } + } + return true; +}; + +/** + * depth first traverse, from root to leaves, children in inverse order + * if the fn returns false, terminate the traverse + */ +export const traverseTree = ( + data: T, + fn: (param: T) => boolean, +) => { + if (typeof fn !== 'function') { + return; + } + traverse(data, fn); +}; diff --git a/plugins/mind/src/component/index.ts b/plugins/mind/src/component/index.ts new file mode 100644 index 00000000..774c6c2e --- /dev/null +++ b/plugins/mind/src/component/index.ts @@ -0,0 +1,80 @@ +import { ShapeData } from '../types'; +import { Node, Edge } from '@antv/x6'; +import { + $, + Card, + CardType, + isEngine, + NodeInterface, + Parser, +} from '@aomao/engine'; +import GraphEditor from './editor'; + +export type MindValue = { + data: Array; +}; + +export default class MindCard extends Card { + private graphEditor?: GraphEditor; + + static get cardName() { + return 'mind'; + } + + static get cardType() { + return CardType.BLOCK; + } + + contenteditable = ['div.mind-content']; + + onChange(_: 'local' | 'remote', node: NodeInterface) { + const height = node.height(); + const width = node.width(); + console.log(width, height); + const { schema, conversion } = this.editor; + const parser = new Parser(node.clone(true), this.editor); + const value = parser.toValue(schema, conversion, false, true); + this.graphEditor?.setEditableNodeValue(value); + } + + render() { + const height = 600; + if (!this.graphEditor) { + this.graphEditor = new GraphEditor(this.getCenter(), { + height, + onChange: (data) => { + this.setValue({ data }); + }, + }); + } + const value = this.getValue(); + const data = value?.data || [ + { + id: 'main', // String,可选,节点的唯一标识 + shape: 'html', + totalHeight: height, + totalWidth: 690, + x: 40, // Number,必选,节点位置的 x 值 + y: 40, // Number,必选,节点位置的 y 值 + width: 180, // Number,可选,节点大小的 width 值 + height: 40, // Number,可选,节点大小的 height 值 + data: { + hierarchy: 1, + value: `

      思维导图

      `, + classNames: 'mind-main-node', + }, + }, + ]; + this.graphEditor.render(data); + } + + didRender() { + super.didRender(); + this.graphEditor?.didRender(); + } + + destroy() { + this.graphEditor?.destroy(); + this.graphEditor = undefined; + } +} diff --git a/plugins/mind/src/index.ts b/plugins/mind/src/index.ts new file mode 100644 index 00000000..a31c445f --- /dev/null +++ b/plugins/mind/src/index.ts @@ -0,0 +1,18 @@ +import { Plugin, isEngine, SchemaBlock, PluginOptions } from '@aomao/engine'; +import MindComponent from './component'; + +export interface Options extends PluginOptions {} + +export default class Mind extends Plugin { + static get pluginName() { + return 'mind'; + } + + execute() { + if (!isEngine(this.editor)) return; + const { card } = this.editor; + card.insert(MindComponent.cardName); + } +} + +export { MindComponent }; diff --git a/plugins/mind/src/types.ts b/plugins/mind/src/types.ts new file mode 100644 index 00000000..a2b570f6 --- /dev/null +++ b/plugins/mind/src/types.ts @@ -0,0 +1,19 @@ +export type NodeData = { + editable?: boolean; + value?: string; + attributes?: { [key: string]: string }; + styles?: { [key: string]: string }; + classNames?: Array | string; + isPlaceholder?: boolean; + hierarchy?: number; +}; + +export type ShapeData = { + id: string; + x: number; + y: number; + width: number; + height: number; + data?: NodeData; + children?: Array; +}; diff --git a/plugins/mind/tsconfig.json b/plugins/mind/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/mind/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/orderedlist/README.md b/plugins/orderedlist/README.md new file mode 100644 index 00000000..4894a575 --- /dev/null +++ b/plugins/orderedlist/README.md @@ -0,0 +1,68 @@ +# @aomao/plugin-orderedlist + +有序列表插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-orderedlist +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Orderedlist from '@aomao/plugin-orderedlist'; + +new Engine(...,{ plugins:[Orderedlist] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键`mod+shift+7` + +```ts +//快捷键 +hotkey?: string | Array;//默认mod+shift+7 +//使用配置 +new Engine(...,{ + config:{ + "orderedlist":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Orderedlist 插件 markdown 语法为`1.` 序号+点 + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "orderedlist":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +有一个参数 `start:number` 默认为 1,表示列表开始序号 + +```ts +//使用 command 执行插件、并传入所需参数 +engine.command.execute('orderedlist', 1); +//使用 command 执行查询当前状态,返回 false 或者当前列表插件名称 orderedlist tasklist unorderedlist +engine.command.queryState('orderedlist'); +``` diff --git a/plugins/orderedlist/package.json b/plugins/orderedlist/package.json new file mode 100644 index 00000000..86ab8f1a --- /dev/null +++ b/plugins/orderedlist/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-orderedlist", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/orderedlist/src/index.ts b/plugins/orderedlist/src/index.ts new file mode 100644 index 00000000..41ccbc5e --- /dev/null +++ b/plugins/orderedlist/src/index.ts @@ -0,0 +1,176 @@ +import { + $, + NodeInterface, + ListPlugin, + isEngine, + SchemaBlock, + PluginEntry, + PluginOptions, +} from '@aomao/engine'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; + markdown?: boolean; +} + +export default class extends ListPlugin { + tagName = 'ol'; + + attributes = { + start: '@var0', + 'data-indent': '@var1', + }; + + variable = { + '@var0': '@number', + '@var1': '@number', + }; + + allowIn = ['blockquote', '$root']; + + static get pluginName() { + return 'orderedlist'; + } + + init() { + super.init(); + if (isEngine(this.editor)) { + this.editor.on('paste:markdown', (child) => + this.pasteMarkdown(child), + ); + // 有序列表原生结构和markdown结构一样,不检测,以免太多误报 + // this.editor.on( + // 'paste:markdown-check', + // (child) => !this.checkMarkdown(child)?.match, + // ); + } + } + + schema(): Array { + const scheam = super.schema() as SchemaBlock; + return [ + scheam, + { + name: 'ol', + type: 'block', + }, + { + name: 'li', + type: 'block', + allowIn: ['ol'], + }, + ]; + } + + isCurrent(node: NodeInterface) { + const { list } = this.editor!; + return !node.hasClass(list.CUSTOMZIE_UL_CLASS) && node.name === 'ol'; + } + + execute(start: number = 1) { + if (!isEngine(this.editor)) return; + const { change, list, block } = this.editor; + list.split(); + const range = change.range.get(); + const activeBlocks = block.findBlocks(range); + if (activeBlocks) { + const selection = range.createSelection(); + + if (list.getPluginNameByNodes(activeBlocks) === 'orderedlist') { + list.unwrap(activeBlocks); + } else { + list.toNormal(activeBlocks, 'ol', start); + } + selection.move(); + change.range.select(range); + list.merge(); + } + } + + hotkey() { + return this.options.hotkey || 'mod+shift+7'; + } + + //设置markdown + markdown(event: KeyboardEvent, text: string, block: NodeInterface) { + if (!isEngine(this.editor) || this.options.markdown === false) return; + if (block.name !== 'p') { + return; + } + + if (!/^\d{1,9}\.$/.test(text)) return; + event.preventDefault(); + this.editor.block.removeLeftText(block); + if (this.editor.node.isEmpty(block)) { + block.empty(); + block.append('
      '); + } + this.editor.command.execute( + (this.constructor as PluginEntry).pluginName, + parseInt(text.replace(/\./, ''), 10), + ); + return false; + } + + checkMarkdown(node: NodeInterface) { + if (!isEngine(this.editor) || !this.markdown || !node.isText()) return; + + const text = node.text(); + if (!text) return; + + const reg = /(^|\r\n|\n)(\d{1,9}\.)/; + const match = reg.exec(text); + return { + reg, + match, + }; + } + + pasteMarkdown(node: NodeInterface) { + const result = this.checkMarkdown(node); + if (!result) return; + const { match } = result; + if (!match) return; + + const { list } = this.editor; + + const createList = (nodes: Array, start?: number) => { + const listNode = $( + `<${this.tagName} start="${start || 1}">${nodes.join('')}`, + ); + list.addBr(listNode); + return listNode.get()?.outerHTML; + }; + const text = node.text(); + let newText = ''; + const rows = text.split(/\n|\r\n/); + let nodes: Array = []; + let start: number | undefined = undefined; + rows.forEach((row) => { + const match = /^(\d{1,9}\.)/.exec(row); + if (match) { + const codeLength = match[1].length; + if (start === undefined) + start = parseInt(match[1].substr(0, codeLength - 1), 10); + const content = row.substr( + /^\s+/.test(row.substr(codeLength)) + ? codeLength + 1 + : codeLength, + ); + nodes.push(`
    • ${content}
    • `); + } else if (nodes.length > 0) { + newText += createList(nodes, start) + '\n' + row + '\n'; + nodes = []; + start = undefined; + } else { + newText += row + '\n'; + } + }); + if (nodes.length > 0) { + newText += createList(nodes, start) + '\n'; + } + node.text(newText); + } +} diff --git a/plugins/orderedlist/tsconfig.json b/plugins/orderedlist/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/orderedlist/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/paintformat/README.md b/plugins/paintformat/README.md new file mode 100644 index 00000000..929700a8 --- /dev/null +++ b/plugins/paintformat/README.md @@ -0,0 +1,53 @@ +# @aomao/plugin-paintformat + +格式刷插件 + +支持所有 mark 标签插件 + +block 支持以下插件:`@aomao/plugin-heading` `@aomao/plugin-orderlist` `@aomao/plugin-unorderedlist` + +## 安装 + +```bash +$ yarn add @aomao/plugin-paintformat +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Paintformat from '@aomao/plugin-paintformat'; + +new Engine(...,{ plugins:[Paintformat] }) +``` + +## 可选项 + +### 移除 + +移除样式命令,或提供方法。默认为 removeformat ,需要添加 `@aomao/plugin-removeformat` 插件 + +```ts +removeCommand?:string | ((range:RangeInterface) => void); +``` + +### 绘制 + +如何绘制 block 节点,返回 false,不执行内置绘制,包括不复制 block 节点的 css 样式 + +```ts +/** + * @param currentBlock 当前需要绘制的节点 + * @param block 需要被复制格式的节点 + * */ +paintBlock?:(currentBlock:NodeInterface,block:NodeInterface) => boolean | void +``` + +## 命令 + +```ts +//使用 command 执行插件 +engine.command.execute('paintformat'); +//使用 command 执行查询当前状态,返回 boolean +engine.command.queryState('paintformat'); +``` diff --git a/plugins/paintformat/package.json b/plugins/paintformat/package.json new file mode 100644 index 00000000..00df2c6b --- /dev/null +++ b/plugins/paintformat/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-paintformat", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/paintformat/src/index.css b/plugins/paintformat/src/index.css new file mode 100644 index 00000000..97a6f51a --- /dev/null +++ b/plugins/paintformat/src/index.css @@ -0,0 +1,5 @@ +.am-engine.data-paintformat-mode, +.am-engine-view.data-paintformat-mode { + cursor: text; + cursor: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAUCAYAAACTQC2+AAAABGdBTUEAALGPC/xhBQAAATtJREFUSA3dlTFLA0EQhU+jlSkEG63EYGdlZZFG7QUbSyG/QP/C/Q0rqzQWQX+BwUKwtrexEFRQtFSI35NZWDa3soOHhQ8eM/v2zWw2N9xVVVUdwXUYoFxaq5il2wa8hvtG5dJaxYx16xFvLN8i3lmehiFC6Y94wbsTGsxZosajKA/7aewjrKZiyTocJO9DSYHT82r+YXyQs0eRfYBrD/Y1DB58OMyfeM/hrWq8N9qlZkmFBXiPPd6D7ikWUxwjrKWirTeJkzDeNYttKIxhDUuxiFGjfAnfMkUX3hs19emYqFt9P48mU6zVLEQPFjCfwgk8gyuwEd6pS5t0EQYmHhCXLZ8Kvz1oqmFO+H8HxVOXfZC5vwNd46y3xbx5ni1mQ4+dJ6NyDw4xX0GN9484YfcRhg+fcmmt408+5V99wSyVTWN94gAAAABJRU5ErkJggg==) 5 10, text; +} \ No newline at end of file diff --git a/plugins/paintformat/src/index.ts b/plugins/paintformat/src/index.ts new file mode 100644 index 00000000..51b772ad --- /dev/null +++ b/plugins/paintformat/src/index.ts @@ -0,0 +1,196 @@ +import { + $, + isEngine, + isNode, + NodeInterface, + Plugin, + RangeInterface, + PluginOptions, +} from '@aomao/engine'; +import './index.css'; + +export interface Options extends PluginOptions { + removeCommand?: string | ((range: RangeInterface) => void); + paintBlock?: ( + currentBlocl: NodeInterface, + block: NodeInterface, + ) => boolean | void; +} + +const PAINTFORMAT_CLASS = 'data-paintformat-mode'; + +export default class extends Plugin { + private activeMarks?: NodeInterface[]; + private activeBlocks?: NodeInterface[]; + private type?: string; + private event?: (event: KeyboardEvent) => void; + private isFormat: boolean = false; + + static get pluginName() { + return 'paintformat'; + } + + init() { + if (!isEngine(this.editor)) return; + + this.editor.on('beforeCommandExecute', (name) => { + if ('paintformat' !== name && !this.isFormat && this.event) { + this.removeActiveNodes( + this.editor!.container[0].ownerDocument!, + ); + } + }); + + // 鼠标选中文本之后添加样式 + this.editor.container.on('mouseup', (e) => { + if (!this.activeMarks) { + return; + } + // 在Card里不生效 + if (this.editor!.card.closest(e.target)) { + this.removeActiveNodes( + this.editor!.container[0].ownerDocument!, + ); + return; + } + this.isFormat = true; + this.paintFormat(this.activeMarks, this.activeBlocks); + this.isFormat = false; + if (this.type === 'single') + this.removeActiveNodes( + this.editor!.container[0].ownerDocument!, + ); + }); + } + + removeActiveNodes(node: NodeInterface | Node) { + if (isNode(node)) node = $(node); + this.editor!.container.removeClass(PAINTFORMAT_CLASS); + this.activeMarks = undefined; + this.activeBlocks = undefined; + if (this.event) { + node.off('keydown', this.event); + this.event = undefined; + } + if (isEngine(this.editor)) this.editor.trigger('select'); + } + + bindEvent(node: NodeInterface) { + const ownerDocument = node[0].ownerDocument; + + const keyEvent = (event: KeyboardEvent) => { + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + this.event = undefined; + if ('Escape' === event.key || 27 === event.keyCode) { + node.off('keydown', keyEvent); + if (ownerDocument) this.removeActiveNodes(ownerDocument); + } + }; + if (ownerDocument) { + $(ownerDocument).on('keydown', keyEvent); + this.event = keyEvent; + } + } + + paintFormat(activeMarks: NodeInterface[], activeBlocks?: NodeInterface[]) { + if (!isEngine(this.editor)) return; + const { change, command, block } = this.editor; + const range = change.range.get(); + const removeCommand = this.options.removeCommand || 'removeformat'; + // 选择范围为折叠状态,应用在整个段落,包括段落自己的样式 + if (range.collapsed) { + const dummy = $(''); + range.insertNode(dummy[0]); + const currentBlock = block.closest(range.startNode); + range.select(currentBlock, true); + change.range.select(range); + if (typeof removeCommand === 'function') removeCommand(range); + else command.execute(removeCommand); + this.paintMarks(activeMarks); + if (activeBlocks) this.paintBlocks(currentBlock, activeBlocks); + range.select(dummy); + range.collapse(true); + dummy.remove(); + change.range.select(range); + } else { + // 选择范围为展开状态 + if (typeof removeCommand === 'function') removeCommand(range); + else command.execute(removeCommand); + this.paintMarks(activeMarks); + const blocks = block.getBlocks(range); + blocks.forEach((block) => { + if (activeBlocks) this.paintBlocks(block, activeBlocks); + }); + } + } + + paintMarks(activeMarks: NodeInterface[]) { + const { mark } = this.editor!; + activeMarks.forEach((node) => { + mark.wrap(this.editor.node.clone(node)); + }); + } + + paintBlocks(currentBlock: NodeInterface, activeBlocks: NodeInterface[]) { + if (!isEngine(this.editor)) return; + const { node, change } = this.editor!; + const blockApi = this.editor.block; + const range = change.range.get(); + const selection = range.createSelection('removeformat'); + activeBlocks.forEach((block) => { + if (this.options.paintBlock) { + const paintResult = this.options.paintBlock( + currentBlock, + block, + ); + if (paintResult === false) return; + } + if (block.name !== currentBlock.name) { + range.select(currentBlock).shrinkToElementNode(); + change.blocks = [currentBlock]; + if (block.name === 'p') { + const plugin = blockApi.findPlugin(currentBlock); + if (plugin) plugin.execute(block.name); + } else if (node.isRootBlock(block)) { + const plugin = blockApi.findPlugin(block); + if (plugin) plugin.execute(block.name); + } else if (node.isList(block) && block.name !== 'li') { + const plugin = blockApi.findPlugin(block); + const curPlugin = blockApi.findPlugin( + currentBlock.name === 'li' + ? currentBlock.parent()! + : currentBlock, + ); + if (plugin && curPlugin !== plugin) plugin.execute(); + } + } + const css = block.css(); + if (Object.keys(css).length > 0) { + blockApi.setBlocks({ + style: css, + }); + } + }); + selection.move(); + } + + execute(type: string = 'single') { + if (!isEngine(this.editor)) return; + if (this.activeMarks) { + this.removeActiveNodes(this.editor.container); + return; + } + this.type = type; + this.bindEvent(this.editor.container); + const { change, mark, block } = this.editor; + const range = change.range.get(); + this.activeMarks = mark.findMarks(range); + this.activeBlocks = block.findBlocks(range); + this.editor.trigger('select'); + this.editor.container.addClass('data-paintformat-mode'); + } + + queryState() { + return !!this.activeMarks; + } +} diff --git a/plugins/paintformat/tsconfig.json b/plugins/paintformat/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/paintformat/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/quote/README.md b/plugins/quote/README.md new file mode 100644 index 00000000..b72befd2 --- /dev/null +++ b/plugins/quote/README.md @@ -0,0 +1,66 @@ +# @aomao/plugin-quote + +引用样式插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-quote +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Quote from '@aomao/plugin-quote'; + +new Engine(...,{ plugins:[Quote] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+shift+u` + +```ts +//快捷键 +hotkey?: string | Array; +//使用配置 +new Engine(...,{ + config:{ + "quote":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Quote 插件 markdown 语法为`>`回车后触发 + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "quote":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +```ts +//使用 command 执行插件、并传入所需参数 +engine.command.execute('quote'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('quote'); +``` diff --git a/plugins/quote/package.json b/plugins/quote/package.json new file mode 100644 index 00000000..20dabaee --- /dev/null +++ b/plugins/quote/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-quote", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/quote/src/index.css b/plugins/quote/src/index.css new file mode 100644 index 00000000..9c908531 --- /dev/null +++ b/plugins/quote/src/index.css @@ -0,0 +1,22 @@ +.am-engine blockquote, .am-engine-view blockquote { + margin: 5px 0 5px 0; + border-left: 3px solid #eee; + color: #8C8C8C; +} + +.am-engine blockquote + blockquote, .am-engine-view blockquote + blockquote { + margin-top: -5px; +} + +.am-engine blockquote > *, .am-engine-view blockquote > * { + margin-left: 14px; + color: #8C8C8C; +} + +.am-engine blockquote *, .am-engine-view blockquote * { + color: #8C8C8C; +} + +.am-engine blockquote a, .am-engine blockquote a:hover, .am-engine-view blockquote a, .am-engine-view blockquote a:hover { + color: #1890ff !important; +} \ No newline at end of file diff --git a/plugins/quote/src/index.ts b/plugins/quote/src/index.ts new file mode 100644 index 00000000..83fbcf1b --- /dev/null +++ b/plugins/quote/src/index.ts @@ -0,0 +1,244 @@ +import { + $, + isEngine, + NodeInterface, + BlockPlugin, + PluginEntry, + PluginOptions, +} from '@aomao/engine'; +import './index.css'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; + markdown?: boolean; +} +export default class extends BlockPlugin { + tagName: string = 'blockquote'; + + canMerge = true; + + static get pluginName() { + return 'quote'; + } + + init() { + super.init(); + this.editor.schema.addAllowIn(this.tagName); + this.editor.on('parse:html', (node) => this.parseHtml(node)); + if (isEngine(this.editor)) { + this.editor.on('paste:each', (child) => this.pasteHtml(child)); + this.editor.on('keydown:backspace', (event) => + this.onBackspace(event), + ); + this.editor.on('keydown:enter', (event) => this.onEnter(event)); + this.editor.on('paste:markdown', (child) => + this.pasteMarkdown(child), + ); + this.editor.on('paste:each', (child) => this.pasteEach(child)); + this.editor.on( + 'paste:markdown-check', + (child) => !this.checkMarkdown(child)?.match, + ); + } + } + + execute() { + if (!isEngine(this.editor)) return; + const { change, block, node } = this.editor; + if (!this.queryState()) { + block.wrap(`<${this.tagName} />`); + } else { + const range = change.range.get(); + const blockquote = change.blocks[0].closest(this.tagName); + const selection = range.createSelection(); + node.unwrap(blockquote); + selection.move(); + change.range.select(range); + return; + } + } + + queryState() { + if (!isEngine(this.editor)) return; + const { change } = this.editor; + const blocks = change.blocks; + if (blocks.length === 0) { + return false; + } + const blockquote = blocks[0].closest(this.tagName); + return this.isSelf(blockquote); + } + + hotkey() { + return this.options.hotkey || 'mod+shift+u'; + } + + //设置markdown + markdown(event: KeyboardEvent, text: string, block: NodeInterface) { + if (this.options.markdown === false || !isEngine(this.editor)) return; + const { node, command } = this.editor; + const blockApi = this.editor.block; + const plugin = blockApi.findPlugin(block); + // fix: 列表、引用等 markdown 快捷方式不应该在标题内生效 + if ( + block.name !== 'p' || + (plugin && + (plugin.constructor as PluginEntry).pluginName === 'heading') + ) { + return; + } + if (['>'].indexOf(text) < 0) return; + event.preventDefault(); + blockApi.removeLeftText(block); + if (node.isEmpty(block)) { + block.empty(); + block.append('
      '); + } + command.execute((this.constructor as PluginEntry).pluginName); + return false; + } + + pasteEach(node: NodeInterface) { + if (node.isText() && node.parent()?.name === this.tagName) { + this.editor.node.wrap(node, $('

      ')); + } + } + + checkMarkdown(node: NodeInterface) { + if (!isEngine(this.editor) || !this.markdown || !node.isText()) return; + + const text = node.text(); + if (!text) return; + + const reg = /(^|\r\n|\n)([>]{1,})/; + const match = reg.exec(text); + return { + reg, + match, + }; + } + + pasteMarkdown(node: NodeInterface) { + const result = this.checkMarkdown(node); + if (!result) return; + const { reg, match } = result; + if (!match) return; + + const text = node.text(); + let newText = ''; + const rows = text.split(/\n|\r\n/); + let nodes: Array = []; + rows.forEach((row) => { + const match = /^([>]{1,})/.exec(row); + if (match) { + const codeLength = match[1].length; + const content = row.substr( + /^\s+/.test(row.substr(codeLength)) + ? codeLength + 1 + : codeLength, + ); + const container = $('
      '); + container.html(content); + const childNodes = container.children(); + if ( + childNodes.length > 1 || + (childNodes.length === 1 && + !this.editor.node.isBlock(childNodes[0]) && + !childNodes.eq(0)?.isBlockCard()) + ) { + nodes.push(`

      ${content}

      `); + } else { + nodes.push(content); + } + } else if (nodes.length > 0) { + newText += + `<${this.tagName}>${nodes.join('')}` + + '\n' + + row + + '\n'; + nodes = []; + } else { + newText += row + '\n'; + } + }); + if (nodes.length > 0) { + newText += + `<${this.tagName}>${nodes.join('')}` + '\n'; + } + node.text(newText); + } + + onBackspace(event: KeyboardEvent) { + if (!isEngine(this.editor)) return; + const { change, node } = this.editor; + const range = change.range.get(); + const blockApi = this.editor.block; + if (!blockApi.isFirstOffset(range, 'start')) return; + const block = blockApi.closest(range.startNode); + const parentBlock = block.parent(); + + if ( + parentBlock && + parentBlock.name === 'blockquote' && + node.isBlock(block) + ) { + event.preventDefault(); + if (block.prevElement()) { + change.mergeAfterDelete(block); + } else { + if (node.isEmpty(parentBlock)) { + const newBlock = $('


      '); + parentBlock.replaceWith(newBlock); + range.select(newBlock, true).collapse(false); + change.apply(range); + } else { + blockApi.unwrap('
      '); + } + } + return false; + } + return; + } + + onEnter(event: KeyboardEvent) { + if (!isEngine(this.editor)) return; + const { change } = this.editor; + const blockApi = this.editor.block; + const range = change.range.get(); + // 选区选中最后的节点 + const block = blockApi.closest(range.endNode); + + const parent = block.parent(); + if ( + parent?.name === this.tagName && + 'p' === block.name && + block.nextElement() + ) { + event.preventDefault(); + blockApi.insertOrSplit(range, block); + return false; + } + return; + } + + parseHtml(root: NodeInterface) { + root.find('blockquote').css({ + 'margin-top': '5px', + 'margin-bottom': '5px', + 'padding-left': '1em', + 'margin-left': '0px', + 'border-left': '3px solid #eee', + opacity: '0.6', + }); + } + + pasteHtml(node: NodeInterface) { + if (!isEngine(this.editor)) return; + if (node.name === this.tagName) { + node.css('padding-left', ''); + node.css('text-indent', ''); + return false; + } + return true; + } +} diff --git a/plugins/quote/tsconfig.json b/plugins/quote/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/quote/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/redo/README.md b/plugins/redo/README.md new file mode 100644 index 00000000..fa575658 --- /dev/null +++ b/plugins/redo/README.md @@ -0,0 +1,47 @@ +# @aomao/plugin-redo + +重做历史插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-redo +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Redo from '@aomao/plugin-redo'; + +new Engine(...,{ plugins:[Redo] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+y` `shift+mod+y` + +```ts +//快捷键 +hotkey?: string | Array; +//使用配置 +new Engine(...,{ + config:{ + "redo":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +## 命令 + +```ts +//使用 command 执行插件、并传入所需参数 +engine.command.execute('redo'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('redo'); +``` diff --git a/plugins/redo/package.json b/plugins/redo/package.json new file mode 100644 index 00000000..229d6286 --- /dev/null +++ b/plugins/redo/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-redo", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/redo/src/index.ts b/plugins/redo/src/index.ts new file mode 100644 index 00000000..7e358ad3 --- /dev/null +++ b/plugins/redo/src/index.ts @@ -0,0 +1,24 @@ +import { isEngine, Plugin, PluginOptions } from '@aomao/engine'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; +} +export default class extends Plugin { + static get pluginName() { + return 'redo'; + } + + execute() { + if (!isEngine(this.editor)) return; + if (!this.editor.readonly) this.editor.history.redo(); + } + + queryState() { + if (!isEngine(this.editor) || this.editor.readonly) return; + return this.editor.history.hasRedo(); + } + + hotkey() { + return this.options.hotkey || ['mod+y', 'shift+mod+y']; + } +} diff --git a/plugins/redo/tsconfig.json b/plugins/redo/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/redo/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/removeformat/README.md b/plugins/removeformat/README.md new file mode 100644 index 00000000..96c51495 --- /dev/null +++ b/plugins/removeformat/README.md @@ -0,0 +1,49 @@ +# @aomao/plugin-removeformat + +移除样式插件 + +移除所有 mark 标签插件 + +移除所有 block 样式 + +## 安装 + +```bash +$ yarn add @aomao/plugin-removeformat +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Removeformat from '@aomao/plugin-removeformat'; + +new Engine(...,{ plugins:[Removeformat] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+\` + +```ts +//快捷键 +hotkey?: string | Array; +//使用配置 +new Engine(...,{ + config:{ + "redo":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +## 命令 + +```ts +//使用 command 执行插件 +engine.command.execute('removeformat'); +``` diff --git a/plugins/removeformat/package.json b/plugins/removeformat/package.json new file mode 100644 index 00000000..d9a217b9 --- /dev/null +++ b/plugins/removeformat/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-removeformat", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/removeformat/src/index.ts b/plugins/removeformat/src/index.ts new file mode 100644 index 00000000..9be28aaf --- /dev/null +++ b/plugins/removeformat/src/index.ts @@ -0,0 +1,41 @@ +import { isEngine, Plugin, PluginOptions } from '@aomao/engine'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; +} +export default class extends Plugin { + static get pluginName() { + return 'removeformat'; + } + + execute() { + if (!isEngine(this.editor)) return; + const { change, block, mark, inline } = this.editor; + const blockApi = block; + const range = change.range.get(); + const blocks = blockApi.getBlocks(range); + // 没有mark和inline节点的时候才对block节点移除格式 + if (change.marks.length > 0) { + mark.unwrap(); + } else if (change.inlines.length > 0) { + inline.unwrap(); + } else { + const selection = range.createSelection('removeformat'); + blocks.forEach((block) => { + const plugin = blockApi.findPlugin( + block.name === 'li' ? block.parent()! : block, + ); + if (plugin) { + range.select(block).shrinkToElementNode(); + plugin.execute(); + } + block.removeAttributes('style'); + }); + selection.move(); + } + } + + hotkey() { + return this.options.hotkey || 'mod+\\'; + } +} diff --git a/plugins/removeformat/tsconfig.json b/plugins/removeformat/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/removeformat/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/selectall/README.md b/plugins/selectall/README.md new file mode 100644 index 00000000..a6738b05 --- /dev/null +++ b/plugins/selectall/README.md @@ -0,0 +1,29 @@ +# @aomao/plugin-selectall + +全选插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-selectall +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Selectall from '@aomao/plugin-selectall'; + +new Engine(...,{ plugins:[Selectall] }) +``` + +## 快捷键 + +快捷键为 `mod+a`,不可修改 + +## 命令 + +```ts +//使用 command 执行插件 +engine.command.execute('selectall'); +``` diff --git a/plugins/selectall/package.json b/plugins/selectall/package.json new file mode 100644 index 00000000..900be8c6 --- /dev/null +++ b/plugins/selectall/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-selectall", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/selectall/src/index.ts b/plugins/selectall/src/index.ts new file mode 100644 index 00000000..24954de3 --- /dev/null +++ b/plugins/selectall/src/index.ts @@ -0,0 +1,39 @@ +import { + $, + isEngine, + Plugin, + EDITABLE_SELECTOR, + NodeInterface, +} from '@aomao/engine'; + +export default class extends Plugin { + static get pluginName() { + return 'selectall'; + } + + init() { + this.editor.on('keydown:all', (event) => this.onSelectAll(event)); + } + + execute() { + if (!isEngine(this.editor)) return; + const { change } = this.editor; + const range = change.range.get(); + const editableElement = range.startNode.closest(EDITABLE_SELECTOR); + if (editableElement.length > 0) { + range.select(editableElement, true); + } else { + range.select(this.editor.container, true); + } + change.range.select(range); + this.editor.trigger('select'); + } + + onSelectAll(event: KeyboardEvent) { + if (!isEngine(this.editor)) return; + + const { command } = this.editor; + event.preventDefault(); + command.execute('selectall'); + } +} diff --git a/plugins/selectall/tsconfig.json b/plugins/selectall/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/selectall/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/status/README.md b/plugins/status/README.md new file mode 100644 index 00000000..5300c2cb --- /dev/null +++ b/plugins/status/README.md @@ -0,0 +1,98 @@ +# @aomao/plugin-status + +状态插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-status +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Status , { StatusComponent } from '@aomao/plugin-status'; + +new Engine(...,{ plugins:[Status] , cards:[StatusComponent]}) +``` + +## 可选项 + +### 快捷键 + +默认无快捷键 + +```ts +hotkey?:string; + +//使用配置 +new Engine(...,{ + config:{ + "status":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### 自定义颜色 + +可以通过 `StatusComponent.colors` 修改或增加到默认颜色列表中 + +`colors` 是 `StatusComponent` 的静态属性,它的类型如下: + +```ts +static colors: Array<{ + background: string, + color: string, + border?: string +}> +``` + +- `background` 背景颜色 +- `color` 字体颜色 +- `border` 可选,在颜色列表中可以设置边框颜色,除了可以美化外,在比较接近白色的色块中可能肉眼不好观察到,也可以设置边框 + +```ts +//默认颜色列表 +[ + { + background: '#FFE8E6', + color: '#820014', + border: '#FF4D4F', + }, + { + background: '#FCFCCA', + color: '#614700', + border: '#FFEC3D', + }, + { + background: '#E4F7D2', + color: '#135200', + border: '#73D13D', + }, + { + background: '#E9E9E9', + color: '#595959', + border: '#E9E9E9', + }, + { + background: '#D4EEFC', + color: '#003A8C', + border: '#40A9FF', + }, + { + background: '#DEE8FC', + color: '#061178', + border: '#597EF7', + }, +]; +``` + +## 命令 + +```ts +engine.command.execute('status'); +``` diff --git a/plugins/status/package.json b/plugins/status/package.json new file mode 100644 index 00000000..fde2ce2c --- /dev/null +++ b/plugins/status/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-status", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/status/src/components/editor.ts b/plugins/status/src/components/editor.ts new file mode 100644 index 00000000..031cbaf7 --- /dev/null +++ b/plugins/status/src/components/editor.ts @@ -0,0 +1,141 @@ +import { + $, + DATA_ELEMENT, + NodeInterface, + UI, + TRIGGER_CARD_ID, + isMobile, +} from '@aomao/engine'; + +export type Options = { + colors: Array<{ + background: string; + color: string; + border?: string; + }>; + onFocus?: () => void; + onBlur?: () => void; + onChange?: ( + value: string, + color: { + background: string; + color: string; + }, + ) => void; + onOk?: (event: MouseEvent) => void; + onDestroy?: () => void; +}; + +class StatusEditor { + private options: Options; + private container?: NodeInterface; + #color?: { + background: string; + color: string; + }; + #value?: string; + + constructor(options: Options) { + this.options = options; + } + + focus() { + this.container?.find('input').get()?.focus(); + } + + change() { + const { onChange } = this.options; + if (onChange) onChange(this.#value!, this.#color!); + } + + render( + cardId: string, + defaultValue: string, + defaultColor: { + background: string; + color: string; + }, + ) { + this.destroy(); + this.#value = defaultValue; + this.#color = defaultColor; + this.container = $( + `
      `, + ); + + const { colors, onBlur, onFocus, onOk } = this.options; + const input = $(``); + + input.on('focus', () => { + if (onFocus) onFocus(); + }); + + input.on('blur', () => { + if (onBlur) onBlur(); + }); + + input.on('input', (event: KeyboardEvent) => { + this.#value = (event.target as HTMLTextAreaElement).value; + this.change(); + }); + + input.on( + isMobile ? 'touchstart' : 'mousedown', + (event: MouseEvent | TouchEvent) => { + //event.preventDefault(); + //input.get()?.focus(); + }, + ); + if (onOk) { + input.on('keydown', (event) => { + if (-1 !== [13, 27].indexOf(event.keyCode)) { + onOk(event); + } + }); + } + + this.container.append(input); + const colorPanle = $( + `
      `, + ); + colors.forEach((color) => { + const item = $(``); + + item.on('click', () => { + colorPanle.find('svg').each((svg) => { + (svg as SVGAElement).style.display = 'none'; + }); + item.find('svg').css('display', 'block'); + this.#color = color; + this.change(); + }); + colorPanle.append(item); + }); + this.container.append(colorPanle); + return this.container; + } + + destroy() { + this.container?.remove(); + const { onDestroy } = this.options; + if (onDestroy) onDestroy(); + } +} + +export default StatusEditor; diff --git a/plugins/status/src/components/index.css b/plugins/status/src/components/index.css new file mode 100644 index 00000000..c3e90eba --- /dev/null +++ b/plugins/status/src/components/index.css @@ -0,0 +1,123 @@ +.am-engine [data-card-key="status"].card-selected [data-card-element="center"].data-card-border-selected { + outline: none; +} + +.am-engine [data-card-key="status"].card-selected [data-card-element="center"].data-card-border-selected .data-label-container +{ + border: 2px solid #1890FF; +} + +.data-label-container { + font-weight: 400; + font-size: 12px; + overflow: hidden; + max-width: 200px; + display: inline-block; + white-space: nowrap; + margin-bottom: -4px; + border-radius: 4px; + border: 2px solid transparent; + padding: 0 3px; + text-overflow: ellipsis; + line-height: 14px; + margin-left: 1px; + margin-right: 1px; +} + +.data-card-status-editor { + outline: none; + width: 162px; + border-radius: 3px 3px; + position: absolute; + border: 1px solid #e8e8e8; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + z-index: 125; + text-indent: 0; + top:0; + padding: 8px; + background: #fff; +} + +.data-card-status-editor-mobile { + width: calc(100vw - 40px); +} + +.data-card-status-editor input { + box-sizing: border-box; + margin: 0; + font-variant: tabular-nums; + list-style: none; + font-feature-settings: "tnum","tnum"; + position: relative; + display: inline-block; + width: 100%; + min-width: 0; + padding: 4px 11px; + color: #595959; + font-size: 14px; + line-height: 24px; + background-color: #fff; + background-image: none; + border: 1px solid #d9d9d9; + border-radius: 2px; + transition: all .3s; +} + +.data-card-status-editor input:focus +{ + border-color: #5c9dff; + border-right-width: 1px!important; + outline: 0; + box-shadow: 0 0 0 2px rgba(52,126,255,.2) +} + +.data-card-status-editor-mobile input{ + min-width:auto; +} + +.data-card-status-editor .data-status-editor-color-panle { + position: relative; + text-align: left; + text-indent: 0; + display: flex; + justify-content: space-between; + width: 100%; + height: auto; + margin-top: 8px; +} + +.data-status-editor-color-panle > span { + width: 24px; + height: 24px; + display: inline-block; + cursor: pointer; + background-color: rgb(255, 255, 255); + padding: 2px; + border-radius: 3px; + border-width: 1px; + border-style: solid; + border-color: transparent; + border-image: initial; + flex: 0 0 auto; +} + +.data-status-editor-color-panle > span > span +{ + position: relative; + width: 18px; + height: 18px; + display: block; + border-radius: 2px; + border-width: 1px; + border-style: solid; + border-color: transparent; + border-image: initial; +} + +.data-status-editor-color-panle > span > span > svg { + position: absolute; + top: -1px; + left: 1px; + width: 12px; + height: 12px; +} \ No newline at end of file diff --git a/plugins/status/src/components/index.ts b/plugins/status/src/components/index.ts new file mode 100644 index 00000000..09da3e28 --- /dev/null +++ b/plugins/status/src/components/index.ts @@ -0,0 +1,182 @@ +import { + $, + Card, + CardType, + isEngine, + NodeInterface, + Position, +} from '@aomao/engine'; +import StatusEditor from './editor'; +import './index.css'; + +export type StatusValue = { + text: string; + color: { + background: string; + color: string; + }; +}; + +class Status extends Card { + #position?: Position; + + static get cardName() { + return 'status'; + } + + static get cardType() { + return CardType.INLINE; + } + + static get autoSelected() { + return false; + } + + static colors: Array<{ + background: string; + color: string; + border?: string; + }> = [ + { + background: '#FFE8E6', + color: '#820014', + border: '#FF4D4F', + }, + { + background: '#FCFCCA', + color: '#614700', + border: '#FFEC3D', + }, + { + background: '#E4F7D2', + color: '#135200', + border: '#73D13D', + }, + { + background: '#E9E9E9', + color: '#595959', + border: '#E9E9E9', + }, + { + background: '#D4EEFC', + color: '#003A8C', + border: '#40A9FF', + }, + { + background: '#DEE8FC', + color: '#061178', + border: '#597EF7', + }, + ]; + + #container?: NodeInterface; + #editorContainer?: NodeInterface; + #statusEditor?: StatusEditor; + + init() { + super.init(); + const { card } = this.editor; + if (!this.#position) this.#position = new Position(this.editor); + if (this.#statusEditor) return; + this.#statusEditor = new StatusEditor({ + colors: Status.colors, + onChange: ( + text: string, + color: { + background: string; + color: string; + }, + ) => { + this.setValue({ + text, + color, + }); + this.updateContent(); + }, + onOk: (event: MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + card.activate($(document.body)); + card.focus(this); + }, + onDestroy: () => { + this.#position?.destroy(); + }, + }); + } + + updateContent() { + if (!this.#container) return; + const value = this.getValue(); + let { text, color } = value || { text: '', color: undefined }; + + let opacity = 1; + if (!text) { + text = this.editor.language.get('status')['defaultValue']; + opacity = 0.45; + } + if (!color) { + color = this.getDefaultColor(); + } + this.#container.css('background', color.background); + this.#container.css('color', color.color); + this.#container.css('opacity', opacity); + this.#container.html(text); + } + + getDefaultColor() { + return Status.colors.length > 0 + ? Status.colors[0] + : { + background: '#FFFFFF', + color: '#222222', + }; + } + + focusEditor() { + this.#statusEditor?.focus(); + } + + onActivate(activated: boolean) { + super.onActivate(activated); + if (!isEngine(this.editor) || this.editor.readonly) return; + if (activated) this.renderEditor(); + else this.#statusEditor?.destroy(); + } + + renderEditor() { + if (!this.#statusEditor) return; + const value = this.getValue(); + if (!value) return; + this.#position?.destroy(); + this.#editorContainer = this.#statusEditor.render( + value.id, + value.text || '', + value.color || this.getDefaultColor(), + ); + if (!this.#container) return; + this.#position?.bind(this.#editorContainer, this.#container); + } + + render() { + if (this.#container) { + this.updateContent(); + return; + } + this.#container = $(``); + this.updateContent(); + if (isEngine(this.editor)) { + this.#container.css('cursor', 'pointer'); + this.#container.attributes('draggable', 'true'); + this.#container.css('user-select', 'none'); + } + return this.#container; + } + + destroy() { + this.#statusEditor?.destroy(); + this.#position?.destroy(); + } +} + +export default Status; diff --git a/plugins/status/src/index.ts b/plugins/status/src/index.ts new file mode 100644 index 00000000..b691e87f --- /dev/null +++ b/plugins/status/src/index.ts @@ -0,0 +1,117 @@ +import { + $, + Plugin, + NodeInterface, + CARD_KEY, + isEngine, + PluginOptions, + PluginEntry, + SchemaInterface, +} from '@aomao/engine'; +import StatusComponent from './components'; +import locales from './locales'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; +} +export default class extends Plugin { + static get pluginName() { + return 'status'; + } + + 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() { + if (!isEngine(this.editor)) return; + const { card } = this.editor; + const component = card.insert( + StatusComponent.cardName, + ) as StatusComponent; + card.activate(component.root); + setTimeout(() => { + component.focusEditor(); + }, 50); + } + + hotkey() { + return this.options.hotkey || ''; + } + + pasteSchema(schema: SchemaInterface) { + schema.add({ + type: 'mark', + name: 'span', + attributes: { + 'data-type': { + required: true, + value: StatusComponent.cardName, + }, + style: { + background: { + required: true, + value: '@color', + }, + color: { + required: true, + value: '@color', + }, + }, + }, + }); + } + + pasteHtml(node: NodeInterface) { + if (!isEngine(this.editor)) return; + if (node.isElement()) { + const type = node.attributes('data-type'); + if (type === StatusComponent.cardName) { + this.editor.card.replaceNode(node, StatusComponent.cardName, { + text: node.text(), + color: { + background: node.css('background'), + color: node.css('color'), + }, + }); + node.remove(); + return false; + } + } + return true; + } + + parseHtml(root: NodeInterface) { + root.find(`[${CARD_KEY}=${StatusComponent.cardName}`).each( + (statusNode) => { + const node = $(statusNode); + const container = node.find('span.data-label-container'); + container.css({ + 'font-weight': 400, + 'font-size': '12px', + overflow: 'hidden', + 'max-width': '200px', + display: 'inline-block', + 'white-space': 'nowrap', + 'margin-bottom': '-4px', + 'border-radius': '4px', + border: 'none', + padding: '2px 5px', + 'text-overflow': 'ellipsis', + 'line-height': '14px', + 'margin-left': '1px', + 'margin-right': '1px', + }); + container.attributes('data-type', StatusComponent.cardName); + node.replaceWith(container); + }, + ); + } +} +export { StatusComponent }; diff --git a/plugins/status/src/locales/en-US.ts b/plugins/status/src/locales/en-US.ts new file mode 100644 index 00000000..51edba0d --- /dev/null +++ b/plugins/status/src/locales/en-US.ts @@ -0,0 +1,5 @@ +export default { + status: { + defaultValue: 'SET A STATUS', + }, +}; diff --git a/plugins/status/src/locales/index.ts b/plugins/status/src/locales/index.ts new file mode 100644 index 00000000..6266072c --- /dev/null +++ b/plugins/status/src/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/plugins/status/src/locales/zh-cn.ts b/plugins/status/src/locales/zh-cn.ts new file mode 100644 index 00000000..8f8358cd --- /dev/null +++ b/plugins/status/src/locales/zh-cn.ts @@ -0,0 +1,5 @@ +export default { + status: { + defaultValue: '设置状态', + }, +}; diff --git a/plugins/status/tsconfig.json b/plugins/status/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/status/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/strikethrough/README.md b/plugins/strikethrough/README.md new file mode 100644 index 00000000..0d3393e0 --- /dev/null +++ b/plugins/strikethrough/README.md @@ -0,0 +1,66 @@ +# @aomao/plugin-strikethrough + +删除线样式插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-strikethrough +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Strikethrough from '@aomao/plugin-strikethrough'; + +new Engine(...,{ plugins:[Strikethrough] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+shift+x`,以数组形式传入多个快捷键 + +```ts +//快捷键, +hotkey?: string | Array; + +//使用配置 +new Engine(...,{ + config:{ + "strikethrough":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Strikethrough 插件 markdown 语法为`~~` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "strikethrough":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +```ts +engine.command.execute('strikethrough'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('strikethrough'); +``` diff --git a/plugins/strikethrough/package.json b/plugins/strikethrough/package.json new file mode 100644 index 00000000..fb165b7a --- /dev/null +++ b/plugins/strikethrough/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-strikethrough", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/strikethrough/src/index.ts b/plugins/strikethrough/src/index.ts new file mode 100644 index 00000000..c49d8ae4 --- /dev/null +++ b/plugins/strikethrough/src/index.ts @@ -0,0 +1,43 @@ +import { MarkPlugin, PluginOptions } from '@aomao/engine'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; + markdown?: string; +} +export default class extends MarkPlugin { + tagName = 'del'; + + static get pluginName() { + return 'strikethrough'; + } + + hotkey() { + return this.options.hotkey || 'mod+shift+x'; + } + + markdown = + this.options.markdown === undefined ? '~~' : this.options.markdown; + + conversion() { + return [ + { + from: { + span: { + style: { + 'text-decoration': 'line-through', + }, + }, + }, + to: this.tagName, + }, + { + from: 's', + to: this.tagName, + }, + { + from: 'strike', + to: this.tagName, + }, + ]; + } +} diff --git a/plugins/strikethrough/tsconfig.json b/plugins/strikethrough/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/strikethrough/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/sub/README.md b/plugins/sub/README.md new file mode 100644 index 00000000..20700b37 --- /dev/null +++ b/plugins/sub/README.md @@ -0,0 +1,66 @@ +# @aomao/plugin-sub + +下标样式插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-sub +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Sub from '@aomao/plugin-sub'; + +new Engine(...,{ plugins:[Sub] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+,`,以数组形式传入多个快捷键 + +```ts +//快捷键, +hotkey?: string | Array; + +//使用配置 +new Engine(...,{ + config:{ + "sub":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Sub 插件 markdown 语法为`~` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "sub":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +```ts +engine.command.execute('sub'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('sub'); +``` diff --git a/plugins/sub/package.json b/plugins/sub/package.json new file mode 100644 index 00000000..a78704d3 --- /dev/null +++ b/plugins/sub/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-sub", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/sub/src/index.ts b/plugins/sub/src/index.ts new file mode 100644 index 00000000..96ceb2cc --- /dev/null +++ b/plugins/sub/src/index.ts @@ -0,0 +1,20 @@ +import { MarkPlugin, PluginOptions } from '@aomao/engine'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; + markdown?: string; +} +export default class extends MarkPlugin { + tagName = 'sub'; + + static get pluginName() { + return 'sub'; + } + + hotkey() { + return this.options.hotkey || 'mod+,'; + } + + markdown = + this.options.markdown === undefined ? '~' : this.options.markdown; +} diff --git a/plugins/sub/tsconfig.json b/plugins/sub/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/sub/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/sup/README.md b/plugins/sup/README.md new file mode 100644 index 00000000..ad55b146 --- /dev/null +++ b/plugins/sup/README.md @@ -0,0 +1,66 @@ +# @aomao/plugin-sup + +上标样式插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-sup +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Sup from '@aomao/plugin-sup'; + +new Engine(...,{ plugins:[Sup] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+.`,以数组形式传入多个快捷键 + +```ts +//快捷键, +hotkey?: string | Array; + +//使用配置 +new Engine(...,{ + config:{ + "sup":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Sup 插件 markdown 语法为`^` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "sup":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +```ts +engine.command.execute('sup'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('sup'); +``` diff --git a/plugins/sup/package.json b/plugins/sup/package.json new file mode 100644 index 00000000..0e619c75 --- /dev/null +++ b/plugins/sup/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-sup", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/sup/src/index.ts b/plugins/sup/src/index.ts new file mode 100644 index 00000000..6590d016 --- /dev/null +++ b/plugins/sup/src/index.ts @@ -0,0 +1,20 @@ +import { MarkPlugin, PluginOptions } from '@aomao/engine'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; + markdown?: string; +} +export default class extends MarkPlugin { + tagName = 'sup'; + + static get pluginName() { + return 'sup'; + } + + markdown = + this.options.markdown === undefined ? '^' : this.options.markdown; + + hotkey() { + return this.options.hotkey || 'mod+.'; + } +} diff --git a/plugins/sup/tsconfig.json b/plugins/sup/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/sup/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/table/README.md b/plugins/table/README.md new file mode 100644 index 00000000..5461ea00 --- /dev/null +++ b/plugins/table/README.md @@ -0,0 +1,49 @@ +# @aomao/plugin-table + +表格插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-table +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Table, { TableComponent } from '@aomao/plugin-table'; + +new Engine(...,{ plugins:[Table] , cards:[TableComponent]}) +``` + +## 可选项 + +### 快捷键 + +默认无快捷键 + +```ts +//快捷键,key 组合键,args,执行参数,[rows?: string, cols?: string] 行数:默认3行,列数:默认3列 +hotkey?:string | {key:string,args:Array};//默认无 + +//使用配置 +new Engine(...,{ + config:{ + "table":{ + //修改快捷键 + hotkey:{ + key:"mod+t", + args:[5,5] + } + } + } + }) +``` + +## 命令 + +```ts +//可携带两个参数,行数,列数,都是可选的 +engine.command.execute('table', 5, 5); +``` diff --git a/plugins/table/package.json b/plugins/table/package.json new file mode 100644 index 00000000..21663e6e --- /dev/null +++ b/plugins/table/package.json @@ -0,0 +1,29 @@ +{ + "name": "@aomao/plugin-table", + "version": "2.5.3", + "description": "table plugin", + "author": "AoMao ", + "homepage": "https://github.com/yanmao-cc/am-editor/tree/master/plugins/plugin-table#readme", + "license": "MIT", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10", + "eventemitter2": "^6.4.4", + "tinycolor2": "^1.4.2" + } +} diff --git a/plugins/table/src/component/command.ts b/plugins/table/src/component/command.ts new file mode 100644 index 00000000..ab57b3af --- /dev/null +++ b/plugins/table/src/component/command.ts @@ -0,0 +1,746 @@ +import { + $, + ClipboardData, + DATA_TRANSIENT_ATTRIBUTES, + EditorInterface, + isEngine, + NodeInterface, + Parser, +} from '@aomao/engine'; +import { EventEmitter2 } from 'eventemitter2'; +import { TableCommandInterface, TableInterface } from '../types'; +import Template from './template'; + +class TableCommand extends EventEmitter2 implements TableCommandInterface { + private editor: EditorInterface; + private table: TableInterface; + private tableCleared: boolean = false; + private rowCleared: boolean = false; + private colCleared: boolean = false; + tableRoot?: NodeInterface; + colsHeader?: NodeInterface; + rowsHeader?: NodeInterface; + tableHeader?: NodeInterface; + viewport?: NodeInterface; + + constructor(editor: EditorInterface, table: TableInterface) { + super(); + this.editor = editor; + this.table = table; + } + + init() { + const { wrapper } = this.table; + if (!wrapper) return; + this.tableRoot = wrapper.find(Template.TABLE_CLASS); + this.colsHeader = wrapper.find(Template.COLS_HEADER_CLASS); + this.rowsHeader = wrapper.find(Template.ROWS_HEADER_CLASS); + this.tableHeader = wrapper.find(Template.HEADER_CLASS); + this.viewport = wrapper.find(Template.VIEWPORT); + } + + insertColAt( + index: number, + count: number, + isLeft?: boolean, + widths?: number | Array, + ...args: any + ) { + const { selection, wrapper } = this.table; + const { tableModel } = selection; + if (!wrapper || !tableModel || !this.tableRoot) return; + // 第一行插在前面,其他行插在后面 + const colBase = index; + const insertMethod = isLeft ? 'after' : 'before'; + const colsHeader = wrapper.find(Template.COLS_HEADER_ITEM_CLASS); + const baseColHeader = colsHeader.eq(colBase)?.get()!; + const insertCol = isLeft ? colBase + 1 : colBase; + const head = wrapper.find(Template.COLS_HEADER_CLASS); + let totalWidth = 0; + + if (!widths) { + widths = baseColHeader.offsetWidth; + } + + if (Array.isArray(widths)) { + widths.forEach((w) => { + totalWidth += w; + }); + } else if (typeof widths === 'number') { + totalWidth = count * widths; + } + + head.css( + 'width', + head.get()!.offsetWidth + totalWidth + 'px', + ); + const colgroup = this.tableRoot.find('colgroup'); + const trs = wrapper.find('tr'); + const cols = this.tableRoot.find('col'); + const cloneNode = cols.eq(colBase)?.clone(); + if (!cloneNode) return; + let counter = count; + + while (counter > 0) { + // 插入头 和 col + const cloneColHeader = $(baseColHeader.outerHTML); + $(baseColHeader)[insertMethod](cloneColHeader); + const width = Array.isArray(widths) + ? widths[count - counter] + : widths; + cloneColHeader.css({ width: `${width}px` }); + const insertCloneCol = cloneNode?.clone(); + insertCloneCol.attributes('width', width); + insertCloneCol.attributes( + DATA_TRANSIENT_ATTRIBUTES, + 'table-cell-selection', + ); + if (insertCloneCol.find(Template.TABLE_TD_BG_CLASS).length === 0) { + insertCloneCol.append($(Template.CellBG)); + } + const baseCol = cols[index]; + if (insertMethod === 'after') $(baseCol).after(insertCloneCol); + else colgroup[0].insertBefore(insertCloneCol[0], baseCol); + counter--; + } + // 插入 td + trs.each((tr, r) => { + const insertIndex = selection.getCellIndex(r, insertCol); + for (let r = 0; r < count; r++) { + const td = (tr as HTMLTableRowElement).insertCell(insertIndex); + td.innerHTML = Template.EmptyCell; + $(td).attributes( + DATA_TRANSIENT_ATTRIBUTES, + 'table-cell-selection', + ); + } + }); + + this.emit('actioned', 'insertCol', ...args); + // 必须等插入完在选择,否则 tableModel 没更新 + if (selection.selectArea) { + selection.selectCol(index, index + count - 1); + } + } + + insertCol( + position?: 'left' | 'end' | 'right', + count: number = 1, + ...args: any + ) { + const { selection, wrapper } = this.table; + const { tableModel } = selection; + if (!wrapper || !tableModel) return; + const selectArea = selection.getSelectArea(); + let isLeft = position === 'left'; + const isEnd = position === 'end' || !position; + + const colBars = wrapper.find(Template.COLS_HEADER_ITEM_CLASS); + let colBase = tableModel.cols - 1; + + if (!isEnd) { + colBase = isLeft ? selectArea.begin.col : selectArea.end.col; + } + + let insertCol = isLeft ? colBase - 1 : colBase + 1; + // 插入的列索引小于0, + if (insertCol < 0) { + insertCol = 0; + isLeft = false; + } else if (!isLeft && colBase === tableModel.cols - 1) { + insertCol--; + isLeft = true; + } + const width = colBars.eq(colBase)?.get()!.offsetWidth; + + this.insertColAt(insertCol, count, isLeft, width, ...args); + if (isEnd) { + const viewPort = this.viewport?.get(); + if (!viewPort) return; + viewPort.scrollLeft = viewPort.scrollWidth - viewPort.offsetWidth; + } + } + + removeCol(...args: any) { + const { selection, conltrollBar, helper } = this.table; + const { tableModel } = selection; + if (!tableModel || !this.tableRoot) return; + const table = tableModel.table; + const selectArea = { ...selection.getSelectArea() }; + selection.each((cell) => { + if (!helper.isEmptyModelCol(cell)) { + selectArea.end.col += cell.colSpan - 1; + } + }); + const count = selectArea.end.col - selectArea.begin.col + 1; + const colgroup = this.tableRoot.find('colgroup'); + let trs = this.tableRoot.find('tr'); + let cols = colgroup.find('col'); + if (selectArea.allCol) { + this.removeTable(); + return; + } + + for (let c = selectArea.end.col; c >= selectArea.begin.col; c--) { + conltrollBar.removeCol(c); + cols.eq(c)?.remove(); + } + + table.forEach((trModel, r) => { + for ( + let _c = selectArea.end.col; + _c >= selectArea.begin.col; + _c-- + ) { + const tdModel = trModel[_c]; + if (helper.isEmptyModelCol(tdModel)) { + // 删除列如果在单元格内,修正单元格的 colSpan + const parentTd = + table[tdModel.parent.row][tdModel.parent.col]; + if ( + !helper.isEmptyModelCol(parentTd) && + tdModel.parent.col < selectArea.begin.col + ) { + const colRemoved = Math.min( + count, + tdModel.parent.col + + parentTd.colSpan - + selectArea.begin.col, + ); + if (parentTd.element) { + ( + parentTd.element as HTMLTableDataCellElement + ).colSpan = parentTd.colSpan - colRemoved; + } + } + continue; + } else { + if (tdModel.isMulti) { + // 合并单元格的头部被切掉,要生成尾部单元格补充到行内 + const cutHeader = + _c + tdModel.colSpan - 1 > selectArea.end.col; + const cutCount = selectArea.end.col + 1 - _c; + + if (cutHeader) { + let insertIndex = 0; + + for (let i = 0; i <= selectArea.end.col; i++) { + if (!helper.isEmptyModelCol(trModel[i])) { + insertIndex++; + } + } + + const td = trs + .eq(r) + ?.get() + ?.insertCell(insertIndex); + if (!td) return; + td.innerHTML = Template.EmptyCell; + td.colSpan = tdModel.colSpan - cutCount; + td.rowSpan = tdModel.rowSpan; + //if(tdModel.element) + // helper.copyCss(tdModel.element, td) + } + } + tdModel.element?.remove(); + } + } + }); + this.emit('actioned', 'removeCol', ...args); + } + + insertColLeft(count: number = 1) { + this.insertCol('left', count); + } + + insertColRight(count: number = 1) { + this.insertCol('right', count); + } + + insertRowAt(index: number, count: number, isUp?: boolean, ...args: any) { + const { wrapper, selection, helper } = this.table; + const { tableModel } = selection; + if (!wrapper || !tableModel) return; + + const insertMethod = isUp ? 'after' : 'before'; + const baseRow = index; + const rowBars = wrapper.find(Template.ROWS_HEADER_ITEM_CLASS); + const baseRowBar = rowBars[baseRow]; + const insertRow = isUp ? baseRow : baseRow + 1; + let insertTdProps: Array<{ tdBase: HTMLTableCellElement }> = []; + const trModel = tableModel.table[baseRow]; + trModel.forEach((tdModel, c) => { + if (!helper.isEmptyModelCol(tdModel) && tdModel.isMulti) { + if ( + !isUp && + tdModel.rowSpan > 1 && + insertRow <= baseRow + tdModel.rowSpan - 1 + ) { + (tdModel.element as HTMLTableCellElement).rowSpan = + tdModel.rowSpan + count; + } else { + insertTdProps.push({ + tdBase: tdModel.element as HTMLTableCellElement, + }); + } + return; + } + + if (helper.isEmptyModelCol(tdModel)) { + const parentTd = + tableModel.table[tdModel.parent.row][tdModel.parent.col]; + if ( + !helper.isEmptyModelCol(parentTd) && + tdModel.parent.row < insertRow && + tdModel.parent.row + parentTd.rowSpan - 1 >= insertRow + ) { + (parentTd.element as HTMLTableCellElement).rowSpan = + parentTd.rowSpan + count; + } else { + if ( + !helper.isEmptyModelCol(parentTd) && + tdModel.parent.row < baseRow && + tdModel.parent.col === c + ) { + insertTdProps.push({ + tdBase: parentTd.element as HTMLTableCellElement, + }); + } + } + return; + } + insertTdProps.push({ + tdBase: tdModel.element as HTMLTableCellElement, + }); + }); + let _count = count; + const _loop = () => { + const tr = this.tableRoot + ?.get() + ?.insertRow(insertRow); + if (!tr) return; + insertTdProps.forEach((props) => { + const td = tr.insertCell(); + td.innerHTML = Template.EmptyCell; + td.colSpan = props.tdBase.colSpan; + }); + $(baseRowBar)[insertMethod]( + $((baseRowBar as HTMLElement).outerHTML), + ); + _count--; + }; + + while (_count > 0) { + _loop(); + } + + this.emit('actioned', 'insertRow', ...args); + // 必须等插入完在选择,否则 tableModel 没更新 + if (selection.selectArea) { + selection.selectRow(index, index + count - 1); + } + } + + insertRow( + position?: 'up' | 'end' | 'down', + count: number = 1, + ...args: any + ) { + const { selection, helper } = this.table; + const { tableModel } = selection; + if (!tableModel) return; + const selectArea = selection.getSelectArea(); + let isUp = position === 'up'; + const isEnd = position === 'end' || !position; + let baseRow = tableModel.rows - 1; + + if (!isEnd) { + let rows = selectArea.end.row; + selection.each((cell) => { + if (!helper.isEmptyModelCol(cell)) { + rows += cell.rowSpan - 1; + } + }); + baseRow = isUp ? selectArea.begin.row : rows; + } + let insertRow = isUp ? baseRow : baseRow; + + this.insertRowAt(insertRow, count, isUp, ...args); + } + + insertRowUp(count: number = 1) { + this.insertRow('up', count); + } + + insertRowDown(count: number = 1) { + this.insertRow('down', count); + } + + removeRow(...args: any) { + const { selection, conltrollBar, helper } = this.table; + const { tableModel } = selection; + if (!tableModel || !this.tableRoot) return; + const table = tableModel.table; + const selectArea = { ...selection.getSelectArea() }; + const { begin, end } = selectArea; + selection.each((cell) => { + if (!helper.isEmptyModelCol(cell)) { + end.row += cell.rowSpan - 1; + } + }); + const count = end.row - begin.row + 1; + const trs = this.tableRoot.find('tr'); + + if (selectArea.allRow) { + this.removeTable(); + return; + } + // 修正 rowSpan 和 补充单元格 + const _loop = (r: number) => { + const trModel = table[r]; + trModel.forEach((tdModel, c) => { + if ( + !helper.isEmptyModelCol(tdModel) && + tdModel.isMulti && + tdModel.rowSpan > 1 + ) { + // 合并单元格头部被切掉,需要补充 td + if (r + tdModel.rowSpan - 1 > end.row) { + const insertIndex = selection.getCellIndex( + end.row + 1, + c, + ); + const td = ( + trs[end.row + 1] as HTMLTableRowElement + ).insertCell(insertIndex); + const cutCount = end.row - r + 1; + td.innerHTML = Template.EmptyCell; + td.colSpan = tdModel.colSpan; + td.rowSpan = tdModel.rowSpan - cutCount; + } + } + + if (helper.isEmptyModelCol(tdModel)) { + const parentTd = + table[tdModel.parent.row][tdModel.parent.col]; // 合并单元格尾部或中部被切掉,修正 rowSpan + if ( + !helper.isEmptyModelCol(parentTd) && + tdModel.parent.row < begin.row + ) { + const _cutCount = Math.min( + count, + tdModel.parent.row + parentTd.rowSpan - begin.row, + ); + (parentTd.element as HTMLTableDataCellElement).rowSpan = + parentTd.rowSpan - _cutCount; + } + } + }); + }; + + for (let r = begin.row; r <= end.row; r++) { + _loop(r); + } + + for (let r = end.row; r >= begin.row; r--) { + this.tableRoot.get()?.deleteRow(r); + conltrollBar.removeRow(r); + } + this.emit('actioned', 'removeRow', ...args); + } + + removeTable() { + if (!isEngine(this.editor)) this.emit('tableRemoved'); + this.editor.card.remove(this.table.id); + } + + copy() { + const { selection, helper } = this.table; + const areaHtml = selection.getSelectionHtml(); + if (!areaHtml) return; + this.editor.clipboard.copy($(areaHtml)[0]); + helper.copyHTML(areaHtml); + } + + mockCopy() { + const { selection, helper } = this.table; + const areaHtml = selection.getSelectionHtml(); + if (!areaHtml) return; + helper.copyHTML(areaHtml); + } + + shortcutCopy(event: ClipboardEvent) { + const { selection, helper } = this.table; + const areaHtml = selection.getSelectionHtml(); + if (!areaHtml) return; + event.clipboardData?.clearData(); + event.clipboardData?.setData('text/plain', $(areaHtml).html()); + event.clipboardData?.setData('text/html', areaHtml); + helper.copyHTML(areaHtml); + event.preventDefault(); + } + + cut() { + this.copy(); + this.clear(); + } + + shortcutCut(event: ClipboardEvent) { + this.shortcutCopy(event); + this.clear(); + } + + clear() { + const { selection, helper } = this.table; + const selectArea = selection.getSelectArea(); + + if (selectArea.allCol && selectArea.allRow) { + if (this.tableCleared) { + this.removeTable(); + this.tableCleared = false; + return; + } + this.tableCleared = true; + } + + if (selectArea.allRow) { + if (this.rowCleared) { + this.removeRow(); + this.rowCleared = false; + return; + } + this.rowCleared = true; + } + + if (selectArea.allCol) { + if (this.colCleared) { + this.removeCol(); + this.colCleared = false; + return; + } + this.colCleared = true; + } + + selection.each((tdModel) => { + if (!helper.isEmptyModelCol(tdModel) && tdModel.element) { + tdModel.element.innerHTML = Template.EmptyCell; + } + }); + this.emit('actioned', 'clear'); + } + + clearFormat = () => { + const { selection, helper } = this.table; + const selectArea = selection.getSelectArea(); + selection.each((tdModel) => { + if (!helper.isEmptyModelCol(tdModel) && tdModel.element) { + tdModel.element.removeAttribute('style'); + } + }); + this.emit('actioned', 'clearFormat'); + }; + + hasCopyData = () => { + return !!this.table.helper.getCopyData(); + }; + + mockPaste(...args: any) { + const data = this.table.helper.getCopyData(); + if (!data) return; + this.paste(data as ClipboardData, ...args); + } + + shortcutPaste(event: ClipboardEvent) { + event.preventDefault(); + event.stopPropagation(); + const data = this.editor.clipboard.getData(event); + this.paste(data); + } + + paste(data: ClipboardData, ...args: any) { + const { selection, helper } = this.table; + const { tableModel } = selection; + if (!tableModel) return; + const selectArea = selection.getSelectArea(); + const { begin, end } = selectArea; + const isSingleTd = begin.row === end.row && begin.col === end.col; + const { html, text } = data; + if (!html) return; + const { schema, conversion } = this.editor; + const pasteHTML = new Parser(html, this.editor).toValue( + schema, + conversion, + ); + const element = helper.trimBlankSpan($(pasteHTML)); + + if (element.name === 'table') { + helper.normalizeTable(element); + const pasteTableModel = helper.getTableModel(element); + const rowCount = pasteTableModel.rows; + const colCount = pasteTableModel.cols; + const startCell = pasteTableModel.table[0][0]; + const cell = tableModel.table[begin.row][begin.col]; + if ( + helper.isEmptyModelCol(startCell) || + helper.isEmptyModelCol(cell) + ) + return; + const { rowSpan, colSpan } = startCell; + const isPasteSingle = rowSpan === rowCount && colSpan === colCount; + + if (isPasteSingle && !selectArea && startCell.element) { + helper.copyTo(startCell.element, cell.element!); + this.emit('actioned', 'paste', ...args); + return; + } + // 只在选中一个非合并单元格的时候才会延伸平铺,遇到表格边界会自动增加行列 + // 若选中的是一个区域或合并单元格,则只要将区域中的单元格填充上数据即可 + if (isSingleTd) { + if (colCount + begin.col > tableModel.cols) { + const insertColCount = + colCount + begin.col - tableModel.cols; + this.insertCol('end', insertColCount, true); + } + + if (rowCount + begin.row > tableModel.rows) { + const insertRowCount = + rowCount + begin.row - tableModel.rows; + this.insertRow('end', insertRowCount, true); + } + // 选中和将要粘贴表格等大的区域 + selection.select(begin, { + row: begin.row + rowCount - 1, + col: begin.col + colCount - 1, + }); + } + + const newArea = selection.getSelectArea(); + + // 先拆分单元格,拷贝的表格中可能有合并单元格,需要重新复制合并单元格情况 + if (!args[0]) this.splitCell(true); + selection.each((tdModel, r, c) => { + const paste_r = (r - newArea.begin.row) % rowCount; + const paste_c = (c - newArea.begin.col) % colCount; + const paste_td = pasteTableModel.table[paste_r][paste_c]; + + if (!paste_td) { + return; + } + + if ( + !helper.isEmptyModelCol(paste_td) && + paste_td.isMulti && + !helper.isEmptyModelCol(tdModel) + ) { + const element = tdModel.element as HTMLTableCellElement; + element.rowSpan = Math.min( + paste_td.rowSpan, + newArea.end.row - r + 1, + ); + element.colSpan = Math.min( + paste_td.colSpan, + newArea.end.col - c + 1, + ); + helper.copyTo(paste_td.element!, element); + return; + } + + if (helper.isEmptyModelCol(paste_td)) { + if (!helper.isEmptyModelCol(tdModel)) { + tdModel.element?.remove(); + } + return; + } + + if (paste_td.element) { + if (!helper.isEmptyModelCol(tdModel) && tdModel.element) + helper.copyTo(paste_td.element, tdModel.element); + } + }); + } else { + this.mergeCell(true); + } + this.emit('actioned', 'paste', ...args); + } + + mergeCell(...args: any) { + const { selection, helper } = this.table; + const { selectArea, tableModel } = selection; + if (!selectArea || !tableModel) return; + const { begin, end } = selectArea; + const row_count = end.row - begin.row + 1; + const col_count = end.col - begin.col + 1; + let content: Array = []; + let mergeTd: HTMLTableCellElement | null = null; + this.splitCell(); + selection.select(begin, end); + selection.each((tdModel, r, c) => { + if (helper.isEmptyModelCol(tdModel)) return; + if (c === begin.col && r === begin.row) { + mergeTd = tdModel.element as HTMLTableCellElement; + mergeTd.rowSpan = row_count; + mergeTd.colSpan = col_count; + return; + } + + if (tdModel.element) { + // 空单元格里面也有 html,只有在有实际内容时才会在合并的时候将内容合并 + if (tdModel.element.innerText.trim() !== '') { + content.unshift( + $(tdModel.element) + .find(Template.TABLE_TD_CONTENT_CLASS) + .html(), + ); + } + + tdModel.element.remove(); + } + }); + if (mergeTd) { + const tdNode = $(mergeTd); + const contentNode = tdNode.find(Template.TABLE_TD_CONTENT_CLASS); + contentNode.html(contentNode.html() + content.join('')); + this.emit('actioned', 'mergeCell', ...args); + } + } + + splitCell(...args: any) { + const { selection, helper } = this.table; + const { tableModel } = selection; + if (!tableModel || !this.tableRoot) return; + let trs = this.tableRoot.find('tr'); + selection.each((cell, row, col) => { + if (helper.isEmptyModelCol(cell)) return; + const rows = row + cell.rowSpan; + const cols = col + cell.colSpan; + // 注意这里用倒序,见 selection.each 方法的最后一个参数传的时 true + // 因为是倒序,所有空位一定先转换为 td, 这样在补齐切断的单元格时,需要考虑插入时的偏移量 + for (let r = rows - 1; r >= row; r--) { + if (r >= trs.length) { + this.insertRowAt(row, 1); + } + const tr = + r >= trs.length ? this.tableRoot!.find('tr')[r] : trs[r]; + for (let c = cols - 1; c >= col; c--) { + const tdModel = tableModel.table[r][c]; + if (!helper.isEmptyModelCol(tdModel) && tdModel.isMulti) { + tdModel.element = + tdModel.element as HTMLTableCellElement; + tdModel.element.colSpan = 1; + tdModel.element.rowSpan = 1; + } else if (helper.isEmptyModelCol(tdModel)) { + const _insertIndex2 = selection.getCellIndex(r, c); + const _td2 = (tr as HTMLTableRowElement).insertCell( + _insertIndex2, + ); + _td2.innerHTML = Template.EmptyCell; + } + } + } + }); + + this.emit('actioned', 'splitCell', ...args); + } +} + +export default TableCommand; diff --git a/plugins/table/src/component/controllbar.ts b/plugins/table/src/component/controllbar.ts new file mode 100644 index 00000000..96f764bb --- /dev/null +++ b/plugins/table/src/component/controllbar.ts @@ -0,0 +1,1565 @@ +import { + TableInterface, + ControllBarInterface, + ControllDragging, + ControllChangeSize, + ControllOptions, + ControllDraggingHeader, +} from '../types'; +import { EventEmitter2 } from 'eventemitter2'; +import { + $, + CardActiveTrigger, + EditorInterface, + isEngine, + isHotkey, + isMobile, + NodeInterface, + removeUnit, +} from '@aomao/engine'; +import Template from './template'; + +class ControllBar extends EventEmitter2 implements ControllBarInterface { + private editor: EditorInterface; + private table: TableInterface; + private readonly COL_MIN_WIDTH: number; + private readonly ROW_MIN_HEIGHT: number; + tableRoot?: NodeInterface; + colsHeader?: NodeInterface; + rowsHeader?: NodeInterface; + tableHeader?: NodeInterface; + menuBar?: NodeInterface; + + dragging?: ControllDragging; + draggingHeader?: ControllDraggingHeader; + changeSize?: ControllChangeSize; + + viewport?: NodeInterface; + placeholder?: NodeInterface; + contextVisible: boolean = false; + //行删除按钮 + rowDeleteButton?: NodeInterface; + //列删除按钮 + colDeleteButton?: NodeInterface; + //列增加按钮相关 + colAddButton?: NodeInterface; + colAddAlign?: 'right' | 'left'; + colAddButtonSplit?: NodeInterface; + moveColIndex: number = -1; + hideColAddButtonTimeount?: NodeJS.Timeout; + //行增加按钮相关 + rowAddButton?: NodeInterface; + rowAddAlign?: 'up' | 'down'; + rowAddButtonSplit?: NodeInterface; + moveRowIndex: number = -1; + hideRowAddButtonTimeount?: NodeJS.Timeout; + + constructor( + editor: EditorInterface, + table: TableInterface, + options: ControllOptions, + ) { + super(); + this.table = table; + this.editor = editor; + this.COL_MIN_WIDTH = options.col_min_width; + this.ROW_MIN_HEIGHT = options.row_min_height; + } + + init() { + const { wrapper } = this.table; + if (!wrapper) return; + this.tableRoot = wrapper.find(Template.TABLE_CLASS); + this.colsHeader = wrapper.find(Template.COLS_HEADER_CLASS); + this.rowsHeader = wrapper.find(Template.ROWS_HEADER_CLASS); + this.tableHeader = wrapper.find(Template.HEADER_CLASS); + this.viewport = wrapper.find(Template.VIEWPORT); + this.menuBar = wrapper.find(Template.MENUBAR_CLASS); + this.placeholder = wrapper.find(Template.PLACEHOLDER_CLASS); + this.rowDeleteButton = this.rowsHeader?.find( + Template.ROW_DELETE_BUTTON_CLASS, + ); + this.colDeleteButton = wrapper.find(Template.COL_DELETE_BUTTON_CLASS); + this.colAddButton = this.colsHeader?.find( + Template.COL_ADD_BUTTON_CLASS, + ); + this.colAddButtonSplit = this.colAddButton.find( + Template.COL_ADD_BUTTON_SPLIT_CLASS, + ); + this.rowAddButton = this.rowsHeader?.find( + Template.ROW_ADD_BUTTON_CLASS, + ); + this.rowAddButtonSplit = this.rowAddButton.find( + Template.ROW_ADD_BUTTON_SPLIT_CLASS, + ); + this.renderRowBars(); + this.renderColBars(); + this.bindEvents(); + } + + renderRowBars(start: number = 0, end?: number) { + const table = this.tableRoot?.get(); + if (!table) return; + const trs = table.rows; + end = end || trs.length; + const rowBars = this.rowsHeader?.find(Template.ROWS_HEADER_ITEM_CLASS); + for (let i = start; i < end; i++) { + rowBars?.eq(i)?.css('height', `${trs[i].offsetHeight}px`); + } + const rowTrigger = this.rowsHeader?.find( + Template.ROWS_HEADER_TRIGGER_CLASS, + ); + const tableWidth = this.tableRoot!.width(); + const wrapperWidth = this.table.wrapper?.width() || 0; + const width = tableWidth < wrapperWidth ? tableWidth : wrapperWidth; + rowTrigger?.css( + 'width', + `${width + (this.rowsHeader?.width() || 0) - 1}px`, + ); + } + + renderColBars() { + const table = this.tableRoot?.get(); + if (!table) return; + const tableWidth = table.offsetWidth; + this.tableRoot?.css('width', `${tableWidth}px`); + this.colsHeader?.css('width', `${tableWidth}px`); + + const cols = this.tableRoot?.find('col'); + if (!cols) return; + + let isInit = true; + + const colWidthArray = {}; + let allColWidth = 0; + let colIndex = 0; + cols.each((col, i) => { + const colWidth = $(col).attributes('width'); + if (colWidth) { + colWidthArray[i] = colWidth; + allColWidth += parseInt(colWidth); + isInit = false; + } else { + colIndex++; + } + }); + const colBars = this.colsHeader?.find(Template.COLS_HEADER_ITEM_CLASS); + if (!colBars) return; + //初始化,col的宽度为0的时候 + const { tableModel } = this.table.selection; + if (isInit) { + let tdWidth: Array = []; + tableModel?.table?.forEach((trModel) => { + trModel.forEach((tdModel, c) => { + if ( + !tdWidth[c] && + !this.table.helper.isEmptyModelCol(tdModel) && + !tdModel.isMulti && + tdModel.element + ) { + tdWidth[c] = tdModel.element.offsetWidth; + } + }); + }); + // 合并单元格的存在,可能出现某些列全部属于合并单元格,导致无法通过 td 的 offsetWidth 直接获得,需要把剩余的未知行求平均数 + let unkownCount = 0; + let knownWidth = 0; + for (let c = 0; c < cols.length; c++) { + if (!tdWidth[c]) { + unkownCount++; + } else { + knownWidth += tdWidth[c]; + } + } + let averageWidth = 0; + if (unkownCount > 0) { + averageWidth = Math.round( + (tableWidth - knownWidth) / unkownCount, + ); + } + for (let i = 0; i < cols.length; i++) { + const width = tdWidth[i] || averageWidth; + colBars.eq(i)?.css('width', width + 'px'); + cols.eq(i)?.attributes('width', width); + } + } else if (colIndex) { + const averageWidth = Math.round( + (tableWidth - allColWidth) / colIndex, + ); + cols.each((_, index) => { + const width = + undefined === colWidthArray[index] + ? averageWidth + : colWidthArray[index]; + colBars.eq(index)?.css('width', width + 'px'); + cols.eq(index)?.attributes('width', width); + }); + } else { + cols.each((_, index) => { + const width = Math.round( + (tableWidth * colWidthArray[index]) / allColWidth, + ); + colBars.eq(index)?.css('width', width + 'px'); + cols.eq(index)?.attributes('width', width); + }); + } + const colTrigger = this.colsHeader?.find( + Template.COLS_HEADER_TRIGGER_CLASS, + ); + colTrigger?.css( + 'height', + `${(tableModel?.height || 0) + (this.colsHeader?.height() || 0)}px`, + ); + } + /** + * 绑定事件 + */ + bindEvents() { + this.colsHeader + ?.on(isMobile ? 'touchstart' : 'mousedown', (event) => + this.onMouseDownColsHeader(event), + ) + .on('click', (event) => this.onClickColsHeader(event)) + .on('dragstart', (event) => this.onDragStartColsHeader(event)); + this.rowsHeader + ?.on(isMobile ? 'touchstart' : 'mousedown', (event) => + this.onMouseDownRowsHeader(event), + ) + .on('click', (event) => this.onClickRowsHeader(event)) + .on('dragstart', (event) => this.onDragStartRowsHeader(event)); + this.tableHeader?.on('click', (event) => + this.onClickTableHeader(event), + ); + this.table.wrapper?.on('contextmenu', (event) => + event.preventDefault(), + ); + this.tableRoot?.on('contextmenu', (event) => event.preventDefault()); + this.colsHeader?.on('contextmenu', (event) => event.preventDefault()); + this.rowsHeader?.on('contextmenu', (event) => event.preventDefault()); + this.tableRoot?.on('mousedown', (event) => + this.onTableMouseDown(event), + ); + this.menuBar?.on('click', (event) => this.handleClickMenu(event)); + this.menuBar?.on('mouseover', (event) => this.handleHoverMenu(event)); + this.menuBar?.on('mouseleave', (event) => this.hideHighlight(event)); + //列头部 padding 区域单击让其选中表格卡片上方的blcok + this.viewport?.on( + isMobile ? 'touchstart' : 'mousedown', + (event: MouseEvent) => { + if (!event.target) return; + const targetNode = $(event.target); + if ( + !isEngine(this.editor) || + !event.target || + !this.viewport?.equal(targetNode) + ) + return; + event.preventDefault(); + event.stopPropagation(); + const { change } = this.editor; + const range = change.range.get(); + this.editor.card.focusPrevBlock(this.table, range, true); + this.editor.card.activate( + range.startNode, + CardActiveTrigger.MOUSE_DOWN, + ); + this.editor.focus(); + }, + ); + //行删除按钮 + this.rowDeleteButton + ?.on('mouseover', (event) => this.handleHighlightRow()) + .on('mouseleave', (event) => this.hideHighlight(event)) + .on('click', (event) => { + this.table.command['removeRow'](); + }); + //列删除按钮 + this.colDeleteButton + ?.on('mouseover', (event) => this.handleHighlightCol()) + .on('mouseleave', (event) => this.hideHighlight(event)) + .on('click', (event) => { + this.table.command['removeCol'](); + }); + //列增加按钮 + this.colAddButton + ?.on('mouseenter', () => { + if (this.hideColAddButtonTimeount) + clearTimeout(this.hideColAddButtonTimeount); + }) + .on('mouseleave', () => { + this.hideColAddButtonTimeount = setTimeout(() => { + this.colAddButton?.hide(); + this.moveColIndex = -1; + }, 200); + }) + .on('click', () => { + if (this.moveColIndex > -1) + this.table.command.insertColAt( + this.moveColIndex, + 1, + this.colAddAlign === 'right' ? false : true, + ); + }); + this.colsHeader + ?.on('mouseenter', () => { + if (this.hideColAddButtonTimeount) + clearTimeout(this.hideColAddButtonTimeount); + }) + .on('mousemove', (event: MouseEvent) => + this.onMouseMoveColsHeader(event), + ) + .on('mouseleave', () => { + this.hideColAddButtonTimeount = setTimeout(() => { + this.colAddButton?.hide(); + }, 200); + }); + //行增加按钮 + this.rowAddButton + ?.on('mouseenter', () => { + if (this.hideRowAddButtonTimeount) + clearTimeout(this.hideRowAddButtonTimeount); + this.rowsHeader?.css('z-index', 126); + }) + .on('mouseleave', () => { + this.hideRowAddButtonTimeount = setTimeout(() => { + this.rowAddButton?.hide(); + this.rowsHeader?.css('z-index', 1); + this.moveRowIndex = -1; + }, 200); + }) + .on('click', () => { + this.table.command.insertRowAt( + this.moveRowIndex, + 1, + this.rowAddAlign === 'down' ? false : true, + ); + }); + + this.rowsHeader + ?.on('mouseenter', () => { + if (this.hideRowAddButtonTimeount) + clearTimeout(this.hideRowAddButtonTimeount); + }) + .on('mousemove', (event: MouseEvent) => { + this.onMouseMoveRowsHeader(event); + this.rowsHeader?.css('z-index', 126); + }) + .on('mouseleave', () => { + this.hideRowAddButtonTimeount = setTimeout(() => { + this.rowsHeader?.css('z-index', ''); + this.rowAddButton?.hide(); + }, 200); + }); + } + /** + * 在表格上单击 + * @param event + */ + onTableMouseDown(event: MouseEvent) { + if (!event.target) return; + const td = $(event.target).closest('td'); + if (td.length > 0 && event.button === 2) { + this.showContextMenu(event); + } else { + this.hideContextMenu(); + } + } + + /** + * 鼠标在列表头上移动 + * @param event + */ + onMouseMoveColsHeader(event: MouseEvent) { + if (!event.target || !this.colAddButton || !this.colAddButtonSplit) + return; + const targetNode = $(event.target); + const itemNode = targetNode.closest(Template.COLS_HEADER_ITEM_CLASS); + if (itemNode.length === 0) return; + const items = this.colsHeader!.find( + Template.COLS_HEADER_ITEM_CLASS, + ).toArray(); + const width = itemNode.width(); + const buttonWidth = this.colAddButton.width(); + let left = itemNode.get()!.offsetLeft; + const index = items.findIndex((item) => item.equal(itemNode)); + const isEnd = + event.offsetX > width / 2 || targetNode.hasClass('cols-trigger'); + const isLast = items[items.length - 1].equal(itemNode); + if (isEnd) { + left += isLast ? width - buttonWidth / 2 : width; + } + this.colAddAlign = isEnd ? 'left' : 'right'; + this.moveColIndex = index; //+ (isEnd ? 1 : 0); + this.colAddButton?.show('flex'); + this.colAddButton.css('left', `${left}px`); + this.colAddButton.css('z-index', 126); + const splitHeight = + (this.table.selection.tableModel?.height || 0) + + itemNode.height() + + 4; + this.colAddButtonSplit.css('height', `${splitHeight}px`); + this.colAddButtonSplit.css( + 'left', + `${isLast && isEnd ? buttonWidth - 3 + 'px' : ''}`, + ); + } + + /** + * 鼠标在行表头上移动 + * @param event + * @returns + */ + onMouseMoveRowsHeader(event: MouseEvent) { + if (!event.target || !this.rowAddButton || !this.rowAddButtonSplit) + return; + const targetNode = $(event.target); + const itemNode = targetNode.closest(Template.ROWS_HEADER_ITEM_CLASS); + if (itemNode.length === 0) return; + const items = this.rowsHeader!.find( + Template.ROWS_HEADER_ITEM_CLASS, + ).toArray(); + const height = itemNode.height(); + let top = itemNode.get()!.offsetTop; + const index = items.findIndex((item) => item.equal(itemNode)); + const isEnd = + event.offsetY > height / 2 || targetNode.hasClass('rows-trigger'); + if (isEnd) { + top += height; + } + this.moveRowIndex = index; //+ (isEnd ? (index === items.length - 1 ? 0 : 1) : 0); + this.rowAddButton.show('flex'); + this.rowAddButton.css('top', `${top}px`); + this.rowAddAlign = isEnd ? 'down' : 'up'; + const splitWidth = + (this.table.selection.tableModel?.width || 0) + + itemNode.width() + + 4; + this.rowAddButtonSplit.css('width', `${splitWidth}px`); + } + + /** + * 鼠标在列头部按下 + * @param event 事件 + * @returns + */ + onMouseDownColsHeader(event: MouseEvent | TouchEvent) { + const trigger = $(event.target || []).closest( + Template.COLS_HEADER_TRIGGER_CLASS, + ); + //不可移动状态 + if (trigger.length === 0) { + //右键显示菜单 + if (event instanceof MouseEvent && event.button === 2) { + this.showContextMenu(event); + } + return; + } + //开始调整列宽度 + this.startChangeCol(trigger, event); + } + /** + * 鼠标在行头部按下 + * @param event 事件 + * @returns + */ + onMouseDownRowsHeader(event: MouseEvent | TouchEvent) { + const trigger = $(event.target || []).closest( + Template.ROWS_HEADER_TRIGGER_CLASS, + ); + //不可移动状态 + if (trigger.length === 0) { + //右键显示菜单 + if (event instanceof MouseEvent && event.button === 2) { + this.showContextMenu(event); + } + return; + } + //开始调整行高度 + this.startChangeRow(trigger, event); + } + /** + * 鼠标在列头部单击 + * @param event 事件 + */ + onClickColsHeader(event: MouseEvent) { + const { selection } = this.table; + const trigger = $(event.target || []).closest( + Template.COLS_HEADER_TRIGGER_CLASS, + ); + if (trigger.length > 0) return; + const colHeader = $(event.target || []).closest( + Template.COLS_HEADER_ITEM_CLASS, + ); + if (colHeader.length === 0) return; + const index = this.colsHeader + ?.find(Template.COLS_HEADER_ITEM_CLASS) + .toArray() + .findIndex((item) => item.equal(colHeader)); + if (index === undefined) return; + selection.selectCol(index); + } + /** + * 鼠标在行头部单击 + * @param event 事件 + */ + onClickRowsHeader(event: MouseEvent) { + const { selection } = this.table; + const trigger = $(event.target || []).closest( + Template.ROWS_HEADER_TRIGGER_CLASS, + ); + if (trigger.length > 0) return; + const rowHeader = $(event.target || []).closest( + Template.ROWS_HEADER_ITEM_CLASS, + ); + if (rowHeader.length === 0) return; + const index = this.rowsHeader + ?.find(Template.ROWS_HEADER_ITEM_CLASS) + .toArray() + .findIndex((item) => item.equal(rowHeader)); + if (index === undefined) return; + selection.selectRow(index); + } + /** + * 鼠标在表格左上角头部单击 + * @param event 事件 + */ + onClickTableHeader(event: MouseEvent) { + const { selection } = this.table; + if (this.tableHeader?.hasClass('selected')) { + selection.clearSelect(); + } else { + const { tableModel } = selection; + if (!tableModel) return; + selection.select( + { row: 0, col: 0 }, + { row: tableModel.rows - 1, col: tableModel.cols - 1 }, + ); + } + } + /** + * 激活表头状态 + * @returns + */ + activeHeader() { + const selectArea = this.table.selection.getSelectArea(); + this.clearActiveStatus(); + const colBars = this.colsHeader?.find(Template.COLS_HEADER_ITEM_CLASS); + const rowBars = this.rowsHeader?.find(Template.ROWS_HEADER_ITEM_CLASS); + const { begin, end, allCol, allRow } = selectArea; + for (let r = begin.row; r <= end.row; r++) { + if (allCol) { + rowBars?.eq(r)?.addClass('selected'); + if (allRow) rowBars?.eq(r)?.addClass('no-dragger'); + } + } + + for (let c = begin.col; c <= end.col; c++) { + if (allRow) { + colBars?.eq(c)?.addClass('selected'); + if (allCol) colBars?.eq(c)?.addClass('no-dragger'); + } + } + if (allCol && allRow) { + this.tableHeader?.addClass('selected'); + } else { + this.tableHeader?.removeClass('selected'); + } + //行删除按钮 + if (allCol && !allRow) { + const tr = this.tableRoot?.find('tr').eq(begin.row); + if (tr) { + const top = tr.get()!.offsetTop; + this.rowDeleteButton?.show('flex'); + this.rowDeleteButton?.css( + 'top', + `${top - this.rowDeleteButton.height()}px`, + ); + } + } else { + this.rowDeleteButton?.hide(); + } + //列删除按钮 + if (!allCol && allRow) { + let width = 0; + for (let c = begin.col; c <= end.col; c++) { + width += colBars?.eq(c)?.width() || 0; + } + const left = + colBars?.eq(begin.col)?.get()?.offsetLeft || 0; + + this.colDeleteButton?.show('flex'); + this.colDeleteButton?.css('left', `${left + width / 2}px`); + } else { + this.colDeleteButton?.hide(); + } + } + + /** + * 清楚表头活动状态 + */ + clearActiveStatus() { + const colBars = this.colsHeader?.find(Template.COLS_HEADER_ITEM_CLASS); + const rowBars = this.rowsHeader?.find(Template.ROWS_HEADER_ITEM_CLASS); + colBars?.removeClass('selected'); + colBars?.removeClass('no-dragger'); + rowBars?.removeClass('selected'); + rowBars?.removeClass('no-dragger'); + this.tableHeader?.removeClass('selected'); + } + /** + * 刷新控制UI + */ + refresh() { + this.renderColBars(); + this.renderRowBars(); + this.activeHeader(); + } + /** + * 开始改变列宽度 + * @param col 列节点 + * @param event 事件 + */ + startChangeCol(trigger: NodeInterface, event: MouseEvent | TouchEvent) { + event.stopPropagation(); + event.preventDefault(); + const col = trigger.parent()!; + const colElement = col.get()!; + this.table.selection.clearSelect(); + this.dragging = { + x: + event instanceof MouseEvent + ? event.clientX + : event.touches[0].clientX, + y: -1, + }; + const index = + this.colsHeader + ?.find(Template.COLS_HEADER_ITEM_CLASS) + .toArray() + .findIndex((item) => item.equal(col)) || 0; + this.changeSize = { + trigger: { + element: trigger, + height: trigger.height(), + width: trigger.width(), + }, + element: col, + width: colElement.offsetWidth, + height: -1, + index, + table: { + width: this.table.selection.tableModel?.width || 0, + height: this.table.selection.tableModel?.height || 0, + }, + }; + this.bindChangeSizeEvent(); + } + /** + * 开始改变行高度 + * @param col 列节点 + * @param event 事件 + */ + startChangeRow(trigger: NodeInterface, event: MouseEvent | TouchEvent) { + event.stopPropagation(); + event.preventDefault(); + const row = trigger.parent()!; + const rowElement = row.get()!; + this.table.selection.clearSelect(); + this.dragging = { + x: -1, + y: + event instanceof MouseEvent + ? event.clientY + : event.touches[0].clientY, + }; + const index = + this.rowsHeader + ?.find(Template.ROWS_HEADER_ITEM_CLASS) + .toArray() + .findIndex((item) => item.equal(row)) || 0; + this.changeSize = { + trigger: { + element: trigger, + height: trigger.height(), + width: trigger.width(), + }, + element: row, + width: -1, + height: rowElement.offsetHeight, + index, + table: { + width: this.table.selection.tableModel?.width || 0, + height: this.table.selection.tableModel?.height || 0, + }, + }; + this.bindChangeSizeEvent(); + } + /** + * 绑定改变大小事件 + */ + bindChangeSizeEvent() { + //添加鼠标样式 + this.colsHeader?.addClass('resize'); + this.rowsHeader?.addClass('resize'); + document.addEventListener( + isMobile ? 'touchmove' : 'mousemove', + this.onChangeSize, + ); + document.addEventListener( + isMobile ? 'touchend' : 'mouseup', + this.onChangeSizeEnd, + ); + if (!isMobile) + document.addEventListener('mouseleave', this.onChangeSizeEnd); + } + /** + * 移除绑定改变不大小事件 + */ + unbindChangeSizeEvent() { + //添加鼠标样式 + this.colsHeader?.removeClass('resize'); + this.rowsHeader?.removeClass('resize'); + document.removeEventListener( + isMobile ? 'touchmove' : 'mousemove', + this.onChangeSize, + ); + document.removeEventListener( + isMobile ? 'touchend' : 'mouseup', + this.onChangeSizeEnd, + ); + if (!isMobile) + document.removeEventListener('mouseleave', this.onChangeSizeEnd); + } + + onChangeSize = (event: MouseEvent | TouchEvent) => { + if (!this.dragging) return; + if (this.dragging.y > -1) { + this.onChangeRowHeight(event); + } else if (this.dragging.x > -1) { + this.onChangeColWidth(event); + } + }; + /** + * 列宽度改变 + * @param event 事件 + * @returns + */ + onChangeColWidth(event: MouseEvent | TouchEvent) { + if (!this.dragging || !this.changeSize) return; + //鼠标移动宽度 + let width = + (event instanceof MouseEvent + ? event.clientX + : event.touches[0].clientX) - this.dragging.x; + //获取合法的宽度 + const colWidth = Math.max( + this.COL_MIN_WIDTH, + this.changeSize.width + width, + ); + //需要移动的宽度 + width = colWidth - this.changeSize.width; + //表格变化后的宽度 + const tableWidth = this.changeSize.table.width + width; + this.changeSize.element.css('width', colWidth + 'px'); + const currentElement = this.changeSize.element.get()!; + this.colsHeader?.css('width', tableWidth + 'px'); + const viewportElement = this.viewport?.get()!; + // 拖到边界时,需要滚动表格视窗的滚动条 + const currentColRightSide = + currentElement.offsetLeft + currentElement.offsetWidth; + if ( + currentColRightSide - viewportElement.scrollLeft + 20 > + viewportElement.offsetWidth + ) { + // 拖宽单元格时,若右侧已经到边,需要滚动左侧的滚动条 + viewportElement.scrollLeft = + currentColRightSide + 20 - viewportElement.offsetWidth; + } else if ( + viewportElement.scrollLeft + viewportElement.offsetWidth === + viewportElement.scrollWidth + ) { + // 拖窄单元格时,若右侧已经到边,需要滚动左侧的滚动条 + viewportElement.scrollLeft = Math.max( + 0, + tableWidth + 34 - viewportElement.offsetWidth, + ); + } + this.clearActiveStatus(); + this.hideContextMenu(); + this.renderRowBars(); + this.renderColSplitBars( + this.changeSize.element, + this.changeSize.trigger.element, + ); + //设置列头宽度 + this.tableRoot + ?.find('col') + .eq(this.changeSize.index) + ?.attributes('width', colWidth); + //设置表格宽度 + this.tableRoot?.css('width', `${tableWidth}px`); + } + + onChangeRowHeight(event: MouseEvent | TouchEvent) { + if (!this.dragging || !this.changeSize) return; + let height = + (event instanceof MouseEvent + ? event.clientY + : event.touches[0].clientY) - this.dragging.y; + const rowHeight = Math.max( + this.ROW_MIN_HEIGHT, + this.changeSize.height + height, + ); + height = rowHeight - this.changeSize.height; + this.changeSize.element.css('height', rowHeight + 'px'); + this.clearActiveStatus(); + this.hideContextMenu(); + this.renderRowSplitBars( + this.changeSize.element, + this.changeSize.trigger.element, + ); + this.tableRoot + ?.find('tr') + .eq(this.changeSize.index) + ?.css('height', `${rowHeight}px`); + } + + renderColSplitBars(col: NodeInterface, trigger: NodeInterface) { + const tableHeight = this.table.selection.tableModel?.height || 0; + trigger + .addClass('dragging') + .css('height', `${tableHeight + col.height()}px`); + } + + renderRowSplitBars(row: NodeInterface, trigger: NodeInterface) { + const viewportElement = this.viewport?.get()!; + const tableWidth = this.table.selection.tableModel?.width || 0; + const width = Math.min(viewportElement.offsetWidth, tableWidth); + trigger.addClass('dragging').css('width', `${width + row.width()}px`); + } + + onChangeSizeEnd = (event: MouseEvent | TouchEvent) => { + if ( + event.type === 'mouseleave' && + this.table.getCenter().contains(event['toElement']) + ) { + return; + } + + if (this.dragging && this.changeSize) { + const { width, height, element } = this.changeSize.trigger; + element.removeClass('dragging'); + if (this.dragging.x > -1) element.css('height', `${height}px`); + if (this.dragging.y > -1) element.css('width', `${width}px`); + this.dragging = undefined; + // 拖完再渲染一次,行高会受内容限制,无法拖到你想要的高度 + this.renderRowBars(); + this.unbindChangeSizeEvent(); + this.emit('sizeChanged'); + } + }; + + onDragStartColsHeader(event: DragEvent) { + event.stopPropagation(); + const { selection } = this.table; + const selectArea = selection.getSelectArea(); + if (!event.target || !selectArea.allRow) return; + const colBar = $(event.target).closest(Template.COLS_HEADER_ITEM_CLASS); + if (colBar.length === 0) return; + const index = this.colsHeader + ?.find(Template.COLS_HEADER_ITEM_CLASS) + .toArray() + .findIndex((item) => item.equal(colBar)); + if (index === undefined) return; + const drag_col = index; + if (drag_col < selectArea.begin.col || drag_col > selectArea.end.col) + return; + this.draggingHeader = { + element: colBar, + minIndex: selectArea.begin.col, + maxIndex: selectArea.end.col, + count: selectArea.end.col - selectArea.begin.col + 1, + }; + colBar.addClass('dragging'); + colBar + .find('.drag-info') + .html( + this.editor.language + .get('table', 'draggingCol') + .replace('$data', this.draggingHeader.count.toString()), + ); + this.colsHeader?.addClass('dragging'); + this.table.helper.fixDragEvent(event); + this.bindDragColEvent(); + } + + onDragStartRowsHeader(event: DragEvent) { + event.stopPropagation(); + const { selection } = this.table; + const selectArea = selection.getSelectArea(); + if (!event.target || !selectArea.allCol) return; + const rowBar = $(event.target).closest(Template.ROWS_HEADER_ITEM_CLASS); + if (rowBar.length === 0) return; + const index = this.rowsHeader + ?.find(Template.ROWS_HEADER_ITEM_CLASS) + .toArray() + .findIndex((item) => item.equal(rowBar)); + if (index === undefined) return; + const drag_row = index; + + if (drag_row < selectArea.begin.row || drag_row > selectArea.end.row) + return; + this.draggingHeader = { + element: rowBar, + minIndex: selectArea.begin.row, + maxIndex: selectArea.end.row, + count: selectArea.end.row - selectArea.begin.row + 1, + }; + rowBar.addClass('dragging'); + rowBar + .find('.drag-info') + .html( + this.editor.language + .get('table', 'draggingRow') + .replace('$data', this.draggingHeader.count.toString()), + ); + this.rowsHeader?.addClass('dragging'); + this.table.helper.fixDragEvent(event); + this.bindDragRowEvent(); + } + + bindDragColEvent() { + const { wrapper } = this.table; + wrapper?.on('dragover', this.onDragCol); + wrapper?.on('drop', this.onDragColEnd); + wrapper?.on('dragend', this.onDragColEnd); + } + + unbindDragColEvent() { + const { wrapper } = this.table; + const colBars = this.colsHeader?.find(Template.COLS_HEADER_ITEM_CLASS); + colBars?.removeClass('dragging'); + this.colsHeader?.removeClass('dragging'); + wrapper?.off('dragover', this.onDragCol); + wrapper?.off('drop', this.onDragColEnd); + wrapper?.off('dragend', this.onDragColEnd); + } + + bindDragRowEvent() { + const { wrapper } = this.table; + wrapper?.on('dragover', this.onDragRow); + wrapper?.on('drop', this.onDragRowEnd); + wrapper?.on('dragend', this.onDragRowEnd); + } + + unbindDragRowEvent() { + const { wrapper } = this.table; + const rowBars = this.rowsHeader?.find(Template.ROWS_HEADER_ITEM_CLASS); + rowBars?.removeClass('dragging'); + this.rowsHeader?.removeClass('dragging'); + wrapper?.off('dragover', this.onDragRow); + wrapper?.off('drop', this.onDragRowEnd); + wrapper?.off('dragend', this.onDragRowEnd); + } + + showPlaceHolder(dropIndex: number, isNext?: boolean) { + if (!this.draggingHeader) return; + const { element, minIndex, maxIndex } = this.draggingHeader; + if (element.closest(Template.COLS_HEADER_CLASS).length > 0) { + if (dropIndex === this.draggingHeader.index) return; + if (minIndex <= dropIndex && dropIndex <= maxIndex + 1) { + delete this.draggingHeader.index; + delete this.draggingHeader.isNext; + this.placeholder?.css('display', 'none'); + return; + } + this.draggingHeader.isNext = isNext; + this.draggingHeader.index = dropIndex; + const colBars = this.colsHeader?.find( + Template.COLS_HEADER_ITEM_CLASS, + ); + if (!colBars) return; + + const left = + this.draggingHeader.index !== colBars.length + ? colBars.eq(this.draggingHeader.index)!.get()! + .offsetLeft + 2 + : colBars + .eq(this.draggingHeader.index - 1)! + .get()!.offsetLeft + + colBars + .eq(this.draggingHeader.index - 1)! + .get()!.offsetWidth + + 2; + const viewportElement = this.viewport?.get()!; + const { scrollLeft, offsetWidth } = viewportElement; + if (left < scrollLeft) { + viewportElement.scrollLeft = left - 5; + } + if (left > scrollLeft + offsetWidth) { + viewportElement.scrollLeft = left - offsetWidth + 5; + } + const height = + (this.table.selection.tableModel?.height || 0) + + colBars.height(); + const paddingTop = this.viewport?.css('padding-top'); + const paddingLeft = this.viewport?.css('padding-left') || '0'; + this.placeholder?.css('width', '2px'); + this.placeholder?.css('height', `${height}px`); + this.placeholder?.css( + 'left', + left - 4 + removeUnit(paddingLeft) + 'px', + ); + this.placeholder?.css('top', paddingTop); + this.placeholder?.css('display', 'block'); + } else if (element.closest(Template.ROWS_HEADER_CLASS).length > 0) { + if (dropIndex === this.draggingHeader.index) return; + if (minIndex <= dropIndex && dropIndex <= maxIndex + 1) { + delete this.draggingHeader.index; + delete this.draggingHeader.isNext; + this.placeholder?.css('display', 'none'); + return; + } + this.draggingHeader.index = dropIndex; + this.draggingHeader.isNext = isNext; + const rowBars = this.rowsHeader?.find( + Template.ROWS_HEADER_ITEM_CLASS, + ); + if (!rowBars) return; + const top = + this.draggingHeader.index !== rowBars.length + ? rowBars.eq(this.draggingHeader.index)!.get()! + .offsetTop + 2 + : rowBars + .eq(this.draggingHeader.index - 1)! + .get()!.offsetTop + + rowBars + .eq(this.draggingHeader.index - 1)! + .get()!.offsetHeight - + 2; + const width = this.table.selection.tableModel?.width || 0; + const paddingTop = this.viewport?.css('padding-top'); + const paddingLeft = this.viewport?.css('padding-left') || '0'; + const colBars = this.colsHeader?.find( + Template.COLS_HEADER_ITEM_CLASS, + ); + this.placeholder?.css('height', '2px'); + this.placeholder?.css('width', `${width}px`); + this.placeholder?.css('left', paddingLeft); + this.placeholder?.css( + 'top', + top + + removeUnit(paddingTop || '0') + + (colBars?.height() || 0) - + 2 + + 'px', + ); + this.placeholder?.css('display', 'block'); + } + } + + onDragCol = (event: DragEvent) => { + event.stopPropagation(); + if (!this.draggingHeader || !event.target) return; + if (undefined === this.dragging) { + this.dragging = { + x: event.offsetX, + y: event.offsetY, + }; + } + if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; + // dragover会不断的触发事件,这里做一个截流,鼠标在3像素以内不去计算 + if (Math.abs(this.dragging.x - event.offsetX) < 3) return; + this.dragging.x = event.offsetX; + this.draggingHeader.element.removeClass('dragging'); + const td = $(event.target).closest('td'); + const colBar = $(event.target).closest(Template.COLS_HEADER_ITEM_CLASS); + if (td.length === 0 && colBar.length === 0) return; + + if (colBar.length > 0) { + const index = this.colsHeader + ?.find(Template.COLS_HEADER_ITEM_CLASS) + .toArray() + .findIndex((item) => item.equal(colBar)); + if (index === undefined) return; + const currentCol = index; + const _dropCol = + event.offsetX > colBar.get()!.offsetWidth / 2 + ? currentCol + 1 + : currentCol; + this.showPlaceHolder(_dropCol, _dropCol !== currentCol); + return; + } + const colBars = this.colsHeader?.find(Template.COLS_HEADER_ITEM_CLASS); + if (!colBars) return; + const tdElement = td.get()!; + const colSpan = tdElement.colSpan; + const [row, col] = this.table.selection.getCellPoint(td); + let dropCol = col; + let _passWidth = 0; + + for (let i = 0; i < colSpan; i++) { + const colElement = colBars.eq(col + i)!.get()!; + if (_passWidth + colElement.offsetWidth / 2 > event.offsetX) { + dropCol = col + i; + break; + } + if (_passWidth + colElement.offsetWidth > event.offsetX) { + dropCol = col + i + 1; + break; + } + _passWidth += colElement.offsetWidth; + } + this.showPlaceHolder(dropCol, dropCol !== col); + }; + + onDragColEnd = () => { + this.unbindDragColEvent(); + const { index, count, isNext } = this.draggingHeader || {}; + if (!this.draggingHeader || index === undefined || count === undefined) + return; + const { command, selection } = this.table; + const selectArea = selection.getSelectArea(); + const colBars = this.table.wrapper?.find( + Template.COLS_HEADER_ITEM_CLASS, + ); + if (!colBars) return; + + let widths = []; + for (let c = selectArea.begin.col; c <= selectArea.end.col; c++) { + widths.push(colBars.eq(c)?.get()?.offsetWidth || 0); + } + command.mockCopy(); + if (selectArea.begin.col > index) { + command.insertColAt( + isNext ? index - 1 : index, + count, + isNext, + widths, + true, + ); + selection.selectCol(index, index + count - 1); + command.mockPaste(true); + setTimeout(() => { + selection.selectCol( + selectArea.begin.col + count, + selectArea.end.col + count, + ); + command.removeCol(); + selection.selectCol(index, index + count - 1); + }, 10); + } else { + command.insertColAt( + isNext ? index - 1 : index, + count, + isNext, + widths, + true, + ); + selection.selectCol(index, index + count - 1); + command.mockPaste(true); + setTimeout(() => { + selection.selectCol(selectArea.begin.col, selectArea.end.col); + command.removeCol(); + selection.selectCol(index - count, index - 1); + }, 10); + } + this.placeholder?.css('display', 'none'); + this.draggingHeader = undefined; + this.dragging = undefined; + }; + + onDragRow = (event: DragEvent) => { + event.stopPropagation(); + if (!this.draggingHeader || !event.target) return; + if (undefined === this.dragging) { + this.dragging = { + x: event.offsetX, + y: event.offsetY, + }; + } + // dragover会不断的触发事件,这里做一个截流,鼠标在3像素以内不去计算 + if (Math.abs(this.dragging.y - event.offsetY) < 3) return; + this.dragging.y = event.offsetY; + this.draggingHeader.element.removeClass('dragging'); + + const td = $(event.target).closest('td'); + const rowBar = $(event.target).closest(Template.ROWS_HEADER_ITEM_CLASS); + if (td.length === 0 && rowBar.length === 0) return; + + if (rowBar.length > 0) { + const index = this.rowsHeader + ?.find(Template.ROWS_HEADER_ITEM_CLASS) + .toArray() + .findIndex((item) => item.equal(rowBar)); + if (index === undefined) return; + const currentRow = index; + const _dropRow = + event.offsetY > rowBar.get()!.offsetHeight / 2 + ? currentRow + 1 + : currentRow; + this.showPlaceHolder(_dropRow, _dropRow !== currentRow); + return; + } + const rowBars = this.rowsHeader?.find(Template.ROWS_HEADER_ITEM_CLASS); + if (!rowBars) return; + const rowSpan = td.get()!.rowSpan; + const [row] = this.table.selection.getCellPoint(td); + let dropRow = row; + let _passHeight = 0; + + for (let i = 0; i < rowSpan; i++) { + const rowElement = rowBars[row + i] as HTMLTableRowElement; + if (_passHeight + rowElement.offsetHeight / 2 > event.offsetY) { + dropRow = row + i; + break; + } + if (_passHeight + rowElement.offsetHeight > event.offsetY) { + dropRow = row + i + 1; + break; + } + _passHeight += rowElement.offsetHeight; + } + this.showPlaceHolder(dropRow, dropRow !== row); + }; + + onDragRowEnd = () => { + this.unbindDragRowEvent(); + const { index, count, isNext } = this.draggingHeader || {}; + if (!this.draggingHeader || index === undefined || count === undefined) + return; + const { command, selection } = this.table; + const selectArea = selection.getSelectArea(); + const { begin, end } = selectArea; + command.mockCopy(); + if (begin.row > index) { + command.insertRowAt( + isNext ? index - 1 : index, + count, + !isNext, + true, + ); + selection.selectRow(index, index + count - 1); + setTimeout(() => { + command.mockPaste(true); + selection.selectRow(begin.row + count, end.row + count); + command.removeRow(); + selection.selectRow(index, index + count - 1); + }, 10); + } else { + command.insertRowAt( + isNext ? index - 1 : index, + count, + !isNext, + true, + ); + selection.selectRow(index, index + count - 1); + setTimeout(() => { + command.mockPaste(true); + selection.selectRow(begin.row, end.row); + command.removeRow(); + selection.selectRow(index - count, index - 1); + }, 10); + } + this.placeholder?.css('display', 'none'); + this.draggingHeader = undefined; + this.dragging = undefined; + }; + + removeRow(index: number) { + const rowsHeaderItem = this.rowsHeader?.find( + Template.ROWS_HEADER_ITEM_CLASS, + ); + const item = rowsHeaderItem?.eq(index)?.get(); + if (item) this.rowsHeader?.get()?.removeChild(item); + } + + removeCol(index: number) { + const colsHeaderItem = this.colsHeader?.find( + Template.COLS_HEADER_ITEM_CLASS, + ); + const headerElement = this.colsHeader?.get(); + const item = colsHeaderItem?.eq(index)?.get(); + if (!headerElement || !item) return; + this.colsHeader?.css( + 'width', + headerElement.offsetWidth - item.offsetWidth + 'px', + ); + headerElement.removeChild(item); + this.tableRoot?.css('width', this.colsHeader?.css('width')); + } + + showContextMenu(event: MouseEvent) { + if (!this.menuBar || !event.target) return; + event.preventDefault(); + const { selection } = this.table; + const menuItems = this.menuBar.find(Template.MENUBAR_ITEM_CLASS); + menuItems.removeClass('disabled'); + menuItems.each((menu) => { + const menuNode = $(menu); + const action = menuNode.attributes('data-action'); + if (this.getMenuDisabled(action)) { + menuNode.addClass('disabled'); + } else { + const inputNode = menuNode.find( + `input${Template.MENUBAR_ITEM_INPUT_CALSS}`, + ); + if (inputNode.length === 0) return; + const inputElement = inputNode.get()!; + inputNode + .on('blur', () => { + inputElement.value = ( + parseInt(inputElement.value, 10) || 1 + ).toString(); + }) + .on('keydown', (event) => { + if (isHotkey('enter', event)) { + this.handleTriggerMenu(menuNode); + } + }); + const selectArea = selection.getSelectArea(); + const isInsertCol = + ['insertColLeft', 'insertColRight'].indexOf(action) > -1; + const isInsertRow = + ['insertRowUp', 'insertRowDown'].indexOf(action) > -1; + if (isInsertCol) { + inputElement.value = `${ + selectArea.end.col - selectArea.begin.col + 1 + }`; + } + if (isInsertRow) { + inputElement.value = `${ + selectArea.end.row - selectArea.begin.row + 1 + }`; + } + inputNode.on('mousedown', this.onMenuInputMousedown); + } + }); + const splits = this.menuBar.find('div.split'); + splits.each((splitNode) => { + const split = $(splitNode); + let prev = split.prev(); + while (prev) { + if (prev.hasClass('split')) { + split.remove(); + break; + } + if (!prev.hasClass('disabled')) break; + prev = prev.prev(); + } + if (!prev) split.remove(); + }); + const tartgetNode = $(event.target); + let prevRect = tartgetNode.getBoundingClientRect() || { + top: 0, + left: 0, + }; + let parentNode = tartgetNode.parent(); + let top = 0, + left = 0; + while ( + parentNode && + parentNode.closest(Template.TABLE_WRAPPER_CLASS).length > 0 + ) { + const rect = parentNode.getBoundingClientRect() || { + top: 0, + left: 0, + }; + top += prevRect.top - rect.top; + left += prevRect.left - rect.left; + prevRect = rect; + parentNode = parentNode.parent(); + } + this.menuBar.css('left', left + event.offsetX + 'px'); + this.menuBar.css('top', top + event.offsetY + 'px'); + this.menuBar.css('display', 'block'); + //绑定input事件 + + this.contextVisible = true; + } + + onMenuInputMousedown = (event: MouseEvent) => { + event.stopPropagation(); + }; + + hideContextMenu() { + if (!this.contextVisible) { + return; + } + const menuItems = this.menuBar?.find(Template.MENUBAR_ITEM_CLASS); + menuItems?.removeClass('disabled'); + menuItems?.each((menu) => { + const menuNode = $(menu); + const inputNode = menuNode.find( + `input${Template.MENUBAR_ITEM_INPUT_CALSS}`, + ); + if (inputNode.length === 0) return; + inputNode.removeAllEvents(); + }); + this.contextVisible = false; + this.menuBar?.hide(); + } + + getMenuDisabled(action: string) { + const { selection, command } = this.table; + switch (action) { + case 'cut': + case 'copy': + return !selection.selectArea || selection.selectArea.count <= 1; + case 'splitCell': + return !selection.hasMergeCell(); + case 'mergeCell': + return !selection.selectArea; + case 'mockPaste': + return !command.hasCopyData(); + case 'removeCol': + case 'insertColLeft': + case 'insertColRight': + return selection.isColSelected(); + case 'removeRow': + case 'insertRowUp': + case 'insertRowDown': + return selection.isRowSelected(); + default: + return false; + } + } + + handleClickMenu(event: MouseEvent) { + if (!event.target) return; + const targetNode = $(event.target); + const menu = targetNode.closest('.table-menubar-item'); + if (menu.length === 0 || targetNode.name === 'input') return; + event.stopPropagation(); + this.handleTriggerMenu(menu); + } + + handleTriggerMenu(menu: NodeInterface) { + if (!menu.hasClass('disabled')) { + const action = menu.attributes('data-action'); + const inputNode = menu.find( + `input${Template.MENUBAR_ITEM_INPUT_CALSS}`, + ); + let args: undefined | number = undefined; + if (inputNode.length > 0) { + args = parseInt( + inputNode.get()?.value || '1', + 10, + ); + } + this.table.command[action](args); + } + this.hideContextMenu(); + } + + handleHoverMenu(event: MouseEvent) { + if (!event.target) return; + const menu = $(event.target).closest('.table-menubar-item'); + if (menu.length === 0) return; + event.stopPropagation(); + + const { selection } = this.table; + + if (!menu.hasClass('disabled')) { + const action = menu.attributes('data-action'); + switch (action) { + case 'removeCol': + this.handleHighlightCol(); + break; + case 'removeRow': + this.handleHighlightRow(); + break; + case 'removeTable': + this.handleHighlightTable(); + break; + default: + selection.hideHighlight(); + } + } + } + + hideHighlight(event: MouseEvent) { + event.stopPropagation(); + this.table.selection.hideHighlight(); + } + + handleHighlightRow = () => { + const { selection } = this.table; + const { tableModel } = selection; + if (!tableModel) return; + const selectArea = { ...selection.getSelectArea() }; + selectArea.allCol = true; + selectArea.begin = { row: selectArea.begin.row, col: 0 }; + selectArea.end = { row: selectArea.end.row, col: tableModel.cols - 1 }; + selection.showHighlight(selectArea); + }; + + handleHighlightCol = () => { + const { selection } = this.table; + const { tableModel } = selection; + if (!tableModel) return; + const selectArea = { ...selection.getSelectArea() }; + selectArea.allRow = true; + selectArea.begin = { row: 0, col: selectArea.begin.col }; + selectArea.end = { row: tableModel.rows - 1, col: selectArea.end.col }; + selection.showHighlight(selectArea); + }; + + handleHighlightTable = () => { + const { selection } = this.table; + const { tableModel } = selection; + if (!tableModel) return; + const selectArea = { ...selection.getSelectArea() }; + selectArea.allRow = true; + selectArea.allCol = true; + selectArea.begin = { row: 0, col: 0 }; + selectArea.end = { row: tableModel.rows - 1, col: tableModel.cols - 1 }; + selection.showHighlight(selectArea); + }; + + drawBackgroundColor(color?: string) { + const { selection, helper } = this.table; + selection.each((cell) => { + if (!helper.isEmptyModelCol(cell) && cell.element) { + if (!color || color === 'transparent') + cell.element.style.removeProperty('background-color'); + else cell.element.style.backgroundColor = color; + } + }); + } + + setAlign(align?: 'top' | 'middle' | 'bottom') { + const { selection, helper } = this.table; + selection.each((cell) => { + if (!helper.isEmptyModelCol(cell) && cell.element) { + if (!align || align === 'top') + cell.element.style.removeProperty('vertical-align'); + else cell.element.style.verticalAlign = align; + } + }); + } + + destroy() { + this.colsHeader?.removeAllEvents(); + this.rowsHeader?.removeAllEvents(); + this.tableHeader?.removeAllEvents(); + this.table.wrapper?.removeAllEvents(); + this.tableRoot?.removeAllEvents(); + this.menuBar?.removeAllEvents(); + //列头部 padding 区域单击让其选中表格卡片上方的blcok + this.viewport?.removeAllEvents(); + //行删除按钮 + this.rowDeleteButton?.removeAllEvents(); + //列删除按钮 + this.colDeleteButton?.removeAllEvents(); + //列增加按钮 + this.colAddButton?.removeAllEvents(); + this.colsHeader?.removeAllEvents(); + //行增加按钮 + this.rowAddButton?.removeAllEvents(); + + this.rowsHeader?.removeAllEvents(); + } +} + +export default ControllBar; diff --git a/plugins/table/src/component/helper.ts b/plugins/table/src/component/helper.ts new file mode 100644 index 00000000..dd82c562 --- /dev/null +++ b/plugins/table/src/component/helper.ts @@ -0,0 +1,613 @@ +import { + HelperInterface, + TableModel, + TableModelCol, + TableModelEmptyCol, +} from '../types'; +import isInteger from 'lodash-es/isInteger'; +import { + $, + EDITABLE_SELECTOR, + DATA_TRANSIENT_ATTRIBUTES, + isNode, + NodeInterface, + transformCustomTags, +} from '@aomao/engine'; +import Template from './template'; + +class Helper implements HelperInterface { + private clipboard?: { + html: string; + text: string; + }; + + isEmptyModelCol( + model: TableModelCol | TableModelEmptyCol, + ): model is TableModelEmptyCol { + return (model as TableModelEmptyCol).isEmpty; + } + + /** + * 获取表格数据模型 + * @param table + * @returns + */ + getTableModel(table: NodeInterface): TableModel { + let model: Array> = []; + const tableElement = table.get()!; + const rows = tableElement.rows; + const rowCount = rows.length; + + for (let r = 0; r < rowCount; r++) { + const tr = rows[r]; + const cells = tr.cells; + const cellCount = cells.length; + + for (let c = 0; c < cellCount; c++) { + const td = cells[c]; + let { rowSpan, colSpan } = td; + rowSpan = rowSpan === void 0 ? 1 : rowSpan; + colSpan = colSpan === void 0 ? 1 : colSpan; + const isMulti = rowSpan > 1 || colSpan > 1; + model[r] = model[r] || []; + // 如果当前单元格的 model 已经存在,说明是前面已经有合并单元格覆盖了当前坐标,需要往右推移 + let _c = c; + while (model[r][_c]) { + _c++; + } + + model[r][_c] = { + rowSpan: rowSpan, + colSpan: colSpan, + isMulti: isMulti, + element: td, + }; + + if (isMulti) { + // 补齐被合并的单元格占位 + let _rowCount = rowSpan; + + while (_rowCount > 0) { + let colCount = colSpan; + while (colCount > 0) { + if (colCount !== 1 || _rowCount !== 1) { + const rowIndex = r + _rowCount - 1; + const colIndex = _c + colCount - 1; + model[rowIndex] = model[rowIndex] || []; + model[rowIndex][colIndex] = { + isEmpty: true, + parent: { + row: r, + col: _c, + }, + element: null, + }; + } + colCount--; + } + _rowCount--; + } + } + } + } + + const colCounts = model.map((trModel) => { + return trModel.length; + }); + const MaxColCount = Math.max.apply(Math, [...colCounts]); + model.forEach((trModel) => { + if (trModel.length < MaxColCount) { + let addCount = MaxColCount - trModel.length; + while (addCount--) { + trModel.push({ + rowSpan: 1, + colSpan: 1, + isShadow: true, + element: null, + }); + } + } + // 表格内有空数组项,补齐为 shadow + for (let i = 0; i < MaxColCount; i++) { + if (!trModel[i]) { + trModel[i] = { + rowSpan: 1, + colSpan: 1, + isShadow: true, + element: null, + }; + } + } + }); + const result = { + rows: model.length, + cols: MaxColCount, + width: tableElement.offsetWidth, + height: tableElement.offsetHeight, + table: model, + }; + return result; + } + /** + * 修复表格,补全丢失的单元格 + * @param table + * @returns + */ + normalize(table: NodeInterface) { + this.trimStartTr(table); + this.fixNumberTr(table); + table.addClass('data-table'); + table.attributes(DATA_TRANSIENT_ATTRIBUTES, 'class'); + // 修正表格宽度为 pt 场景 + const width = table.css('width'); + + if (parseInt(width) === 0) { + table.css('width', 'auto'); + } + table.css('background-color', ''); + + const model = this.getTableModel(table); + + // 修正列的 span 场景 + let cols = table.find('col'); + if (cols.length !== 0) { + for (let c = cols.length - 1; c >= 0; c--) { + const colElement = cols[c] as HTMLTableColElement; + const _width = cols.eq(c)?.attributes('width'); + if (_width) { + cols.eq(c)?.attributes('width', parseInt(_width)); + } + + if (colElement.span > 1) { + let addCount = colElement.span - 1; + while (addCount--) { + cols[c].parentNode?.insertBefore( + cols[c].cloneNode(), + cols[c], + ); + } + } + } + cols = table.find('col'); + if (cols.length < model.cols) { + const lastCol = cols.length - 1; + let colsAddCount = model.cols - cols.length; + while (colsAddCount--) { + cols[0].parentNode?.appendChild(cols[lastCol].cloneNode()); + } + } + table.find('col').attributes('span', 1); + } else { + let colgroup = table.find('colgroup')[0]; + if (!colgroup) { + colgroup = document.createElement('colgroup'); + } + table.prepend(colgroup); + const widths = (function (table) { + const tr = table.find('tr')[0]; + const tds = $(tr).find('td'); + const widthArray: Array = []; + tds.each((_, index) => { + const $td = tds.eq(index); + if (!$td) return; + let colWidth: string | Array = + $td.attributes('data-colwidth'); + let tdWidth: string | number = $td.attributes('width'); + const tdColSpan = ($td[0] as HTMLTableDataCellElement) + .colSpan; + if (colWidth) { + colWidth = colWidth.split(','); + } else if (tdWidth) { + tdWidth = parseInt(tdWidth) / tdColSpan; + } + for (let o = 0; tdColSpan > o; o++) { + if (colWidth && colWidth[o]) { + widthArray.push(parseInt(colWidth[o])); + } else if (tdWidth) { + widthArray.push(parseInt(tdWidth.toString())); + } else { + widthArray.push(undefined); + } + } + }); + const td = table.find('td'); + td.removeAttributes('data-colwidth'); + td.removeAttributes('width'); + return widthArray; + })(table); + const col = document.createElement('col'); + for (let f = 0; model.cols > f; f++) { + const node = col.cloneNode(); + if (widths[f]) { + (node as HTMLElement).setAttribute( + 'width', + (widths[f] || '').toString(), + ); + } + colgroup.appendChild(node); + } + } + // 数据模型和实际 dom 结构的行数不一致,需要寻找并补齐行 + const tableElement = table.get()!; + model.table.forEach((tr, r) => { + if (!tableElement.rows[r]) { + tableElement.insertRow(r); + } + const shadow = tr.filter((td) => { + return this.isEmptyModelCol(td) ? false : td.isShadow; + }); + let shadowCount = shadow.length; + while (shadowCount--) { + if (r === 0) { + tableElement.rows[r].insertCell(0); + } else { + tableElement.rows[r].insertCell(); + } + } + }); + // 修正行高 + const trs = table.find('tr'); + trs.each((_, index) => { + const $tr = trs.eq(index); + if (!$tr) return; + let height = parseInt($tr.css('height')); + height = height || 33; + $tr.css('height', height + 'px'); + }); + //补充可编辑器区域 + const tds = table.find('td'); + tds.each((_, index) => { + const tdNode = tds.eq(index); + if (!tdNode) return; + tdNode.attributes( + DATA_TRANSIENT_ATTRIBUTES, + 'table-cell-selection', + ); + let editableElement = tdNode.find(EDITABLE_SELECTOR); + if (editableElement.length === 0) { + const content = tdNode.html(); + tdNode.empty(); + tdNode.append(Template.EmptyCell); + editableElement = tdNode.find(EDITABLE_SELECTOR); + editableElement.html(content); + } + editableElement.find('p').each((pNode) => { + if (pNode.childNodes.length === 0) { + pNode.appendChild(document.createElement('br')); + } + }); + }); + return table; + } + + /** + * 过滤 table 中的首行空tr, 当表格尾部有空白单元格时,网页拷贝时头部会莫名其妙的出现一个空的Tr + * @param {nodeModel} table 传入的table + */ + trimStartTr(table: NodeInterface) { + const tr = table.find('tr'); + const first = tr.eq(0); + if (first && first.children().length === 0) { + first.remove(); + } + } + + fixNumberTr(table: NodeInterface) { + const tableElement = table.get()!; + const rows = tableElement.rows; + const rowCount = rows.length; + let colCounts: Array = []; + let firstColCount: number = 0; // 第一列的单元格个数 + let cellCounts = []; // 每行单元格个数 + let totalCellCounts = 0; // 总单元格个数 + let emptyCounts = 0; // 跨行合并缺损的单元格 + // 已经存在一行中的 td 的最大数,最终算出来的最大列数一定要大于等于这个值 + let maxCellCounts = 0; // 在不确定是否缺少tr时,先拿到已经存在的td,和一些关键信息 + + for (let r = 0; r < rowCount; r++) { + const row = rows[r]; + const cells = row.cells; + let cellCountThisRow = 0; + + for (let c = 0; c < cells.length; c++) { + const { rowSpan, colSpan } = cells[c]; + totalCellCounts += rowSpan * colSpan; + cellCountThisRow += colSpan; + if (rowSpan > 1) { + emptyCounts += (rowSpan - 1) * colSpan; + } + } + cellCounts[r] = cellCountThisRow; + if (r === 0) { + firstColCount = cellCountThisRow; + } + maxCellCounts = Math.max(cellCountThisRow, maxCellCounts); + } + // number拷贝的一定是首行列数能被单元格总数整除 + const isNumber1 = isInteger(totalCellCounts / firstColCount); // number拷贝的一定是首行列数最大 + const isNumber2 = firstColCount === maxCellCounts; + const isNumber = isNumber1 && isNumber2; // 判断是否是 number, 是因为 number 需要考虑先修复省略的 tr,否则后面修复出来就会有问题 + + if (isNumber) { + let lossCellCounts = 0; + cellCounts.forEach((cellCount) => { + lossCellCounts += maxCellCounts - cellCount; + }); + + if (lossCellCounts !== emptyCounts) { + const missCellCounts = emptyCounts - lossCellCounts; + if (isInteger(missCellCounts / maxCellCounts)) { + let lossRowIndex = []; // 记录哪一行缺 tr + + for (let _r = 0; _r < rowCount; _r++) { + const _row = rows[_r]; + const _cells = _row.cells; + let realRow: number = _r + lossRowIndex.length; + + while (colCounts[realRow] === maxCellCounts) { + lossRowIndex.push(realRow); + realRow++; + } + + for (let _c2 = 0; _c2 < _cells.length; _c2++) { + const { rowSpan, colSpan } = _cells[_c2]; + if (rowSpan > 1) { + for (let rr = 1; rr < rowSpan; rr++) { + colCounts[realRow + rr] = + (colCounts[realRow + rr] || 0) + + colSpan; + } + } + } + } + + lossRowIndex.forEach((row) => { + tableElement.insertRow(row); + }); + } + } + } + } + + // firefox 下的拖拽需要这样处理 + // clearData 是为了防止新开 tab + // hack: 如果不 setData, firefox 不会触发拖拽事件,但设置 data 之后,又会开新 tab, 这里设置一个 firefox 不识别的 mimetype: aomao + fixDragEvent(event: DragEvent) { + event.dataTransfer?.clearData(); + event.dataTransfer?.setData('aomao', ''); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'all'; + } + } + + /** + * 从源节点复制样式到目标节点 + * @param from 源节点 + * @param to 目标节点 + */ + copyCss(from: NodeInterface | Node, to: NodeInterface | Node) { + if (isNode(from)) from = $(from); + if (isNode(to)) to = $(to); + to.css('text-align', from.css('text-align')); + to.css('vertical-align', from.css('vertical-align')); + let tdBgColor = from.css('background-color'); + tdBgColor = tdBgColor !== 'rgba(0, 0, 0, 0)' ? tdBgColor : '#fff'; + to.css('background-color', tdBgColor); + to.css('color', from.css('color')); + to.css('font-weight', from.css('font-weight')); + } + + /** + * 从源节点复制样式和内容到目标节点 + * @param from 源节点 + * @param to 目标节点 + */ + copyTo(from: NodeInterface | Node, to: NodeInterface | Node) { + if (isNode(from)) from = $(from); + if (isNode(to)) to = $(to); + + let editableElement = to.find(EDITABLE_SELECTOR); + if (editableElement.length === 0) { + to.html(Template.EmptyCell); + editableElement = to.find(EDITABLE_SELECTOR); + } + editableElement.html(transformCustomTags(from.html())); + if ( + to.name === 'td' && + to.find(Template.TABLE_TD_BG_CLASS).length === 0 + ) { + to.append($(Template.CellBG)); + } + if (to.name === 'td') { + to.attributes('data-transient-attributes', 'table-cell-selection'); + } + //this.copyCss(from, to) + } + + /** + * 复制html + * @param html HTML + */ + copyHTML(html: string) { + this.clipboard = { + html: html, + text: $(html).get()?.innerText || '', + }; + } + /** + * 获取复制的数据 + * @returns + */ + getCopyData() { + return this.clipboard; + } + + trimBlankSpan(node: NodeInterface) { + const len = node.length; + let nodelist = []; + let i = 0; + let j = len - 1; + while ( + node[i] && + (node[i] as HTMLElement).tagName.toLowerCase() === 'span' && + (node[i] as HTMLElement).innerText.trim() === '' + ) { + i++; + } + + while ( + node[j] && + (node[j] as HTMLElement).tagName.toLowerCase() === 'span' && + (node[j] as HTMLElement).innerText.trim() === '' + ) { + j--; + } + + if (i <= j) { + for (let k = i; k <= j; k++) { + nodelist.push(node[k]); + } + } + + if (nodelist.length) { + return $(nodelist); + } + return node; + } + + /** + * table 结构标准化,补齐丢掉的单元格和行 + * 场景1. number 拷贝过来的 html 中,如果这一行没有单元格,就会省掉 tr,渲染的时候会有问题 + * 场景2. 从网页中鼠标随意选取表格中的一部分,会丢掉没有选中的单元格,需要补齐单元格 + * @param {NodeInterface} table 表格 Dom + * @return {NodeInterface} 修复过的 table dom + */ + normalizeTable(table: NodeInterface) { + this.trimStartTr(table); + this.fixNumberTr(table); + table.addClass('data-table'); + // 修正表格宽度为 pt 场景 + const width = table.css('width'); + + if (parseInt(width) === 0) { + table.css('width', 'auto'); + } else { + // pt 直接转为 px, 因为 col 的 width 属性是没有单位的,会直接被理解为 px, 这里 table 的 width 也直接换成 px。 + table.css('width', parseInt(width, 10) + 'px'); + } // 表格 table 标签不允许有背景色,无法设置 + + table.css('background-color', ''); + const model = this.getTableModel(table); + // 修正列的 span 场景 + let cols = table.find('col'); + if (cols.length !== 0) { + for (let c = cols.length - 1; c >= 0; c--) { + const colElement = cols[c] as HTMLTableColElement; + const _width = cols.eq(c)?.attributes('width'); + if (_width) { + const widthValue = parseInt(_width); + if (widthValue !== NaN) + cols.eq(c)?.attributes('width', widthValue); + } + + if (colElement.span > 1) { + let addCount = colElement.span - 1; + while (addCount--) { + cols[c].parentNode?.insertBefore( + cols[c].cloneNode(), + cols[c], + ); + } + } + } + cols = table.find('col'); + if (cols.length < model.cols) { + const lastCol = cols.length - 1; + let colsAddCount = model.cols - cols.length; + while (colsAddCount--) { + cols[0].parentNode?.appendChild(cols[lastCol].cloneNode()); + } + } + table.find('col').attributes('span', 1); + } else { + let colgroup = table.find('colgroup')[0]; + if (!colgroup) { + colgroup = document.createElement('colgroup'); + } + table.prepend(colgroup); + const widths = (function (table) { + const tr = table.find('tr')[0]; + const tds = $(tr).find('td'); + const widthArray: Array = []; + tds.each((_, index) => { + const tdNode = tds.eq(index); + if (!tdNode) return; + let colWidth: string | Array = + tdNode.attributes('data-colwidth'); + let tdWidth: string | number = tdNode.attributes('width'); + const tdColSpan = (tdNode[0] as HTMLTableDataCellElement) + .colSpan; + if (colWidth) { + colWidth = colWidth.split(','); + } else if (tdWidth) { + tdWidth = parseInt(tdWidth) / tdColSpan; + } + for (let o = 0; tdColSpan > o; o++) { + if (colWidth && colWidth[o]) { + widthArray.push(parseInt(colWidth[o])); + } else if (tdWidth) { + widthArray.push(parseInt(tdWidth.toString())); + } else { + widthArray.push(undefined); + } + } + }); + const td = table.find('td'); + td.removeAttributes('data-colwidth'); + td.removeAttributes('width'); + return widthArray; + })(table); + const col = document.createElement('col'); + for (let f = 0; model.cols > f; f++) { + const node = col.cloneNode(); + if (widths[f]) { + (node as HTMLElement).setAttribute( + 'width', + (widths[f] || '').toString(), + ); + } + colgroup.appendChild(node); + } + } + // 数据模型和实际 dom 结构的行数不一致,需要寻找并补齐行 + const tableElement = table.get()!; + model.table.forEach((tr, r) => { + if (!tableElement.rows[r]) { + tableElement.insertRow(r); + } + const shadow = tr.filter((td) => { + return (td as TableModelCol).isShadow; + }); + let shadowCount = shadow.length; + while (shadowCount--) { + if (r === 0) { + tableElement.rows[r].insertCell(0); + } else { + tableElement.rows[r].insertCell(); + } + } + }); + // 修正行高 + const trs = table.find('tr'); + trs.each((_, index) => { + const $tr = trs.eq(index); + if (!$tr) return; + let height = parseInt($tr.css('height')); + height = height || 33; + $tr.css('height', height + 'px'); + }); + return table; + } +} + +export default Helper; diff --git a/plugins/table/src/component/index.ts b/plugins/table/src/component/index.ts new file mode 100644 index 00000000..3feb1668 --- /dev/null +++ b/plugins/table/src/component/index.ts @@ -0,0 +1,480 @@ +import { + $, + Card, + CardToolbarItemOptions, + CardType, + EDITABLE_SELECTOR, + isEngine, + NodeInterface, + Parser, + RangeInterface, + Scrollbar, + ToolbarItemOptions, +} from '@aomao/engine'; +import { + ControllBarInterface, + HelperInterface, + TableCommandInterface, + TableInterface, + TableSelectionInterface, + TableValue, + TemplateInterface, +} from '../types'; +import Helper from './helper'; +import Template from './template'; +import menuData from './menu'; +import ControllBar from './controllbar'; +import TableSelection from './selection'; +import TableCommand from './command'; +import { ColorTool, Palette } from './toolbar'; + +class TableComponent extends Card implements TableInterface { + readonly contenteditable: string[] = [ + `div${Template.TABLE_TD_CONTENT_CLASS}`, + ]; + + static get cardName() { + return 'table'; + } + + static get cardType() { + return CardType.BLOCK; + } + + static get selectStyleType(): 'background' { + return 'background'; + } + + static get autoSelected() { + return false; + } + + static colors = Palette.getColors().map((group) => + group.map((color) => { + return { color, border: Palette.getStroke(color) }; + }), + ); + + wrapper?: NodeInterface; + helper: HelperInterface = new Helper(); + template: TemplateInterface = new Template(this); + selection: TableSelectionInterface = new TableSelection(this.editor, this); + conltrollBar: ControllBarInterface = new ControllBar(this.editor, this, { + col_min_width: 40, + row_min_height: 33, + }); + command: TableCommandInterface = new TableCommand(this.editor, this); + scrollbar?: Scrollbar; + viewport?: NodeInterface; + colorTool?: ColorTool; + noBorderToolButton?: NodeInterface; + alignToolButton?: NodeInterface; + #changeTimeout?: NodeJS.Timeout; + + init() { + super.init(); + if (isEngine(this.editor)) { + this.editor.on('undo', this.doChange); + this.editor.on('redo', this.doChange); + } + if (this.colorTool) return; + this.colorTool = new ColorTool(this.editor, this.id, { + colors: TableComponent.colors, + defaultColor: this.getValue()?.color, + onChange: (color: string) => { + this.setValue({ + color, + }); + this.conltrollBar.drawBackgroundColor(color); + }, + }); + } + + doChange = () => { + this.onChange('remote'); + }; + + toolbar(): Array { + if (!isEngine(this.editor) || this.editor.readonly) + return [ + { + type: 'maximize', + }, + ]; + const language = this.editor.language.get('table'); + const funBtns: Array = [ + { + type: 'node', + title: this.editor.language.get( + 'table', + 'color', + 'title', + ), + node: this.colorTool!.getButton(), + }, + { + type: 'button', + title: language['noBorder'], + content: '', + didMount: (node) => { + const value = this.getValue(); + if (value?.noBorder === true) { + node.addClass('active'); + } + this.noBorderToolButton = node; + }, + onClick: (_, node) => { + const value = this.getValue(); + this.setValue({ + noBorder: !value?.noBorder, + }); + const table = this.wrapper?.find('.data-table'); + if (value?.noBorder === true) { + table?.removeAttributes('data-table-no-border'); + node.removeClass('active'); + } else { + table?.attributes('data-table-no-border', 'true'); + node.addClass('active'); + } + }, + }, + { + type: 'dropdown', + content: '', + title: language['verticalAlign']['title'], + didMount: (node) => { + this.alignToolButton = node.find('.data-toolbar-btn'); + }, + items: [ + { + type: 'button', + content: ` ${language['verticalAlign']['top']}`, + onClick: (event: MouseEvent) => + this.updateAlign(event, 'top'), + }, + { + type: 'button', + content: ` ${language['verticalAlign']['middle']}`, + onClick: (event: MouseEvent) => + this.updateAlign(event, 'middle'), + }, + { + type: 'button', + content: ` ${language['verticalAlign']['bottom']}`, + onClick: (event: MouseEvent) => + this.updateAlign(event, 'bottom'), + }, + ], + }, + ]; + if (this.isMaximize) return funBtns; + return [ + { + type: 'dnd', + }, + { + type: 'maximize', + }, + { + type: 'copy', + }, + { + type: 'delete', + }, + { + type: 'separator', + }, + ...funBtns, + ]; + } + + updateAlign(event: MouseEvent, align: 'top' | 'middle' | 'bottom' = 'top') { + event.preventDefault(); + this.conltrollBar.setAlign(align); + this.updateAlignText(align); + } + + updateAlignText(align: 'top' | 'middle' | 'bottom' = 'top') { + const alignHtml = ``; + this.alignToolButton?.html(alignHtml); + } + + getTableValue() { + if (!this.wrapper) return; + const tableRoot = this.wrapper.find(Template.TABLE_CLASS); + if (!tableRoot) return; + const { tableModel } = this.selection; + if (!tableModel) return; + const { schema, conversion } = this.editor; + const container = $('
      '); + container.append(tableRoot.clone(true)); + const parser = new Parser(container, this.editor, (node) => { + node.find(Template.TABLE_TD_BG_CLASS).remove(); + node.find(EDITABLE_SELECTOR).each((root) => { + this.editor.node.unwrap($(root)); + }); + }); + const { rows, cols, height, width } = tableModel; + const html = parser.toValue(schema, conversion, false, true); + return { + rows, + cols, + height, + width, + html, + }; + } + + updateBackgroundSelection?(range: RangeInterface): void { + const { selectArea, tableModel } = this.selection; + if (selectArea && selectArea.count > 1 && tableModel) { + const { begin, end } = selectArea; + const startModel = tableModel.table[begin.row][begin.col]; + if ( + !this.helper.isEmptyModelCol(startModel) && + startModel.element + ) { + range.setStart(startModel.element, 0); + } + const endModel = tableModel.table[end.row][end.col]; + if (!this.helper.isEmptyModelCol(endModel) && endModel.element) { + range.setEnd(endModel.element, 0); + } + } + } + + drawBackground?( + node: NodeInterface, + range: RangeInterface, + ): DOMRect | void | false | RangeInterface[] { + const backgroundRect = node.get()!.getBoundingClientRect(); + const domRect = new DOMRect(backgroundRect.x, backgroundRect.y, 0, 0); + const { startNode, endNode } = range; + const startElement = startNode.closest('td'); + const endElement = endNode.closest('td'); + if ( + startElement.name !== 'td' || + endElement.name !== 'td' || + startElement.equal(endElement) + ) + return [range]; + + const startRect = startElement + .get()! + .getBoundingClientRect(); + const vLeft = + (this.viewport?.getBoundingClientRect()?.left || 0) + + (this.activated ? 13 : 0); + domRect.x = Math.max( + startRect.left - backgroundRect.left, + vLeft - (this.editor.root.getBoundingClientRect()?.left || 0), + ); + domRect.y = startRect.top - backgroundRect.top; + domRect.width = startRect.right - startRect.left; + domRect.height = startRect.bottom - startRect.top; + + const rect = endElement.get()!.getBoundingClientRect(); + domRect.width = Math.min( + rect.right - (startRect.left < vLeft ? vLeft : startRect.left), + (this.viewport?.width() || 0) - (this.activated ? 13 : 0), + ); + if (domRect.width < 0) domRect.width = 0; + domRect.height = rect.bottom - startRect.top; + return domRect; + } + + activate(activated: boolean) { + super.activate(activated); + if (activated) { + this.wrapper?.addClass('active'); + this.scrollbar?.enableScroll(); + } else { + this.selection.clearSelect(); + this.conltrollBar.hideContextMenu(); + this.wrapper?.removeClass('active'); + this.scrollbar?.disableScroll(); + } + this.scrollbar?.refresh(); + } + + onChange = (trigger: 'remote' | 'local' = 'local') => { + if (!isEngine(this.editor)) return; + if (this.#changeTimeout) clearTimeout(this.#changeTimeout); + this.#changeTimeout = setTimeout(() => { + this.conltrollBar.refresh(); + this.selection.render('change'); + const oldValue = this.getValue(); + if (oldValue?.noBorder) { + this.noBorderToolButton?.addClass('active'); + } else this.noBorderToolButton?.removeClass('active'); + if (trigger === 'local') { + const value = this.getTableValue(); + if (value) this.setValue(value); + } + }, 100); + }; + + maximize() { + super.maximize(); + this.scrollbar?.refresh(); + } + + minimize() { + super.minimize(); + this.scrollbar?.refresh(); + } + + getSelectionNodes() { + const nodes: Array = []; + this.selection.each((cell) => { + if (!this.helper.isEmptyModelCol(cell) && cell.element) { + nodes.push($(cell.element).find(EDITABLE_SELECTOR)); + } + }); + // 如果值选中了一个单元格,并且不是拖蓝方式选中就返回空的 + if ( + nodes.length === 1 && + nodes[0].closest('[table-cell-selection=true]').length === 0 + ) + return []; + return nodes; + } + + didRender() { + super.didRender(); + this.viewport = isEngine(this.editor) + ? this.wrapper?.find(Template.VIEWPORT) + : this.wrapper; + + this.selection.init(); + this.conltrollBar.init(); + this.command.init(); + if (!isEngine(this.editor) || this.editor.readonly) + this.toolbarModel?.setOffset([0, 0]); + else this.toolbarModel?.setOffset([0, -28, 0, -6]); + if (this.viewport) { + this.scrollbar = new Scrollbar(this.viewport, true, false, true); + this.scrollbar.setContentNode(this.viewport.find('.data-table')!); + this.scrollbar.on('display', (display: 'node' | 'block') => { + if (display === 'block') { + this.wrapper?.addClass('scrollbar-show'); + } else { + this.wrapper?.removeClass('scrollbar-show'); + } + }); + this.scrollbar.disableScroll(); + this.scrollbar.on('change', () => { + if (isEngine(this.editor)) this.editor.ot.initSelection(); + }); + } + this.scrollbar?.refresh(); + this.selection.on('select', () => { + this.conltrollBar.refresh(); + if (!isEngine(this.editor)) return; + const { selectArea, tableModel } = this.selection; + if (selectArea && selectArea.count > 1 && tableModel) { + this.editor.ot.updateSelection(); + } + const align = this.selection.getSingleCell()?.css('vertical-align'); + this.updateAlignText(align as any); + }); + + this.conltrollBar.on('sizeChanged', () => { + this.selection.refreshModel(); + this.onChange(); + this.scrollbar?.refresh(); + }); + this.command.on('actioned', (action, silence) => { + if (action === 'paste') { + this.editor.card.render(this.wrapper); + } + this.selection.render(action); + this.toolbarModel?.showCardToolbar(); + if (!silence) { + this.onChange(); + } + this.scrollbar?.refresh(); + }); + + const tableRoot = this.wrapper?.find(Template.TABLE_CLASS); + if (!tableRoot) return; + const value = this.getValue(); + if (!value?.html) { + const tableValue = this.getTableValue(); + if (tableValue) this.setValue(tableValue); + this.onChange(); + } + } + + render() { + Template.isReadonly = !isEngine(this.editor) || this.editor.readonly; + const value = this.getValue(); + // 重新渲染 + if (this.wrapper) { + // 重新绘制列头部和行头部 + const colsHeader = this.wrapper.find(Template.COLS_HEADER_CLASS); + colsHeader.replaceWith( + $(this.template.renderColsHeader(value?.cols || 0)), + ); + const rowsHeader = this.wrapper.find(Template.ROWS_HEADER_CLASS); + rowsHeader.replaceWith( + $(this.template.renderRowsHeader(value?.rows || 0)), + ); + setTimeout(() => { + // 找到所有可编辑节点,对没有 contenteditable 属性的节点添加contenteditable一下 + this.wrapper?.find(EDITABLE_SELECTOR).each((editableNode) => { + const editableElement = editableNode as Element; + if (!editableElement.hasAttribute('contenteditable')) { + editableElement.setAttribute( + 'contenteditable', + Template.isReadonly ? 'false' : 'true', + ); + } + }); + }, 10); + return; + } + // 第一次渲染 + if (!value) return 'Error value'; + if (value.html) { + const model = this.helper.getTableModel($(value.html)); + value.rows = model.rows; + value.cols = model.cols; + } + //渲染卡片 + this.wrapper = isEngine(this.editor) + ? $( + this.template.htmlEdit( + value, + menuData(this.editor.language.get('table')), + ), + ) + : $(this.template.htmlView(value)); + if (!isEngine(this.editor)) { + this.wrapper + .find('table') + .addClass('data-table') + .addClass('data-table-view'); + } + value.rows = this.wrapper.find('tr').length; + if (value.width) + this.wrapper.find('table').css('width', `${value.width}px`); + return this.wrapper; + } + + destroy() { + super.destroy(); + this.scrollbar?.destroy(); + this.command.removeAllListeners(); + this.selection.removeAllListeners(); + this.selection.destroy(); + this.conltrollBar.removeAllListeners(); + this.conltrollBar.destroy(); + this.editor.off('undo', this.doChange); + this.editor.off('redo', this.doChange); + } +} + +export default TableComponent; + +export { Template }; diff --git a/plugins/table/src/component/menu.ts b/plugins/table/src/component/menu.ts new file mode 100644 index 00000000..9d8ca1e3 --- /dev/null +++ b/plugins/table/src/component/menu.ts @@ -0,0 +1,86 @@ +import { TableMenu } from '../types'; + +export default (locale: { [key: string]: string }): TableMenu => { + return [ + { + action: 'cut', + icon: 'cut', + text: locale.cut, + }, + { + action: 'copy', + icon: 'copy', + text: locale.copy, + }, + { + action: 'mockPaste', + icon: 'paste', + text: locale.paste, + }, + { + split: true, + }, + { + action: 'insertColLeft', + icon: 'insert-col-left', + text: locale.insertColLeft, + }, + { + action: 'insertColRight', + icon: 'insert-col-right', + text: locale.insertColRight, + }, + { + action: 'insertRowUp', + icon: 'insert-row-up', + text: locale.insertRowUp, + }, + { + action: 'insertRowDown', + icon: 'insert-row-down', + text: locale.insertRowDown, + }, + { + split: true, + }, + { + action: 'mergeCell', + icon: 'merge-cell', + text: locale.mergeCell, + }, + { + action: 'splitCell', + icon: 'split-cell', + text: locale.splitCell, + }, + { + split: true, + }, + { + action: 'removeCol', + icon: 'remove-col', + text: locale.removeCol, + }, + { + action: 'removeRow', + icon: 'remove-row', + text: locale.removeRow, + }, + { + split: true, + }, + { + action: 'removeTable', + icon: 'remove-table', + text: locale.removeTable, + }, + { + split: true, + }, + { + action: 'clear', + icon: 'clear', + text: locale.clear, + }, + ]; +}; diff --git a/plugins/table/src/component/selection.ts b/plugins/table/src/component/selection.ts new file mode 100644 index 00000000..1eac4a1f --- /dev/null +++ b/plugins/table/src/component/selection.ts @@ -0,0 +1,1139 @@ +import { + TableInterface, + TableModel, + TableSelectionInterface, + TableSelectionArea, + TableSelectionDragging, + TableModelCol, + TableModelEmptyCol, +} from '../types'; +import { EventEmitter2 } from 'eventemitter2'; +import { + $, + EditorInterface, + NodeInterface, + isHotkey, + isEngine, + EDITABLE_SELECTOR, + isNode, + isMobile, + removeUnit, +} from '@aomao/engine'; +import Template from './template'; + +class TableSelection extends EventEmitter2 implements TableSelectionInterface { + private editor: EditorInterface; + private table: TableInterface; + + tableRoot?: NodeInterface; + colsHeader?: NodeInterface; + rowsHeader?: NodeInterface; + tableHeader?: NodeInterface; + tableModel?: TableModel; + selectArea?: TableSelectionArea; + selectRange?: { + type: 'left' | 'right' | 'top' | 'bottom'; + startOffset: number; + endOffset: number; + }; + dragging?: TableSelectionDragging; + isShift: boolean = false; + prevMouseDownTd?: NodeInterface; + prevOverTd?: NodeInterface; + highlight?: NodeInterface; + // 第一次选中一行的行号 + beginAllRow?: number; + // 第一次选中一列的列号 + beginAllCol?: number; + + constructor(editor: EditorInterface, table: TableInterface) { + super(); + this.table = table; + this.editor = editor; + } + + init() { + const { wrapper } = this.table; + if (!wrapper) return; + this.tableRoot = wrapper.find(Template.TABLE_CLASS); + this.colsHeader = wrapper.find(Template.COLS_HEADER_CLASS); + this.rowsHeader = wrapper.find(Template.ROWS_HEADER_CLASS); + this.tableHeader = wrapper.find(Template.HEADER_CLASS); + this.highlight = wrapper.find(Template.TABLE_HIGHLIGHT_CLASS); + + this.render('init'); + this.bindEvents(); + } + + render(action: string) { + this.refreshModel(); + const { tableModel } = this; + if (!tableModel) { + return; + } + const { begin, end } = this.getSelectArea(); + if (action === 'mergeCell' || action === 'splitCell') { + const row = + begin.row < 0 ? 0 : Math.min(begin.row, tableModel.rows - 1); + const col = + begin.col < 0 ? 0 : Math.min(begin.col, tableModel.cols - 1); + let cell = tableModel.table[row][col]; + if (this.table.helper.isEmptyModelCol(cell)) { + cell = tableModel.table[cell.parent.row][cell.parent.col]; + } + if (!this.table.helper.isEmptyModelCol(cell) && cell.element) { + if (action === 'mergeCell') { + this.clearSelect(); + this.selectCellRange(cell.element); + } + } + } else if (action === 'removeRow') { + const row = + begin.row < 0 ? 0 : Math.min(begin.row, tableModel.rows - 1); + const cell = tableModel.table[row][0]; + if (!this.table.helper.isEmptyModelCol(cell) && cell.element) { + this.focusCell(cell.element); + } + } else if (action === 'removeCol') { + const col = + begin.col < 0 ? 0 : Math.min(begin.col, tableModel.cols - 1); + const cell = tableModel.table[0][col]; + if (!this.table.helper.isEmptyModelCol(cell) && cell.element) { + this.focusCell(cell.element); + } + } else { + this.select(begin, end); + } + this.renderBorder(); + } + + renderBorder() { + const { tableModel } = this; + if (!tableModel) return; + //this.tableRoot?.find('td.table-last-column').removeClass('table-last-column'); + //this.tableRoot?.find('td.table-last-row').removeClass('table-last-row'); + tableModel.table.forEach((cols, row) => { + cols.forEach((cell, col) => { + if (!this.table.helper.isEmptyModelCol(cell)) { + if (!cell.element) return; + let isLastCol = row === tableModel.rows - 1; + let isLastRow = col === tableModel.cols - 1; + if (cell.isMulti) { + if (col + cell.colSpan === tableModel.cols) + isLastRow = true; + if (row + cell.rowSpan === tableModel.rows) + isLastCol = true; + } + if (isLastCol) { + cell.element.classList.add('table-last-column'); + } else { + cell.element.classList.remove('table-last-column'); + } + if (isLastRow) { + cell.element.classList.add('table-last-row'); + } else { + cell.element.classList.remove('table-last-row'); + } + } + }); + }); + } + + bindEvents() { + document.addEventListener('keydown', this.onShiftKeydown); + document.addEventListener('keyup', this.onShiftKeyup); + this.table.wrapper + ?.on(isMobile ? 'touchstart' : 'mousedown', this.onTdMouseDown) + .on('keydown', this.onKeydown); + } + + unbindEvents() { + document.removeEventListener('keydown', this.onShiftKeydown); + document.removeEventListener('keyup', this.onShiftKeyup); + this.table.wrapper + ?.off(isMobile ? 'touchstart' : 'mousedown', this.onTdMouseDown) + .off('keydown', this.onKeydown); + } + + refreshModel() { + if (!this.tableRoot || this.tableRoot.length === 0) return; + this.tableModel = this.table.helper.getTableModel(this.tableRoot); + } + + each( + fn: ( + cell: TableModelCol | TableModelEmptyCol, + row: number, + col: number, + ) => void, + reverse: boolean = false, + ) { + const { tableModel } = this; + if (!tableModel) return; + const { begin, end } = this.getSelectArea(); + if (begin.row < 0 || begin.col < 0) return; + if (reverse) { + for (let r = end.row; r > -1 && r >= begin.row; r--) { + for (let c = end.col; c > -1 && c >= begin.col; c--) { + const tdModel = tableModel.table[r][c]; + fn(tdModel, r, c); + } + } + } else { + for (let _r = begin.row; _r > -1 && _r <= end.row; _r++) { + for (let _c = begin.col; _c > -1 && _c <= end.col; _c++) { + const _tdModel = tableModel.table[_r][_c]; + fn(_tdModel, _r, _c); + } + } + } + } + + getCellPoint(td: NodeInterface) { + if (td.name !== 'td') return [-1, -1]; + const row = td.parent()?.index(); + if (row === undefined || row < 0) return [-1, -1]; + const col = this.tableModel?.table[row].findIndex((cell) => + td.equal( + (this.table.helper.isEmptyModelCol(cell) + ? (this.tableModel?.table[cell.parent.row][ + cell.parent.col + ] as TableModelCol) + : cell + ).element!, + ), + ); + if (col === undefined || col < 0) return [-1, -1]; + return [row, col]; + } + + getCellIndex(row: number, col: number) { + if (!this.tableModel) return 0; + const trModel = this.tableModel.table[row]; + let index = 0; + for (let i = 0; i < col; i++) { + const tdModel = trModel[i]; + if ( + !this.table.helper.isEmptyModelCol(tdModel) && + tdModel.element + ) { + index++; + } + } + return index; + } + + getSingleCell() { + if (!this.prevMouseDownTd) return null; + return this.prevMouseDownTd; + } + + getSingleCellPoint() { + const td = this.getSingleCell(); + if (!td) return [-1, -1]; + return this.getCellPoint(td); + } + + getSelectArea() { + if (this.selectArea) return this.selectArea; + let curPoint = this.getSingleCellPoint(); + if (!this.tableModel || curPoint[0] === -1) + return { + begin: { row: -1, col: -1 }, + end: { row: -1, col: -1 }, + count: 0, + allCol: false, + allRow: false, + }; + + const { cols, rows, table } = this.tableModel; + let cell = table[curPoint[0]][curPoint[1]]; + if (this.table.helper.isEmptyModelCol(cell)) { + cell = table[cell.parent.row][cell.parent.col] as TableModelCol; + if (cell.element) curPoint = this.getCellPoint($(cell.element)); + } + return { + begin: { row: curPoint[0], col: curPoint[1] }, + end: { row: curPoint[0], col: curPoint[1] }, + count: curPoint[0] === -1 ? 0 : 1, + allCol: cols === 1, + allRow: rows === 1, + }; + } + + selectCol(begin: number, end: number = begin) { + if (!this.tableModel) return; + if (this.isShift) { + // 有全选的列 + if (this.beginAllCol) { + if (begin < this.beginAllCol) { + end = this.beginAllCol; + } else { + begin = this.beginAllCol; + } + } else if (this.prevMouseDownTd) { + const [row, col] = this.getCellPoint(this.prevMouseDownTd); + begin = col; + this.beginAllCol = col; + } else if (this.selectArea) { + begin = this.selectArea.begin.col; + if (this.tableModel) { + const tdModel = + this.tableModel.table[this.selectArea.begin.row][ + this.selectArea.begin.col + ]; + if ( + !this.table.helper.isEmptyModelCol(tdModel) && + tdModel.element + ) + this.focusCell(tdModel.element); + } + } + } + this.select( + { row: 0, col: begin }, + { row: this.tableModel.rows - 1, col: end }, + ); + } + + selectRow(begin: number, end: number = begin) { + if (!this.tableModel) return; + if (this.isShift) { + // 有全选的行 + if (this.beginAllRow) { + if (begin < this.beginAllRow) { + end = this.beginAllRow; + } else { + begin = this.beginAllRow; + } + } else if (this.prevMouseDownTd) { + const [row, col] = this.getCellPoint(this.prevMouseDownTd); + begin = row; + this.beginAllRow = row; + } else if (this.selectArea) { + begin = this.selectArea.begin.row; + if (this.tableModel) { + const tdModel = + this.tableModel.table[this.selectArea.begin.row][ + this.selectArea.begin.col + ]; + if ( + !this.table.helper.isEmptyModelCol(tdModel) && + tdModel.element + ) + this.focusCell(tdModel.element); + } + } + } + this.select( + { row: begin, col: 0 }, + { row: end, col: this.tableModel.cols - 1 }, + ); + } + + selectCell(begin: NodeInterface, end: NodeInterface) { + if (begin.name !== 'td' || end.name !== 'td') { + return; + } + const beginPoint = this.getCellPoint(begin); + const endPoint = this.getCellPoint(end); + this.select( + { row: beginPoint[0], col: beginPoint[1] }, + { row: endPoint[0], col: endPoint[1] }, + ); + } + + clearSelect() { + this.select({ row: -1, col: -1 }, { row: -1, col: -1 }); + } + + select( + begin: { row: number; col: number }, + end: { row: number; col: number }, + ) { + if (!this.tableModel) return; + const isSame = begin.row === end.row && begin.col === end.col; + let beginRow = Math.min(begin.row, end.row); + let endRow = Math.max(begin.row, end.row); + let beginCol = Math.min(begin.col, end.col); + let endCol = Math.max(begin.col, end.col); + + this.tableRoot + ?.find('td[table-cell-selection]') + .removeAttributes('table-cell-selection'); + + const fBeginRow = beginRow; + const fEndRow = endRow; + const fBeginCol = beginCol; + const fEndCol = endCol; + for (let row = fBeginRow; row > -1 && row <= fEndRow; row++) { + for (let col = fBeginCol; col > -1 && col <= fEndCol; col++) { + const cell = this.tableModel.table[row][col]; + if (this.table.helper.isEmptyModelCol(cell)) { + if (beginRow > cell.parent.row) beginRow = cell.parent.row; + if (beginCol >= cell.parent.col) beginCol = cell.parent.col; + const parent = + this.tableModel.table[cell.parent.row][cell.parent.col]; + if (!this.table.helper.isEmptyModelCol(parent)) { + if ( + parent.rowSpan > 1 && + endRow < parent.rowSpan - 1 + cell.parent.row + ) + endRow = parent.rowSpan - 1 + cell.parent.row; + if ( + parent.colSpan > 1 && + endCol < parent.colSpan - 1 + cell.parent.col + ) + endCol = parent.colSpan - 1 + cell.parent.col; + } + } else if (!this.table.helper.isEmptyModelCol(cell)) { + if (cell.rowSpan > 1) { + if (endRow < cell.rowSpan - 1 + row) + endRow = cell.rowSpan - 1 + row; + } + if (cell.colSpan > 1) { + if (endCol < cell.colSpan - 1 + col) + endCol = cell.colSpan - 1 + col; + } + } + } + } + + let count = 0; + if ( + beginRow >= 0 && + beginCol >= 0 && + endRow < this.tableModel.rows && + endCol < this.tableModel.cols + ) { + for (let r = beginRow; r <= endRow; r++) { + for (let c = beginCol; c <= endCol; c++) { + const col = this.tableModel.table[r][c]; + if (!this.table.helper.isEmptyModelCol(col)) { + if (!isSame && col.element) { + $(col.element).attributes( + 'table-cell-selection', + 'true', + ); + } + count++; + } + } + } + } + if (isSame && begin.row > -1 && begin.col > -1) { + const cell = this.tableModel.table[begin.row][begin.col]; + if ( + !this.table.helper.isEmptyModelCol(cell) && + cell.element && + !this.prevMouseDownTd?.equal(cell.element) + ) { + this.focusCell(cell.element); + } + } + const allCol = beginCol === 0 && endCol === this.tableModel.cols - 1; + const allRow = beginRow === 0 && endRow === this.tableModel.rows - 1; + if (allCol && !this.beginAllRow) { + this.beginAllRow = beginRow; + } else if (!allCol && this.beginAllRow) { + this.beginAllRow = undefined; + } + + if (allRow && !this.beginAllCol) { + this.beginAllCol = beginCol; + } else if (!allRow && this.beginAllCol) { + this.beginAllCol = undefined; + } + + this.selectArea = + count === 0 || isSame + ? undefined + : { + begin: { row: beginRow, col: beginCol }, + end: { row: endRow, col: endCol }, + count, + allCol, + allRow, + }; + this.emit('select', this.selectArea); + } + + focusCell(cell: NodeInterface | Node) { + if (!isEngine(this.editor)) return; + const { change } = this.editor; + if (isNode(cell)) cell = $(cell); + const range = change.range.get(); + const editableElement = cell.find(EDITABLE_SELECTOR); + if (editableElement.length > 0) { + range + .select(editableElement, true) + .shrinkToElementNode() + .shrinkToTextNode() + .collapse(false); + setTimeout(() => { + change.range.select(range); + }, 20); + editableElement.get()?.focus(); + this.prevMouseDownTd = cell; + this.selectCell(cell, cell); + } + } + + selectCellRange(cell: NodeInterface | Node) { + if (!isEngine(this.editor)) return; + const { change } = this.editor; + if (isNode(cell)) cell = $(cell); + const range = change.range.get(); + const editableElement = cell.find(EDITABLE_SELECTOR); + if (editableElement.length === 0) return; + + range.select(editableElement, true).shrinkToElementNode(); + const children = editableElement.children(); + const firstChildren = children.eq(0); + if (children.length === 1 && firstChildren?.first()?.name === 'br') { + range.collapse(false); + change.range.select(range); + editableElement.get()?.focus(); + } else { + change.range.select(range); + } + this.prevMouseDownTd = cell; + this.selectCell(cell, cell); + } + + onTdMouseDown = (event: MouseEvent | TouchEvent) => { + this.selectRange = undefined; + if (!event.target || !isEngine(this.editor)) return; + const { change } = this.editor; + const target = $(event.target); + const td = target.closest('td'); + if (td.length === 0) return; + const range = change.range.get(); + const [row, col] = this.getCellPoint(td); + const isSelection = !!td.attributes('table-cell-selection'); + //shift 多选 + if (this.isShift) { + let begin = { row: 0, col: 0 }; + if (this.prevMouseDownTd) { + const [row, col] = this.getCellPoint(this.prevMouseDownTd); + begin = { row, col }; + } else if (this.selectArea) { + begin = this.selectArea.begin; + if (this.tableModel) { + const tdModel = this.tableModel.table[begin.row][begin.col]; + if ( + !this.table.helper.isEmptyModelCol(tdModel) && + tdModel.element + ) + this.prevMouseDownTd = $(tdModel.element); + } + } + + this.select(begin, { row, col }); + return; + } else { + this.prevMouseDownTd = td; + if (event instanceof MouseEvent && event.button !== 2) + this.select({ row, col }, { row, col }); + } + //点击单元格空白处,聚焦内部编辑区域 + if ( + target.name === 'td' && + (isSelection || + !range.startNode.closest('td').equal(td) || + !range.endNode.closest('td').equal(td)) + ) { + if ( + event instanceof MouseEvent && + event.button === 2 && + !!target.attributes('table-cell-selection') + ) { + return; + } + event.preventDefault(); + this.focusCell(td); + } else if (target.name === 'td') { + event.preventDefault(); + } + // 右键不触发拖选 + if (event instanceof MouseEvent && event.button === 2) { + if (!!target.attributes('table-cell-selection')) { + event.preventDefault(); + } + return; + } + this.select({ row, col }, { row, col }); + this.dragging = { + trigger: { + element: td, + }, + }; + this.addDragEvent(); + }; + + addDragEvent() { + this.tableRoot?.addClass('drag-select'); + this.table.wrapper + ?.on(isMobile ? 'touchend' : 'mouseup', this.removeDragEvent) + .on(isMobile ? 'touchmove' : 'mousemove', this.onDragMove); + } + + removeDragEvent = () => { + this.tableRoot?.removeClass('drag-select'); + this.tableRoot?.removeClass('drag-selecting'); + this.table.wrapper + ?.off(isMobile ? 'touchend' : 'mouseup', this.removeDragEvent) + .off(isMobile ? 'touchmove' : 'mousemove', this.onDragMove); + this.dragging = undefined; + }; + + onDragMove = (event: MouseEvent | TouchEvent) => { + if (!this.dragging || !event.target) return; + const dragoverTd = $(event.target).closest('td'); + if ( + dragoverTd.length === 0 || + (this.prevOverTd && dragoverTd.equal(this.prevOverTd)) + ) + return; + this.prevOverTd = dragoverTd; + if (!this.dragging.trigger.element.equal(dragoverTd)) { + this.tableRoot?.addClass('drag-selecting'); + this.selectCell(this.dragging.trigger.element, dragoverTd); + } else { + this.tableRoot?.removeClass('drag-selecting'); + this.clearSelect(); + } + }; + + onShiftKeydown = (event: KeyboardEvent) => { + if (!event.target || !this.tableModel || !isEngine(this.editor)) return; + if (isHotkey('shift', event)) { + this.isShift = true; + } + }; + + onKeydown = (event: KeyboardEvent) => { + if (!event.target || !this.tableModel || !isEngine(this.editor)) return; + //获取单元格节点 + const td = $(event.target).closest('td'); + if (td.length === 0) { + return; + } + //获取单元格位置 + const [row, col] = this.getCellPoint(td); + if (row < 0 || col < 0) return; + + if (isHotkey('shift+left', event)) { + this.selectLeft(event, td); + } else if (isHotkey('shift+right', event)) { + this.selectRigth(event, td); + } else if (isHotkey('shift+up', event)) { + this.selectUp(event, td); + } else if (isHotkey('shift+down', event)) { + this.selectDown(event, td); + } + if (isHotkey('shift', event)) { + this.isShift = true; + } else { + if (this.selectRange) { + this.isShift = false; + this.selectRange = undefined; + } + if ( + isHotkey('tab', event) || + isHotkey('mod', event) || + isHotkey('opt', event) || + isHotkey('shift', event) || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.altKey + ) + return; + + // 等待删除键删除后再清除选择 + setTimeout(() => { + this.clearSelect(); + }, 50); + } + }; + + onShiftKeyup = (event: KeyboardEvent) => { + if (this.isShift === false && this.selectRange) { + this.selectRange = undefined; + this.clearSelect(); + } + this.isShift = false; + }; + + selectLeft(event: KeyboardEvent, td: NodeInterface) { + if (!isEngine(this.editor)) return; + //获取单元格位置 + const [row, col] = this.getCellPoint(td); + if (row < 0 || col < 0) return; + const count = this.selectArea?.count || 0; + //查看当前光标是否处于单元格可编辑节点的开始位置 + const range = this.editor.change.range.get(); + if (count === 0) { + if (this.selectRange && this.selectRange.type === 'right') { + if (range.endOffset !== this.selectRange.startOffset) { + return; + } + } + this.selectRange = { + type: 'left', + startOffset: range.startOffset, + endOffset: range.endOffset, + }; + const { startNode } = range; + //光标不在开始位置,不执行操作 + if (range.startOffset !== 0) { + return; + } + //如果还有上一级不执行操作 + if (startNode.prev()) return; + //循环父级节点,要求父级节点在其开始位置 + let currentParent = startNode.parent(); + while (currentParent && !currentParent.isEditable()) { + if (currentParent.prev()) return; + currentParent = currentParent.parent(); + } + } + //总行数和列数 + const begin = this.selectArea?.begin || { row, col }; + const end = this.selectArea?.end || { row, col }; + const isLeft = begin.col !== col; + let triggerCol = isLeft ? begin.col - 1 : end.col - 1; + if (triggerCol < 0) return; + event.preventDefault(); + if (triggerCol === col && count === 2) { + triggerCol = -1; + } + if (isLeft) { + this.select({ ...begin, col: triggerCol }, end); + } else { + this.select(begin, { ...end, col: triggerCol }); + } + } + + selectRigth(event: KeyboardEvent, td: NodeInterface) { + if (!isEngine(this.editor) || !this.tableModel) return; + event.stopPropagation(); + //获取单元格位置 + const [row, col] = this.getCellPoint(td); + if (row < 0 || col < 0) return; + const count = this.selectArea?.count || 0; + //当前没有选择任何单元格的时候判断光标位置 + const range = this.editor.change.range.get(); + if (count === 0) { + if (this.selectRange && this.selectRange.type === 'left') { + if (range.startOffset !== this.selectRange.endOffset) { + return; + } + } + this.selectRange = { + type: 'right', + startOffset: range.startOffset, + endOffset: range.endOffset, + }; + //查看当前光标是否处于单元格可编辑节点的最后位置 + const { endNode } = range; + //文本节点,光标不在最后位置,不执行操作 + if (endNode.isText() && range.endOffset !== endNode.text().length) { + return; + } + //其它节点,光标不在最后位置,不执行操作 + const children = endNode.children(); + if ( + endNode.isElement() && + range.endOffset !== children.length && + endNode.last()?.name !== 'br' + ) { + return; + } + //如果还有下一级不执行操作 + if (endNode.next()) return; + //循环父级节点,要求父级节点在其末尾 + let currentParent = endNode.parent(); + while (currentParent && !currentParent.isEditable()) { + if (currentParent.next()) return; + currentParent = currentParent.parent(); + } + } + + const { cols } = this.tableModel; + + const begin = this.selectArea?.begin || { row, col }; + const end = this.selectArea?.end || { row, col }; + const isLeft = begin.col !== col; + + let triggerCol = isLeft ? begin.col + 1 : end.col + 1; + if (triggerCol > cols - 1) return; + event.preventDefault(); + if (triggerCol === col && count === 2) { + triggerCol = -1; + } + if (isLeft) { + this.select({ ...begin, col: triggerCol }, end); + } else this.select(begin, { ...end, col: triggerCol }); + } + + selectUp(event: KeyboardEvent, td: NodeInterface) { + if (!isEngine(this.editor) || !this.tableModel) return; + //获取单元格位置 + const [row, col] = this.getCellPoint(td); + if (row < 0 || col < 0) return; + const count = this.selectArea?.count || 0; + //当前没有选择任何单元格的时候判断光标位置 + const range = this.editor.change.range.get(); + if (count === 0) { + if (this.selectRange && this.selectRange.type === 'bottom') { + if (range.endOffset !== this.selectRange.startOffset) { + return; + } + } + this.selectRange = { + type: 'top', + startOffset: range.startOffset, + endOffset: range.endOffset, + }; + //查看当前光标是否处于单元格可编辑节点的开始位置 + const rangeRect = range.getBoundingClientRect(); + const tdRect = td.find(EDITABLE_SELECTOR).getBoundingClientRect(); + if ( + rangeRect.width !== 0 && + rangeRect.height === 0 && + rangeRect.top - (tdRect?.top || 0) > 10 + ) + return; + } + + const begin = this.selectArea?.begin || { row, col }; + const end = this.selectArea?.end || { row, col }; + const isUp = begin.row !== row; + + let triggerRow = isUp ? begin.row - 1 : end.row - 1; + event.preventDefault(); + if (triggerRow < 0) return; + + if (triggerRow === row && count === 2) { + triggerRow = -1; + } + if (isUp) { + this.select({ ...begin, row: triggerRow }, end); + } else this.select(begin, { ...end, row: triggerRow }); + } + + selectDown(event: KeyboardEvent, td: NodeInterface) { + if (!isEngine(this.editor) || !this.tableModel) return; + //获取单元格位置 + const [row, col] = this.getCellPoint(td); + if (row < 0 || col < 0) return; + const count = this.selectArea?.count || 0; + //当前没有选择任何单元格的时候判断光标位置 + const range = this.editor.change.range.get(); + range.shrinkToElementNode(); + if (count === 0) { + if (this.selectRange && this.selectRange.type === 'top') { + if (range.startOffset !== this.selectRange.endOffset) { + return; + } + } + this.selectRange = { + type: 'bottom', + startOffset: range.startOffset, + endOffset: range.endOffset, + }; + //查看当前光标是否处于单元格可编辑节点的开始位置 + const rangeRect = range.getBoundingClientRect(); + const tdRect = td.find(EDITABLE_SELECTOR).getBoundingClientRect(); + if ( + rangeRect.width !== 0 && + rangeRect.height === 0 && + (tdRect?.bottom || 0) - rangeRect.bottom > 10 + ) + return; + } + + const { rows } = this.tableModel; + const begin = this.selectArea?.begin || { row, col }; + const end = this.selectArea?.end || { row, col }; + const isUp = begin.row !== row; + + let triggerRow = isUp ? begin.row + 1 : end.row + 1; + if (triggerRow > rows - 1) return; + event.preventDefault(); + if (triggerRow === row && count === 2) { + triggerRow = -1; + } + if (isUp) { + this.select({ ...begin, row: triggerRow }, end); + } else this.select(begin, { ...end, row: triggerRow }); + } + + getSelectionHtml() { + const { tableModel } = this; + const { helper } = this.table; + if (!tableModel || !this.tableRoot) return null; + const { begin, end } = this.getSelectArea(); + const colsEl = this.tableRoot.find('col'); + let cols = []; + let tableWidth = 0; + + for (let c = begin.col; c <= end.col; c++) { + const colElement = colsEl.eq(c)?.get(); + if (!colElement) continue; + cols.push('')); + tableWidth += parseInt(colElement.width); + } + + const colgroup = ''.concat(cols.join(''), ''); + let trHtml = []; + + for (let r = begin.row; r <= end.row; r++) { + let tdHtml = []; + + for (let _c2 = begin.col; _c2 <= end.col; _c2++) { + const tdModel = tableModel.table[r][_c2]; + let rowRemain = undefined; + let colRemain = undefined; + let tdClone = undefined; + + if (!helper.isEmptyModelCol(tdModel) && tdModel.element) { + tdClone = tdModel.element.cloneNode(true); + } + + if (!helper.isEmptyModelCol(tdModel) && tdModel.isMulti) { + // 合并单元格尾部被选区切断的情况,需要重新计算合并单元格的跨度 + rowRemain = + Math.min(r + tdModel.rowSpan - 1, end.row) - r + 1; + colRemain = + Math.min(_c2 + tdModel.colSpan - 1, end.col) - _c2 + 1; + } + + if (helper.isEmptyModelCol(tdModel)) { + const parentTd = + tableModel.table[tdModel.parent.row][ + tdModel.parent.col + ]; + // 选区中含有合并单元格的一部分时,需要补充这一部分的dom结构,这种情况只会出现在行列选择时 + // 列选择时,切断合并单元格后,第一个和父单元格同行,并在选取左测第一个列的位置,补充此单元格 + if ( + tdModel.parent.row === r && + tdModel.parent.col < begin.col && + _c2 === begin.col + ) { + const colCut = begin.col - tdModel.parent.col; + if (!helper.isEmptyModelCol(parentTd)) { + colRemain = Math.min( + parentTd.colSpan - colCut, + end.col - begin.col + 1, + ); + rowRemain = parentTd.rowSpan; + tdClone = parentTd.element?.cloneNode(true); + } + } + // 行选择时,切断合并单元格后,第一个和父单元格同列,并在选取上测第一个行的位置,补充此单元格 + if ( + tdModel.parent.col === _c2 && + tdModel.parent.row < begin.row && + r === begin.row + ) { + const rowCut = begin.row - tdModel.parent.row; + if (!helper.isEmptyModelCol(parentTd)) { + rowRemain = Math.min( + parentTd.rowSpan - rowCut, + end.row - begin.row + 1, + ); + colRemain = parentTd.colSpan; + tdClone = parentTd.element?.cloneNode(true); + } + } + } + + if (tdClone) { + tdClone = tdClone as HTMLElement; + if (rowRemain) + tdClone.setAttribute('rowspan', `${rowRemain}`); + if (colRemain) + tdClone.setAttribute('colspan', `${colRemain}`); + const editoableElement = tdClone.firstChild as HTMLElement; + if ( + editoableElement.classList.contains( + 'table-main-content', + ) + ) { + const copyNode = tdClone.cloneNode( + false, + ) as HTMLElement; + copyNode.innerHTML = editoableElement.innerHTML; + tdHtml.push(copyNode.outerHTML); + } else { + tdHtml.push(tdClone.outerHTML); + } + } + } + trHtml.push(''.concat(tdHtml.join(''), '')); + } + + return `${colgroup}${trHtml.join( + '', + )}
      `; + } + + hasMergeCell() { + const { table, tableModel } = this; + if (!tableModel) return false; + const { begin, end, count } = this.getSelectArea(); + if (count !== 1) return false; + const cell = tableModel.table[begin.row][begin.col]; + return !table.helper.isEmptyModelCol(cell) && cell.isMulti === true; + } + + isRowSelected() { + return !!this.selectArea && this.selectArea.allRow; + } + + isColSelected() { + return !!this.selectArea && this.selectArea.allCol; + } + + isTableSelected() { + return ( + !!this.selectArea && + this.selectArea.allCol && + this.selectArea.allRow + ); + } + + showHighlight(area: TableSelectionArea) { + const { helper } = this.table; + const { tableModel } = this; + if (!tableModel) return; + + const { begin, end, allCol, allRow } = area; + if (begin.row < 0 || begin.col < 0) return; + const fBeginRow = begin.row; + const fEndRow = end.row; + const fBeginCol = begin.col; + const fEndCol = end.col; + this.hideHighlight(); + const colsHeader = this.colsHeader?.find( + Template.COLS_HEADER_ITEM_CLASS, + ); + const rowsHeader = this.rowsHeader?.find( + Template.ROWS_HEADER_ITEM_CLASS, + ); + for (let row = fBeginRow; row <= fEndRow; row++) { + for (let col = fBeginCol; col <= fEndCol; col++) { + const cell = tableModel.table[row][col]; + if (this.table.helper.isEmptyModelCol(cell)) { + if (begin.row > cell.parent.row) + begin.row = cell.parent.row; + if (begin.col >= cell.parent.col) + begin.col = cell.parent.col; + const parent = + tableModel.table[cell.parent.row][cell.parent.col]; + if (!this.table.helper.isEmptyModelCol(parent)) { + if ( + parent.rowSpan > 1 && + end.row < parent.rowSpan - 1 + cell.parent.row + ) + end.row = parent.rowSpan - 1 + cell.parent.row; + if ( + parent.colSpan > 1 && + end.col < parent.colSpan - 1 + cell.parent.col + ) + end.col = parent.colSpan - 1 + cell.parent.col; + } + } else if (!this.table.helper.isEmptyModelCol(cell)) { + if (cell.rowSpan > 1 && end.row < cell.rowSpan - 1 + row) + end.row = cell.rowSpan - 1 + row; + if (cell.colSpan > 1 && end.col < cell.colSpan - 1 + col) + end.col = cell.colSpan - 1 + col; + } + } + } + + let height: number = 0; + let width: number = 0; + for (let r = begin.row; r <= end.row; r++) { + const cell = tableModel.table[r][begin.col]; + if (!helper.isEmptyModelCol(cell) && cell.element) { + height += cell.element.offsetHeight; + rowsHeader?.eq(r)?.addClass('active'); + } + } + + for (let c = begin.col; c <= end.col; c++) { + const cell = tableModel.table[begin.row][c]; + if (!helper.isEmptyModelCol(cell) && cell.element) { + width += cell.element.offsetWidth; + colsHeader?.eq(c)?.addClass('active'); + } + } + + if ( + end.row === tableModel.rows - 1 && + end.col === tableModel.cols - 1 + ) { + this.tableHeader?.addClass('active'); + } + + const firstCell = tableModel.table[begin.row][begin.col]; + let top = 0; + let left = 0; + if (!helper.isEmptyModelCol(firstCell) && firstCell.element) { + const viewport = this.tableRoot?.parent(); + const vRect = viewport?.getBoundingClientRect(); + const rect = firstCell.element.getBoundingClientRect(); + top += rect.top - (vRect?.top || 0) - 13; + left += rect.left - (vRect?.left || 0); + } + const sLeft = removeUnit( + this.table.wrapper?.find('.data-scrollbar')?.css('left') || '0', + ); + left += sLeft; + + const headerHeight = + this.colsHeader + ?.find(Template.COLS_HEADER_ITEM_CLASS) + .get()?.offsetHeight || 0; + top += headerHeight; + + if (height > 0 && width > 0) { + this.highlight?.css('width', `${width}px`); + this.highlight?.css('height', `${height}px`); + this.highlight?.css('top', `${top}px`); + this.highlight?.css('left', `${left}px`); + this.highlight?.show('block'); + this.table.wrapper?.addClass('data-table-highlight'); + if (allCol) { + this.table.wrapper?.addClass('data-table-highlight-row'); + } + if (allRow) { + this.table.wrapper?.addClass('data-table-highlight-col'); + } + if (allCol && allRow) { + this.table.wrapper?.addClass('data-table-highlight-all'); + } + } + } + + hideHighlight() { + this.highlight?.hide(); + this.colsHeader?.find('.active').removeClass('active'); + this.rowsHeader?.find('.active').removeClass('active'); + this.tableHeader?.removeClass('active'); + this.table.wrapper?.removeClass('data-table-highlight'); + this.table.wrapper?.removeClass('data-table-highlight-row'); + this.table.wrapper?.removeClass('data-table-highlight-col'); + this.table.wrapper?.removeClass('data-table-highlight-all'); + } + + destroy() { + this.unbindEvents(); + } +} + +export default TableSelection; diff --git a/plugins/table/src/component/template.ts b/plugins/table/src/component/template.ts new file mode 100644 index 00000000..a75c8704 --- /dev/null +++ b/plugins/table/src/component/template.ts @@ -0,0 +1,205 @@ +import { + $, + DATA_ELEMENT, + DATA_TRANSIENT_ATTRIBUTES, + EDITABLE, + UI, +} from '@aomao/engine'; +import { + TableValue, + TableMenu, + TemplateInterface, + TableInterface, +} from '../types'; + +const TABLE_WRAPPER_CLASS_NAME = 'table-wrapper'; +const TABLE_CLASS_NAME = 'data-table'; +const COLS_HEADER_CLASS_NAME = 'table-cols-header'; +const COLS_HEADER_ITEM_CLASS_NAME = 'table-cols-header-item'; +const COLS_HEADER_TRIGGER_CLASS_NAME = 'cols-trigger'; +const COLS_ADDITION_HEADER_CLASS_NAME = 'cols-addition-header'; +const ROWS_HEADER_CLASS_NAME = 'table-rows-header'; +const ROWS_HEADER_ITEM_CLASS_NAME = 'table-rows-header-item'; +const ROWS_HEADER_TRIGGER_CLASS_NAME = 'rows-trigger'; +const HEADER_CLASS_NAME = 'table-header'; +const MENUBAR_CLASS_NAME = 'table-menubar'; +const MENUBAR_ITEM_CLASS_NAME = 'table-menubar-item'; +const MENUBAR_ITEM_INPUT_CALSS_NAME = 'table-menubar-item-input'; +const VIEWPORT = 'table-viewport'; +const VIEWPORT_READER = 'data-table-reader'; +const PLACEHOLDER_CLASS_NAME = 'table-placeholder'; +const MULTI_ADDITION_CLASS_NAME = 'multi-addition'; +const TABLE_HIGHLIGHT = 'table-highlight'; +const ROW_DELETE_BUTTON_CLASS_NAME = 'table-row-delete-button'; +const COL_DELETE_BUTTON_CLASS_NAME = 'table-col-delete-button'; +const ROW_ADD_BUTTON_CLASS_NAME = 'table-row-add-button'; +const ROW_ADD_BUTTON_SPLIT_CLASS_NAME = 'table-row-add-split-button'; +const COL_ADD_BUTTON_CLASS_NAME = 'table-col-add-button'; +const COL_ADD_BUTTON_SPLIT_CLASS_NAME = 'table-col-add-split-button'; +const TABLE_TD_CONTENT_CLASS_NAME = 'table-main-content'; +const TABLE_TD_BG_CLASS_NAME = 'table-main-bg'; + +class Template implements TemplateInterface { + static readonly TABLE_WRAPPER_CLASS = `.${TABLE_WRAPPER_CLASS_NAME}`; + static readonly TABLE_CLASS = `.${TABLE_CLASS_NAME}`; + static readonly COLS_HEADER_CLASS = `.${COLS_HEADER_CLASS_NAME}`; + static readonly COLS_HEADER_ITEM_CLASS = `.${COLS_HEADER_ITEM_CLASS_NAME}`; + static readonly COLS_HEADER_TRIGGER_CLASS = `.${COLS_HEADER_TRIGGER_CLASS_NAME}`; + static readonly COLS_ADDITION_HEADER_CLASS = `.${COLS_ADDITION_HEADER_CLASS_NAME}`; + static readonly ROWS_HEADER_CLASS = `.${ROWS_HEADER_CLASS_NAME}`; + static readonly ROWS_HEADER_ITEM_CLASS = `.${ROWS_HEADER_ITEM_CLASS_NAME}`; + static readonly ROWS_HEADER_TRIGGER_CLASS = `.${ROWS_HEADER_TRIGGER_CLASS_NAME}`; + static readonly HEADER_CLASS = `.${HEADER_CLASS_NAME}`; + static readonly MENUBAR_CLASS = `.${MENUBAR_CLASS_NAME}`; + static readonly MENUBAR_ITEM_CLASS = `.${MENUBAR_ITEM_CLASS_NAME}`; + static readonly MENUBAR_ITEM_INPUT_CALSS = `.${MENUBAR_ITEM_INPUT_CALSS_NAME}`; + static readonly VIEWPORT = `.${VIEWPORT}`; + static readonly VIEWPORT_READER = `.${VIEWPORT_READER}`; + static readonly PLACEHOLDER_CLASS = `.${PLACEHOLDER_CLASS_NAME}`; + static readonly MULTI_ADDITION_CLASS = `.${MULTI_ADDITION_CLASS_NAME}`; + static readonly TABLE_HIGHLIGHT_CLASS = `.${TABLE_HIGHLIGHT}`; + static readonly ROW_DELETE_BUTTON_CLASS = `.${ROW_DELETE_BUTTON_CLASS_NAME}`; + static readonly COL_DELETE_BUTTON_CLASS = `.${COL_DELETE_BUTTON_CLASS_NAME}`; + static readonly ROW_ADD_BUTTON_CLASS = `.${ROW_ADD_BUTTON_CLASS_NAME}`; + static readonly COL_ADD_BUTTON_CLASS = `.${COL_ADD_BUTTON_CLASS_NAME}`; + static readonly ROW_ADD_BUTTON_SPLIT_CLASS = `.${ROW_ADD_BUTTON_SPLIT_CLASS_NAME}`; + static readonly COL_ADD_BUTTON_SPLIT_CLASS = `.${COL_ADD_BUTTON_SPLIT_CLASS_NAME}`; + static readonly TABLE_TD_CONTENT_CLASS = `.${TABLE_TD_CONTENT_CLASS_NAME}`; + static readonly TABLE_TD_BG_CLASS = `.${TABLE_TD_BG_CLASS_NAME}`; + static readonly CellBG = `
      `; + static isReadonly = false; + static get EmptyCell() { + return `


      ${this.CellBG}`; + } + private table: TableInterface; + + constructor(table: TableInterface) { + this.table = table; + } + + renderRowsHeader(rows: number) { + return ( + `
      ` + + `
      ` + + `
      ` + + `
      +
      + + +
      +
      +
      `.repeat(rows) + + ` +
      ` + ); + } + + renderColsHeader(cols: number) { + return ( + `
      ` + + `
      ` + + `
      ` + + `
      +
      + +

      +
      +
      +
      `.repeat(cols) + + ` +
      ` + ); + } + + /** + * 用于Card渲染 + * @param {object} value 参数 + * @param {number} value.rows 行数 + * @param {number} value.cols 列数 + * @param {string} value.html html 字符串 + * @return {string} 返回 html 字符串 + */ + htmlEdit( + { rows, cols, html, noBorder }: TableValue, + menus: TableMenu, + ): string { + cols = cols === -Infinity ? 1 : cols; + rows = rows === -Infinity ? 1 : rows; + cols = cols === Infinity ? 10 : cols; + rows = rows === Infinity ? 10 : rows; + const tds = + `${Template.EmptyCell}`.repeat( + cols, + ); + const trs = `${tds}`.repeat(rows); + const col = ``.repeat(cols); + const colgroup = `${col}`; + + const tableHighlight = `
      `; + + let tableHeader = `
      `; + + const placeholder = `
      `; + let menuBar = menus.map((menu) => { + if (menu.split) { + return '
      '; + } + let menuContent = menu.text; + switch (menu.action) { + case 'insertColLeft': + case 'insertColRight': + case 'insertRowUp': + case 'insertRowDown': + menuContent = + menuContent?.replace( + '$data', + ``, + ) || ''; + break; + } + return `
      + ${menuContent}
      `; + }); + menuBar = [ + `
      ${menuBar.join( + '', + )}
      `, + ]; + + if (html) { + const hasColGroup = html.indexOf(' -1; + + if (!hasColGroup) { + html = html.replace(/^(]+>)/, function (match) { + return match + colgroup; + }); + } + const normalTable = this.table.helper.normalize($(html)); + const trs = normalTable.find('tr'); + rows = trs.length; + html = normalTable.get()!.outerHTML; + } + + const table = + html || + `${colgroup}${trs}
      `; + + return `
      ${tableHeader}
      ${this.renderColsHeader( + cols, + )}${table}${placeholder}${tableHighlight}
      ${this.renderRowsHeader( + rows, + )}${menuBar}
      `; + } + + htmlView({ html, noBorder }: TableValue) { + return `
      ${html}
      `; + } +} + +export default Template; diff --git a/plugins/table/src/component/toolbar/color.ts b/plugins/table/src/component/toolbar/color.ts new file mode 100644 index 00000000..92932c33 --- /dev/null +++ b/plugins/table/src/component/toolbar/color.ts @@ -0,0 +1,236 @@ +import { + $, + DATA_ELEMENT, + NodeInterface, + UI, + TRIGGER_CARD_ID, + isMobile, + EditorInterface, + Position, +} from '@aomao/engine'; +import tinycolor2, { ColorInput } from 'tinycolor2'; +import Palette from './palette'; + +export type Options = { + colors: Array< + Array<{ + color: string; + border?: string; + }> + >; + defaultColor?: string; + onChange?: (color: string) => void; + onDestroy?: () => void; +}; + +class Color { + #editor: EditorInterface; + #options: Options; + #color: string; + #button: NodeInterface; + #cardId: string; + #container?: NodeInterface; + #position?: Position; + + constructor(editor: EditorInterface, cardId: string, options: Options) { + this.#editor = editor; + this.#cardId = cardId; + this.#options = options; + this.#position = new Position(this.#editor); + this.#color = options.defaultColor || 'transparent'; + this.#button = $(`
      + + +
      `); + this.#button + .find('.table-color-dropdown-arrow') + .on('mousedown', (event: MouseEvent) => { + event.preventDefault(); + if ( + this.#container !== undefined && + this.#container.length > 0 + ) { + this.remove(); + } else this.render(); + }); + this.#button + .find('.table-color-dropdown-button-text') + .on('mousedown', (event: MouseEvent) => { + event.preventDefault(); + const { onChange } = this.#options; + if (onChange) onChange(this.#color!); + }); + } + + getButton() { + return this.#button; + } + + select(color: string) { + const stroke = Palette.getStroke(color); + const rectElement = this.#button.find('rect'); + rectElement.attributes('stroke', stroke); + rectElement.attributes('fill', color); + } + + change(color: string) { + this.#color = color; + this.select(color); + const { onChange } = this.#options; + if (onChange) onChange(color); + } + + toState(color: ColorInput, oldHue?: number) { + const tinyColor = color['hex'] + ? tinycolor2(color['hex']) + : tinycolor2(color); + const hsl = tinyColor.toHsl(); + const hsv = tinyColor.toHsv(); + const rgb = tinyColor.toRgb(); + const hex = tinyColor.toHex(); + + if (hsl.s === 0) { + hsl.h = oldHue || 0; + hsv.h = oldHue || 0; + } + + const transparent = hex === '000000' && rgb.a === 0; + return { + hsl: hsl, + hex: transparent ? 'transparent' : '#'.concat(hex), + rgb: rgb, + hsv: hsv, + oldHue: color['h'] || oldHue || hsl.h, + source: color['source'], + }; + } + + render() { + this.#container = $( + `
      `, + ); + + const colorPanle = $(`
      `); + const { colors } = this.#options; + + const getItem = ( + color: { color: string; border?: string }, + display?: boolean, + ) => { + //接近白色的颜色,需要添加一个边框。不然看不见 + const state = this.toState(color.color || '#FFFFFF'); + const needBorder = + ['#ffffff', '#fafafa', 'transparent'].indexOf(state.hex) >= 0; + const item = $(``); + item.on('mousedown', (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + colorPanle.find('svg').each((svg) => { + (svg as SVGAElement).style.display = 'none'; + }); + if (display !== false) item.find('svg').css('display', 'block'); + this.change(color.color); + }); + return item; + }; + + const defaultItem = $( + `
      `, + ); + const item = getItem({ color: 'transparent' }, false); + defaultItem.append(item); + defaultItem.on('mousedown', (event: MouseEvent) => { + event.preventDefault(); + colorPanle.find('svg').each((svg) => { + (svg as SVGAElement).style.display = 'none'; + }); + this.change('transparent'); + }); + const nofillText = this.#editor.language.get( + 'table', + 'color', + 'nonFillText', + ); + defaultItem.append( + $( + `${nofillText}`, + ), + ); + this.#container.append(defaultItem); + + colors.forEach((group) => { + const groupElement = $( + `
      `, + ); + group.forEach((color) => { + const item = getItem(color); + groupElement.append(item); + }); + colorPanle.append(groupElement); + }); + this.#container.append(colorPanle); + this.#position?.bind(this.#container, this.#button); + document.addEventListener('mousedown', this.windowClick, true); + } + + windowClick = (event: MouseEvent) => { + const { target } = event; + if (!target) return; + if ( + $(target).closest( + '.data-table-color-tool,.table-color-dropdown-arrow', + ).length === 0 + ) + this.remove(); + }; + + remove() { + this.#container?.remove(); + this.#position?.destroy(); + document.removeEventListener('mousedown', this.windowClick, true); + this.#container = undefined; + } + + destroy() { + this.remove(); + const { onDestroy } = this.#options; + if (onDestroy) onDestroy(); + } +} + +export default Color; diff --git a/plugins/table/src/component/toolbar/index.css b/plugins/table/src/component/toolbar/index.css new file mode 100644 index 00000000..71e67438 --- /dev/null +++ b/plugins/table/src/component/toolbar/index.css @@ -0,0 +1,162 @@ +.table-color-dropdown-trigger { + display: flex; +} + +.table-color-dropdown-trigger:hover { + background: transparent !important; +} + +.table-color-dropdown-trigger .table-color-dropdown-button-text, .table-color-dropdown-trigger .table-color-dropdown-arrow { + display: inline-block; + width: auto; + margin: 0; + text-align: center; + background-color: transparent; + border: 1px solid transparent; + border-radius: 3px 3px; + font-size: 16px; + cursor: pointer; + color: #595959; + outline: none; + margin-right: 0; + min-width: 26px; + border-radius: 3px 0 0 3px; + padding: 0 4px; +} + +.table-color-dropdown-trigger:hover .table-color-dropdown-button-text, .table-color-dropdown-trigger:hover .table-color-dropdown-arrow +{ + border: 1px solid #e8e8e8; +} + +.table-color-dropdown-trigger .table-color-dropdown-button-text:hover, .table-color-dropdown-trigger .table-color-dropdown-arrow:hover { + background-color: #f5f5f5; +} + +.table-color-dropdown-trigger .table-color-dropdown-arrow { + margin-left: -1px; + min-width: 17px; + text-align: center; + border-radius: 0 3px 3px 0; +} + +.table-color-dropdown-trigger .table-color-dropdown-arrow .table-color-dropdown-empty { + display: inline-block; +} + +.table-color-dropdown-trigger .table-color-dropdown-arrow .data-icon-arrow { + position: absolute; + right: 5px; + top: 12px; + width: 8px; + height: 8px; + background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4NCjxzdmcgd2lkdGg9IjhweCIgaGVpZ2h0PSI1cHgiIHZpZXdCb3g9IjAgMCA4IDUiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+DQogICAgPCEtLSBHZW5lcmF0b3I6IFNrZXRjaCA1Mi41ICg2NzQ2OSkgLSBodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2ggLS0+DQogICAgPHRpdGxlPkdyb3VwIENvcHkgNjwvdGl0bGU+DQogICAgPGRlc2M+Q3JlYXRlZCB3aXRoIFNrZXRjaC48L2Rlc2M+DQogICAgPGcgaWQ9IlN5bWJvbHMiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG9wYWNpdHk9IjAuNDUiPg0KICAgICAgICA8ZyBpZD0idG9vbGJhciIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTMyOC4wMDAwMDAsIC0xOC4wMDAwMDApIj4NCiAgICAgICAgICAgIDxnIGlkPSJwYXJhZ3JhcGgtc3R5bGUiPg0KICAgICAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDIyNi4wMDAwMDAsIDQuMDAwMDAwKSI+DQogICAgICAgICAgICAgICAgICAgIDxnIGlkPSJHcm91cC1Db3B5LTYiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDEwMi4wMDAwMDAsIDEyLjAwMDAwMCkiPg0KICAgICAgICAgICAgICAgICAgICAgICAgPHJlY3QgaWQ9IlJlY3RhbmdsZSIgeD0iMCIgeT0iMCIgd2lkdGg9IjgiIGhlaWdodD0iOCI+PC9yZWN0Pg0KICAgICAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTAuNTk2MDkzNzUsMi41NTcwMzEyNSBMMy43NDUzMTI1LDYuMzc4MTI1IEMzLjg3NzM0Mzc1LDYuNTI1NzgxMjUgNC4xMDg1OTM3NSw2LjUyNTc4MTI1IDQuMjQwNjI1LDYuMzc4MTI1IEw3LjQwNTQ2ODc1LDIuNTU3MDMxMjUgQzcuNTk2MDkzNzUsMi4zNDI5Njg3NSA3LjQ0NDUzMTI1LDIuMDAzOTA2MjUgNy4xNTc4MTI1LDIuMDAzOTA2MjUgTDAuODQ0NTMxMjUsMi4wMDM5MDYyNSBDMC41NTcwMzEyNSwyLjAwMzkwNjI1IDAuNDA0Njg3NSwyLjM0Mjk2ODc1IDAuNTk2MDkzNzUsMi41NTcwMzEyNSBaIiBpZD0iU2hhcGUiIGZpbGw9IiMwMDAwMDAiIGZpbGwtcnVsZT0ibm9uemVybyI+PC9wYXRoPg0KICAgICAgICAgICAgICAgICAgICA8L2c+DQogICAgICAgICAgICAgICAgPC9nPg0KICAgICAgICAgICAgPC9nPg0KICAgICAgICA8L2c+DQogICAgPC9nPg0KPC9zdmc+); + background-repeat: no-repeat; + transition: all 0.25s cubic-bezier(0.3, 1.2, 0.2, 1); +} + +.data-table-color-tool { + outline: none; + width: auto; + border-radius: 3px 3px; + position: absolute; + border: 1px solid #e8e8e8; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + z-index: 124; + text-indent: 0; + top:0; + padding: 8px 0; + background: #fff; +} + +.data-table-color-tool-mobile { + width: calc(100vw - 20px); +} + +.data-table-color-tool .data-table-color-tool-panle { + position: relative; + text-align: left; + text-indent: 0; + width: 100%; + height: auto; + margin-top: 8px; + padding: 0 8px; +} + +.data-table-color-tool .data-table-color-tool-group +{ + display: flex; +} + +.data-table-color-tool .data-table-color-tool-group > span { + width: 24px; + height: 24px; + display: inline-block; + cursor: pointer; + background-color: rgb(255, 255, 255); + padding: 2px; + border-radius: 3px; + border-width: 1px; + border-style: solid; + border-color: transparent; + border-image: initial; + flex: 0 0 auto; + position: relative; +} + +.data-table-color-tool span.data-table-color-tool-border > span { + border: 1px solid #e8e8e8 !important; +} + +.data-table-color-tool .data-table-color-tool-group > span > span +{ + position: relative; + width: 18px; + height: 18px; + display: block; + border-radius: 2px; + border-width: 1px; + border-style: solid; + border-color: transparent; + border-image: initial; +} + +.data-table-color-tool .data-table-color-tool-group > span > span > svg { + position: absolute; + top: -1px; + left: 1px; + width: 12px; + height: 12px; +} + +.data-table-color-tool .data-table-color-tool-default { + display: flex; + align-items: center; + margin: 2px 0 8px; + border-radius: 2px; + cursor: pointer; + padding: 2px 8px; +} + +.data-table-color-tool .data-table-color-tool-default:hover { + background: #f5f5f5; +} + +.data-table-color-tool .data-table-color-tool-default > span:first-child::after { + content: ""; + display: block; + position: absolute; + top: 10px; + left: 0px; + width: 22px; + height: 0; + border-bottom: 2px solid #ff5151; + transform: rotate(45deg); +} + +.data-table-color-tool .data-table-color-tool-default .data-table-color-tool-default-text { + width: auto; + margin-left: 8px; + height: auto; + background: transparent; +} \ No newline at end of file diff --git a/plugins/table/src/component/toolbar/index.ts b/plugins/table/src/component/toolbar/index.ts new file mode 100644 index 00000000..a67a2523 --- /dev/null +++ b/plugins/table/src/component/toolbar/index.ts @@ -0,0 +1,5 @@ +import ColorTool from './color'; +import Palette from './palette'; +import './index.css'; + +export { ColorTool, Palette }; diff --git a/plugins/table/src/component/toolbar/palette.ts b/plugins/table/src/component/toolbar/palette.ts new file mode 100644 index 00000000..165e9340 --- /dev/null +++ b/plugins/table/src/component/toolbar/palette.ts @@ -0,0 +1,140 @@ +class Palette { + static colors: Array>; + static _map: { [k: string]: { x: number; y: number } }; + /** + * 获取描边颜色 + * 默认为当前 color,浅色不明显区域:第 3 组、第 4 组的第 3、4 个用第 5 组的颜色描边 + * + * @param {string} color 颜色 + * @return {string} 描边颜色 + */ + static getStroke: (color: string) => string; + static getColors: () => Array>; +} + +Palette.colors = [ + [ + '#000000', + '#262626', + '#595959', + '#8C8C8C', + '#BFBFBF', + '#D9D9D9', + '#E9E9E9', + '#F5F5F5', + '#FAFAFA', + '#FFFFFF', + ], + [ + '#F5222D', + '#FA541C', + '#FA8C16', + '#FADB14', + '#52C41A', + '#13C2C2', + '#1890FF', + '#2F54EB', + '#722ED1', + '#EB2F96', + ], + [ + '#FFE8E6', + '#FFECE0', + '#FFEFD1', + '#FCFCCA', + '#E4F7D2', + '#D3F5F0', + '#D4EEFC', + '#DEE8FC', + '#EFE1FA', + '#FAE1EB', + ], + [ + '#FFA39E', + '#FFBB96', + '#FFD591', + '#FFFB8F', + '#B7EB8F', + '#87E8DE', + '#91D5FF', + '#ADC6FF', + '#D3ADF7', + '#FFADD2', + ], + [ + '#FF4D4F', + '#FF7A45', + '#FFA940', + '#FFEC3D', + '#73D13D', + '#36CFC9', + '#40A9FF', + '#597EF7', + '#9254DE', + '#F759AB', + ], + [ + '#CF1322', + '#D4380D', + '#D46B08', + '#D4B106', + '#389E0D', + '#08979C', + '#096DD9', + '#1D39C4', + '#531DAB', + '#C41D7F', + ], + [ + '#820014', + '#871400', + '#873800', + '#614700', + '#135200', + '#00474F', + '#003A8C', + '#061178', + '#22075E', + '#780650', + ], +]; + +Palette._map = (function () { + let map = {}; + const colors = Palette.colors; + for (let i = 0, l1 = colors.length; i < l1; i++) { + const group = colors[i]; + for (let k = 0, l2 = group.length; k < l2; k++) { + const color = colors[i][k]; + map[color] = { + y: i, + x: k, + }; + } + } + return map; +})(); + +/** + * 获取描边颜色 + * 默认为当前 color,浅色不明显区域:第 3 组、第 4 组的第 3、4 个用第 5 组的颜色描边 + * + * @param {string} color 颜色 + * @return {string} 描边颜色 + */ +Palette.getStroke = function (color: string): string { + const pos = Palette._map[color]; + if (!pos) return color; + + if (pos.y === 2 || (pos.y === 3 && pos.x > 2 && pos.x < 5)) { + return this.colors[4][pos.x]; + } + + return color; +}; + +Palette.getColors = function () { + return this.colors; +}; + +export default Palette; diff --git a/plugins/table/src/index.css b/plugins/table/src/index.css new file mode 100644 index 00000000..b43be344 --- /dev/null +++ b/plugins/table/src/index.css @@ -0,0 +1,722 @@ +div[dnd-trigger-key="table"], div[toolbar-trigger-key="table"] { + margin-left: -13px; + margin-top: -13px; +} + +.data-table { + border: none; + position: relative; + z-index: 1; + table-layout: fixed; + white-space: pre-wrap; + width: 100%; + border-collapse: collapse; + background-color: #ffffff; +} + +div[data-card-key="table"].card-selected .data-table, div[data-card-key="table"].card-selected-other .data-table { + background: transparent +} + +.am-engine-mobile div[data-card-key="table"].card-activated { + margin-left: 20px; +} + +.data-table tr,.data-table td { + position: relative; +} + +.data-table tr { + height: 33px; +} + +.data-table tr td { + border: none; + vertical-align: top; + cursor: text; +} + +.data-table tr td[table-cell-selection]:after { + content: ' '; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(180, 213, 254, 0.5); + pointer-events: none; + z-index: 2; +} + +.data-table.drag-selecting, .data-table tr td[table-cell-selection] { + caret-color:transparent +} + +.data-table tr td[table-cell-selection] ::selection{ + background: transparent !important; +} + +.table-wrapper { + position: relative; + cursor: default; +} + +.table-wrapper.active { + margin-top: -42px; + width: 100%; +} + +.table-wrapper.scrollbar-show { + margin-bottom: -8px; +} + +.table-wrapper.data-table-highlight tr td[table-cell-selection]:after { + background: transparent; +} + +.table-wrapper .table-header { + position: absolute; + visibility: hidden; + left: -13px; + top: 28px; + width: 14px; + height: 14px; + cursor: pointer; + z-index: 2; + background-color: #ffffff; +} + +.table-wrapper .table-header .table-header-item { + border: 1px solid #dfdfdf; + background-color: #f2f3f5; + border-top-left-radius: 50%; + width: 100%; + height: 100%; +} + +.table-wrapper .table-header:hover { + background-color: #ffffff; +} + +.table-wrapper.data-table-highlight-all .table-header { + background: rgba(255, 77, 79, 0.4) !important; + border-color: rgba(255, 77, 79, 0.4) !important; +} + +.table-wrapper .table-header-item:hover{ + background-color: #e2e4e6; +} + +.table-wrapper.active .table-header { + visibility:visible +} + +.table-wrapper .table-header.selected .table-header-item { + background: #4daaff; +} + +.table-wrapper .table-cols-header { + position: relative; + height: 14px; + display: none; + width: 100%; + cursor: default; + margin-bottom: -1px; + z-index: 1; +} + +.table-wrapper.active .table-cols-header { + display: flex; +} + +.table-wrapper .table-cols-header .table-cols-header-item { + position: relative; + height: 14px; + width: auto; + border: 1px solid #dfdfdf; + border-bottom: 0 none; + overflow: visible; + background: #f2f3f5; + cursor: pointer; + border-right: 0 none; +} + +.table-wrapper .table-cols-header .table-cols-header-item:hover{ + background-color: #e2e4e6; +} + +.table-wrapper .table-cols-header .table-cols-header-item:first-child { + border-top-left-radius: 2px; + border-bottom-left-radius: 2px; +} +.table-wrapper .table-cols-header .table-cols-header-item:last-child { + border-top-right-radius: 2px; + border-bottom-right-radius: 2px; + border-right: 1px solid #dfdfdf; +} +.table-wrapper.data-table-highlight-col .table-cols-header .table-cols-header-item.active, .table-wrapper.data-table-highlight-all .table-cols-header .table-cols-header-item { + background: rgba(255, 77, 79, 0.4) !important; +} +.table-wrapper .table-cols-header .table-cols-header-item.selected { + background: #fff; + z-index: 1; + border-radius: 0; + height: 14px; + border-bottom: 0; + cursor: move; +} +.table-wrapper .table-cols-header .table-cols-header-item.selected .col-dragger { + display: block; + position: absolute; + left: -1px; + top: -1px; + right: -1px; + bottom: -1px; + background: #4daaff; + border-radius: 0; + z-index: 1; +} + +.table-wrapper.data-table-highlight-col .table-cols-header .table-cols-header-item .col-dragger,.table-wrapper.data-table-highlight-all .table-cols-header .table-cols-header-item .col-dragger { + background: transparent !important; +} +.table-wrapper .table-cols-header .table-cols-header-item.selected .col-dragger .drag-info { + display: none; +} +.table-wrapper .table-cols-header .table-cols-header-item.no-dragger .col-dragger .data-icon { + display: none; +} +.table-wrapper .table-cols-header .table-cols-header-item.dragging .col-dragger { + height: 50px; +} +.table-wrapper .table-cols-header .table-cols-header-item.dragging .col-dragger .drag-info { + display: block; + color: #fff; +} +.table-wrapper .table-cols-header .table-cols-header-item .cols-trigger { + position: absolute; + right: -1px; + top: -1px; + width: 2px; + height: 14px; + z-index: 10; + cursor: col-resize; +} + +.am-engine-mobile .table-wrapper .table-cols-header .table-cols-header-item .cols-trigger { + right: -3px; + width: 6px; +} + +.am-engine:not(.am-engine-mobile) .table-wrapper .table-cols-header .table-cols-header-item .cols-trigger:hover { + background: #0589f3; +} + +.am-engine:not(.am-engine-mobile) .table-wrapper .table-cols-header .table-cols-header-item .cols-trigger.dragging { + width: 2px; + background: #0589f3; + right: -1px; +} + +.table-wrapper .table-cols-header .table-cols-header-item .col-dragger { + text-align: center; + display: none; +} +.table-wrapper .table-cols-header .table-cols-header-item .col-dragger .data-icon-drag { + font-size: 10px; + color: #fff; + position: relative; + top: -6px; +} + +.table-wrapper .table-cols-header .table-cols-header-item .col-dragger .data-icon-drag::before { + transform: rotate(90deg); + display: inline-block; +} + +.table-wrapper.data-table-highlight-col .table-cols-header .table-cols-header-item .col-dragger .data-icon-drag,.table-wrapper.data-table-highlight-all .table-cols-header .table-cols-header-item .col-dragger .data-icon-drag{ + display: none; +} +.table-wrapper .table-cols-header.dragging .table-cols-header-item .cols-trigger { + display: none; +} +.table-wrapper .table-cols-header.resize .table-cols-header-item { + cursor: col-resize; +} +.table-wrapper .table-rows-header { + position: absolute; + left: -13px; + top: 41px; + width: 14px; + z-index: 1; + border-right: 0; + visibility: hidden; +} + +.table-wrapper.active .table-rows-header { + visibility: visible; +} + +.table-wrapper .table-rows-header .table-rows-header-item { + position: relative; + width: 100%; + border: 1px solid #dfdfdf; + border-bottom: 0; + background: #f2f3f5; + cursor: pointer; +} + +.table-wrapper .table-rows-header .table-rows-header-item:hover +{ + background-color: #e2e4e6; +} + +.table-wrapper .table-rows-header .table-rows-header-item:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; +} + +.table-wrapper .table-rows-header .table-rows-header-item:last-child{ + border-bottom: 1px solid #dfdfdf; +} + +.table-wrapper.data-table-highlight-row .table-rows-header .table-rows-header-item.active,.table-wrapper.data-table-highlight-all .table-rows-header .table-rows-header-item { + background: rgba(255, 77, 79, 0.4) !important; +} + +.table-wrapper .table-rows-header .table-rows-header-item.selected { + width: 14px; + background: #fff; + cursor: move; +} +.table-wrapper .table-rows-header .table-rows-header-item.selected .row-dragger { + display: flex; + position: absolute; + align-items: center; + text-align: left; + white-space: nowrap; + content: ' '; + left: -1px; + top: -1px; + bottom: -1px; + right: -1px; + background: #4daaff; + border-radius: 0; + z-index: 1; +} +.table-wrapper.data-table-highlight-row .table-rows-header .table-rows-header-item .row-dragger,.table-wrapper.data-table-highlight-all .table-rows-header .table-rows-header-item .row-dragger{ + background: transparent !important; +} +.table-wrapper .table-rows-header .table-rows-header-item.selected .row-dragger .drag-info { + display: none; +} +.table-wrapper .table-rows-header .table-rows-header-item.no-dragger .row-dragger .data-icon { + display: none; +} +.table-wrapper .table-rows-header .table-rows-header-item.dragging .row-dragger { + width: 150px; +} +.table-wrapper .table-rows-header .table-rows-header-item.dragging .row-dragger .drag-info { + margin-left: 5px; + display: flex; + padding: 10px; + color: #fff; +} +.table-wrapper .table-rows-header .table-rows-header-item .rows-trigger { + position: absolute; + bottom: -1px; + height: 2px; + width: 14px; + left: -1px; + z-index: 10; + cursor: row-resize; +} + +.am-engine-mobile .table-wrapper .table-rows-header .table-rows-header-item .rows-trigger { + bottom: -3px; + height: 6px; +} + +.am-engine:not(.am-engine-mobile) .table-wrapper .table-rows-header .table-rows-header-item .rows-trigger:hover { + background: #0589f3; +} + +.am-engine:not(.am-engine-mobile) .table-wrapper .table-rows-header .table-rows-header-item .rows-trigger.dragging { + height: 2px; + background: #0589f3; + bottom: -1px; +} + +.table-wrapper .table-rows-header .table-rows-header-item .row-dragger { + display: none; +} +.table-wrapper .table-rows-header .table-rows-header-item .row-dragger .data-icon-drag { + font-size: 10px; + color: #fff; + margin-left: 1px; +} + +.table-wrapper.data-table-highlight-row .table-rows-header .table-rows-header-item .row-dragger .data-icon-drag,.table-wrapper.data-table-highlight-all .table-rows-header .table-rows-header-item .row-dragger .data-icon-drag{ + display: none; +} + +.table-wrapper .table-rows-header .table-rows-header-item .row-dragger .drag-info { + height: 100%; + display: flex; + align-items: center; +} +.table-wrapper .table-rows-header.dragging .table-rows-header-item .rows-trigger { + display: none; +} +.table-wrapper .table-rows-header.resize .table-rows-header-item { + cursor: row-resize; +} + +.table-wrapper .table-viewport { + position: relative; + overflow: hidden; + overflow-y: hidden; + cursor: text; +} + +.table-wrapper.active .table-viewport{ + padding-top: 28px; + padding-left: 13px; + margin-left: -13px; +} + +.table-wrapper .table-viewport .scrollbar-shadow-left { + top: 0; + bottom: 8px; +} + +.table-wrapper.active .table-viewport .scrollbar-shadow-left { + top: 28px; + margin-left: 14px; +} + +.table-wrapper .table-viewport .scrollbar-shadow-right { + top: 0; + bottom: 8px; +} + +.table-wrapper.active .table-viewport .scrollbar-shadow-right { + top: 28px; +} + +.table-wrapper .table-placeholder { + position: absolute; + border: 1px solid #008dff; + background: #008dff; + display: none; + z-index: 126; +} +.table-wrapper .table-menubar { + position: absolute; + display: none; + padding: 4px 0; + border-radius: 4px; + border: 1px solid #e9e9e9; + box-shadow: 0px 1px 4px 0px rgba(0, 0, 0, 0.08); + background: #fff; + /* \u4ee3\u7801\u5757\u7684 codemirror \u9ed8\u8ba4\u7ed9\u6bcf\u884c\u4ee3\u7801\u7684 pre \u8bbe\u7f6e\u4e86 z-index 2, \u5982\u679c\u8868\u683c\u5728\u4ee3\u7801\u5757\u524d\u9762\uff0c\u9020\u6210\u53f3\u952e\u83dc\u5355\u65e0\u6cd5\u70b9\u51fb */ + z-index: 10; + min-width: 200px; +} +.table-wrapper .table-menubar .table-menubar-item { + padding: 6px 16px; + cursor: default; +} +.table-wrapper .table-menubar .table-menubar-item:hover { + background: #f0f0f0; +} + +.table-wrapper .table-menubar .table-menubar-item.disabled { + color: #aaa; + display: none; +} + +.table-wrapper .table-menubar .table-menubar-item .table-menubar-item-input { + width: 46px; + line-height: 12px; + font-size: 12px; + outline: none; + border: 1px solid #dadada; + border-radius: 4px; + text-align: center; +} + +.table-wrapper .table-menubar .table-menubar-item .table-menubar-item-input::selection { + color: inherit; + background:transparent +} + +.table-wrapper .table-menubar .table-menubar-item .table-menubar-item-input:focus::selection +{ + color: #fff; + background: #1890ff; +} + +.table-wrapper .table-menubar .split { + height: 0; + border-top: 1px solid #e8e8e8; + margin: 2px 0; +} + +.table-wrapper .table-main-content { + margin: 4px 8px; + position: relative; + z-index: 3; +} + +.table-wrapper .table-main-bg { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 1; + bottom: 0; + pointer-events: none; +} + +.table-wrapper .table-main-bg .table-main-border-top { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 1; + height: 0; + border-top: 1px solid rgb(217, 217, 217); +} + +.table-wrapper:not(.active) [data-table-no-border="true"] .table-main-bg .table-main-border-top { + border-top-color: transparent; +} + +.table-wrapper.active [data-table-no-border="true"] .table-main-bg .table-main-border-top { + border-top-style: dashed; +} + +.table-wrapper .table-main-bg .table-main-border-right { + display: none; + position: absolute; + top: 0; + bottom: 0; + right: -1px; + z-index: 1; + width: 0; + border-right: 1px solid rgb(217, 217, 217); +} + +.table-wrapper:not(.active) [data-table-no-border="true"] .table-main-bg .table-main-border-right { + border-right-color: transparent; +} + +.table-wrapper.active [data-table-no-border="true"] .table-main-bg .table-main-border-right { + border-right-style: dashed; +} + +.table-wrapper tr td.table-last-row .table-main-bg .table-main-border-right { + display: block; + right: 0; +} + +.table-wrapper .table-main-bg .table-main-border-bottom { + display: none; + position: absolute; + bottom: -1px; + left: 0; + right: 0; + z-index: 1; + height: 0; + border-bottom: 1px solid rgb(217, 217, 217); +} + +.table-wrapper:not(.active) [data-table-no-border="true"] .table-main-bg .table-main-border-bottom { + border-bottom-color: transparent; +} + +.table-wrapper.active [data-table-no-border="true"] .table-main-bg .table-main-border-bottom { + border-bottom-style: dashed; +} + +.table-wrapper tr td.table-last-column .table-main-bg .table-main-border-bottom{ + display: block; + bottom: 0; +} + +.table-wrapper .table-main-bg .table-main-border-left { + position: absolute; + top: 0; + bottom: 0; + left: 0; + z-index: 1; + width: 0; + border-left: 1px solid rgb(217, 217, 217); +} + +.table-wrapper:not(.active) [data-table-no-border="true"] .table-main-bg .table-main-border-left { + border-left-color: transparent; +} + +.table-wrapper.active [data-table-no-border="true"] .table-main-bg .table-main-border-left { + border-left-style: dashed; +} + +.table-wrapper .table-highlight { + background: #ff4d4f; + opacity: 0.08; + position: absolute; + z-index: 2; + pointer-events: none; + display: none; +} + +.table-wrapper.scrollbar-show .data-scrollable.scroll-x { + padding-bottom: 8px; +} + +.table-wrapper .data-scrollable.scroll-x { + padding-bottom: 0; +} + +.table-wrapper .data-scrollable.scroll-x:hover { + overflow: hidden; +} + +.table-wrapper.scrollbar-show .data-scrollable.scroll-x:hover { + /**overflow-x: auto;**/ +} + +.table-wrapper.scrollbar-show .data-scrollable.scroll-x .data-scrollbar-x{ + margin-bottom: 2px; +} + +.table-wrapper .data-scrollable .data-scrollbar.data-scrollbar-x { + height: 4px; +} + +.table-wrapper .data-scrollable .data-scrollbar.data-scrollbar-x .data-scrollbar-trigger { + height: 4px; +} + +.table-wrapper .table-rows-header .table-row-delete-button,.table-wrapper .table-rows-header .table-row-add-button { + position: absolute; + right: 100%; + display: none; + justify-content: center; + align-items: center; + z-index: 2; + width: 24px; + height: 24px; + border: 1px solid #e4e4e4; + border-radius: 2px; + cursor: pointer; + background-color: #fff; + background-position: center center; + background-repeat: no-repeat; + + left: 14px; + margin-left: 2px; + margin-top: -2px; +} + +.table-wrapper .table-rows-header .table-row-delete-button .data-icon,.table-wrapper .table-rows-header .table-row-add-button .data-icon { + font-size: 12px; +} + +.table-wrapper .table-rows-header .table-row-add-button { + left: -30px; + margin-top: -12px; +} + +.table-wrapper .table-rows-header .table-row-delete-button:hover,.table-wrapper .table-col-delete-button:hover { + color:#ff4d4f; +} + +.table-wrapper .table-col-delete-button, .table-wrapper .table-col-add-button { + position: absolute; + bottom: 100%; + display: none; + justify-content: center; + align-items: center; + z-index: 128; + margin-bottom: 4px; + width: 24px; + height: 24px; + border: 1px solid #e4e4e4; + border-radius: 2px; + cursor: pointer; + background: #fff; + background-position: center center; + background-repeat: no-repeat; + margin-left: -12px; +} + +.table-wrapper .table-col-delete-button .data-icon, .table-wrapper .table-col-add-button .data-icon { + font-size: 12px; +} + +.table-wrapper .table-cols-header .table-col-add-button .table-col-add-split-button { + position: absolute; + width: 2px; + left: 10px; + top: 22px; + background: #008dff; + display: none; +} + +.table-wrapper .table-rows-header .table-row-add-button .table-row-add-split-button { + position: absolute; + height: 2px; + left: 22px; + background: #008dff; + display: none; +} + +.table-wrapper .table-col-add-button:hover, .table-wrapper .table-row-add-button:hover { + color: #008dff; +} + +.table-wrapper .table-col-add-button:hover .table-col-add-split-button{ + display: block; +} + +.table-wrapper .table-row-add-button:hover .table-row-add-split-button{ + display: block; +} + +.data-table-reader .data-table tr td { + border: 1px solid rgb(217, 217, 217); + cursor: auto; + padding: 4px 8px; +} + +.data-table-reader .data-table[data-table-no-border="true"] tr td { + border:0 none +} + +.data-table-reader.data-scrollable.scroll-x { + padding-bottom: 8px; +} + +.data-table-reader .scrollbar-shadow-left, .data-table-reader .scrollbar-shadow-right { + bottom: 8px; +} + +.data-table-reader.scrollbar-show.data-scrollable.scroll-x .data-scrollbar-x{ + margin-bottom: 2px; +} + +.data-table-reader.data-scrollable .data-scrollbar.data-scrollbar-x { + height: 4px; +} + +.data-table-reader.data-scrollable .data-scrollbar.data-scrollbar-x .data-scrollbar-trigger { + height: 4px; +} + +[data-card-key="table"].data-card-block-max > [data-card-element="body"] > [data-card-element="center"] { + padding: 48px; + margin-top: 4px; +} \ No newline at end of file diff --git a/plugins/table/src/index.ts b/plugins/table/src/index.ts new file mode 100644 index 00000000..8f34ef1a --- /dev/null +++ b/plugins/table/src/index.ts @@ -0,0 +1,503 @@ +import { + $, + CARD_KEY, + EDITABLE_SELECTOR, + isEngine, + NodeInterface, + Plugin, + SchemaBlock, + PluginOptions, + SchemaInterface, + getDocument, +} from '@aomao/engine'; +import TableComponent, { Template } from './component'; +import locales from './locale'; +import './index.css'; +import { TableInterface } from './types'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; + markdown?: boolean; +} + +class Table extends Plugin { + static get pluginName() { + return 'table'; + } + + init() { + const editor = this.editor; + editor.language.add(locales); + editor.schema.add(this.schema()); + editor.conversion.add('th', 'td'); + editor.on('parse:html', (node) => this.parseHtml(node)); + editor.on('paste:each-after', (child) => this.pasteHtml(child)); + editor.on('paste:schema', (schema: SchemaInterface) => + this.pasteSchema(schema), + ); + editor.on( + 'paste:markdown-check', + (child) => !this.checkMarkdown(child)?.match, + ); + editor.on('paste:markdown-after', (child) => this.pasteMarkdown(child)); + if (isEngine(editor)) { + editor.change.event.onDocument( + 'copy', + (event) => this.onCopy(event), + 0, + ); + editor.change.event.onDocument( + 'cut', + (event) => this.onCut(event), + 0, + ); + editor.change.event.onDocument( + 'paste', + (event) => this.onPaste(event), + 0, + ); + } + } + + onCopy(event: ClipboardEvent) { + if (!isEngine(this.editor)) return true; + const { change, card } = this.editor; + const range = change.range.get(); + const component = card.find(range.commonAncestorNode, true); + if ( + component && + component.getSelectionNodes && + component.name === TableComponent.cardName + ) { + const nodes = component.getSelectionNodes(); + if (nodes.length > 1) { + event.preventDefault(); + const tableComponent = component as TableInterface; + tableComponent.command.copy(); + return false; + } + } + return true; + } + + onCut(event: ClipboardEvent) { + if (!isEngine(this.editor)) return true; + const { change, card } = this.editor; + const range = change.range.get(); + const component = card.find(range.commonAncestorNode, true); + if ( + component && + component.getSelectionNodes && + component.name === TableComponent.cardName + ) { + const nodes = component.getSelectionNodes(); + if (nodes.length > 1) { + event.preventDefault(); + const tableComponent = component as TableInterface; + tableComponent.command.cut(); + return false; + } + } + return true; + } + + onPaste(event: ClipboardEvent) { + if (!isEngine(this.editor)) return true; + const { change, card } = this.editor; + const range = change.range.get(); + const component = card.find(range.commonAncestorNode, true); + if ( + component && + component.getSelectionNodes && + component.name === TableComponent.cardName + ) { + const nodes = component.getSelectionNodes(); + if (nodes.length > 0) { + event.preventDefault(); + const tableComponent = component as TableInterface; + tableComponent.command.mockPaste(); + return false; + } + } + return true; + } + + hotkey() { + return this.options.hotkey || ''; + } + + schema(): Array { + return [ + { + name: 'table', + type: 'block', + attributes: { + class: ['data-table'], + 'data-table-no-border': '*', + style: { + width: '@length', + }, + }, + }, + { + name: 'colgroup', + type: 'block', + }, + { + name: 'col', + type: 'block', + isVoid: true, + attributes: { + width: '@number', + span: '@number', + }, + allowIn: ['colgroup'], + }, + { + name: 'thead', + type: 'block', + }, + { + name: 'tbody', + type: 'block', + }, + { + name: 'tr', + type: 'block', + attributes: { + style: { + height: '@length', + }, + }, + allowIn: ['tbody'], + }, + { + name: 'td', + type: 'block', + attributes: { + colspan: '@number', + rowspan: '@number', + class: [ + 'table-last-column', + 'table-last-row', + 'table-last-column', + 'table-cell-selection', + ], + style: { + 'background-color': '@color', + 'vertical-align': ['top', 'middle', 'bottom'], + }, + }, + allowIn: ['tr'], + }, + { + name: 'th', + type: 'block', + attributes: { + colspan: '@number', + rowspan: '@number', + }, + allowIn: ['tr'], + }, + ]; + } + + pasteSchema(schema: SchemaInterface) { + schema.find((r) => r.name === 'table')[0].attributes = { + class: ['data-table'], + 'data-table-no-border': '*', + 'data-wdith': '@length', + style: { + width: '@length', + background: '@color', + 'background-color': '@color', + }, + }; + schema.find((r) => r.name === 'tr')[0].attributes = { + class: ['data-table'], + 'data-table-no-border': '*', + style: { + width: '@length', + background: '@color', + 'background-color': '@color', + }, + }; + schema.find((r) => r.name === 'td')[0].attributes = { + colspan: '@number', + rowspan: '@number', + class: [ + 'table-last-column', + 'table-last-row', + 'table-last-column', + 'table-cell-selection', + ], + style: { + 'background-color': '@color', + background: '@color', + 'vertical-align': ['top', 'middle', 'bottom'], + valign: ['top', 'middle', 'bottom'], + }, + }; + } + + execute(rows?: number, cols?: number): void { + if (!isEngine(this.editor)) return; + //可编辑子区域内不插入表格 + const { change } = this.editor; + const range = change.range.get(); + if (range.startNode.closest(EDITABLE_SELECTOR).length > 0) return; + //插入表格 + this.editor.card.insert(TableComponent.cardName, { + rows: rows || 3, + cols: cols || 3, + }); + } + + convertToPX(value: string) { + const match = /([\d\.]+)(pt|px)$/i.exec(value); + if (match && match[2] === 'pt') { + return ( + String(Math.round((parseInt(match[1], 10) * 96) / 72)) + 'px' + ); + } + return value; + } + + pasteHtml(node: NodeInterface) { + if (!isEngine(this.editor)) return; + const clearWH = ( + node: NodeInterface, + type: 'width' | 'height' = 'width', + ) => { + const dataWidth = node.attributes('data-width'); + const width = dataWidth ? dataWidth : node.css(type); + if (width.endsWith('%')) node.css(type, ''); + if (width.endsWith('pt')) node.css(type, this.convertToPX(width)); + }; + if (node.name === 'table') { + clearWH(node); + clearWH(node, 'height'); + // 表头放在tbody最前面 + const thead = node.find('thead'); + if (thead && thead.length > 0) + node.find('tbody').prepend(thead.children()); + // 表头放在tbody最前面 + const tfoot = node.find('thead'); + if (tfoot && tfoot.length > 0) + node.find('tbody').append(tfoot.children()); + + const tds = node.find('td'); + let fragment = getDocument().createDocumentFragment(); + tds.each((_, index) => { + fragment = getDocument().createDocumentFragment(); + const element = tds.eq(index); + if (!element) return; + clearWH(element); + clearWH(element, 'height'); + const background = element.css('background'); + if (background) element.css('background-color', background); + const valign = element.attributes('valign'); + if (valign) element.attributes('vertical-align', valign); + const children = element.children(); + for (let i = 0; i < children.length; i++) { + const child = children.eq(i); + if (child) fragment.appendChild(child[0]); + } + // 对单元格内的内容标准化 + const fragmentNode = $(fragment); + element + ?.empty() + .append(this.editor.node.normalize(fragmentNode)); + }); + const background = + node?.css('background') || node?.css('background-color'); + if (background) tds.css('background', background); + + const trs = node.find('tr'); + trs.each((_, index) => { + const element = trs.eq(index); + + const tds = element?.find('td'); + if (tds?.length === 0) element?.remove(); + + if (element) { + clearWH(element); + clearWH(element, 'height'); + } + + const background = + element?.css('background') || + element?.css('background-color'); + if (background) tds?.css('background', background); + }); + this.editor.card.replaceNode(node, TableComponent.cardName, { + html: node + .get()! + .outerHTML.replace(/\n|\r\n/g, '') + .replace(/>\s+<'), + }); + return false; + } + return true; + } + + parseHtml(root: NodeInterface) { + root.find(`[${CARD_KEY}=${TableComponent.cardName}`).each( + (tableNode) => { + const node = $(tableNode); + const table = node.find('table'); + if (table.length === 0) { + node.remove(); + return; + } + const width = table.attributes('width') || table.css('width'); + table.css({ + outline: 'none', + 'border-collapse': 'collapse', + width: '100%', + }); + table.attributes('data-width', width); + const tds = table.find('td'); + tds.each((_, index) => { + const tdElement = tds.eq(index); + tdElement?.css({ + 'min-width': 'auto', + 'white-space': 'flat', + 'word-wrap': 'break-word', + margin: '4px 8px', + border: !!table.attributes('data-table-no-border') + ? '0 none' + : '1px solid #d9d9d9', + padding: '4px 8px', + cursor: 'default', + 'vertical-align': + tdElement.css('vertical-align') || 'top', + }); + }); + table.find(Template.TABLE_TD_BG_CLASS).remove(); + table.find(Template.TABLE_TD_CONTENT_CLASS).each((content) => { + this.editor.node.unwrap($(content)); + }); + node.replaceWith(table); + }, + ); + } + + getMarkdownCell(match: RegExpExecArray, count?: number) { + const cols = match[0].split('|'); + const headeText = match[0].trim().replace(/\n/, ''); + if (headeText.endsWith('|')) cols.pop(); + if (headeText.startsWith('|')) cols.shift(); + const colNodes: Array = []; + cols.some((col) => { + if (count !== undefined && colNodes.length === count) return true; + colNodes.push(col); + return false; + }); + return colNodes; + } + + checkMarkdown(node: NodeInterface) { + if ( + !isEngine(this.editor) || + this.options.markdown === false || + !node.isText() + ) + return; + const text = node.text(); + if (!text) return; + // 匹配 |-|-| 或者 -|- 或者 |-|- 或者 -|-| + const reg = /(?:\|)+\n\s*(\|?(\s*:?-+:?\s*)+\|?)+\s*(\n|$)/; + const tbMatch = reg.exec(text); + if (!tbMatch || tbMatch[0].indexOf('|') < 0) return; + return { + reg, + match: tbMatch, + }; + } + + pasteMarkdown(node: NodeInterface) { + const result = this.checkMarkdown(node); + if (!result) return; + const { reg, match } = result; + if (!match) return; + const parse = (node: NodeInterface) => { + let text = node.text(); + if (!text) return; + const tbMatch = reg.exec(text); + if (!tbMatch || tbMatch[0].indexOf('|') < 0) return; + // 文本节点 + const textNode = node.clone(true).get()!; + // 列数 + const colCount = tbMatch[0] + .split('|') + .filter( + (cell) => cell.trim() !== '' && cell.includes('-'), + ).length; + // 从匹配出分割 + const tbRegNode = textNode.splitText(tbMatch.index); + // 获取表头 + const thReg = new RegExp( + `(\\|?([^\\|\\n]+)\\|?){${colCount},}\\s*$`, + ); + const headRows = (textNode.textContent || '').split(/\n/); + let match = thReg.exec( + headRows.length > 0 ? headRows[headRows.length - 1] : '', + ); + if (!match || match[0].indexOf('|') < 0) return; + headRows.pop(); + textNode.splitText(headRows.join('\n').length + match.index); + // 拼接之前的文本 + let regNode = tbRegNode.splitText(tbMatch[0].length); + let newText = textNode.textContent || ''; + // 生成头部td + const colNodes = this.getMarkdownCell(match); + // 表头数量不等于列数,不操作 + if (colNodes.length !== colCount) return; + const nodes: Array = []; + nodes.push( + `${colNodes.map((col) => `${col}`).join('')}`, + ); + // 遍历剩下的行 + const tdReg = new RegExp( + `^\\n*(\\|?([^\\|\\n]+)\\|?){1,${colCount}}(?:\\n|$)`, + ); + while (match) { + match = tdReg.exec(regNode.textContent || ''); + if ( + !match || + match[0].indexOf('|') < 0 || + match[0].startsWith('\n\n') + ) + break; + const colNodes = this.getMarkdownCell(match, colCount); + if (colNodes.length === 0) break; + if (colNodes.length < colCount) { + while (colCount - colNodes.length > 0) { + colNodes.push(''); + } + } + nodes.push( + `${colNodes + .map((col) => `${col}`) + .join('')}`, + ); + regNode = regNode.splitText(match[0].length); + } + + const createTable = (nodes: Array) => { + const tableNode = $(`${nodes.join('')}
      `); + return tableNode.get()?.outerHTML; + }; + newText += createTable(nodes) + '\n'; + newText += regNode.textContent; + node.text(newText); + parse(node); + }; + parse(node); + } +} + +export default Table; + +export { TableComponent }; diff --git a/plugins/table/src/locale/en-US.ts b/plugins/table/src/locale/en-US.ts new file mode 100644 index 00000000..e466b800 --- /dev/null +++ b/plugins/table/src/locale/en-US.ts @@ -0,0 +1,30 @@ +export default { + table: { + insertColLeft: 'Insert column(s) $data left', + insertColRight: 'Insert column(s) $data right', + insertRowUp: 'Insert row(s) $data up', + insertRowDown: 'Insert row(s) $data down', + mergeCell: 'Merge cells', + splitCell: 'Unmerge cells', + removeCol: 'Delete selected column(s)', + removeRow: 'Delete selected row(s)', + removeTable: 'Delete table', + copy: 'Copy', + cut: 'Cut', + paste: 'Paste', + clear: 'Clear', + draggingCol: 'Moving $data column', + draggingRow: 'Moving $data row', + color: { + title: 'Cell background color', + nonFillText: 'No fill color', + }, + noBorder: 'Hide border', + verticalAlign: { + title: 'Vertical align', + top: 'Align top', + middle: 'Align middle', + bottom: 'Align bottom', + }, + }, +}; diff --git a/plugins/table/src/locale/index.ts b/plugins/table/src/locale/index.ts new file mode 100644 index 00000000..6266072c --- /dev/null +++ b/plugins/table/src/locale/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/plugins/table/src/locale/zh-cn.ts b/plugins/table/src/locale/zh-cn.ts new file mode 100644 index 00000000..9aa4dd72 --- /dev/null +++ b/plugins/table/src/locale/zh-cn.ts @@ -0,0 +1,30 @@ +export default { + table: { + insertColLeft: '左边插入 $data 列', + insertColRight: '右边插入 $data 列', + insertRowUp: '上方插入 $data 行', + insertRowDown: '下方插入 $data 行', + mergeCell: '合并单元格', + splitCell: '拆分单元格', + removeCol: '删除选中列', + removeRow: '删除选中行', + removeTable: '删除表格', + copy: '复制', + cut: '剪切', + paste: '粘贴', + clear: '清空内容', + draggingCol: '正在移动 $data 列', + draggingRow: '正在移动 $data 行', + color: { + title: '单元格背景色', + nonFillText: '无填充色', + }, + noBorder: '隐藏边框', + verticalAlign: { + title: '垂直对齐', + top: '顶对齐', + middle: '垂直居中', + bottom: '底对齐', + }, + }, +}; diff --git a/plugins/table/src/types.ts b/plugins/table/src/types.ts new file mode 100644 index 00000000..562d58f9 --- /dev/null +++ b/plugins/table/src/types.ts @@ -0,0 +1,375 @@ +import { CardInterface, ClipboardData, NodeInterface } from '@aomao/engine'; +import { EventEmitter2 } from 'eventemitter2'; + +export interface HelperInterface { + isEmptyModelCol( + model: TableModelCol | TableModelEmptyCol, + ): model is TableModelEmptyCol; + /** + * 提取表格数据模型,由于合并单元格的存在,html 结构不利于操作 + * @param {NativeNode} table 表格原生对象 + * @return {(tdModel[])[]} result 一个二维数组 + * [ + * [tdModel, tdModel...], + * [tdModel, tdModel...], + * ... + * ] + * @tdModel: + * { + * isMulti: {boolean} 合并的单元格 + * isEmpty: {boolean} 为 true 时表示被合并单元格覆盖到的占位 + * isShadow: {boolean} 为 true 时表示这是一个补充的单元格,html拷贝的时候会出现遗漏单元格,这里需要补充上 + * parent: {row, col} 标记占位格的父单元格的坐标位置 + * element: {NativeNode} 指针作用,指向对应的 td + * rowSpan: {number} 单元格的 rowSpan + * colSpan: {number} 单元格的 colSpan + * } + */ + getTableModel(table: NodeInterface): TableModel; + + /** + * table 结构标准化,补齐丢掉的单元格和行 + * 场景1. number 拷贝过来的 html 中,如果这一行没有单元格,就会省掉 tr,渲染的时候会有问题 + * 场景2. 从网页中鼠标随意选取表格中的一部分,会丢掉没有选中的单元格,需要补齐单元格 + * @param {nativeNode} table 表格 Dom + * @return {nativeNode} 修复过的 table dom + */ + normalize(table: NodeInterface): NodeInterface; + + /** + * firefox 下的拖拽需要这样处理 + * clearData 是为了防止新开 tab + * hack: 如果不 setData, firefox 不会触发拖拽事件,但设置 data 之后,又会开新 tab, 这里设置一个 firefox 不识别的 mimetype: aomao + * @param event + */ + fixDragEvent(event: DragEvent): void; + + /** + * 从源节点复制样式到目标节点 + * @param from 源节点 + * @param to 目标节点 + */ + copyCss(from: NodeInterface | Node, to: NodeInterface | Node): void; + + /** + * 从源节点复制样式和内容到目标节点 + * @param from 源节点 + * @param to 目标节点 + */ + copyTo(from: NodeInterface | Node, to: NodeInterface | Node): void; + + /** + * 复制html + * @param html HTML + */ + copyHTML(html: string): void; + + /** + * 获取复制的数据 + * @returns + */ + getCopyData(): { html: string; text: string } | undefined; + + trimBlankSpan(node: NodeInterface): NodeInterface; + + /** + * table 结构标准化,补齐丢掉的单元格和行 + * 场景1. number 拷贝过来的 html 中,如果这一行没有单元格,就会省掉 tr,渲染的时候会有问题 + * 场景2. 从网页中鼠标随意选取表格中的一部分,会丢掉没有选中的单元格,需要补齐单元格 + * @param {NodeInterface} table 表格 Dom + * @return {NodeInterface} 修复过的 table dom + */ + normalizeTable(table: NodeInterface): NodeInterface; +} + +export interface TemplateInterface { + /** + * 用于Card渲染 + * @param {object} value 参数 + * @param {number} value.rows 行数 + * @param {number} value.cols 列数 + * @param {string} value.html html 字符串 + * @return {string} 返回 html 字符串 + */ + htmlEdit(value: TableValue, menus: TableMenu): string; + + htmlView(value: TableValue): string; + + renderRowsHeader(rows: number): string; + renderColsHeader(rows: number): string; +} + +export type TableValue = { + rows: number; + cols: number; + width?: number; + height?: number; + html?: string; + color?: string; + noBorder?: boolean; +}; + +export type TableMenuItem = { + action?: string; + icon?: string; + text?: string; + split?: true; +}; + +export type TableMenu = Array; + +export type TableModelCol = { + isShadow?: boolean; + rowSpan: number; + colSpan: number; + isMulti?: boolean; + element: HTMLTableColElement | HTMLTableDataCellElement | null; +}; + +export type TableModelEmptyCol = { + isEmpty: true; + parent: { + row: number; + col: number; + }; +}; + +export type TableModel = { + rows: number; + cols: number; + width: number; + height: number; + table: Array>; +}; + +export interface TableInterface extends CardInterface { + wrapper?: NodeInterface; + helper: HelperInterface; + template: TemplateInterface; + selection: TableSelectionInterface; + conltrollBar: ControllBarInterface; + command: TableCommandInterface; + /** + * 渲染 + */ + render(): string | NodeInterface | void; + + getTableValue(): TableValue | undefined; +} + +export type ControllOptions = { + col_min_width: number; + row_min_height: number; +}; + +export type ControllDragging = { + x: number; + y: number; +}; + +export type ControllDraggingHeader = { + element: NodeInterface; + minIndex: number; + maxIndex: number; + count: number; + index?: number; + isNext?: boolean; +}; + +export type ControllChangeSize = { + trigger: { + element: NodeInterface; + height: number; + width: number; + }; + width: number; + height: number; + element: NodeInterface; + index: number; + table: { + width: number; + height: number; + }; +}; + +export interface ControllBarInterface extends EventEmitter2 { + /** + * 拖动参数 + */ + dragging?: ControllDragging; + /** + * 拖动行列头部 + */ + draggingHeader?: ControllDraggingHeader; + /** + * 调整大小参数 + */ + changeSize?: ControllChangeSize; + + init(): void; + + refresh(): void; + + renderRowBars(start?: number, end?: number): void; + + renderColBars(): void; + + removeRow(index: number): void; + + removeCol(index: number): void; + + showContextMenu(event: MouseEvent): void; + + hideContextMenu(): void; + + drawBackgroundColor(color?: string): void; + + setAlign(align?: 'top' | 'middle' | 'bottom'): void; + + destroy(): void; +} + +export interface TableCommandInterface extends EventEmitter2 { + init(): void; + + insertColAt( + index: number, + count: number, + isLeft?: boolean, + widths?: number | Array, + ...args: any + ): void; + + insertCol( + position?: 'left' | 'end' | 'right', + count?: number, + ...args: any + ): void; + + removeCol(...args: any): void; + + insertColLeft(): void; + + insertColRight(): void; + + insertRowAt( + index: number, + count: number, + isUp?: boolean, + ...args: any + ): void; + + insertRow( + position?: 'up' | 'end' | 'down', + count?: number, + ...args: any + ): void; + + insertRowUp(): void; + + insertRowDown(): void; + + removeRow(...args: any): void; + + removeTable(): void; + + copy(): void; + + mockCopy(): void; + + shortcutCopy(event: ClipboardEvent): void; + + cut(): void; + + shortcutCut(event: ClipboardEvent): void; + + clear(): void; + + mockPaste(...args: any): void; + + shortcutPaste(event: ClipboardEvent): void; + + paste(data: ClipboardData, ...args: any): void; + + mergeCell(...args: any): void; + + splitCell(...args: any): void; + + hasCopyData(): boolean; +} + +export type TableSelectionArea = { + begin: { row: number; col: number }; + end: { row: number; col: number }; + count: number; + allCol: boolean; + allRow: boolean; +}; + +export type TableSelectionDragging = { + trigger: { + element: NodeInterface; + }; +}; + +export interface TableSelectionInterface extends EventEmitter2 { + tableModel?: TableModel; + + selectArea?: TableSelectionArea; + + init(): void; + + render(action: string): void; + + each( + fn: ( + cell: TableModelCol | TableModelEmptyCol, + row: number, + col: number, + ) => void, + reverse?: boolean, + ): void; + + refreshModel(): void; + + getCellPoint(td: NodeInterface): Array; + + getCellIndex(row: number, col: number): number; + + getSingleCell(): NodeInterface | null; + + getSingleCellPoint(): Array; + + getSelectArea(): TableSelectionArea; + + selectCol(begin: number, end?: number): void; + + selectRow(begin: number, end?: number): void; + + select( + start: { row: number; col: number }, + end: { row: number; col: number }, + ): void; + + clearSelect(): void; + + getSelectionHtml(): string | null; + + hasMergeCell(): boolean; + + isRowSelected(): boolean; + + isColSelected(): boolean; + + isTableSelected(): boolean; + + showHighlight(area: TableSelectionArea): void; + + hideHighlight(): void; + + focusCell(cell: NodeInterface | Node): void; + + selectCellRange(cell: NodeInterface | Node): void; + + destroy(): void; +} diff --git a/plugins/table/tsconfig.json b/plugins/table/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/table/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/tasklist/README.md b/plugins/tasklist/README.md new file mode 100644 index 00000000..24b3817d --- /dev/null +++ b/plugins/tasklist/README.md @@ -0,0 +1,68 @@ +# @aomao/plugin-tasklist + +任务列表插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-tasklist +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Tasklist , { CheckboxComponent } from '@aomao/plugin-tasklist'; + +new Engine(...,{ plugins:[Tasklist] , cards:[CheckboxComponent] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键`mod+shift+9` + +```ts +//快捷键 +hotkey?: string | Array;//默认mod+shift+9 +//使用配置 +new Engine(...,{ + config:{ + "tasklist":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Tasklist 插件 markdown 语法为`[]`, `[ ]`, `[x]` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "tasklist":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +可传入 { checked:true } 表示选中,可选参数 + +```ts +//使用 command 执行插件、并传入所需参数 +engine.command.execute('tasklist', { checked: boolean }); +//使用 command 执行查询当前状态,返回 false 或者当前列表插件名称 tasklist tasklist unorderedlist +engine.command.queryState('tasklist'); +``` diff --git a/plugins/tasklist/package.json b/plugins/tasklist/package.json new file mode 100644 index 00000000..de11963c --- /dev/null +++ b/plugins/tasklist/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-tasklist", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/tasklist/src/checkbox/index.css b/plugins/tasklist/src/checkbox/index.css new file mode 100644 index 00000000..ba20a8f1 --- /dev/null +++ b/plugins/tasklist/src/checkbox/index.css @@ -0,0 +1,123 @@ +.data-checkbox { + font-size: 14px; + font-variant: tabular-nums; + color: rgba(0, 0, 0, 0.65); + box-sizing: border-box; + margin: 0; + padding: 0; + width: 16px; + height: 16px; + list-style: none; + white-space: nowrap; + cursor: pointer; + outline: none; + display: inline-block; + line-height: 1; + position: relative; + vertical-align: middle; +} + +.data-checkbox-checked:after { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 2px; + border: 1px solid #347eff; + content: ""; + visibility: hidden; +} + +.data-checkbox:not(.data-checkbox-mobile):focus .data-checkbox-inner,.data-checkbox:not(.data-checkbox-mobile) .data-checkbox-inner { + border-color: #347eff +} + +.data-checkbox:hover:after{ + visibility: visible; +} + +.data-checkbox-inner { + position: relative; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: inline-block; + border: 1px solid #d9d9d9; + border-radius: 2px; + background-color: #fff; + -webkit-transition: all 0.3s; + transition: all 0.3s; + border-collapse: separate; +} + +.data-checkbox-inner:after { + -webkit-transform: rotate(45deg) scale(0); + -ms-transform: rotate(45deg) scale(0); + transform: rotate(45deg) scale(0); + position: absolute; + left: 4.57142857px; + top: 1.14285714px; + display: table; + width: 5.71428571px; + height: 9.14285714px; + border: 2px solid #fff; + border-top: 0; + border-left: 0; + content: ' '; + -webkit-transition: all 0.1s cubic-bezier(0.71, -0.46, 0.88, 0.6), opacity 0.1s; + transition: all 0.1s cubic-bezier(0.71, -0.46, 0.88, 0.6), opacity 0.1s; + opacity: 0; +} + +.data-checkbox-checked .data-checkbox-inner:after { + -webkit-transform: rotate(45deg) scale(1); + -ms-transform: rotate(45deg) scale(1); + transform: rotate(45deg) scale(1); + position: absolute; + display: table; + border: 2px solid #fff; + border-top: 0; + border-left: 0; + content: ' '; + -webkit-transition: all 0.2s cubic-bezier(0.12, 0.4, 0.29, 1.46) 0.1s; + transition: all 0.2s cubic-bezier(0.12, 0.4, 0.29, 1.46) 0.1s; + opacity: 1; +} + +.data-checkbox-checked .data-checkbox-inner { + background-color: #347eff; + border-color: #347eff; +} + +.data-checkbox-disabled { + cursor: not-allowed; +} + +.data-checkbox-disabled.data-checkbox-checked .data-checkbox-inner:after { + -webkit-animation-name: none; + animation-name: none; + border-color: rgba(0, 0, 0, 0.25); +} + +.data-checkbox-disabled .data-checkbox-input { + cursor: not-allowed; +} + +.data-checkbox-disabled .data-checkbox-inner { + border-color: #d9d9d9 !important; + background-color: #f5f5f5; +} + +.data-checkbox-disabled .data-checkbox-inner:after { + -webkit-animation-name: none; + animation-name: none; + border-color: #f5f5f5; + border-collapse: separate; +} + +.data-checkbox-disabled + span { + color: rgba(0, 0, 0, 0.25); + cursor: not-allowed; +} diff --git a/plugins/tasklist/src/checkbox/index.ts b/plugins/tasklist/src/checkbox/index.ts new file mode 100644 index 00000000..cb005d18 --- /dev/null +++ b/plugins/tasklist/src/checkbox/index.ts @@ -0,0 +1,101 @@ +import { + $, + Card, + CardType, + isEngine, + isMobile, + NodeInterface, +} from '@aomao/engine'; +import './index.css'; + +const CHECKBOX_CLASS = 'data-checkbox'; +const CHECKBOX_INNER_CLASS = 'data-checkbox-inner'; +const CHECKBOX_CHECKED_CLASS = 'data-checkbox-checked'; + +export type CheckboxValue = { + checked: boolean; +}; + +class Checkbox extends Card { + #container?: NodeInterface; + + static get cardName() { + return 'checkbox'; + } + + static get cardType() { + return CardType.INLINE; + } + + static get singleSelectable() { + return false; + } + + static get autoSelected() { + return false; + } + + static get collab() { + return false; + } + + static get focus() { + return false; + } + + onSelectByOther() {} + + onSelect() {} + + update = (isChecked?: boolean) => { + const checked = isChecked === undefined ? this.isChecked() : isChecked; + const parent = this.root.parent(); + if (checked) { + this.#container?.removeClass(CHECKBOX_CHECKED_CLASS); + parent?.removeAttributes('checked'); + } else { + this.#container?.addClass(CHECKBOX_CHECKED_CLASS); + parent?.attributes('checked', 'true'); + } + return checked; + }; + + isChecked = () => { + return !!this.#container?.hasClass(CHECKBOX_CHECKED_CLASS); + }; + + onClick = () => { + const checked = this.update(); + this.setValue({ + checked: !checked, + }); + }; + + onActivateByOther() {} + + render() { + const html = ` + + + `; + const value = this.getValue(); + if (!this.#container) { + this.#container = $(html); + this.getCenter().append(this.#container); + } else { + this.#container = this.getCenter().first()!; + } + this.update(!value?.checked); + if (!isEngine(this.editor) || this.editor.readonly) { + return; + } + this.#container.on('click', this.onClick); + } + + destroy() { + this.#container?.off('click', this.onClick); + } +} +export default Checkbox; diff --git a/plugins/tasklist/src/index.css b/plugins/tasklist/src/index.css new file mode 100644 index 00000000..02a28eff --- /dev/null +++ b/plugins/tasklist/src/index.css @@ -0,0 +1,18 @@ +.am-engine .data-list-item [data-card-key="checkbox"],.am-engine-view .data-list-item [data-card-key="checkbox"] { + margin-left: 0; +} + +.am-engine .data-list-task,.am-engine-view .data-list-task { + list-style: none; + text-indent: 0; +} + +.am-engine .data-list-task [data-card-key="checkbox"],.am-engine-view .data-list-task [data-card-key="checkbox"] { + position: relative; + margin-left: -22px; + height: 100%; + display: inline-block; + width: 22px; + top: -2px; + vertical-align: top; +} diff --git a/plugins/tasklist/src/index.ts b/plugins/tasklist/src/index.ts new file mode 100644 index 00000000..d4e04cd4 --- /dev/null +++ b/plugins/tasklist/src/index.ts @@ -0,0 +1,283 @@ +import { + $, + NodeInterface, + ListPlugin, + CARD_KEY, + SchemaBlock, + isEngine, + PluginEntry, + PluginOptions, +} from '@aomao/engine'; +import CheckboxComponent, { CheckboxValue } from './checkbox'; +import './index.css'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; + markdown?: boolean; +} + +export default class extends ListPlugin { + static get pluginName() { + return 'tasklist'; + } + + cardName = 'checkbox'; + + tagName = 'ul'; + + attributes = { + class: '@var0', + }; + + variable = { + '@var0': { + required: true, + value: [this.editor.list.CUSTOMZIE_UL_CLASS, 'data-list-task'], + }, + }; + + allowIn = ['blockquote', '$root']; + + init() { + super.init(); + this.editor.on('parse:html', (node) => this.parseHtml(node)); + if (isEngine(this.editor)) { + this.editor.on( + 'paste:markdown-check', + (child) => !this.checkMarkdown(child)?.match, + ); + this.editor.on('paste:markdown', (child) => + this.pasteMarkdown(child), + ); + this.editor.on('paste:each-after', (child) => { + if ( + child.name === 'li' && + child.hasClass(this.editor.list.CUSTOMZIE_LI_CLASS) + ) { + const firstChild = child.first(); + if ( + firstChild && + firstChild.name === CheckboxComponent.cardName + ) { + const card = this.editor.card.find(firstChild); + if (card) { + const parent = child.parent(); + parent?.addClass('data-list-task'); + const value = card.getValue() as CheckboxValue; + if (value && value.checked) { + parent?.attributes('checked', 'true'); + } else { + parent?.removeAttributes('checked'); + } + } + } + } + }); + } + } + + schema(): Array { + const scheam = super.schema() as SchemaBlock; + return [ + scheam, + { + name: 'li', + type: 'block', + attributes: { + class: { + required: true, + value: this.editor.list.CUSTOMZIE_LI_CLASS, + }, + checked: ['true', 'false'], + }, + allowIn: ['ul'], + }, + ]; + } + + isCurrent(node: NodeInterface) { + if (node.name === 'li') + return ( + node.hasClass(this.editor.list.CUSTOMZIE_LI_CLASS) && + node.first()?.attributes(CARD_KEY) === 'checkbox' + ); + return node.hasClass('data-list') && node.hasClass('data-list-task'); + } + + execute(value?: any) { + if (!isEngine(this.editor)) return; + const { change, list, block } = this.editor; + list.split(); + const range = change.range.get(); + const activeBlocks = block.findBlocks(range); + if (activeBlocks) { + const selection = range.createSelection(); + if (list.isSpecifiedType(activeBlocks, 'ul', 'checkbox')) { + list.unwrap(activeBlocks); + } else { + const listBlocks = list.toCustomize( + activeBlocks, + 'checkbox', + value, + ) as Array; + listBlocks.forEach((list) => { + if (this.editor.node.isList(list)) + list.addClass('data-list-task'); + }); + } + selection.move(); + if ( + range.collapsed && + range.startContainer.nodeType === Node.ELEMENT_NODE && + range.startContainer.childNodes.length === 0 && + range.startContainer.parentNode + ) { + const brNode = document.createElement('br'); + range.startNode.before(brNode); + range.startContainer.parentNode.removeChild( + range.startContainer, + ); + range.select(brNode); + range.collapse(false); + } + change.apply(range); + list.merge(); + } + } + + hotkey() { + return this.options.hotkey || 'mod+shift+9'; + } + + parseHtml(root: NodeInterface) { + const getBox = (inner: string = '') => { + return `${inner}`; + }; + root.find(`[${CARD_KEY}=checkbox`).each((checkboxNode) => { + const node = $(checkboxNode); + + const checkbox = $( + `${ + node.find('.data-checkbox-checked').length > 0 + ? getBox( + '', + ) + : getBox() + }`, + ); + checkbox.css({ + margin: '3px 0.5ex', + 'vertical-align': 'middle', + width: '16px', + height: '16px', + color: 'color: rgba(0, 0, 0, 0.65)', + }); + node.empty(); + node.append(checkbox); + }); + root.find('.data-list-task').css({ + 'list-style': 'none', + }); + } + + //设置markdown + markdown(event: KeyboardEvent, text: string, block: NodeInterface) { + if (!isEngine(this.editor) || this.options.markdown === false) return; + const { node, command } = this.editor; + const blockApi = this.editor.block; + const plugin = blockApi.findPlugin(block); + // fix: 列表、引用等 markdown 快捷方式不应该在标题内生效 + if ( + block.name !== 'p' || + (plugin && + (plugin.constructor as PluginEntry).pluginName === 'heading') + ) { + return; + } + + if (['[]', '[ ]', '[x]'].indexOf(text) < 0) return; + event.preventDefault(); + blockApi.removeLeftText(block); + if (node.isEmpty(block)) { + block.empty(); + block.append('
      '); + } + command.execute( + (this.constructor as PluginEntry).pluginName, + text === '[x]' ? { checked: true } : undefined, + ); + return false; + } + + checkMarkdown(node: NodeInterface) { + if (!isEngine(this.editor) || !this.markdown || !node.isText()) return; + + const text = node.text(); + if (!text) return; + + const reg = /(^|\r\n|\n)(-\s*)?(\[[\sx]{0,1}\])/; + const match = reg.exec(text); + return { + reg, + match, + }; + } + + pasteMarkdown(node: NodeInterface) { + const result = this.checkMarkdown(node); + if (!result) return; + const { match } = result; + if (!match) return; + + const { list, card } = this.editor; + + const createList = (nodes: Array) => { + const listNode = $( + `<${this.tagName} class="${ + list.CUSTOMZIE_UL_CLASS + } data-list-task">${nodes.join('')}`, + ); + list.addBr(listNode); + return listNode.get()?.outerHTML; + }; + const text = node.text(); + let newText = ''; + const rows = text.split(/\n|\r\n/); + let nodes: Array = []; + rows.forEach((row) => { + const match = /^(-\s*)?(\[[\sx]{0,1}\])/.exec(row); + if (match && !/(\[(.*)\]\(([\S]+?)\))/.test(row)) { + const codeLength = match[0].length; + const content = row.substr( + /^\s+/.test(row.substr(codeLength)) + ? codeLength + 1 + : codeLength, + ); + const tempNode = $(''); + const cardNode = card.replaceNode(tempNode, this.cardName, { + checked: match[0].indexOf('x') > 0, + }); + tempNode.remove(); + nodes.push( + `
    • ${ + cardNode.get()?.outerHTML + }${content}
    • `, + ); + } else if (nodes.length > 0) { + newText += createList(nodes) + '\n' + row + '\n'; + nodes = []; + } else { + newText += row + '\n'; + } + }); + if (nodes.length > 0) { + newText += createList(nodes) + '\n'; + } + node.text(newText); + } +} +export { CheckboxComponent }; diff --git a/plugins/tasklist/tsconfig.json b/plugins/tasklist/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/tasklist/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/underline/README.md b/plugins/underline/README.md new file mode 100644 index 00000000..2ed2bddf --- /dev/null +++ b/plugins/underline/README.md @@ -0,0 +1,47 @@ +# @aomao/plugin-underline + +下划线样式插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-underline +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Underline from '@aomao/plugin-underline'; + +new Engine(...,{ plugins:[Underline] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+u`,以数组形式传入多个快捷键 + +```ts +//快捷键, +hotkey?: string | Array; + +//使用配置 +new Engine(...,{ + config:{ + "underline":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +## 命令 + +```ts +engine.command.execute('underline'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('underline'); +``` diff --git a/plugins/underline/package.json b/plugins/underline/package.json new file mode 100644 index 00000000..4d3a0d96 --- /dev/null +++ b/plugins/underline/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-underline", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/underline/src/index.ts b/plugins/underline/src/index.ts new file mode 100644 index 00000000..fc5cb042 --- /dev/null +++ b/plugins/underline/src/index.ts @@ -0,0 +1,31 @@ +import { MarkPlugin, PluginOptions } from '@aomao/engine'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; +} +export default class extends MarkPlugin { + tagName = 'u'; + + static get pluginName() { + return 'underline'; + } + + hotkey() { + return this.options.hotkey || 'mod+u'; + } + + conversion() { + return [ + { + from: { + span: { + style: { + 'text-decoration': 'underline', + }, + }, + }, + to: this.tagName, + }, + ]; + } +} diff --git a/plugins/underline/tsconfig.json b/plugins/underline/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/underline/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/undo/README.md b/plugins/undo/README.md new file mode 100644 index 00000000..2c3b8909 --- /dev/null +++ b/plugins/undo/README.md @@ -0,0 +1,47 @@ +# @aomao/plugin-undo + +撤销历史插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-undo +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Undo from '@aomao/plugin-undo'; + +new Engine(...,{ plugins:[Undo] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键为 `mod+z` `shift+mod+z` + +```ts +//快捷键 +hotkey?: string | Array; +//使用配置 +new Engine(...,{ + config:{ + "undo":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +## 命令 + +```ts +//使用 command 执行插件、并传入所需参数 +engine.command.execute('undo'); +//使用 command 执行查询当前状态,返回 boolean | undefined +engine.command.queryState('undo'); +``` diff --git a/plugins/undo/package.json b/plugins/undo/package.json new file mode 100644 index 00000000..a012fc5f --- /dev/null +++ b/plugins/undo/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-undo", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/undo/src/index.ts b/plugins/undo/src/index.ts new file mode 100644 index 00000000..020cbc43 --- /dev/null +++ b/plugins/undo/src/index.ts @@ -0,0 +1,24 @@ +import { isEngine, Plugin, PluginOptions } from '@aomao/engine'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; +} +export default class extends Plugin { + static get pluginName() { + return 'undo'; + } + + execute() { + if (!isEngine(this.editor)) return; + if (!this.editor.readonly) this.editor.history.undo(); + } + + queryState() { + if (!isEngine(this.editor) || this.editor.readonly) return; + return this.editor.history.hasUndo(); + } + + hotkey() { + return this.options.hotkey || ['mod+z', 'shift+mod+z']; + } +} diff --git a/plugins/undo/tsconfig.json b/plugins/undo/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/undo/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/unorderedlist/README.md b/plugins/unorderedlist/README.md new file mode 100644 index 00000000..012d884d --- /dev/null +++ b/plugins/unorderedlist/README.md @@ -0,0 +1,66 @@ +# @aomao/plugin-unorderedlist + +无序列表插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-unorderedlist +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Unorderedlist from '@aomao/plugin-unorderedlist'; + +new Engine(...,{ plugins:[Unorderedlist] }) +``` + +## 可选项 + +### 快捷键 + +默认快捷键`mod+shift+8` + +```ts +//快捷键 +hotkey?: string | Array;//默认mod+shift+8 +//使用配置 +new Engine(...,{ + config:{ + "unorderedlist":{ + //修改快捷键 + hotkey:"快捷键" + } + } + }) +``` + +### Markdown + +默认支持 markdown,传入`false`关闭 + +Unorderedlist 插件 markdown 语法为`*`, `-`, `+` + +```ts +markdown?: boolean;//默认开启,false 关闭 +//使用配置 +new Engine(...,{ + config:{ + "unorderedlist":{ + //关闭markdown + markdown:false + } + } + }) +``` + +## 命令 + +```ts +//使用 command 执行插件、并传入所需参数 +engine.command.execute('unorderedlist'); +//使用 command 执行查询当前状态,返回 false 或者当前列表插件名称 unorderedlist tasklist unorderedlist +engine.command.queryState('unorderedlist'); +``` diff --git a/plugins/unorderedlist/package.json b/plugins/unorderedlist/package.json new file mode 100644 index 00000000..27f0798b --- /dev/null +++ b/plugins/unorderedlist/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-unorderedlist", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/unorderedlist/src/index.ts b/plugins/unorderedlist/src/index.ts new file mode 100644 index 00000000..8fabf28d --- /dev/null +++ b/plugins/unorderedlist/src/index.ts @@ -0,0 +1,171 @@ +import { + $, + NodeInterface, + ListPlugin, + SchemaBlock, + isEngine, + PluginEntry, + PluginOptions, +} from '@aomao/engine'; + +export interface Options extends PluginOptions { + hotkey?: string | Array; + markdown?: boolean; +} + +export default class extends ListPlugin { + static get pluginName() { + return 'unorderedlist'; + } + + tagName = 'ul'; + + attributes = { + 'data-indent': '@var0', + }; + + variable = { + '@var0': '@number', + }; + + allowIn = ['blockquote', '$root']; + + init() { + super.init(); + if (isEngine(this.editor)) { + this.editor.on( + 'paste:markdown-check', + (child) => !this.checkMarkdown(child)?.match, + ); + this.editor.on('paste:markdown', (child) => + this.pasteMarkdown(child), + ); + } + } + + schema(): Array { + const scheam = super.schema() as SchemaBlock; + return [ + scheam, + { + name: 'ul', + type: 'block', + }, + { + name: 'li', + type: 'block', + allowIn: ['ul'], + }, + ]; + } + + isCurrent(node: NodeInterface) { + return ( + !node.hasClass(this.editor.list.CUSTOMZIE_UL_CLASS) && + node.name === 'ul' + ); + } + + execute() { + if (!isEngine(this.editor)) return; + const { change, list, block } = this.editor; + list.split(); + const range = change.range.get(); + const activeBlocks = block.findBlocks(range); + if (activeBlocks) { + const selection = range.createSelection(); + if (list.getPluginNameByNodes(activeBlocks) === 'unorderedlist') { + list.unwrap(activeBlocks); + } else { + list.toNormal(activeBlocks, 'ul'); + } + selection.move(); + change.range.select(range); + list.merge(); + } + } + + hotkey() { + return this.options.hotkey || 'mod+shift+8'; + } + + //设置markdown + markdown(event: KeyboardEvent, text: string, block: NodeInterface) { + if (!isEngine(this.editor) || this.options.markdown === false) return; + const { node, command } = this.editor; + const blockApi = this.editor.block; + const plugin = blockApi.findPlugin(block); + // fix: 列表、引用等 markdown 快捷方式不应该在标题内生效 + if ( + block.name !== 'p' || + (plugin && + (plugin.constructor as PluginEntry).pluginName === 'heading') + ) { + return; + } + if (['*', '-', '+'].indexOf(text) < 0) return; + event.preventDefault(); + blockApi.removeLeftText(block); + if (node.isEmpty(block)) { + block.empty(); + block.append('
      '); + } + command.execute((this.constructor as PluginEntry).pluginName); + return false; + } + + checkMarkdown(node: NodeInterface) { + if (!isEngine(this.editor) || !this.markdown || !node.isText()) return; + + const text = node.text(); + if (!text) return; + + const reg = /(^|\r\n|\n)([\*\-\+]{1,}\s+)/g; + const match = reg.exec(text); + return { + reg, + match, + }; + } + + pasteMarkdown(node: NodeInterface) { + const result = this.checkMarkdown(node); + if (!result) return; + + const { match } = result; + if (!match) return; + + const { list } = this.editor; + + const createList = (nodes: Array, start?: number) => { + const listNode = $( + `<${this.tagName} start="${start || 1}">${nodes.join('')}`, + ); + list.addBr(listNode); + return listNode.get()?.outerHTML; + }; + const text = node.text(); + let newText = ''; + const rows = text.split(/\n|\r\n/); + let nodes: Array = []; + rows.forEach((row) => { + const match = /^([\*\-\+]{1,}\s+)/.exec(row); + if (match) { + const codeLength = match[1].length; + const content = row.substr(codeLength); + nodes.push(`
    • ${content}
    • `); + } else if (nodes.length > 0) { + newText += createList(nodes) + '\n' + row + '\n'; + nodes = []; + } else { + newText += row + '\n'; + } + }); + if (nodes.length > 0) { + newText += createList(nodes) + '\n'; + } + node.text(newText); + } +} diff --git a/plugins/unorderedlist/tsconfig.json b/plugins/unorderedlist/tsconfig.json new file mode 100644 index 00000000..3ff5ad30 --- /dev/null +++ b/plugins/unorderedlist/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "jsx": "react", + "allowJs": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "sourceMap": true, + "baseUrl": "./", + "strict": true, + "paths": { + "@/*": ["src/*"] + }, + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "lib", + "es", + "dist", + "docs-dist", + "typings", + "**/__test__", + "test", + "fixtures" + ] +} diff --git a/plugins/video/README.md b/plugins/video/README.md new file mode 100644 index 00000000..d067adbb --- /dev/null +++ b/plugins/video/README.md @@ -0,0 +1,209 @@ +# @aomao/plugin-video + +视频插件 + +## 安装 + +```bash +$ yarn add @aomao/plugin-video +``` + +添加到引擎 + +```ts +import Engine, { EngineInterface } from '@aomao/engine'; +import Video , { VideoComponent , VideoUploader } from '@aomao/plugin-video'; + +new Engine(...,{ plugins:[ Video , VideoUploader ] , cards:[ VideoComponent ]}) +``` + +`VideoUploader` 插件主要功能:选择视频文件、上传视频文件 + +## `Video` 可选项 + +`onBeforeRender` 设置视频地址前可或者下载视频时可对地址修改。另外还可以对视频的主图修改地址。 + +```ts +onBeforeRender?: (action: 'download' | 'query' | 'cover', url: string) => string; +``` + +## `VideoUploader` 可选项 + +```ts +//使用配置 +new Engine(...,{ + config:{ + [VideoUploader.pluginName]:{ + //...相关配置 + } + } + }) +``` + +### 文件上传 + +`action`: 上传地址,始终使用 `POST` 请求 + +`crossOrigin`: 是否跨域 + +`headers`: 请求头 + +`contentType`: 文件上传默认以 `multipart/form-data;` 类型上传 + +`accept`: 限制用户文件选择框选择的文件类型,默认 `mp4` 格式 + +`limitSize`: 限制用户选择的文件大小,超过限制将不请求上传。默认:`1024 * 1024 * 5` 5M + +`multiple`: `false` 一次只能上传一个文件,`true` 默认一次最多 100 个文件。可以指定具体数量,但是文件选择框无法限制,只能上传的时候限制上传最前面的张数 + +`data`: 文件上传时同时将这些数据一起`POST`到服务端 + +`name`: 文件上传请求时,请求参数在 `FormData` 中的名称,默认 `file` + +```ts +/** + * 文件上传地址 + */ +action:string +/** + * 是否跨域 + */ +crossOrigin?: boolean; +/** +* 请求头 +*/ +headers?: { [key: string]: string } | (() => { [key: string]: string }); +/** + * 数据返回类型,默认 json + */ +type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js'; +/** + * 视频文件上传时 FormData 的名称,默认 file + */ +name?: string +/** + * 额外携带数据上传 + */ +data?: {}; +/** + * 请求类型,默认 multipart/form-data; + */ +contentType?:string +/** + * 文件接收的格式,默认 "*" 所有的 + */ +accept?: string | Array; +/** + * 文件选择限制数量 + */ +multiple?: boolean | number; +/** + * 上传大小限制,默认 1024 * 1024 * 5 就是5M + */ +limitSize?: number; + +``` + +### 查询视频信息 + +在对视频有播放权限或限制、对其它无法使用 html5 直接播放需要转码后才能播放的视频文件、需要对视频进行其它处理的视频文件都可能需要这个配置 + +以上的视频文件上传处理流程: + +- 选择文件上传后需要返回 `status` 字段并标明值为 `transcoding`,并且需要返回这个视频文件在服务端的唯一标识 `id` ,这个标识能够在后续查询中辨别这个视频文件以或得视频文件处理信息,否则一律视为 `done` 直接传输给 video 标签播放 +- 插件获取到 `status` 字段值为 `transcoding` 时,会展示等待 `转码中...` 信息,并且每 3 秒通过 `id` 参数调用查询接口获取视频文件处理状态,直到 `status` 的值不为 `transcoding` 时终止轮询 + +除此之外,在有配置 `查询视频信息接口` 后,每次展示视频时都会调用 `查询视频信息接口` 查询一次,接口返回的结果将作为展示视频信息的参数 + +```ts +/** + * 查询视频信息 + */ +query?: { + /** + * 查询地址 + */ + action: string; + /** + * 数据返回类型,默认 json + */ + type?: '*' | 'json' | 'xml' | 'html' | 'text' | 'js'; + /** + * 额外携带数据上传 + */ + data?: {}; + /** + * 请求类型,默认 multipart/form-data; + */ + contentType?: string; +} +``` + +### 解析服务端响应数据 + +默认会查找 + +视频文件地址:response.url || response.data && response.data.url 一般为可播放的 mp4 地址 +视频文件标识:response.id || response.data && response.data.id 可选参数,配置了`视频查询接口`必须 +视频文件封面图片地址:response.cover || response.cover && response.data.cover 可选参数 +视频文件处理状态:response.status || response.status && response.data.status 可选参数,配置了`视频查询接口`必须,否则一律视为 `done` +视频下载地址:response.download || response.data && response.data.download 视频文件的下载地址,可以加权限、时间限制等等,如果有可以返回地址 + +`result`: true 上传成功,data 为视频信息。false 上传失败,data 为错误消息 + +```ts +/** + * 解析上传后的Respone,返回 result:是否成功,data:成功:视频信息,失败:错误信息 + */ +parse?: ( + response: any, +) => { + result: boolean; + data: { + url: string, + id?: string, + cover?: string + status?: string + } | string; +}; +``` + +## 命令 + +### `Video` 插件命令 + +插入一个文件 + +参数 1:文件状态`uploading` | `done` | `transcoding` | `error` 上传中、上传完成、转码中、上传错误 + +参数 2:在状态非 `error` 下,为展示文件,否则展示错误消息 + +```ts +//'uploading' | 'done' | `transcoding` | 'error' +engine.command.execute( + Video.pluginName, + 'done', + '视频地址', + '视频名称', //可选,默认为视频地址 + '视频标识', //可选,配置了 查询视频信息接口 必须 + '视频封面', //可选 + '视频大小', //可选 + '下载地址', //可选 +); +``` + +### `VideoUploader` 插件命令 + +弹出文件选择框,并执行上传 + +可选参数 1: 传入文件列表,将上传这些文件。否则弹出文件选择框并,选择文件后执行上传。或者传入 `query` 命令,查询视频文件状态 +可选参数 2: 查询视频文件信息状态的参数,0.视频文件标识,1.成功处理后的回调,2.失败处理后的回调 + +```ts +//方法签名 +async execute(files?: Array | MouseEvent | string,...args:any):void +//执行命令 +engine.command.execute(VideoUploader.pluginName,file); +//查询 +engine.command.execute(VideoUploader.pluginName,"query","标识",success: (data?:{ url: string, name?: string, cover?: string, download?: string, status?: string }) => void, failed: (message: string) => void = () => {}); +``` diff --git a/plugins/video/package.json b/plugins/video/package.json new file mode 100644 index 00000000..4ccb566b --- /dev/null +++ b/plugins/video/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aomao/plugin-video", + "version": "2.5.3", + "main": "dist/index.ts", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "files": [ + "dist", + "lib", + "src" + ], + "author": "me@yanmao.cc", + "license": "MIT", + "homepage": "https://github.com/yanmao-cc/am-editor#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/yanmao-cc/am-editor.git" + }, + "bugs": { + "url": "https://github.com/yanmao-cc/am-editor/issues" + }, + "dependencies": { + "@aomao/engine": "^2.5.3", + "@babel/runtime": "^7.13.10" + } +} diff --git a/plugins/video/src/component/index.css b/plugins/video/src/component/index.css new file mode 100644 index 00000000..6fa0d264 --- /dev/null +++ b/plugins/video/src/component/index.css @@ -0,0 +1,83 @@ +[data-card-key="video"] { + outline: 1px solid #ddd; +} + .data-video-content { + position: relative; + height: 420px; + background: #f7f7f7; + } + .data-video-content video { + width: 100%; + outline: none; + } + .data-video-uploading, + .data-video-uploaded, + .data-video-error { + border: 1px solid #e6e6e6; + background: #f6f6f6; + } + .data-video-done { + height: auto; + border: none; + background: none; + line-height: 0; + } + .data-video-active { + outline: 1px solid #d9d9d9; + } + .data-video-center { + position: absolute; + top: 50%; + margin-top: -48px; + width: 100%; + height: 96px; + } + .data-video-center .data-video-icon, + .data-video-center .data-video-name, + .data-video-center .data-video-message, + .data-video-center .data-video-progress, + .data-video-center .data-video-transcoding { + text-align: center; + } + .data-video-center .data-video-icon { + font-size: 24px; + color: #BFBFBF; + margin-bottom: 12px; + } + .data-video-center .data-video-name { + color: #595959; + margin-bottom: 12px; + } + .data-video-center .data-video-message { + color: #595959; + } + .data-video-center .data-video-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-video-center .data-video-anticon .data-video-anticon-spin { + display: inline-block; + -webkit-animation: loadingCircle 1s infinite linear; + animation: loadingCircle 1s infinite linear; + } + .data-video-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/plugins/video/src/component/index.ts b/plugins/video/src/component/index.ts new file mode 100644 index 00000000..739ecf87 --- /dev/null +++ b/plugins/video/src/component/index.ts @@ -0,0 +1,453 @@ +import { Tooltip } from '@aomao/engine'; +import { + $, + Card, + CardToolbarItemOptions, + CardType, + escape, + getFileSize, + isEngine, + isMobile, + NodeInterface, + sanitizeUrl, + ToolbarItemOptions, +} from '@aomao/engine'; +import './index.css'; + +export type VideoValue = { + /** + * 视频唯一标识 + */ + video_id?: string; + /** + * 视频名称 + */ + name: string; + /** + * 视频地址 + */ + url: string; + /** + * 下载地址 + */ + download?: string; + /** + * 视频封面地址 + */ + cover?: string; + /** + * 状态 + * uploading 上传中 + * done 上传成功 + */ + status?: 'uploading' | 'transcoding' | 'done' | 'error'; + /** + * 上传进度 + */ + percent?: number; + /** + * 视频大小 + */ + size?: number; + /** + * 错误状态下的错误信息 + */ + message?: string; +}; + +class VideoComponent extends Card { + static get cardName() { + return 'video'; + } + + static get cardType() { + return CardType.BLOCK; + } + + static get autoSelected() { + return false; + } + + private container?: NodeInterface; + + getLocales() { + return this.editor.language.get<{ [key: string]: string }>('video'); + } + + renderTemplate(value: VideoValue) { + const { name, status, size, message, percent } = value; + const locales = this.getLocales(); + + const icons = { + video: `
      +
      `, + 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.video} +
      + ${escape(name)} (${escape(fileSize)}) +
      +
      + ${icons.spin} + ${percent || 0}% +
      +
      +
      +
      `; + } + const isLoading = typeof status === 'undefined'; + if (status === 'transcoding' || isLoading) { + return ` +
      +
      +
      + ${icons.video} +
      + ${escape(name)} (${escape(fileSize)}) +
      +
      + ${icons.spin} + ${ + isLoading + ? locales['loading'] + : locales['transcoding'] + }% +
      +
      +
      +
      + `; + } + + return ` +
      +
      +
      + `; + } + + onBeforeRender = (action: 'query' | 'download' | 'cover', url: string) => { + const videoPlugin = this.editor.plugin.components['video']; + if (videoPlugin) { + const { onBeforeRender } = videoPlugin['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 video = document.createElement('video'); + video.preload = 'none'; + video.setAttribute('src', url); + video.setAttribute('webkit-playsinline', 'webkit-playsinline'); + video.setAttribute('playsinline', 'playsinline'); + + const { cover } = value; + + if (cover) { + video.poster = sanitizeUrl(this.onBeforeRender('cover', cover)); + } + + this.container?.find('.data-video-content').append(video); + + video.oncontextmenu = function () { + return false; + }; + // 一次渲染时序开启 controls 会触发一次内容为空的 window.onerror,疑似 chrome bug + setTimeout(() => { + video.controls = true; + if (!isEngine(this.editor)) + (video as HTMLMediaElement)['controlsList'] = 'nodownload'; + }, 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-video-active'); + else this.container?.removeClass('data-video-active'); + } + + checker( + video_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( + 'video-uploader', + 'query', + video_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 { video_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: VideoValue = { + ...value, + url: !!data?.url ? data.url : value.url, + name: !!data?.name ? data.name : value.name, + cover: !!data?.cover ? data.cover : value.cover, + download: !!data?.download + ? data.download + : value.download, + }; + this.container = $(this.renderTemplate(newValue)); + center.empty(); + center.append(this.container); + this.initPlayer(); + }; + if (plugin.components['video-uploader']) { + command.executeMethod( + 'video-uploader', + 'query', + video_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 (!video_id) throw 'video id is undefined'; + this.checker( + video_id, + (data?: { + url: string; + name?: string; + cover?: string; + download?: string; + status?: string; + }) => { + const newValue: VideoValue = { + ...value, + url: !!data?.url ? data.url : value.url, + name: !!data?.name ? data.name : value.name, + cover: !!data?.cover ? data.cover : value.cover, + 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: VideoValue = { + ...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( + 'video-uploader', + 'query', + video_id, + (data?: { + url: string; + name?: string; + cover?: string; + download?: string; + }) => { + const newValue: VideoValue = { + ...value, + url: !!data?.url ? data.url : value.url, + name: !!data?.name ? data.name : value.name, + cover: !!data?.cover ? data.cover : value.cover, + 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 VideoComponent; diff --git a/plugins/video/src/index.ts b/plugins/video/src/index.ts new file mode 100644 index 00000000..868345c7 --- /dev/null +++ b/plugins/video/src/index.ts @@ -0,0 +1,203 @@ +import { + $, + CardEntry, + CardInterface, + CARD_KEY, + decodeCardValue, + encodeCardValue, + isEngine, + NodeInterface, + Plugin, + PluginEntry, + sanitizeUrl, + SchemaInterface, +} from '@aomao/engine'; +import VideoComponent, { VideoValue } from './component'; +import VideoUploader from './uploader'; +import locales from './locales'; + +export default class VideoPlugin extends Plugin<{ + onBeforeRender?: ( + action: 'download' | 'query' | 'cover', + url: string, + ) => string; +}> { + static get pluginName() { + return 'video'; + } + + 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, + video_id?: string, + cover?: string, + size?: number, + download?: string, + ): void { + const value: VideoValue = { + status, + video_id, + cover, + url, + name: name || url, + size, + download, + }; + if (status === 'error') { + value.url = ''; + value.message = url; + } + this.editor.card.insert('video', 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 === + VideoComponent.cardName && + (component as VideoComponent).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(async (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: 'video', + isVoid: true, + attributes: { + src: { + required: true, + value: '@url', + }, + 'data-value': '*', + }, + }); + } + + pasteHtml(node: NodeInterface) { + if (!isEngine(this.editor)) return; + if (node.isElement() && node.name === 'video') { + const value = node.attributes('data-value'); + let cardValue = decodeCardValue(value); + if (!cardValue.url) { + cardValue = { + url: node.attributes('src'), + name: + node.attributes('data-name') || + node.attributes('name') || + node.attributes('title') || + node.attributes('src'), + cover: node.attributes('poster'), + status: 'done', + }; + } + this.editor.card.replaceNode( + node, + VideoComponent.cardName, + cardValue, + ); + node.remove(); + return false; + } + return true; + } + + parseHtml(root: NodeInterface) { + root.find(`[${CARD_KEY}=${VideoComponent.cardName}`).each( + (cardNode) => { + const node = $(cardNode); + const card = this.editor.card.find(node) as VideoComponent; + const value = card?.getValue(); + if (value?.url && value.status === 'done') { + const { onBeforeRender } = this.options; + const { cover, url } = value; + const html = `