@2kog/pkg-editor 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,72 @@
1
+ import sanitizeHtml from "sanitize-html";
2
+
3
+ const FORBID = new Set(["script", "object", "embed", "applet", "base", "meta", "link"]);
4
+
5
+ const EXTRA_TAGS = [
6
+ "img",
7
+ "h1", "h2", "h3", "h4", "h5", "h6",
8
+ "table", "thead", "tbody", "tr", "th", "td",
9
+ "blockquote", "pre", "code", "hr",
10
+ "video", "source",
11
+ "iframe",
12
+ ];
13
+
14
+ export default function sanitizeRichText(html) {
15
+ if (!html) return html;
16
+
17
+ const allowedTags = sanitizeHtml.defaults.allowedTags
18
+ .concat(EXTRA_TAGS)
19
+ .filter((t, i, arr) => arr.indexOf(t) === i) // 去重
20
+ .filter(t => !FORBID.has(t));
21
+
22
+ return sanitizeHtml(html, {
23
+ disallowedTagsMode: "discard",
24
+ allowedTags,
25
+
26
+ allowedAttributes: {
27
+ "*": ["class", "style", "title", "id", "data-*"],
28
+ a: ["href", "target", "rel", "title", "class", "style"],
29
+ img: ["src", "alt", "title", "width", "height", "class", "style", "loading", "decoding"],
30
+ video: ["controls", "width", "height", "class", "style"],
31
+ source: ["src", "type"],
32
+ iframe: ["src","width","height","frameborder","scrolling","allowfullscreen","sandbox","referrerpolicy","class","style"],
33
+ },
34
+
35
+ allowedSchemes: ["http", "https", "mailto", "tel"],
36
+ allowProtocolRelative: false,
37
+ allowedSchemesByTag: { img: ["http", "https", "data"],iframe: ["http", "https"], },
38
+
39
+ transformTags: {
40
+ "*": (tagName, attribs) => {
41
+ const out = { ...attribs };
42
+
43
+ // 移除 on*
44
+ for (const k of Object.keys(out)) if (/^on/i.test(k)) delete out[k];
45
+
46
+ // style 禁 @import / url(
47
+ if (typeof out.style === "string" && out.style) {
48
+ let s = out.style;
49
+ s = s.replace(/@import\s+[^;]+;?/gi, "");
50
+ s = s.replace(/url\s*\(\s*[^)]+\s*\)/gi, "");
51
+ s = s.replace(/;;+/g, ";").trim();
52
+ if (!s) delete out.style;
53
+ else out.style = s;
54
+ }
55
+
56
+ // 兜底禁 javascript:
57
+ for (const key of ["href", "src"]) {
58
+ if (out[key] && /^\s*javascript:/i.test(out[key])) out[key] = "";
59
+ }
60
+
61
+ // 仅允许 data:image/*
62
+ if (tagName === "img" && typeof out.src === "string" && out.src.startsWith("data:")) {
63
+ if (!/^data:image\/(png|jpe?g|gif|webp|avif|bmp|svg\+xml);/i.test(out.src)) {
64
+ out.src = "";
65
+ }
66
+ }
67
+
68
+ return { tagName, attribs: out };
69
+ },
70
+ },
71
+ });
72
+ }
@@ -0,0 +1,218 @@
1
+ <template>
2
+ <div class="c-article-tinymce c-article-box">
3
+ <!-- <div id="c-article-origin" class="c-article-origin" ref="origin"><slot></slot></div> -->
4
+ <div id="c-article" class="c-article" ref="article" v-if="pageable">
5
+ <div
6
+ class="c-article-chunk"
7
+ v-for="(text, i) in data"
8
+ :key="i"
9
+ v-html="text"
10
+ :class="{ on: i == page - 1 || all == true }"
11
+ :id="'c-article-part' + ~~(i + 1)"
12
+ ></div>
13
+ </div>
14
+ <div id="c-article" class="c-article" ref="article" v-else-if="data && data.length" v-html="data[0]"></div>
15
+ <el-button class="c-article-all" type="primary" v-if="!all && hasPages" @click="showAll">加载全部</el-button>
16
+ <el-pagination
17
+ class="c-article-pages"
18
+ v-if="!all"
19
+ background
20
+ center
21
+ :page-size="1"
22
+ :hide-on-single-page="true"
23
+ @current-change="changePage"
24
+ :current-page="page"
25
+ layout="total, prev, pager, next, jumper"
26
+ :total="total"
27
+ ></el-pagination>
28
+
29
+ <!-- 相册 -->
30
+ <!-- <el-image-viewer
31
+ v-if="showImageViewer"
32
+ :url-list="images"
33
+ @close="onImageViewerClose"
34
+ :initialIndex="imageIndex"
35
+ hide-on-click-modal
36
+ ></el-image-viewer> -->
37
+ </div>
38
+ </template>
39
+
40
+ <script>
41
+ import { ElPagination, ElButton } from "element-plus";
42
+ import "element-plus/dist/index.css";
43
+
44
+ // XSS
45
+ import execFilterXSS from "../assets/js/xss";
46
+ // const execFilterXSS = require("xss");
47
+ // const xss_options = {
48
+ // allowCommentTag: true,
49
+ // };
50
+
51
+ // 基本文本
52
+ import execLazyload from "../assets/js/img";
53
+ import execFilterIframe from "../assets/js/iframe";
54
+ import execFilterLink from "../assets/js/a";
55
+ import execSplitPages from "../assets/js/nextpage";
56
+
57
+ // 扩展文本
58
+ import renderFoldBlock from "../assets/js/fold";
59
+ import renderDirectory from "../assets/js/directory";
60
+ import renderKatex from "../assets/js/katex";
61
+ import renderCode from "../assets/js/code";
62
+ import renderImgPreview from "../assets/js/renderImgPreview";
63
+
64
+ export default {
65
+ name: "ArticleRender",
66
+ props: {
67
+ // 内容
68
+ content: String,
69
+
70
+ // 拼接相对路径地址的图片,需要自带协议
71
+ cdnDomain: {
72
+ type: String,
73
+ default: "",
74
+ },
75
+ // 链接白名单检查,不在白名单,使用新窗跳转
76
+ linkWhitelist: {
77
+ type: Array,
78
+ default: function () {
79
+ return [];
80
+ },
81
+ },
82
+ // 链接白名单强制模式,开启后不在白名单的链接一律置空,不允许跳转
83
+ linkStrict: {
84
+ type: Boolean,
85
+ default: false,
86
+ },
87
+ // iframe白名单检查,不在白名单,移除iframe
88
+ iframeWhitelist: {
89
+ type: Array,
90
+ default: function () {
91
+ return [];
92
+ },
93
+ },
94
+ // 目录容器选择器
95
+ directorybox: String,
96
+ // 是否开启分页
97
+ pageable: {
98
+ type: Boolean,
99
+ default: true,
100
+ },
101
+ },
102
+ data: function () {
103
+ return {
104
+ // 作品
105
+ all: false,
106
+ page: 1,
107
+ data: [],
108
+ mode: "",
109
+ };
110
+ },
111
+ computed: {
112
+ total: function () {
113
+ return this.chunks.length;
114
+ },
115
+ hasPages: function () {
116
+ return this.chunks.length > 1;
117
+ },
118
+ origin: function () {
119
+ return this.content;
120
+ },
121
+ chunks: function () {
122
+ return this.pageable ? execSplitPages(this.origin) : [this.origin];
123
+ },
124
+ },
125
+ methods: {
126
+ doReg: function (data) {
127
+ if (data) {
128
+ // 过滤内容
129
+ data = execLazyload(data, this.cdnDomain);
130
+ data = execFilterIframe(data, this.iframeWhitelist);
131
+ data = execFilterXSS(data);
132
+ data = execFilterLink(data, this.linkWhitelist, this.linkStrict);
133
+ return data;
134
+ } else {
135
+ return "";
136
+ }
137
+ },
138
+ doDOM: function ($root) {
139
+ // 折叠块
140
+ renderFoldBlock($root);
141
+ // 代码
142
+ renderCode(`code[class=^'language-']`);
143
+ // Latex
144
+ renderKatex();
145
+
146
+ // 画廊
147
+ renderImgPreview();
148
+ },
149
+ doDir: function () {
150
+ // 显示局部
151
+ let target = "";
152
+ if (this.hasPages && !this.all) {
153
+ target = "#c-article-part" + this.page;
154
+ // 全部
155
+ } else {
156
+ target = "#c-article";
157
+ }
158
+ let dir = renderDirectory(target, this.directorybox);
159
+ this.$emit("directoryRendered", dir);
160
+ },
161
+ changePage: function (i) {
162
+ this.page = i;
163
+ window.scrollTo(0, 0);
164
+ this.$nextTick(() => {
165
+ this.doDir();
166
+ });
167
+ },
168
+ showAll: function () {
169
+ this.all = true;
170
+ this.$nextTick(() => {
171
+ this.doDir();
172
+ });
173
+ },
174
+ render: function () {
175
+ let result = [];
176
+ for (let chunk of this.chunks) {
177
+ let _chunk = this.doReg(chunk);
178
+ result.push(_chunk);
179
+ }
180
+ this.data = result;
181
+ },
182
+ run: function () {
183
+ this.render();
184
+
185
+ // 等待html加载完毕后
186
+ this.$nextTick(() => {
187
+ this.$emit("contentLoaded");
188
+
189
+ // 统一DOM处理
190
+ const $root = this.$refs.article;
191
+ this.doDOM($root);
192
+ this.$emit("contentRendered");
193
+
194
+ // 目录处理
195
+ this.doDir();
196
+ });
197
+ },
198
+ },
199
+ watch: {
200
+ content: function () {
201
+ this.run();
202
+ },
203
+ },
204
+ mounted: function () {
205
+ const params = new URLSearchParams(location.search);
206
+ this.mode = params.get("mode") || "";
207
+ this.run();
208
+ },
209
+ components: {
210
+ "el-pagination": ElPagination,
211
+ "el-button": ElButton,
212
+ },
213
+ };
214
+ </script>
215
+
216
+ <style lang="less">
217
+ @import "../assets/css/article.less";
218
+ </style>
@@ -0,0 +1,189 @@
1
+ <template>
2
+ <div class="c-editor-tinymce">
3
+ <slot name="prepend"></slot>
4
+
5
+ <div class="c-editor-header">
6
+ <Upload v-if="attachmentEnable" @insert="insertAttachments" :uploadFn="attachmentUploadFn" :domain="attachmentCdnDomain" />
7
+ </div>
8
+
9
+ <slot></slot>
10
+
11
+ <editor
12
+ id="tinymce"
13
+ v-model="data"
14
+ :init="init"
15
+ class="c-tinymce"
16
+ placeholder="✔ 图片可右键粘贴或拖拽至编辑器内自动上传 ✔ 支持word/excel内容一键粘贴"
17
+ />
18
+ <el-alert class="u-tutorial" type="warning" show-icon v-if="showTips"
19
+ >进入特殊区域(代码块,折叠块等等)脱离或使用工具栏触发后,请使用键盘方向 → ↓
20
+ 键进行脱离,回车只是正常在区块内换行。去掉样式点击第二行第一个&lt;清除格式&gt;即可复位。
21
+ <!-- <a href="" target="_blank">[编辑器使用指南]</a> -->
22
+ </el-alert>
23
+
24
+ <slot name="append"></slot>
25
+ </div>
26
+ </template>
27
+
28
+ <script>
29
+ import Editor from "@tinymce/tinymce-vue";
30
+ import Upload from "./Upload";
31
+ import hljs_languages from "../assets/js/hljs_languages.js";
32
+ import GlobalConf from '../../config/global.js';
33
+
34
+ export default {
35
+ name: "Tinymce",
36
+ props: {
37
+ // 内容
38
+ modelValue: {
39
+ type: String,
40
+ default: "",
41
+ },
42
+ // 默认高度
43
+ height: {
44
+ type: Number,
45
+ default: 800,
46
+ },
47
+ // Tinymce右键粘贴上传函数
48
+ tinymceUploadFn: {
49
+ type: Function,
50
+ default: () => {},
51
+ },
52
+ // Tinymce资源CDN拼接域名
53
+ tinymceAssetsDomain: {
54
+ type: String,
55
+ default: "",
56
+ },
57
+ // 是否显示编辑器使用提示
58
+ showTips: {
59
+ type: Boolean,
60
+ default: true,
61
+ },
62
+
63
+
64
+ // 是否启用附件上传
65
+ attachmentEnable: {
66
+ type: Boolean,
67
+ default: true,
68
+ },
69
+ // 附件上传函数
70
+ attachmentUploadFn: {
71
+ type: Function,
72
+ default: () => {},
73
+ },
74
+ // 附件CDN拼接域名
75
+ attachmentCdnDomain: {
76
+ type: String,
77
+ default: "",
78
+ },
79
+
80
+ },
81
+ emits: ["update:modelValue"],
82
+ data: function () {
83
+ return {
84
+ data: "",
85
+ init: {
86
+ // 选择器
87
+ selector: "#tinymce",
88
+
89
+ // 语言
90
+ language: "zh_CN",
91
+
92
+ // 设置
93
+ convert_urls: false,
94
+
95
+ // 样式
96
+ content_css: process.env.VUE_APP_TINYMCE_DEV === "true" ? `http://localhost:5120/skins/content/default/content.min.css` : `${this.tinymceAssetsDomain}/static/tinymce/skins/content/default/content.min.css`,
97
+ body_class: "c-article c-article-editor c-article-tinymce",
98
+ height: this.height || 800,
99
+ autosave_ask_before_unload: false,
100
+
101
+ // UI
102
+ icons: "custom",
103
+ menubar: false,
104
+ branding: false,
105
+ contextmenu: "",
106
+ plugins: GlobalConf.plugins,
107
+ toolbar: GlobalConf.toolbar,
108
+ mobile: GlobalConf.mobile,
109
+ block_formats: "段落=p;一级标题=h1;二级标题=h2;三级标题=h3;四级标题=h4;五级标题=h5;六级标题=h6;",
110
+ fontsize_formats: "12px 14px 16px 18px 22px 24px 26px 28px 32px 48px 72px",
111
+ color_map: GlobalConf.color_map,
112
+
113
+ codesample_languages: hljs_languages,
114
+
115
+ // Image
116
+ image_advtab: true,
117
+ file_picker_types: "file image",
118
+ // images_upload_url: this.uploadUrl,
119
+ automatic_uploads: true,
120
+ // images_upload_credentials: true,
121
+ images_upload_handler: this.image_upload_handler,
122
+ valid_children: "+body[style]",
123
+ },
124
+ mode: "tinymce",
125
+ };
126
+ },
127
+ watch: {
128
+ data: function (val) {
129
+ this.$emit("update:modelValue", val);
130
+ },
131
+ modelValue: {
132
+ immediate: true,
133
+ handler: function (val) {
134
+ this.data = val;
135
+ },
136
+ },
137
+ },
138
+ methods: {
139
+ setup: function (editor) {
140
+ console.log("ID为: " + editor.id + " 的编辑器即将初始化.");
141
+ },
142
+ ready: function (editor) {
143
+ console.log("ID为: " + editor.id + " 的编辑器已初始化完成.");
144
+ },
145
+ insertAttachments: function (data) {
146
+ // eslint-disable-next-line no-undef
147
+ tinyMCE.editors["tinymce"].insertContent(data.html);
148
+ },
149
+ insertResource: function (data) {
150
+ // eslint-disable-next-line no-undef
151
+ tinyMCE.editors["tinymce"].insertContent(data);
152
+ },
153
+ image_upload_handler: function (blobInfo, success, failure) {
154
+ const formData = new FormData();
155
+ formData.append("file", blobInfo.blob(), blobInfo.filename());
156
+
157
+ this.tinymceUploadFn(formData)
158
+ .then((res) => {
159
+ const json = res.data;
160
+
161
+ success(json.location);
162
+ })
163
+ .catch((error) => {
164
+ if (error.response) {
165
+ // 请求已发出,但服务器响应的状态码不在 2xx 范围内
166
+ failure("Image upload failed. Status: " + error.response.status);
167
+ } else if (error.request) {
168
+ // 请求已发出,但没有收到响应
169
+ failure("Image upload failed. No response received.");
170
+ } else {
171
+ // 发送请求时出了some问题
172
+ failure("Image upload failed. Error: " + error.message);
173
+ }
174
+ });
175
+ },
176
+ },
177
+ mounted: function () {
178
+ // console.log(process.env.VUE_APP_TINYMCE_DEV)
179
+ },
180
+ components: {
181
+ Editor,
182
+ Upload,
183
+ },
184
+ };
185
+ </script>
186
+
187
+ <style lang="less">
188
+ @import "../assets/css/tinymce.less";
189
+ </style>