@jx3box/jx3box-editor 3.0.2 → 3.0.4
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.
- package/package.json +2 -1
- package/src/Article.vue +68 -15
- package/src/Markdown.vue +330 -62
- package/src/assets/js/normalizeMarkdownForVditor.js +39 -0
- package/src/assets/js/xss.js +35 -7
- package/src/views/article.vue +14 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jx3box/jx3box-editor",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.4",
|
|
4
4
|
"description": "JX3BOX Article & Editor",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"js-base64": "^3.6.1",
|
|
33
33
|
"katex": "^0.16.4",
|
|
34
34
|
"lodash": "^4.17.21",
|
|
35
|
+
"lucide": "^0.577.0",
|
|
35
36
|
"photoswipe": "^4.1.2",
|
|
36
37
|
"sanitize-html": "^2.17.0",
|
|
37
38
|
"sortablejs": "^1.15.0",
|
package/src/Article.vue
CHANGED
|
@@ -8,12 +8,19 @@
|
|
|
8
8
|
v-for="(text, i) in data"
|
|
9
9
|
:key="i"
|
|
10
10
|
v-html="text"
|
|
11
|
-
:class="{ on: i == page - 1 || all == true }"
|
|
11
|
+
:class="{ on: i == page - 1 || all == true, 'markdown-body': isMarkdownMode }"
|
|
12
12
|
:id="'c-article-part' + ~~(i + 1)"
|
|
13
13
|
></div>
|
|
14
14
|
</div>
|
|
15
15
|
|
|
16
|
-
<div
|
|
16
|
+
<div
|
|
17
|
+
id="c-article"
|
|
18
|
+
class="c-article"
|
|
19
|
+
ref="article"
|
|
20
|
+
v-else-if="data && data.length"
|
|
21
|
+
v-html="data[0]"
|
|
22
|
+
:class="{ 'markdown-body': isMarkdownMode }"
|
|
23
|
+
></div>
|
|
17
24
|
|
|
18
25
|
<el-button class="c-article-all" type="primary" v-if="!all && hasPages" @click="showAll">加载全部</el-button>
|
|
19
26
|
|
|
@@ -48,6 +55,8 @@
|
|
|
48
55
|
|
|
49
56
|
<script>
|
|
50
57
|
import $ from "jquery";
|
|
58
|
+
import Vditor from "vditor";
|
|
59
|
+
import "github-markdown-css/github-markdown-light.css";
|
|
51
60
|
|
|
52
61
|
// XSS
|
|
53
62
|
import execFilterXSS from "./assets/js/xss";
|
|
@@ -57,6 +66,7 @@ import execLazyload from "./assets/js/img";
|
|
|
57
66
|
import execFilterIframe from "./assets/js/iframe";
|
|
58
67
|
import execFilterLink from "./assets/js/a";
|
|
59
68
|
import execSplitPages from "./assets/js/nextpage";
|
|
69
|
+
import normalizeMarkdownForVditor from "./assets/js/normalizeMarkdownForVditor";
|
|
60
70
|
|
|
61
71
|
// 扩展文本
|
|
62
72
|
import renderFoldBlock from "./assets/js/fold";
|
|
@@ -85,8 +95,7 @@ import renderJx3Element from "./assets/js/jx3_element";
|
|
|
85
95
|
export default {
|
|
86
96
|
name: "Article",
|
|
87
97
|
props: {
|
|
88
|
-
|
|
89
|
-
post_mode : {
|
|
98
|
+
post_mode: {
|
|
90
99
|
type: String,
|
|
91
100
|
default: "tinymce",
|
|
92
101
|
},
|
|
@@ -134,6 +143,7 @@ export default {
|
|
|
134
143
|
page: 1,
|
|
135
144
|
data: [],
|
|
136
145
|
mode: "",
|
|
146
|
+
renderVersion: 0,
|
|
137
147
|
|
|
138
148
|
// 画廊
|
|
139
149
|
gallery_index: null,
|
|
@@ -180,14 +190,17 @@ export default {
|
|
|
180
190
|
},
|
|
181
191
|
computed: {
|
|
182
192
|
total: function () {
|
|
183
|
-
return this.
|
|
193
|
+
return this.data.length;
|
|
184
194
|
},
|
|
185
195
|
hasPages: function () {
|
|
186
|
-
return this.
|
|
196
|
+
return this.data.length > 1;
|
|
187
197
|
},
|
|
188
198
|
origin: function () {
|
|
189
199
|
return this.content;
|
|
190
200
|
},
|
|
201
|
+
isMarkdownMode: function () {
|
|
202
|
+
return ["markdown", "md", "vditor"].includes(String(this.post_mode || "").toLowerCase());
|
|
203
|
+
},
|
|
191
204
|
chunks: function () {
|
|
192
205
|
return this.pageable ? execSplitPages(this.origin) : [this.origin];
|
|
193
206
|
},
|
|
@@ -195,9 +208,7 @@ export default {
|
|
|
195
208
|
methods: {
|
|
196
209
|
getHeaderHeight: function () {
|
|
197
210
|
// 页面上可能没有这些元素,取存在的第一个:.c-header 优先,其次 .c-breadcrumb
|
|
198
|
-
const el =
|
|
199
|
-
document.querySelector(".c-header") ||
|
|
200
|
-
document.querySelector(".c-breadcrumb");
|
|
211
|
+
const el = document.querySelector(".c-header") || document.querySelector(".c-breadcrumb");
|
|
201
212
|
if (!el) return 0;
|
|
202
213
|
const rect = el.getBoundingClientRect && el.getBoundingClientRect();
|
|
203
214
|
const h = (rect && rect.height) || el.offsetHeight || 0;
|
|
@@ -232,6 +243,34 @@ export default {
|
|
|
232
243
|
return "";
|
|
233
244
|
}
|
|
234
245
|
},
|
|
246
|
+
renderMarkdownChunk: async function (chunk) {
|
|
247
|
+
const temp = document.createElement("div");
|
|
248
|
+
const normalizedChunk = normalizeMarkdownForVditor(chunk);
|
|
249
|
+
|
|
250
|
+
await Vditor.preview(temp, normalizedChunk, {
|
|
251
|
+
mode: "light",
|
|
252
|
+
lang: "zh_CN",
|
|
253
|
+
hljs: {
|
|
254
|
+
enable: true,
|
|
255
|
+
lineNumber: false,
|
|
256
|
+
style: "github",
|
|
257
|
+
},
|
|
258
|
+
markdown: {
|
|
259
|
+
mark: true,
|
|
260
|
+
sanitize: true,
|
|
261
|
+
toc: true,
|
|
262
|
+
},
|
|
263
|
+
icon: "ant",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
return temp.innerHTML;
|
|
267
|
+
},
|
|
268
|
+
renderMarkdown: async function () {
|
|
269
|
+
const html = await this.renderMarkdownChunk(this.origin);
|
|
270
|
+
const chunks = this.pageable ? execSplitPages(html) : [html];
|
|
271
|
+
|
|
272
|
+
return chunks.map((chunk) => this.doReg(chunk));
|
|
273
|
+
},
|
|
235
274
|
doDOM: function ($root) {
|
|
236
275
|
// 折叠块
|
|
237
276
|
renderFoldBlock($root);
|
|
@@ -305,16 +344,27 @@ export default {
|
|
|
305
344
|
this.doDir();
|
|
306
345
|
});
|
|
307
346
|
},
|
|
308
|
-
render: function () {
|
|
347
|
+
render: async function () {
|
|
348
|
+
const version = ++this.renderVersion;
|
|
309
349
|
let result = [];
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
result.
|
|
350
|
+
|
|
351
|
+
if (this.isMarkdownMode) {
|
|
352
|
+
result = await this.renderMarkdown();
|
|
353
|
+
} else {
|
|
354
|
+
for (let chunk of this.chunks) {
|
|
355
|
+
let _chunk = this.doReg(chunk);
|
|
356
|
+
result.push(_chunk);
|
|
357
|
+
}
|
|
313
358
|
}
|
|
359
|
+
|
|
360
|
+
if (version !== this.renderVersion) return;
|
|
314
361
|
this.data = result;
|
|
315
362
|
},
|
|
316
|
-
run: function () {
|
|
317
|
-
this.
|
|
363
|
+
run: async function () {
|
|
364
|
+
this.page = 1;
|
|
365
|
+
this.all = false;
|
|
366
|
+
await this.render();
|
|
367
|
+
if (!this.data.length) return;
|
|
318
368
|
|
|
319
369
|
// 等待html加载完毕后
|
|
320
370
|
this.$nextTick(() => {
|
|
@@ -334,6 +384,9 @@ export default {
|
|
|
334
384
|
content: function () {
|
|
335
385
|
this.run();
|
|
336
386
|
},
|
|
387
|
+
post_mode: function () {
|
|
388
|
+
this.run();
|
|
389
|
+
},
|
|
337
390
|
},
|
|
338
391
|
mounted: function () {
|
|
339
392
|
const params = new URLSearchParams(location.search);
|
package/src/Markdown.vue
CHANGED
|
@@ -1,19 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="c-editor-markdown">
|
|
2
|
+
<div class="c-editor-markdown" :class="{ 'c-editor-markdown--preview': previewVisible }">
|
|
3
3
|
<slot name="prepend"></slot>
|
|
4
4
|
|
|
5
|
-
<div class="c-editor-markdown__toolbar">
|
|
6
|
-
<div class="c-editor-markdown__toolbar-left">
|
|
7
|
-
<span class="c-editor-markdown__toolbar-label">编辑模式</span>
|
|
8
|
-
<el-radio-group v-model="editorMode" size="small">
|
|
9
|
-
<el-radio-button v-for="item in modeOptions" :key="item.value" :value="item.value">
|
|
10
|
-
{{ item.label }}
|
|
11
|
-
</el-radio-button>
|
|
12
|
-
</el-radio-group>
|
|
13
|
-
</div>
|
|
14
|
-
<div class="c-editor-markdown__toolbar-tip">GitHub 样式预览,支持 Ctrl+V 粘贴图片上传</div>
|
|
15
|
-
</div>
|
|
16
|
-
|
|
17
5
|
<slot></slot>
|
|
18
6
|
|
|
19
7
|
<div ref="editorHost" class="c-editor-markdown__host"></div>
|
|
@@ -25,17 +13,82 @@
|
|
|
25
13
|
<script>
|
|
26
14
|
import axios from "axios";
|
|
27
15
|
import JX3BOX from "@jx3box/jx3box-common/data/jx3box.json";
|
|
16
|
+
import {
|
|
17
|
+
Bold,
|
|
18
|
+
Code2,
|
|
19
|
+
Expand,
|
|
20
|
+
Eye,
|
|
21
|
+
EyeOff,
|
|
22
|
+
Heading,
|
|
23
|
+
Italic,
|
|
24
|
+
Link2,
|
|
25
|
+
List,
|
|
26
|
+
ListChecks,
|
|
27
|
+
ListOrdered,
|
|
28
|
+
Quote,
|
|
29
|
+
Redo2,
|
|
30
|
+
SquareCode,
|
|
31
|
+
Strikethrough,
|
|
32
|
+
TableProperties,
|
|
33
|
+
Undo2,
|
|
34
|
+
} from "lucide";
|
|
28
35
|
import Vditor from "vditor";
|
|
29
36
|
import "vditor/dist/index.css";
|
|
30
|
-
import "github-markdown-css/github-markdown.css";
|
|
37
|
+
import "github-markdown-css/github-markdown-light.css";
|
|
38
|
+
import normalizeMarkdownForVditor from "./assets/js/normalizeMarkdownForVditor";
|
|
31
39
|
|
|
32
40
|
const { __cms } = JX3BOX;
|
|
33
41
|
const UPLOAD_API = `${__cms}api/cms/upload`;
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
const TOOLBAR_ICON_SIZE = 16;
|
|
43
|
+
const STATIC_TOOLBAR_ICONS = {
|
|
44
|
+
headings: Heading,
|
|
45
|
+
bold: Bold,
|
|
46
|
+
italic: Italic,
|
|
47
|
+
strike: Strikethrough,
|
|
48
|
+
link: Link2,
|
|
49
|
+
list: List,
|
|
50
|
+
"ordered-list": ListOrdered,
|
|
51
|
+
check: ListChecks,
|
|
52
|
+
quote: Quote,
|
|
53
|
+
code: Code2,
|
|
54
|
+
"inline-code": SquareCode,
|
|
55
|
+
table: TableProperties,
|
|
56
|
+
undo: Undo2,
|
|
57
|
+
redo: Redo2,
|
|
58
|
+
fullscreen: Expand,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function renderLucideIcon(iconNode) {
|
|
62
|
+
if (!iconNode) return "";
|
|
63
|
+
|
|
64
|
+
const attrs = {
|
|
65
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
66
|
+
width: TOOLBAR_ICON_SIZE,
|
|
67
|
+
height: TOOLBAR_ICON_SIZE,
|
|
68
|
+
viewBox: "0 0 24 24",
|
|
69
|
+
fill: "none",
|
|
70
|
+
stroke: "currentColor",
|
|
71
|
+
"stroke-width": "2",
|
|
72
|
+
"stroke-linecap": "round",
|
|
73
|
+
"stroke-linejoin": "round",
|
|
74
|
+
class: "c-editor-markdown__icon",
|
|
75
|
+
"aria-hidden": "true",
|
|
76
|
+
};
|
|
77
|
+
const svgAttrs = Object.entries(attrs)
|
|
78
|
+
.map(([key, value]) => `${key}="${value}"`)
|
|
79
|
+
.join(" ");
|
|
80
|
+
const content = iconNode
|
|
81
|
+
.map(([tag, tagAttrs]) => {
|
|
82
|
+
const elementAttrs = Object.entries(tagAttrs)
|
|
83
|
+
.map(([key, value]) => `${key}="${value}"`)
|
|
84
|
+
.join(" ");
|
|
85
|
+
|
|
86
|
+
return `<${tag} ${elementAttrs}></${tag}>`;
|
|
87
|
+
})
|
|
88
|
+
.join("");
|
|
89
|
+
|
|
90
|
+
return `<svg ${svgAttrs}>${content}</svg>`;
|
|
91
|
+
}
|
|
39
92
|
|
|
40
93
|
export default {
|
|
41
94
|
name: "Markdown",
|
|
@@ -74,10 +127,15 @@ export default {
|
|
|
74
127
|
data: this.modelValue ?? this.content ?? "",
|
|
75
128
|
editor: null,
|
|
76
129
|
editorReady: false,
|
|
77
|
-
editorMode: "
|
|
78
|
-
|
|
130
|
+
editorMode: "sv",
|
|
131
|
+
previewVisible: false,
|
|
79
132
|
isRebuildingEditor: false,
|
|
80
133
|
isUploadingImage: false,
|
|
134
|
+
previewRenderVersion: 0,
|
|
135
|
+
counterElement: null,
|
|
136
|
+
counterClickListener: null,
|
|
137
|
+
counterClickTimestamps: [],
|
|
138
|
+
pendingTip: "",
|
|
81
139
|
};
|
|
82
140
|
},
|
|
83
141
|
watch: {
|
|
@@ -105,13 +163,6 @@ export default {
|
|
|
105
163
|
this.applyEditableState();
|
|
106
164
|
},
|
|
107
165
|
},
|
|
108
|
-
editorMode(nextMode, prevMode) {
|
|
109
|
-
if (!this.editorReady || this.isRebuildingEditor || nextMode === prevMode) {
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
this.rebuildEditor(nextMode);
|
|
114
|
-
},
|
|
115
166
|
},
|
|
116
167
|
mounted() {
|
|
117
168
|
this.initEditor();
|
|
@@ -120,11 +171,28 @@ export default {
|
|
|
120
171
|
this.destroyEditor();
|
|
121
172
|
},
|
|
122
173
|
methods: {
|
|
123
|
-
getPreviewMode(
|
|
124
|
-
return
|
|
174
|
+
getPreviewMode() {
|
|
175
|
+
return this.previewVisible ? "both" : "editor";
|
|
176
|
+
},
|
|
177
|
+
getPreviewTip() {
|
|
178
|
+
return this.previewVisible ? "返回编辑" : "查看预览";
|
|
179
|
+
},
|
|
180
|
+
getToolbarIconMap() {
|
|
181
|
+
return {
|
|
182
|
+
...STATIC_TOOLBAR_ICONS,
|
|
183
|
+
"toggle-preview": this.previewVisible ? EyeOff : Eye,
|
|
184
|
+
};
|
|
125
185
|
},
|
|
126
186
|
getToolbar() {
|
|
127
187
|
return [
|
|
188
|
+
{
|
|
189
|
+
name: "toggle-preview",
|
|
190
|
+
icon: renderLucideIcon(Eye),
|
|
191
|
+
tip: this.getPreviewTip(),
|
|
192
|
+
tipPosition: "ne",
|
|
193
|
+
click: () => this.togglePreview(),
|
|
194
|
+
},
|
|
195
|
+
"|",
|
|
128
196
|
"headings",
|
|
129
197
|
"bold",
|
|
130
198
|
"italic",
|
|
@@ -169,9 +237,11 @@ export default {
|
|
|
169
237
|
style: "github",
|
|
170
238
|
},
|
|
171
239
|
markdown: {
|
|
240
|
+
mark: true,
|
|
172
241
|
sanitize: true,
|
|
242
|
+
toc: true,
|
|
173
243
|
},
|
|
174
|
-
mode: this.getPreviewMode(
|
|
244
|
+
mode: this.getPreviewMode(),
|
|
175
245
|
},
|
|
176
246
|
customWysiwygToolbar() {},
|
|
177
247
|
toolbar: this.getToolbar(),
|
|
@@ -190,6 +260,10 @@ export default {
|
|
|
190
260
|
this.editorReady = true;
|
|
191
261
|
this.syncEditorValue(this.data);
|
|
192
262
|
this.applyEditableState();
|
|
263
|
+
this.applyPreviewState();
|
|
264
|
+
this.syncToolbarState();
|
|
265
|
+
this.bindCounterShortcut();
|
|
266
|
+
this.showPendingTip();
|
|
193
267
|
},
|
|
194
268
|
input: (value) => {
|
|
195
269
|
this.handleEditorInput(value);
|
|
@@ -204,6 +278,7 @@ export default {
|
|
|
204
278
|
this.editor = new Vditor(host, this.buildEditorOptions(initialValue));
|
|
205
279
|
},
|
|
206
280
|
destroyEditor() {
|
|
281
|
+
this.unbindCounterShortcut();
|
|
207
282
|
if (!this.editor) return;
|
|
208
283
|
|
|
209
284
|
this.editor.destroy();
|
|
@@ -212,9 +287,11 @@ export default {
|
|
|
212
287
|
},
|
|
213
288
|
rebuildEditor() {
|
|
214
289
|
const currentValue = this.editor?.getValue?.() ?? this.data ?? "";
|
|
290
|
+
|
|
215
291
|
this.data = currentValue;
|
|
216
292
|
this.isRebuildingEditor = true;
|
|
217
293
|
this.destroyEditor();
|
|
294
|
+
|
|
218
295
|
this.$nextTick(() => {
|
|
219
296
|
this.initEditor(currentValue);
|
|
220
297
|
this.isRebuildingEditor = false;
|
|
@@ -226,6 +303,141 @@ export default {
|
|
|
226
303
|
const nextValue = value ?? "";
|
|
227
304
|
if (this.editor.getValue() === nextValue) return;
|
|
228
305
|
this.editor.setValue(nextValue);
|
|
306
|
+
if (this.previewVisible) {
|
|
307
|
+
this.renderPreviewContent();
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
getToolbarButton(name) {
|
|
311
|
+
return this.$refs.editorHost?.querySelector?.(`.vditor-toolbar button[data-type="${name}"]`) || null;
|
|
312
|
+
},
|
|
313
|
+
syncToolbarState() {
|
|
314
|
+
const iconMap = this.getToolbarIconMap();
|
|
315
|
+
|
|
316
|
+
Object.entries(iconMap).forEach(([name, iconNode]) => {
|
|
317
|
+
const button = this.getToolbarButton(name);
|
|
318
|
+
if (!button) return;
|
|
319
|
+
|
|
320
|
+
button.innerHTML = renderLucideIcon(iconNode);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const previewButton = this.getToolbarButton("toggle-preview");
|
|
324
|
+
if (previewButton) {
|
|
325
|
+
previewButton.setAttribute("aria-label", this.getPreviewTip());
|
|
326
|
+
previewButton.classList.toggle("vditor-menu--current", this.previewVisible);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
this.$refs.editorHost?.querySelectorAll?.(".vditor-toolbar button[data-type]").forEach((button) => {
|
|
330
|
+
const isVisibleInPreview = ["toggle-preview", "fullscreen"].includes(button.dataset.type || "");
|
|
331
|
+
|
|
332
|
+
button.parentElement.style.display = this.previewVisible && !isVisibleInPreview ? "none" : "";
|
|
333
|
+
});
|
|
334
|
+
this.$refs.editorHost?.querySelectorAll?.(".vditor-toolbar .vditor-toolbar__divider").forEach((divider) => {
|
|
335
|
+
divider.style.display = this.previewVisible ? "none" : "";
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
syncEditorPanels() {
|
|
339
|
+
const host = this.$refs.editorHost;
|
|
340
|
+
const preview = host?.querySelector?.(".vditor-preview");
|
|
341
|
+
const sv = host?.querySelector?.(".vditor-sv");
|
|
342
|
+
const irWrapper = host?.querySelector?.(".vditor-ir");
|
|
343
|
+
const wysiwygWrapper = host?.querySelector?.(".vditor-wysiwyg");
|
|
344
|
+
|
|
345
|
+
if (preview) {
|
|
346
|
+
preview.style.display = this.previewVisible ? "block" : "none";
|
|
347
|
+
}
|
|
348
|
+
if (sv) {
|
|
349
|
+
sv.style.display = this.editorMode === "sv" && !this.previewVisible ? "block" : "none";
|
|
350
|
+
}
|
|
351
|
+
if (irWrapper) {
|
|
352
|
+
irWrapper.style.display = "none";
|
|
353
|
+
}
|
|
354
|
+
if (wysiwygWrapper) {
|
|
355
|
+
wysiwygWrapper.style.display = this.editorMode === "wysiwyg" && !this.previewVisible ? "block" : "none";
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
applyPreviewState() {
|
|
359
|
+
if (!this.editorReady || !this.editor) return;
|
|
360
|
+
|
|
361
|
+
this.syncEditorPanels();
|
|
362
|
+
if (this.previewVisible) {
|
|
363
|
+
this.renderPreviewContent();
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
renderPreviewContent: async function () {
|
|
367
|
+
if (!this.editorReady || !this.editor) return;
|
|
368
|
+
|
|
369
|
+
const previewRoot = this.$refs.editorHost?.querySelector?.(".vditor-preview");
|
|
370
|
+
const previewBody = previewRoot?.querySelector?.(".vditor-reset") || previewRoot;
|
|
371
|
+
if (!previewBody) return;
|
|
372
|
+
|
|
373
|
+
const version = ++this.previewRenderVersion;
|
|
374
|
+
const normalizedMarkdown = normalizeMarkdownForVditor(this.editor.getValue());
|
|
375
|
+
|
|
376
|
+
await Vditor.preview(previewBody, normalizedMarkdown, {
|
|
377
|
+
mode: "light",
|
|
378
|
+
lang: "zh_CN",
|
|
379
|
+
hljs: {
|
|
380
|
+
enable: true,
|
|
381
|
+
lineNumber: false,
|
|
382
|
+
style: "github",
|
|
383
|
+
},
|
|
384
|
+
markdown: {
|
|
385
|
+
mark: true,
|
|
386
|
+
sanitize: true,
|
|
387
|
+
toc: true,
|
|
388
|
+
},
|
|
389
|
+
icon: "ant",
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
if (version !== this.previewRenderVersion) return;
|
|
393
|
+
previewBody.classList.add("markdown-body");
|
|
394
|
+
},
|
|
395
|
+
togglePreview() {
|
|
396
|
+
this.previewVisible = !this.previewVisible;
|
|
397
|
+
|
|
398
|
+
if (!this.editorReady || !this.editor) return;
|
|
399
|
+
|
|
400
|
+
this.applyPreviewState();
|
|
401
|
+
this.syncToolbarState();
|
|
402
|
+
},
|
|
403
|
+
toggleSecretEditorMode() {
|
|
404
|
+
if (this.isRebuildingEditor) return;
|
|
405
|
+
|
|
406
|
+
this.editorMode = this.editorMode === "wysiwyg" ? "sv" : "wysiwyg";
|
|
407
|
+
this.pendingTip = this.editorMode === "wysiwyg" ? "已切换到所见即所得模式" : "已切换到 Markdown 源码模式";
|
|
408
|
+
this.rebuildEditor();
|
|
409
|
+
},
|
|
410
|
+
bindCounterShortcut() {
|
|
411
|
+
const counter = this.$refs.editorHost?.querySelector?.(".vditor-counter");
|
|
412
|
+
if (!counter || counter === this.counterElement) return;
|
|
413
|
+
|
|
414
|
+
this.unbindCounterShortcut();
|
|
415
|
+
this.counterClickListener = this.counterClickListener || (() => this.handleCounterClick());
|
|
416
|
+
this.counterElement = counter;
|
|
417
|
+
this.counterElement.addEventListener("click", this.counterClickListener);
|
|
418
|
+
},
|
|
419
|
+
unbindCounterShortcut() {
|
|
420
|
+
if (!this.counterElement) return;
|
|
421
|
+
|
|
422
|
+
this.counterElement.removeEventListener("click", this.counterClickListener);
|
|
423
|
+
this.counterElement = null;
|
|
424
|
+
},
|
|
425
|
+
handleCounterClick() {
|
|
426
|
+
const now = Date.now();
|
|
427
|
+
|
|
428
|
+
this.counterClickTimestamps = this.counterClickTimestamps.filter((time) => now - time <= 2000);
|
|
429
|
+
this.counterClickTimestamps.push(now);
|
|
430
|
+
|
|
431
|
+
if (this.counterClickTimestamps.length < 6) return;
|
|
432
|
+
|
|
433
|
+
this.counterClickTimestamps = [];
|
|
434
|
+
this.toggleSecretEditorMode();
|
|
435
|
+
},
|
|
436
|
+
showPendingTip() {
|
|
437
|
+
if (!this.pendingTip || !this.editor?.tip) return;
|
|
438
|
+
|
|
439
|
+
this.editor.tip(this.pendingTip, 1500);
|
|
440
|
+
this.pendingTip = "";
|
|
229
441
|
},
|
|
230
442
|
applyEditableState() {
|
|
231
443
|
if (!this.editorReady || !this.editor) return;
|
|
@@ -329,28 +541,6 @@ export default {
|
|
|
329
541
|
flex-direction: column;
|
|
330
542
|
gap: 12px;
|
|
331
543
|
|
|
332
|
-
&__toolbar {
|
|
333
|
-
display: flex;
|
|
334
|
-
align-items: center;
|
|
335
|
-
justify-content: space-between;
|
|
336
|
-
gap: 12px;
|
|
337
|
-
flex-wrap: wrap;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
&__toolbar-left {
|
|
341
|
-
display: flex;
|
|
342
|
-
align-items: center;
|
|
343
|
-
gap: 12px;
|
|
344
|
-
flex-wrap: wrap;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
&__toolbar-label,
|
|
348
|
-
&__toolbar-tip {
|
|
349
|
-
font-size: 13px;
|
|
350
|
-
color: #57606a;
|
|
351
|
-
line-height: 1.4;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
544
|
&__host {
|
|
355
545
|
overflow: visible;
|
|
356
546
|
border: 1px solid #dcdfe6;
|
|
@@ -358,8 +548,10 @@ export default {
|
|
|
358
548
|
background-color: #ffffff;
|
|
359
549
|
}
|
|
360
550
|
|
|
361
|
-
|
|
362
|
-
|
|
551
|
+
&__icon {
|
|
552
|
+
display: block;
|
|
553
|
+
width: 16px;
|
|
554
|
+
height: 16px;
|
|
363
555
|
}
|
|
364
556
|
|
|
365
557
|
.vditor {
|
|
@@ -369,10 +561,74 @@ export default {
|
|
|
369
561
|
}
|
|
370
562
|
|
|
371
563
|
.vditor-toolbar {
|
|
564
|
+
display: flex;
|
|
565
|
+
align-items: center;
|
|
372
566
|
padding: 10px 12px;
|
|
373
567
|
background: #f6f8fa;
|
|
374
568
|
border-bottom: 1px solid #d8dee4;
|
|
375
569
|
overflow: visible;
|
|
570
|
+
|
|
571
|
+
.vditor-toolbar__item {
|
|
572
|
+
margin-right: 2px;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
.vditor-toolbar__divider {
|
|
576
|
+
margin: 0 4px;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
.vditor-toolbar__item > .vditor-tooltipped {
|
|
580
|
+
display: inline-flex;
|
|
581
|
+
align-items: center;
|
|
582
|
+
justify-content: center;
|
|
583
|
+
width: 30px;
|
|
584
|
+
height: 30px;
|
|
585
|
+
padding: 0;
|
|
586
|
+
color: #4b5563;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
.vditor-toolbar__item > .vditor-tooltipped svg {
|
|
590
|
+
fill: none !important;
|
|
591
|
+
stroke: currentColor !important;
|
|
592
|
+
stroke-width: 2 !important;
|
|
593
|
+
stroke-linecap: round;
|
|
594
|
+
stroke-linejoin: round;
|
|
595
|
+
width: 16px;
|
|
596
|
+
height: 16px;
|
|
597
|
+
overflow: visible;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
.vditor-toolbar__item > .vditor-tooltipped svg * {
|
|
601
|
+
fill: none !important;
|
|
602
|
+
stroke: currentColor !important;
|
|
603
|
+
stroke-width: 2 !important;
|
|
604
|
+
stroke-linecap: round;
|
|
605
|
+
stroke-linejoin: round;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
.vditor-panel button {
|
|
609
|
+
width: auto;
|
|
610
|
+
height: auto;
|
|
611
|
+
padding: 4px 8px;
|
|
612
|
+
color: inherit;
|
|
613
|
+
display: block;
|
|
614
|
+
line-height: 1.5;
|
|
615
|
+
text-align: left;
|
|
616
|
+
white-space: nowrap;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
&--preview {
|
|
621
|
+
.vditor-sv,
|
|
622
|
+
.vditor-ir,
|
|
623
|
+
.vditor-wysiwyg,
|
|
624
|
+
.vditor-counter {
|
|
625
|
+
display: none !important;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
.vditor-preview {
|
|
629
|
+
display: block !important;
|
|
630
|
+
width: 100%;
|
|
631
|
+
}
|
|
376
632
|
}
|
|
377
633
|
|
|
378
634
|
.vditor-reset {
|
|
@@ -400,9 +656,20 @@ export default {
|
|
|
400
656
|
}
|
|
401
657
|
|
|
402
658
|
.vditor-counter {
|
|
403
|
-
padding: 8px
|
|
404
|
-
border-top:
|
|
405
|
-
background:
|
|
659
|
+
padding: 3px 8px;
|
|
660
|
+
border-top: 0;
|
|
661
|
+
background: rgba(27, 31, 35, 0.05);
|
|
662
|
+
border-radius: 3px;
|
|
663
|
+
line-height: 1.4;
|
|
664
|
+
margin: 0;
|
|
665
|
+
position: absolute;
|
|
666
|
+
right: 12px;
|
|
667
|
+
|
|
668
|
+
&::before,
|
|
669
|
+
&::after {
|
|
670
|
+
display: none !important;
|
|
671
|
+
content: none !important;
|
|
672
|
+
}
|
|
406
673
|
}
|
|
407
674
|
|
|
408
675
|
.vditor-preview__action {
|
|
@@ -414,11 +681,12 @@ export default {
|
|
|
414
681
|
z-index: 30;
|
|
415
682
|
}
|
|
416
683
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
684
|
+
.vditor-menu--current {
|
|
685
|
+
color: #0969da;
|
|
686
|
+
background: #eaf2ff;
|
|
687
|
+
}
|
|
421
688
|
|
|
689
|
+
@media (max-width: 768px) {
|
|
422
690
|
.vditor-reset.markdown-body,
|
|
423
691
|
.vditor-sv__editor,
|
|
424
692
|
.vditor-ir__marker,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const PROTECTED_SEGMENT_REGEXP = /(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`|\$\$[\s\S]*?\$\$|\$[^$\n]+\$)/g;
|
|
2
|
+
|
|
3
|
+
function replaceUnderline(text) {
|
|
4
|
+
return text.replace(/\+\+([^\n+][^\n]*?)\+\+/g, "<u>$1</u>");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function replaceSuperscript(text) {
|
|
8
|
+
return text.replace(
|
|
9
|
+
/(^|[^\w\\])([A-Za-z0-9)\]}\u4e00-\u9fa5]+)\^([^^\s][^^]*?)\^/g,
|
|
10
|
+
(match, prefix, base, value) => {
|
|
11
|
+
return `${prefix}${base}<sup>${value}</sup>`;
|
|
12
|
+
}
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function replaceSubscript(text) {
|
|
17
|
+
return text.replace(/(^|[^\w\\])([A-Za-z0-9)\]}\u4e00-\u9fa5]+)~([^~\s][^~]*?)~/g, (match, prefix, base, value) => {
|
|
18
|
+
return `${prefix}${base}<sub>${value}</sub>`;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizePlainMarkdown(text) {
|
|
23
|
+
return [replaceUnderline, replaceSuperscript, replaceSubscript].reduce((result, transform) => {
|
|
24
|
+
return transform(result);
|
|
25
|
+
}, text);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function normalizeMarkdownForVditor(markdown) {
|
|
29
|
+
const source = String(markdown || "");
|
|
30
|
+
if (!source) return "";
|
|
31
|
+
|
|
32
|
+
const segments = source.split(PROTECTED_SEGMENT_REGEXP);
|
|
33
|
+
|
|
34
|
+
return segments
|
|
35
|
+
.map((segment, index) => {
|
|
36
|
+
return index % 2 === 1 ? segment : normalizePlainMarkdown(segment);
|
|
37
|
+
})
|
|
38
|
+
.join("");
|
|
39
|
+
}
|
package/src/assets/js/xss.js
CHANGED
|
@@ -7,12 +7,28 @@ const FORBID = new Set(["script", "object", "embed", "applet", "base", "meta", "
|
|
|
7
7
|
|
|
8
8
|
const EXTRA_TAGS = [
|
|
9
9
|
"img",
|
|
10
|
-
"h1",
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
10
|
+
"h1",
|
|
11
|
+
"h2",
|
|
12
|
+
"h3",
|
|
13
|
+
"h4",
|
|
14
|
+
"h5",
|
|
15
|
+
"h6",
|
|
16
|
+
"table",
|
|
17
|
+
"thead",
|
|
18
|
+
"tbody",
|
|
19
|
+
"tr",
|
|
20
|
+
"th",
|
|
21
|
+
"td",
|
|
22
|
+
"blockquote",
|
|
23
|
+
"pre",
|
|
24
|
+
"code",
|
|
25
|
+
"hr",
|
|
26
|
+
"video",
|
|
27
|
+
"source",
|
|
28
|
+
"iframe",
|
|
29
|
+
"style",
|
|
30
|
+
"colgroup",
|
|
31
|
+
"col",
|
|
16
32
|
];
|
|
17
33
|
|
|
18
34
|
// 必须顶层的 at-rule(你说不需要动画,但 keyframes 也可能被编辑器/作者写进来,留着更稳)
|
|
@@ -116,6 +132,7 @@ export default function sanitizeRichText(html) {
|
|
|
116
132
|
.filter((t) => !FORBID.has(t));
|
|
117
133
|
|
|
118
134
|
return sanitizeHtml(html, {
|
|
135
|
+
allowVulnerableTags: true,
|
|
119
136
|
disallowedTagsMode: "discard",
|
|
120
137
|
allowedTags,
|
|
121
138
|
|
|
@@ -125,7 +142,18 @@ export default function sanitizeRichText(html) {
|
|
|
125
142
|
img: ["src", "alt", "title", "width", "height", "class", "style", "loading", "decoding"],
|
|
126
143
|
video: ["controls", "width", "height", "class", "style"],
|
|
127
144
|
source: ["src", "type"],
|
|
128
|
-
iframe: [
|
|
145
|
+
iframe: [
|
|
146
|
+
"src",
|
|
147
|
+
"width",
|
|
148
|
+
"height",
|
|
149
|
+
"frameborder",
|
|
150
|
+
"scrolling",
|
|
151
|
+
"allowfullscreen",
|
|
152
|
+
"sandbox",
|
|
153
|
+
"referrerpolicy",
|
|
154
|
+
"class",
|
|
155
|
+
"style",
|
|
156
|
+
],
|
|
129
157
|
td: ["colspan", "rowspan", "align", "valign", "class", "style"],
|
|
130
158
|
th: ["colspan", "rowspan", "align", "valign", "class", "style"],
|
|
131
159
|
col: ["span", "width", "class", "style"],
|
package/src/views/article.vue
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<el-segmented v-model="group" :options="groupOptions" />
|
|
5
5
|
</div>
|
|
6
6
|
<div class="container">
|
|
7
|
-
<Article class="article" :content="content"></Article>
|
|
7
|
+
<Article class="article" :content="content" :post_mode="postMode"></Article>
|
|
8
8
|
<div id="directory"></div>
|
|
9
9
|
</div>
|
|
10
10
|
</template>
|
|
@@ -29,6 +29,11 @@ export default {
|
|
|
29
29
|
],
|
|
30
30
|
};
|
|
31
31
|
},
|
|
32
|
+
computed: {
|
|
33
|
+
postMode() {
|
|
34
|
+
return this.group === "markdown" ? "markdown" : "tinymce";
|
|
35
|
+
},
|
|
36
|
+
},
|
|
32
37
|
watch: {
|
|
33
38
|
group: {
|
|
34
39
|
immediate: true,
|
|
@@ -53,20 +58,19 @@ export default {
|
|
|
53
58
|
.flex(x);
|
|
54
59
|
margin-bottom: 1rem;
|
|
55
60
|
}
|
|
56
|
-
.container{
|
|
57
|
-
#directory{
|
|
61
|
+
.container {
|
|
62
|
+
#directory {
|
|
58
63
|
.w(200px);
|
|
59
64
|
position: fixed;
|
|
60
|
-
right:20px;
|
|
61
|
-
top:20px;
|
|
65
|
+
right: 20px;
|
|
66
|
+
top: 20px;
|
|
62
67
|
}
|
|
63
|
-
.article{
|
|
68
|
+
.article {
|
|
64
69
|
margin-right: 320px;
|
|
65
70
|
}
|
|
66
|
-
|
|
67
71
|
}
|
|
68
|
-
body{
|
|
69
|
-
min-height:2000px;
|
|
70
|
-
padding:0 20px;
|
|
72
|
+
body {
|
|
73
|
+
min-height: 2000px;
|
|
74
|
+
padding: 0 20px;
|
|
71
75
|
}
|
|
72
76
|
</style>
|