@mnykj/alleditor-dual 1.1.1 → 1.2.0

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/README.md CHANGED
@@ -49,6 +49,27 @@ const html = ref('<p>hello</p>')
49
49
  </template>
50
50
  ```
51
51
 
52
+ 默认工具栏已带 **导入 Markdown**、**导入 HTML**:
53
+ - `.md` → wang 引擎
54
+ - `.html` → ue 引擎(并剥离 script / 事件属性)
55
+
56
+ 不需要时:`<DualEditor :enable-import="false" />`。自定义 `toolbarConfig.toolbarKeys` 时,若未包含这两项会自动补上(`enableImport` 为 true)。
57
+
58
+ 仅注册菜单、自己接文件选择(如创作中心):
59
+
60
+ ```ts
61
+ import {
62
+ registerImportMenus,
63
+ bindImportMenuHandlers,
64
+ } from '@mnykj/alleditor-dual'
65
+
66
+ registerImportMenus()
67
+ bindImportMenuHandlers({
68
+ onImportHtml: () => htmlInput.click(),
69
+ onImportMarkdown: () => mdInput.click(),
70
+ })
71
+ ```
72
+
52
73
  一般不用再写 `ueditor-base`。若自建 CDN,可覆盖:
53
74
 
54
75
  ```vue
@@ -58,10 +79,10 @@ const html = ref('<p>hello</p>')
58
79
  ## HTML 协议
59
80
 
60
81
  ```html
61
- <div class="alleditor-root" data-alleditor-engine="ue">…</div>
82
+ <div class="alleditor-root" data-alleditor-engine="wang|ue">…</div>
62
83
  ```
63
84
 
64
- 兼容旧注释 `<!--yunkee-editor:ue-html-->`。
85
+ wang / ue 保存时都会包外层;兼容旧注释 `<!--yunkee-editor:ue-html-->`。
65
86
 
66
87
  ## 开发(monorepo)
67
88
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mnykj/alleditor-dual",
3
- "version": "1.1.1",
4
- "description": "AllEditor dual-engine (wang + UE HTML) Vue3 components, with bundled UEditor static assets",
3
+ "version": "1.2.0",
4
+ "description": "AllEditor dual-engine (wang + UE HTML) Vue3 components, with bundled UEditor static assets and MD/HTML import menus",
5
5
  "author": "mnykj",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -36,12 +36,15 @@
36
36
  "access": "public",
37
37
  "registry": "https://registry.npmjs.org/"
38
38
  },
39
+ "dependencies": {
40
+ "markdown-it": "^14.3.3"
41
+ },
39
42
  "peerDependencies": {
40
- "@mnykj/alleditor": "^1.1.0",
43
+ "@mnykj/alleditor": "^1.1.1",
41
44
  "@mnykj/alleditor-for-vue": "^1.1.0",
42
- "vue": "^3.0.5",
45
+ "@nuxt/kit": ">=3",
43
46
  "vite": ">=4",
44
- "@nuxt/kit": ">=3"
47
+ "vue": "^3.0.5"
45
48
  },
46
49
  "peerDependenciesMeta": {
47
50
  "vite": {
@@ -2,13 +2,15 @@
2
2
  /**
3
3
  * AllEditor 双引擎门面:按 HTML 协议自动选 wang / ue,共用 wang 工具栏。
4
4
  *
5
- * v-model 存「可入库」HTML(UE 模式会包 data-alleditor-engine 根节点)。
5
+ * v-model 存「可入库」HTML(默认 wang/ue 都包 data-alleditor-engine 根节点)。
6
+ * 内置 MD / HTML 导入菜单(默认工具栏可见)。
6
7
  * 宿主需自行提供 UEditor 静态资源(默认 /ueditor/)。
7
8
  */
8
9
  import {
9
10
  computed,
10
11
  nextTick,
11
12
  onBeforeUnmount,
13
+ onMounted,
12
14
  ref,
13
15
  shallowRef,
14
16
  watch,
@@ -21,6 +23,17 @@ import {
21
23
  } from '@mnykj/alleditor'
22
24
  import UeHtmlEngine from './UeHtmlEngine.vue'
23
25
  import { bindUeToolbarAdapter } from './ueToolbar'
26
+ import {
27
+ bindImportMenuHandlers,
28
+ registerImportMenus,
29
+ } from './importMenus'
30
+ import { mergeToolbarConfig } from './defaultToolbar'
31
+ import {
32
+ extractHtmlBody,
33
+ isEffectivelyEmptyHtml,
34
+ markdownToHtml,
35
+ } from './markdown'
36
+ import { stripJavascriptFromHtml } from './stripJavascriptFromHtml'
24
37
 
25
38
  const props = defineProps({
26
39
  modelValue: { type: String, default: '' },
@@ -31,8 +44,12 @@ const props = defineProps({
31
44
  editorConfig: { type: Object, default: () => ({}) },
32
45
  /** wang 占位正文(UE 模式下避免把高保真 HTML 喂进 Slate) */
33
46
  wangPlaceholder: { type: String, default: '<p><br></p>' },
34
- /** 仅当引擎为 ue 时才写外层 div(推荐;wang 正文保持干净) */
35
- wrapOnlyWhenUe: { type: Boolean, default: true },
47
+ /** 为 true 时仅 ue 写外层 div;默认 false,wang/ue 都包 data-alleditor-engine */
48
+ wrapOnlyWhenUe: { type: Boolean, default: false },
49
+ /** 是否注册并显示 MD/HTML 导入按钮(默认开启) */
50
+ enableImport: { type: Boolean, default: true },
51
+ /** 正文非空时导入是否 confirm 确认替换 */
52
+ confirmReplaceOnImport: { type: Boolean, default: true },
36
53
  toolbarSelector: {
37
54
  type: String,
38
55
  default: '.alleditor-dual .w-e-toolbar, .alleditor-dual .editor-toolbar',
@@ -46,6 +63,7 @@ const emit = defineEmits([
46
63
  'wang-created',
47
64
  'ue-created',
48
65
  'ue-ready',
66
+ 'import',
49
67
  ])
50
68
 
51
69
  /** @type {import('vue').Ref<'wang' | 'ue'>} */
@@ -55,11 +73,17 @@ const wangEditorRef = shallowRef(null)
55
73
  const ueEngineRef = shallowRef(null)
56
74
  const wangHtml = ref(props.wangPlaceholder)
57
75
  const ueHtml = ref('')
76
+ const mdFileInputRef = ref(null)
77
+ const htmlFileInputRef = ref(null)
58
78
  let toolbarBinding = null
59
79
  let applyingExternal = false
60
80
 
61
81
  const useUe = computed(() => engineMode.value === 'ue')
62
82
 
83
+ const resolvedToolbarConfig = computed(() =>
84
+ mergeToolbarConfig(props.toolbarConfig, { enableImport: props.enableImport })
85
+ )
86
+
63
87
  function bodyOf(html) {
64
88
  return unwrapEditorEngineHtml(html || '') || '<p><br></p>'
65
89
  }
@@ -180,6 +204,96 @@ function setEngineMode(mode, html) {
180
204
  : body,
181
205
  { forceEngine: next }
182
206
  )
207
+ if (next === 'ue' || next === 'wang') {
208
+ emitModel(body)
209
+ }
210
+ }
211
+
212
+ function currentBodyForEmptyCheck() {
213
+ return bodyOf(getHtml())
214
+ }
215
+
216
+ function confirmReplaceIfNeeded() {
217
+ if (!props.confirmReplaceOnImport) return true
218
+ if (isEffectivelyEmptyHtml(currentBodyForEmptyCheck())) return true
219
+ return window.confirm('导入将替换当前正文内容,是否继续?')
220
+ }
221
+
222
+ function openMarkdownPicker() {
223
+ if (props.disabled) return
224
+ mdFileInputRef.value?.click?.()
225
+ }
226
+
227
+ function openHtmlPicker() {
228
+ if (props.disabled) return
229
+ htmlFileInputRef.value?.click?.()
230
+ }
231
+
232
+ async function onMarkdownFileSelected(e) {
233
+ const file = e.target?.files?.[0]
234
+ if (e.target) e.target.value = ''
235
+ if (!file) return
236
+ if (!/\.md$/i.test(file.name)) {
237
+ window.alert('请选择 .md 文件')
238
+ return
239
+ }
240
+ if (!confirmReplaceIfNeeded()) return
241
+ try {
242
+ const text = await file.text()
243
+ const html = await markdownToHtml(text || '')
244
+ setEngineMode('wang', html)
245
+ emit('import', {
246
+ type: 'markdown',
247
+ fileName: file.name,
248
+ html: getHtml(),
249
+ })
250
+ } catch (err) {
251
+ console.error(err)
252
+ window.alert(err?.message || '导入 Markdown 失败')
253
+ }
254
+ }
255
+
256
+ async function onHtmlFileSelected(e) {
257
+ const file = e.target?.files?.[0]
258
+ if (e.target) e.target.value = ''
259
+ if (!file) return
260
+ const nameOk =
261
+ /\.html?$/i.test(file.name) ||
262
+ /text\/html/i.test(file.type || '')
263
+ if (!nameOk) {
264
+ window.alert('请选择 .html / .htm 文件')
265
+ return
266
+ }
267
+ if (!confirmReplaceIfNeeded()) return
268
+ try {
269
+ const text = await file.text()
270
+ const body = stripJavascriptFromHtml(extractHtmlBody(text || ''))
271
+ const html = body.trim() || '<p><br></p>'
272
+ setEngineMode('ue', html)
273
+ emit('import', {
274
+ type: 'html',
275
+ fileName: file.name,
276
+ html: getHtml(),
277
+ })
278
+ } catch (err) {
279
+ console.error(err)
280
+ window.alert(err?.message || '导入 HTML 失败')
281
+ }
282
+ }
283
+
284
+ function wireImportHandlers() {
285
+ if (!props.enableImport) {
286
+ bindImportMenuHandlers({
287
+ onImportHtml: null,
288
+ onImportMarkdown: null,
289
+ })
290
+ return
291
+ }
292
+ registerImportMenus()
293
+ bindImportMenuHandlers({
294
+ onImportHtml: openHtmlPicker,
295
+ onImportMarkdown: openMarkdownPicker,
296
+ })
183
297
  }
184
298
 
185
299
  watch(
@@ -207,10 +321,23 @@ watch(engineMode, (mode) => {
207
321
  else unbindToolbar()
208
322
  })
209
323
 
324
+ watch(
325
+ () => props.enableImport,
326
+ () => wireImportHandlers()
327
+ )
328
+
210
329
  applyIncoming(props.modelValue)
211
330
 
331
+ onMounted(() => {
332
+ wireImportHandlers()
333
+ })
334
+
212
335
  onBeforeUnmount(() => {
213
336
  unbindToolbar()
337
+ bindImportMenuHandlers({
338
+ onImportHtml: null,
339
+ onImportMarkdown: null,
340
+ })
214
341
  const ed = wangEditorRef.value
215
342
  if (ed && !ed.isDestroyed) {
216
343
  try {
@@ -228,6 +355,8 @@ defineExpose({
228
355
  setEngineMode,
229
356
  getWangEditor: () => wangEditorRef.value,
230
357
  getUeEngine: () => ueEngineRef.value,
358
+ openMarkdownPicker,
359
+ openHtmlPicker,
231
360
  engineMode,
232
361
  })
233
362
  </script>
@@ -237,7 +366,7 @@ defineExpose({
237
366
  <Toolbar
238
367
  v-if="wangEditorRef"
239
368
  :editor="wangEditorRef"
240
- :default-config="toolbarConfig"
369
+ :default-config="resolvedToolbarConfig"
241
370
  :mode="mode"
242
371
  class="alleditor-dual__toolbar editor-toolbar"
243
372
  />
@@ -266,6 +395,22 @@ defineExpose({
266
395
  />
267
396
  </div>
268
397
  </div>
398
+ <input
399
+ v-if="enableImport"
400
+ ref="mdFileInputRef"
401
+ type="file"
402
+ accept=".md,text/markdown"
403
+ class="alleditor-dual__file-input"
404
+ @change="onMarkdownFileSelected"
405
+ />
406
+ <input
407
+ v-if="enableImport"
408
+ ref="htmlFileInputRef"
409
+ type="file"
410
+ accept=".html,.htm,text/html"
411
+ class="alleditor-dual__file-input"
412
+ @change="onHtmlFileSelected"
413
+ />
269
414
  </div>
270
415
  </template>
271
416
 
@@ -327,4 +472,15 @@ defineExpose({
327
472
  min-height: 0;
328
473
  height: 100%;
329
474
  }
475
+
476
+ .alleditor-dual__file-input {
477
+ position: absolute !important;
478
+ width: 1px !important;
479
+ height: 1px !important;
480
+ padding: 0 !important;
481
+ margin: -1px !important;
482
+ overflow: hidden !important;
483
+ clip: rect(0, 0, 0, 0) !important;
484
+ border: 0 !important;
485
+ }
330
486
  </style>
@@ -95,6 +95,29 @@ function syncDisabled() {
95
95
  }
96
96
  }
97
97
 
98
+ /**
99
+ * 清理公众号/Slate 残留:
100
+ * - <span leaf=""> 包住的 img 易让 UEditor 图片缩放框错位,挡住正文无法编辑
101
+ * - 去掉无意义的 leaf 属性
102
+ */
103
+ function sanitizeUeHtml(html) {
104
+ return String(html ?? '')
105
+ .replace(/<span\b[^>]*\bleaf(?:\s*=\s*(?:""|''|[^>\s]+))?\s*[^>]*>\s*(<img\b[^>]*>)\s*<\/span>/gi, '$1')
106
+ .replace(/\sleaf(?:\s*=\s*(?:""|''|"[^"]*"|'[^']*'|[^\s>]+))?/gi, '')
107
+ }
108
+
109
+ function hideImageScaleUi(ue) {
110
+ try {
111
+ const uiRoot = ue?.ui?.getDom?.()
112
+ if (!uiRoot) return
113
+ uiRoot.querySelectorAll('.edui-editor-imagescale, [id$="_imagescale"], [id$="_imagescale_cover"]').forEach((el) => {
114
+ el.style.display = 'none'
115
+ })
116
+ } catch {
117
+ /* ignore */
118
+ }
119
+ }
120
+
98
121
  function getContent() {
99
122
  const ue = instance.value
100
123
  if (!ue || !ready) {
@@ -109,7 +132,7 @@ function getContent() {
109
132
  }
110
133
 
111
134
  function setContent(html) {
112
- const next = html || ''
135
+ const next = sanitizeUeHtml(html || '')
113
136
  const ue = instance.value
114
137
  if (!ue || !ready) {
115
138
  pendingHtml = next
@@ -121,6 +144,7 @@ function setContent(html) {
121
144
  } finally {
122
145
  applying = false
123
146
  }
147
+ hideImageScaleUi(ue)
124
148
  requestAnimationFrame(() => fitEditorHeight(ue))
125
149
  }
126
150
 
@@ -251,6 +275,10 @@ onMounted(async () => {
251
275
  initialFrameHeight: height,
252
276
  initialFrameWidth: '100%',
253
277
  autoHeightEnabled: false,
278
+ // 关闭图片八爪缩放框:编辑区在 iframe 内时缩放层常错位/关不掉,正文点不动
279
+ imageScaleEnabled: false,
280
+ imagePopup: false,
281
+ scaleEnabled: false,
254
282
  serverUrl: '',
255
283
  enableAutoSave: false,
256
284
  elementPathEnabled: false,
@@ -269,12 +297,15 @@ onMounted(async () => {
269
297
  emit('update:modelValue', html)
270
298
  emit('change', html)
271
299
  })
300
+ // 即便外部配置被覆盖,缩放层弹出时立刻隐藏,避免挡住编辑
301
+ ue.addListener('afterscaleshow', () => hideImageScaleUi(ue))
272
302
 
273
303
  ue.ready(() => {
274
304
  if (destroyed) return
275
305
  ready = true
276
- const initial =
306
+ const initial = sanitizeUeHtml(
277
307
  pendingHtml !== undefined ? pendingHtml : props.modelValue || ''
308
+ )
278
309
  pendingHtml = undefined
279
310
  applying = true
280
311
  try {
@@ -283,6 +314,7 @@ onMounted(async () => {
283
314
  applying = false
284
315
  }
285
316
  injectReadableBodyStyle(ue)
317
+ hideImageScaleUi(ue)
286
318
  fitEditorHeight(ue)
287
319
  bindResize(ue)
288
320
  requestAnimationFrame(() => fitEditorHeight(ue))
@@ -300,7 +332,7 @@ watch(
300
332
  () => props.modelValue,
301
333
  (html) => {
302
334
  if (destroyed) return
303
- const next = html || ''
335
+ const next = sanitizeUeHtml(html || '')
304
336
  if (!ready || applying) {
305
337
  pendingHtml = next
306
338
  return
@@ -382,4 +414,12 @@ defineExpose({
382
414
  border: none !important;
383
415
  background: #fff !important;
384
416
  }
417
+
418
+ /* 兜底隐藏图片缩放框(错位时会盖住正文) */
419
+ .alleditor-ue-engine :deep(.edui-editor-imagescale),
420
+ .alleditor-ue-engine :deep([id$='_imagescale']),
421
+ .alleditor-ue-engine :deep([id$='_imagescale_cover']) {
422
+ display: none !important;
423
+ pointer-events: none !important;
424
+ }
385
425
  </style>
@@ -0,0 +1,100 @@
1
+ /**
2
+ * DualEditor 默认工具栏:AllEditor 标准项 + MD/HTML 导入。
3
+ * 宿主传入 toolbarConfig.toolbarKeys 时可完全覆盖。
4
+ */
5
+ export const DEFAULT_DUAL_TOOLBAR_KEYS: Array<string | Record<string, any>> = [
6
+ 'headerSelect',
7
+ 'blockquote',
8
+ '|',
9
+ 'bold',
10
+ 'underline',
11
+ 'italic',
12
+ {
13
+ key: 'group-more-style',
14
+ title: '更多',
15
+ iconSvg:
16
+ '<svg viewBox="0 0 1024 1024"><path d="M204.8 505.6m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z"></path><path d="M505.6 505.6m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z"></path><path d="M806.4 505.6m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z"></path></svg>',
17
+ menuKeys: ['through', 'code', 'sup', 'sub', 'clearStyle'],
18
+ },
19
+ 'color',
20
+ 'bgColor',
21
+ '|',
22
+ 'fontSize',
23
+ 'fontFamily',
24
+ 'lineHeight',
25
+ '|',
26
+ 'bulletedList',
27
+ 'numberedList',
28
+ 'todo',
29
+ {
30
+ key: 'group-justify',
31
+ title: '对齐',
32
+ iconSvg:
33
+ '<svg viewBox="0 0 1024 1024"><path d="M768 793.6v102.4H51.2v-102.4h716.8z m204.8-230.4v102.4H51.2v-102.4h921.6z m-204.8-230.4v102.4H51.2v-102.4h716.8zM972.8 102.4v102.4H51.2V102.4h921.6z"></path></svg>',
34
+ menuKeys: ['justifyLeft', 'justifyRight', 'justifyCenter', 'justifyJustify'],
35
+ },
36
+ {
37
+ key: 'group-indent',
38
+ title: '缩进',
39
+ iconSvg:
40
+ '<svg viewBox="0 0 1024 1024"><path d="M0 64h1024v128H0z m384 192h640v128H384z m0 192h640v128H384z m0 192h640v128H384zM0 832h1024v128H0z m0-128V320l256 192z"></path></svg>',
41
+ menuKeys: ['indent', 'delIndent'],
42
+ },
43
+ '|',
44
+ 'emotion',
45
+ 'insertLink',
46
+ {
47
+ key: 'group-image',
48
+ title: '图片',
49
+ iconSvg:
50
+ '<svg viewBox="0 0 1024 1024"><path d="M959.877 128l0.123 0.123v767.775l-0.123 0.122H64.102l-0.122-0.122V128.123l0.122-0.123h895.775zM960 64H64C28.795 64 0 92.795 0 128v768c0 35.205 28.795 64 64 64h896c35.205 0 64-28.795 64-64V128c0-35.205-28.795-64-64-64zM832 288.01c0 53.023-42.988 96.01-96.01 96.01s-96.01-42.987-96.01-96.01S682.967 192 735.99 192 832 234.988 832 288.01zM896 832H128V704l224.01-384 256 320h64l224.01-192z"></path></svg>',
51
+ menuKeys: ['insertImage', 'uploadImage'],
52
+ },
53
+ {
54
+ key: 'group-video',
55
+ title: '视频',
56
+ iconSvg:
57
+ '<svg viewBox="0 0 1024 1024"><path d="M981.184 160.096C837.568 139.456 678.848 128 512 128S186.432 139.456 42.816 160.096C15.296 267.808 0 386.848 0 512s15.264 244.16 42.816 351.904C186.464 884.544 345.152 896 512 896s325.568-11.456 469.184-32.096C1008.704 756.192 1024 637.152 1024 512s-15.264-244.16-42.816-351.904zM384 704V320l320 192-320 192z"></path></svg>',
58
+ menuKeys: ['insertVideo', 'uploadVideo'],
59
+ },
60
+ 'insertTable',
61
+ 'codeBlock',
62
+ 'divider',
63
+ '|',
64
+ 'undo',
65
+ 'redo',
66
+ '|',
67
+ 'importMarkdown',
68
+ 'sourceCode',
69
+ '|',
70
+ 'fullScreen',
71
+ ]
72
+
73
+ export function mergeToolbarConfig(
74
+ userConfig: Record<string, any> | null | undefined,
75
+ options?: { enableImport?: boolean }
76
+ ) {
77
+ const enableImport = options?.enableImport !== false
78
+ const user = userConfig && typeof userConfig === 'object' ? { ...userConfig } : {}
79
+ if (Array.isArray(user.toolbarKeys) && user.toolbarKeys.length) {
80
+ if (!enableImport) return user
81
+ const keys = user.toolbarKeys
82
+ const hasMd = keys.some(
83
+ (k: any) => k === 'importMarkdown' || k?.key === 'importMarkdown'
84
+ )
85
+ const hasHtml = keys.some(
86
+ (k: any) => k === 'sourceCode' || k?.key === 'sourceCode'
87
+ )
88
+ if (hasMd && hasHtml) return user
89
+ const next = [...keys]
90
+ if (!hasMd) next.push('importMarkdown')
91
+ if (!hasHtml) next.push('sourceCode')
92
+ return { ...user, toolbarKeys: next }
93
+ }
94
+ const keys = enableImport
95
+ ? DEFAULT_DUAL_TOOLBAR_KEYS
96
+ : DEFAULT_DUAL_TOOLBAR_KEYS.filter(
97
+ (k) => k !== 'importMarkdown' && k !== 'sourceCode'
98
+ )
99
+ return { ...user, toolbarKeys: keys }
100
+ }
@@ -0,0 +1,110 @@
1
+ import { Boot } from '@mnykj/alleditor'
2
+
3
+ /**
4
+ * 工具栏「HTML」:导入 .html(外观对齐百度 UEditor)。
5
+ * 实际打开文件选择由 SourceCodeMenu.attrs.onImport 接到宿主。
6
+ */
7
+ export class SourceCodeMenu {
8
+ title = '导入 HTML'
9
+ tag = 'button'
10
+ iconSvg =
11
+ '<svg viewBox="0 0 44 24" width="44" height="24"><text x="22" y="16" text-anchor="middle" font-size="12" font-weight="700" font-family="Segoe UI,Helvetica,Arial,sans-serif" fill="#1a73e8">HTML</text></svg>'
12
+
13
+ getValue() {
14
+ return ''
15
+ }
16
+ isActive() {
17
+ return !!(SourceCodeMenu.attrs as { active?: boolean }).active
18
+ }
19
+ isDisabled() {
20
+ return false
21
+ }
22
+ exec() {
23
+ const handler = SourceCodeMenu.attrs.onImport
24
+ if (typeof handler === 'function') handler()
25
+ }
26
+
27
+ static attrs: {
28
+ active: boolean
29
+ onImport: null | (() => void)
30
+ onToggle: null | (() => void)
31
+ } = {
32
+ active: false,
33
+ onImport: null,
34
+ onToggle: null,
35
+ }
36
+ }
37
+
38
+ /**
39
+ * 工具栏:导入 Markdown 文件(仅 .md)。
40
+ */
41
+ export class ImportMarkdownMenu {
42
+ title = '导入 Markdown'
43
+ tag = 'button'
44
+ iconSvg =
45
+ '<svg viewBox="0 0 16 16"><path d="M14.85 3H1.15C.52 3 0 3.52 0 4.15v7.69C0 12.48.52 13 1.15 13h13.69c.64 0 1.15-.52 1.15-1.15v-7.7C16 3.52 15.48 3 14.85 3zM9 11H7V8L5.5 9.92 4 8v3H2V5h2l1.5 2L7 5h2v6zm2.99.5L9.5 8H11V5h2v3h1.5l-2.51 3.5z"></path></svg>'
46
+
47
+ getValue() {
48
+ return ''
49
+ }
50
+ isActive() {
51
+ return false
52
+ }
53
+ isDisabled() {
54
+ return false
55
+ }
56
+ exec() {
57
+ const handler = ImportMarkdownMenu.attrs.onImport
58
+ if (typeof handler === 'function') handler()
59
+ }
60
+
61
+ static attrs: {
62
+ onImport: null | (() => void)
63
+ } = {
64
+ onImport: null,
65
+ }
66
+ }
67
+
68
+ let menusRegistered = false
69
+
70
+ /** 注册导入菜单(幂等;HMR 安全) */
71
+ export function registerImportMenus() {
72
+ if (menusRegistered) return
73
+ try {
74
+ Boot.registerMenu({
75
+ key: 'sourceCode',
76
+ factory() {
77
+ return new SourceCodeMenu()
78
+ },
79
+ })
80
+ } catch {
81
+ /* 重复注册忽略 */
82
+ }
83
+ try {
84
+ Boot.registerMenu({
85
+ key: 'importMarkdown',
86
+ factory() {
87
+ return new ImportMarkdownMenu()
88
+ },
89
+ })
90
+ } catch {
91
+ /* 重复注册忽略 */
92
+ }
93
+ menusRegistered = true
94
+ }
95
+
96
+ export function bindImportMenuHandlers(handlers: {
97
+ onImportHtml?: null | (() => void)
98
+ onImportMarkdown?: null | (() => void)
99
+ onToggleSource?: null | (() => void)
100
+ }) {
101
+ if (handlers.onImportHtml !== undefined) {
102
+ SourceCodeMenu.attrs.onImport = handlers.onImportHtml
103
+ }
104
+ if (handlers.onToggleSource !== undefined) {
105
+ SourceCodeMenu.attrs.onToggle = handlers.onToggleSource
106
+ }
107
+ if (handlers.onImportMarkdown !== undefined) {
108
+ ImportMarkdownMenu.attrs.onImport = handlers.onImportMarkdown
109
+ }
110
+ }
package/src/index.ts CHANGED
@@ -16,6 +16,23 @@ export type { UeCommandRunner, BindUeToolbarOptions, UeToolbarBinding } from './
16
16
 
17
17
  export { DEFAULT_UEDITOR_PUBLIC_BASE } from './ueditorBase'
18
18
 
19
+ export {
20
+ SourceCodeMenu,
21
+ ImportMarkdownMenu,
22
+ registerImportMenus,
23
+ bindImportMenuHandlers,
24
+ } from './importMenus'
25
+ export {
26
+ DEFAULT_DUAL_TOOLBAR_KEYS,
27
+ mergeToolbarConfig,
28
+ } from './defaultToolbar'
29
+ export {
30
+ markdownToHtml,
31
+ extractHtmlBody,
32
+ isEffectivelyEmptyHtml,
33
+ } from './markdown'
34
+ export { stripJavascriptFromHtml } from './stripJavascriptFromHtml'
35
+
19
36
  // 协议工具从核心包再导出,方便只装 dual 的调用方
20
37
  export type { AllEditorEngine } from '@mnykj/alleditor'
21
38
  export {
@@ -0,0 +1,46 @@
1
+ /** @type {import('markdown-it') | null} */
2
+ let markdownItInstance: any = null
3
+
4
+ export async function markdownToHtml(text: string): Promise<string> {
5
+ if (!markdownItInstance) {
6
+ const mod = await import('markdown-it')
7
+ const MarkdownIt = mod.default || mod
8
+ markdownItInstance = new MarkdownIt({
9
+ html: false,
10
+ linkify: true,
11
+ breaks: true,
12
+ })
13
+ }
14
+ let html = markdownItInstance.render(text || '')
15
+ if (!String(html).trim()) html = '<p><br></p>'
16
+ return html
17
+ }
18
+
19
+ /** 从完整 HTML 文档取出 body 内片段 */
20
+ export function extractHtmlBody(text: string): string {
21
+ let s = String(text || '').replace(/^\uFEFF/, '')
22
+ const bodyMatch = s.match(/<body[^>]*>([\s\S]*?)<\/body>/i)
23
+ if (bodyMatch) s = bodyMatch[1]
24
+ return s.trim()
25
+ }
26
+
27
+ export function isEffectivelyEmptyHtml(html: string): boolean {
28
+ const s = String(html ?? '').trim()
29
+ if (!s) return true
30
+ if (
31
+ /<(img|video|audio|iframe|embed|object|source|hr|table|svg|blockquote|pre|ul|ol|h[1-6])\b/i.test(
32
+ s
33
+ )
34
+ ) {
35
+ return false
36
+ }
37
+ const text = s
38
+ .replace(/<!--[\s\S]*?-->/g, '')
39
+ .replace(/&nbsp;/gi, ' ')
40
+ .replace(/<br\s*\/?>/gi, '')
41
+ .replace(/<p>\s*<\/p>/gi, '')
42
+ .replace(/<div[^>]*>\s*<\/div>/gi, '')
43
+ .replace(/<[^>]+>/g, '')
44
+ .trim()
45
+ return text === ''
46
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 导入 HTML 时剥离 JS:script、事件属性、javascript: 伪协议。
3
+ */
4
+ export function stripJavascriptFromHtml(html: string): string {
5
+ let s = String(html ?? '')
6
+
7
+ const open = '<' + 'script'
8
+ const close = '<' + '/' + 'script>'
9
+ const blockRe = new RegExp(`${open}\\b[^>]*>[\\s\\S]*?${close}`, 'gi')
10
+ const selfRe = new RegExp(`${open}\\b[^>]*\\/?>`, 'gi')
11
+
12
+ s = s.replace(blockRe, '')
13
+ s = s.replace(selfRe, '')
14
+
15
+ s = s.replace(/\s+on[a-zA-Z]+\s*=\s*(['"])[\s\S]*?\1/g, '')
16
+ s = s.replace(/\s+on[a-zA-Z]+\s*=\s*[^\s>]+/g, '')
17
+
18
+ s = s.replace(
19
+ /\s(href|src|action|formaction|xlink:href)\s*=\s*(['"])\s*javascript:[\s\S]*?\2/gi,
20
+ ' $1=$2#$2'
21
+ )
22
+ s = s.replace(
23
+ /\s(href|src|action|formaction|xlink:href)\s*=\s*(['"])?\s*javascript:[^\s>]*/gi,
24
+ ' $1="#"'
25
+ )
26
+
27
+ return s
28
+ }