djm-ui 0.67.4 → 0.72.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +38 -2
  3. package/dist/djm-ui.cjs +1032 -347
  4. package/dist/djm-ui.cjs.map +4 -4
  5. package/dist/djm-ui.css +752 -17
  6. package/dist/djm-ui.esm.js +1105 -420
  7. package/dist/djm-ui.esm.js.map +4 -4
  8. package/dist/djm-ui.esm.min.js +373 -227
  9. package/dist/djm-ui.min.cjs +373 -227
  10. package/dist/djm-ui.min.css +2 -2
  11. package/dist/djm-ui.umd.js +944 -259
  12. package/dist/djm-ui.umd.min.js +373 -227
  13. package/dist/mcp/manifest.json +12195 -0
  14. package/dist/mcp/server.js +90 -0
  15. package/package.json +12 -4
  16. package/src/components/icon/index.js +13 -1
  17. package/src/components/rich-editor/index.js +724 -61
  18. package/src/index.js +1 -1
  19. package/src/locale/en-US.js +33 -1
  20. package/src/locale/zh-CN.js +33 -1
  21. package/src/styles/base.css +2 -3
  22. package/src/styles/components/button.css +2 -1
  23. package/src/styles/components/console.css +5 -0
  24. package/src/styles/components/content.css +517 -0
  25. package/src/styles/components/form.css +5 -0
  26. package/src/styles/components/layout.css +326 -0
  27. package/src/styles/components/media-picker.css +3 -3
  28. package/src/styles/components/menu.css +2 -1
  29. package/src/styles/components/org-picker.css +1 -1
  30. package/src/styles/components/responsive.css +86 -0
  31. package/src/styles/components/rich-editor.css +206 -7
  32. package/src/styles/components/skin.css +54 -0
  33. package/src/styles/components/template.css +5 -0
  34. package/src/styles/index.css +1 -0
  35. package/src/styles/theme-dark.css +2 -0
  36. package/src/styles/tokens.css +8 -0
  37. package/src/theme/index.js +40 -0
  38. package/src/utils/sanitize-html.js +44 -6
  39. package/types/index.d.ts +8 -0
@@ -1,4 +1,4 @@
1
- /*! DJM UI v0.67.4 · MIT · 2026-10-26 */
1
+ /*! DJM UI v0.72.0 · MIT · 2026-10-26 */
2
2
  "use strict";
3
3
  var DjmUI = (() => {
4
4
  var __defProp = Object.defineProperty;
@@ -1119,7 +1119,19 @@ Minimum version required to store current data is: ` + i + `.
1119
1119
  activity: "M22 12h-4l-3 9L9 3l-3 9H2",
1120
1120
  database: "M12 8c4.42 0 8-1.34 8-3s-3.58-3-8-3-8 1.34-8 3 3.58 3 8 3zM4 5v14c0 1.66 3.58 3 8 3s8-1.34 8-3V5M4 12c0 1.66 3.58 3 8 3s8-1.34 8-3",
1121
1121
  sun: "M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42",
1122
- moon: "M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"
1122
+ moon: "M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z",
1123
+ undo: "M3 10h11a5 5 0 1 1 0 10H8M3 10l4-4M3 10l4 4",
1124
+ redo: "M21 10H10a5 5 0 1 0 0 10h6M21 10l-4-4M21 10l-4 4",
1125
+ "align-left": "M4 6h16M4 12h10M4 18h13",
1126
+ "align-center": "M4 6h16M7 12h10M6 18h12",
1127
+ "align-right": "M4 6h16M10 12h10M7 18h13",
1128
+ "align-justify": "M4 6h16M4 12h16M4 18h16",
1129
+ list: "M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01",
1130
+ "list-ordered": "M10 6h10M10 12h10M10 18h10M4 6h1v4M4 12h2M4 16h2v2H4",
1131
+ outdent: "M4 6h16M10 12h10M10 18h10M8 9l-3 3 3 3",
1132
+ indent: "M4 6h16M10 12h10M10 18h10M5 9l3 3-3 3",
1133
+ unlink: "M10 13a5 5 0 0 0 7.5.5M14 11a5 5 0 0 0-7.5-.5M2 2l20 20",
1134
+ images: "M8 3h12v12M4 7h12v12H4zM7 15l3-3 3 3"
1123
1135
  };
1124
1136
  var icon_default = {
1125
1137
  name: "DjmIcon",
@@ -1458,7 +1470,6 @@ Minimum version required to store current data is: ` + i + `.
1458
1470
  "richEditor.italic": "\u659C\u4F53",
1459
1471
  "richEditor.underline": "\u4E0B\u5212\u7EBF",
1460
1472
  "richEditor.strike": "\u5220\u9664\u7EBF",
1461
- "richEditor.heading": "\u4E8C\u7EA7\u6807\u9898",
1462
1473
  "richEditor.paragraph": "\u6B63\u6587",
1463
1474
  "richEditor.bulletList": "\u65E0\u5E8F\u5217\u8868",
1464
1475
  "richEditor.numberList": "\u6709\u5E8F\u5217\u8868",
@@ -1469,6 +1480,39 @@ Minimum version required to store current data is: ` + i + `.
1469
1480
  "richEditor.redo": "\u91CD\u505A",
1470
1481
  "richEditor.source": "\u6E90\u7801",
1471
1482
  "richEditor.visual": "\u53EF\u89C6\u5316",
1483
+ "richEditor.toolbar": "\u7F16\u8F91\u5DE5\u5177\u680F",
1484
+ "richEditor.block": "\u6BB5\u843D\u683C\u5F0F",
1485
+ "richEditor.heading1": "\u6807\u9898 1",
1486
+ "richEditor.heading2": "\u6807\u9898 2",
1487
+ "richEditor.heading3": "\u6807\u9898 3",
1488
+ "richEditor.quote": "\u5F15\u7528",
1489
+ "richEditor.codeBlock": "\u4EE3\u7801\u5757",
1490
+ "richEditor.fontSize": "\u5B57\u53F7",
1491
+ "richEditor.defaultSize": "\u9ED8\u8BA4",
1492
+ "richEditor.inlineCode": "\u884C\u5185\u4EE3\u7801",
1493
+ "richEditor.textColor": "\u6587\u5B57\u989C\u8272",
1494
+ "richEditor.bgColor": "\u80CC\u666F\u8272",
1495
+ "richEditor.alignLeft": "\u5DE6\u5BF9\u9F50",
1496
+ "richEditor.alignCenter": "\u5C45\u4E2D",
1497
+ "richEditor.alignRight": "\u53F3\u5BF9\u9F50",
1498
+ "richEditor.alignJustify": "\u4E24\u7AEF\u5BF9\u9F50",
1499
+ "richEditor.outdent": "\u51CF\u5C11\u7F29\u8FDB",
1500
+ "richEditor.indent": "\u589E\u52A0\u7F29\u8FDB",
1501
+ "richEditor.unlink": "\u53D6\u6D88\u94FE\u63A5",
1502
+ "richEditor.image": "\u63D2\u5165\u56FE\u7247",
1503
+ "richEditor.singleImage": "\u63D2\u5165\u5355\u56FE",
1504
+ "richEditor.multiImage": "\u63D2\u5165\u591A\u56FE",
1505
+ "richEditor.mediaLibrary": "\u4ECE\u7D20\u6750\u5E93\u9009\u62E9",
1506
+ "richEditor.hr": "\u5206\u5272\u7EBF",
1507
+ "richEditor.fullscreen": "\u5168\u5C4F",
1508
+ "richEditor.exitFullscreen": "\u9000\u51FA\u5168\u5C4F",
1509
+ "richEditor.wordCount": "\u5171 {count} \u5B57",
1510
+ "richEditor.uploading": "\u6B63\u5728\u4E0A\u4F20 {count} \u5F20\u56FE\u7247\u2026",
1511
+ "richEditor.uploadFailed": "\u56FE\u7247\u4E0A\u4F20\u5931\u8D25",
1512
+ "richEditor.imageTypeError": "\u53EA\u80FD\u4E0A\u4F20\u56FE\u7247\u6587\u4EF6",
1513
+ "richEditor.imageSizeError": "\u56FE\u7247\u4E0D\u80FD\u8D85\u8FC7 {size}MB",
1514
+ "richEditor.imageUrlInvalid": "\u56FE\u7247\u5730\u5740\u4E0D\u5408\u6CD5\uFF0C\u5DF2\u62D2\u7EDD\u63D2\u5165",
1515
+ "richEditor.linkInvalid": "\u94FE\u63A5\u53EA\u652F\u6301 http(s)\u3001mailto\u3001tel \u6216\u7AD9\u5185\u76F8\u5BF9\u5730\u5740",
1472
1516
  "approval.start": "\u53D1\u8D77",
1473
1517
  "approval.cc": "\u6284\u9001",
1474
1518
  "approval.approved": "\u5DF2\u901A\u8FC7",
@@ -1677,7 +1721,6 @@ Minimum version required to store current data is: ` + i + `.
1677
1721
  "richEditor.italic": "Italic",
1678
1722
  "richEditor.underline": "Underline",
1679
1723
  "richEditor.strike": "Strikethrough",
1680
- "richEditor.heading": "Heading 2",
1681
1724
  "richEditor.paragraph": "Paragraph",
1682
1725
  "richEditor.bulletList": "Bullet list",
1683
1726
  "richEditor.numberList": "Numbered list",
@@ -1688,6 +1731,39 @@ Minimum version required to store current data is: ` + i + `.
1688
1731
  "richEditor.redo": "Redo",
1689
1732
  "richEditor.source": "Source",
1690
1733
  "richEditor.visual": "Visual",
1734
+ "richEditor.toolbar": "Formatting toolbar",
1735
+ "richEditor.block": "Paragraph style",
1736
+ "richEditor.heading1": "Heading 1",
1737
+ "richEditor.heading2": "Heading 2",
1738
+ "richEditor.heading3": "Heading 3",
1739
+ "richEditor.quote": "Quote",
1740
+ "richEditor.codeBlock": "Code block",
1741
+ "richEditor.fontSize": "Font size",
1742
+ "richEditor.defaultSize": "Default",
1743
+ "richEditor.inlineCode": "Inline code",
1744
+ "richEditor.textColor": "Text color",
1745
+ "richEditor.bgColor": "Background color",
1746
+ "richEditor.alignLeft": "Align left",
1747
+ "richEditor.alignCenter": "Align center",
1748
+ "richEditor.alignRight": "Align right",
1749
+ "richEditor.alignJustify": "Justify",
1750
+ "richEditor.outdent": "Decrease indent",
1751
+ "richEditor.indent": "Increase indent",
1752
+ "richEditor.unlink": "Remove link",
1753
+ "richEditor.image": "Insert image",
1754
+ "richEditor.singleImage": "Insert single image",
1755
+ "richEditor.multiImage": "Insert multiple images",
1756
+ "richEditor.mediaLibrary": "Choose from media library",
1757
+ "richEditor.hr": "Divider",
1758
+ "richEditor.fullscreen": "Fullscreen",
1759
+ "richEditor.exitFullscreen": "Exit fullscreen",
1760
+ "richEditor.wordCount": "{count} characters",
1761
+ "richEditor.uploading": "Uploading {count} image(s)\u2026",
1762
+ "richEditor.uploadFailed": "Image upload failed",
1763
+ "richEditor.imageTypeError": "Only image files are allowed",
1764
+ "richEditor.imageSizeError": "Image must be smaller than {size}MB",
1765
+ "richEditor.imageUrlInvalid": "Invalid image URL, insertion rejected",
1766
+ "richEditor.linkInvalid": "Only http(s), mailto, tel or relative URLs are allowed",
1691
1767
  "approval.start": "Started",
1692
1768
  "approval.cc": "CC",
1693
1769
  "approval.approved": "Approved",
@@ -9470,6 +9546,197 @@ Minimum version required to store current data is: ` + i + `.
9470
9546
  `
9471
9547
  };
9472
9548
 
9549
+ // src/components/media-picker/index.js
9550
+ var media_picker_default = {
9551
+ name: "DjmMediaPicker",
9552
+ components: { DjmModal: modal_default, DjmPagination: pagination_default, DjmIcon: icon_default },
9553
+ props: {
9554
+ modelValue: { type: [Array, String, Number], default: "" },
9555
+ items: { type: Array, default: () => [] },
9556
+ multiple: { type: Boolean, default: false },
9557
+ // 最多选几个,0 表示不限
9558
+ limit: { type: Number, default: 0 },
9559
+ // image 只显示图片,file 只显示非图片,空串不过滤
9560
+ accept: { type: String, default: "" },
9561
+ title: { type: String, default: "" },
9562
+ triggerText: { type: String, default: "" },
9563
+ pageSize: { type: Number, default: 12 },
9564
+ disabled: { type: Boolean, default: false }
9565
+ },
9566
+ emits: ["update:modelValue", "change"],
9567
+ setup(props, { emit }) {
9568
+ const open = ref(false);
9569
+ const keyword = ref("");
9570
+ const page = ref(1);
9571
+ const temp = ref([]);
9572
+ const selectedIds = computed(() => {
9573
+ if (props.multiple) return Array.isArray(props.modelValue) ? props.modelValue : [];
9574
+ if (props.modelValue === "" || props.modelValue === null || props.modelValue === void 0) return [];
9575
+ return [props.modelValue];
9576
+ });
9577
+ function isImage(item) {
9578
+ if (item.type) return item.type === "image";
9579
+ return /\.(png|jpe?g|gif|webp|svg|avif)$/i.test(item.url || "");
9580
+ }
9581
+ const filtered = computed(() => {
9582
+ const key = keyword.value.trim().toLowerCase();
9583
+ return props.items.filter((item) => {
9584
+ if (props.accept === "image" && !isImage(item)) return false;
9585
+ if (props.accept === "file" && isImage(item)) return false;
9586
+ if (key && !String(item.name || "").toLowerCase().includes(key)) return false;
9587
+ return true;
9588
+ });
9589
+ });
9590
+ const paged = computed(() => {
9591
+ const start = (page.value - 1) * props.pageSize;
9592
+ return filtered.value.slice(start, start + props.pageSize);
9593
+ });
9594
+ watch(keyword, () => {
9595
+ page.value = 1;
9596
+ });
9597
+ watch(
9598
+ () => filtered.value.length,
9599
+ () => {
9600
+ const max = Math.max(1, Math.ceil(filtered.value.length / props.pageSize));
9601
+ if (page.value > max) page.value = max;
9602
+ }
9603
+ );
9604
+ function isChecked(id) {
9605
+ return temp.value.includes(id);
9606
+ }
9607
+ function atLimit() {
9608
+ return props.limit > 0 && temp.value.length >= props.limit;
9609
+ }
9610
+ function toggle(item) {
9611
+ if (props.multiple) {
9612
+ if (isChecked(item.id)) {
9613
+ temp.value = temp.value.filter((id) => id !== item.id);
9614
+ return;
9615
+ }
9616
+ if (atLimit()) return;
9617
+ temp.value = [...temp.value, item.id];
9618
+ return;
9619
+ }
9620
+ temp.value = [item.id];
9621
+ }
9622
+ function openPanel() {
9623
+ if (props.disabled) return;
9624
+ temp.value = [...selectedIds.value];
9625
+ keyword.value = "";
9626
+ page.value = 1;
9627
+ open.value = true;
9628
+ }
9629
+ function commit() {
9630
+ const next = props.multiple ? [...temp.value] : temp.value[0] !== void 0 ? temp.value[0] : "";
9631
+ emit("update:modelValue", next);
9632
+ emit("change", next);
9633
+ open.value = false;
9634
+ }
9635
+ function cancel() {
9636
+ temp.value = [...selectedIds.value];
9637
+ open.value = false;
9638
+ }
9639
+ return {
9640
+ open,
9641
+ keyword,
9642
+ page,
9643
+ temp,
9644
+ selectedIds,
9645
+ filtered,
9646
+ paged,
9647
+ isImage,
9648
+ isChecked,
9649
+ atLimit,
9650
+ toggle,
9651
+ openPanel,
9652
+ commit,
9653
+ cancel,
9654
+ t
9655
+ };
9656
+ },
9657
+ template: `
9658
+ <span class="djm-media-picker">
9659
+ <slot name="trigger" :open="openPanel" :count="selectedIds.length">
9660
+ <button
9661
+ class="djm-btn"
9662
+ type="button"
9663
+ :disabled="disabled"
9664
+ @click="openPanel"
9665
+ >{{ triggerText || t('mediaPicker.select') }}<span v-if="selectedIds.length" class="djm-media-picker-count">{{ selectedIds.length }}</span></button>
9666
+ </slot>
9667
+
9668
+ <DjmModal
9669
+ :model-value="open"
9670
+ :title="title || t('mediaPicker.title')"
9671
+ width="760px"
9672
+ @update:model-value="cancel"
9673
+ >
9674
+ <div class="djm-media-toolbar">
9675
+ <label class="djm-media-search">
9676
+ <DjmIcon name="search" :size="13" />
9677
+ <input
9678
+ v-model="keyword"
9679
+ class="djm-media-search-input"
9680
+ type="text"
9681
+ :placeholder="t('mediaPicker.search')"
9682
+ :aria-label="t('mediaPicker.search')"
9683
+ />
9684
+ </label>
9685
+ <span class="djm-media-toolbar-slot">
9686
+ <slot name="toolbar"></slot>
9687
+ </span>
9688
+ </div>
9689
+
9690
+ <div class="djm-media-grid" role="listbox" :aria-multiselectable="multiple ? 'true' : 'false'">
9691
+ <div
9692
+ v-for="item in paged"
9693
+ :key="item.id"
9694
+ class="djm-media-item"
9695
+ :class="{
9696
+ 'djm-is-checked': isChecked(item.id),
9697
+ 'djm-is-disabled': multiple && atLimit() && !isChecked(item.id)
9698
+ }"
9699
+ role="option"
9700
+ tabindex="0"
9701
+ :aria-selected="String(isChecked(item.id))"
9702
+ @click="toggle(item)"
9703
+ @keydown.enter.prevent="toggle(item)"
9704
+ @keydown.space.prevent="toggle(item)"
9705
+ >
9706
+ <div class="djm-media-thumb">
9707
+ <img v-if="isImage(item)" :src="item.url" :alt="item.name" loading="lazy" />
9708
+ <DjmIcon v-else name="folder" :size="26" />
9709
+ <span v-if="isChecked(item.id)" class="djm-media-check">
9710
+ <DjmIcon name="check" :size="11" />
9711
+ </span>
9712
+ </div>
9713
+ <div class="djm-media-name" :title="item.name">{{ item.name }}</div>
9714
+ </div>
9715
+ <div v-if="!paged.length" class="djm-media-empty">{{ t('mediaPicker.empty') }}</div>
9716
+ </div>
9717
+
9718
+ <div class="djm-media-foot">
9719
+ <span class="djm-media-total">
9720
+ {{ t('mediaPicker.total', { total: filtered.length }) }}
9721
+ <em v-if="multiple && limit > 0">{{ t('mediaPicker.limit', { limit }) }}</em>
9722
+ </span>
9723
+ <DjmPagination
9724
+ v-if="filtered.length > pageSize"
9725
+ v-model:page="page"
9726
+ :page-size="pageSize"
9727
+ :total="filtered.length"
9728
+ />
9729
+ </div>
9730
+
9731
+ <template #footer>
9732
+ <button class="djm-btn" type="button" @click="cancel">{{ t('common.cancel') }}</button>
9733
+ <button class="djm-btn djm-btn-primary" type="button" @click="commit">{{ t('common.confirm') }}</button>
9734
+ </template>
9735
+ </DjmModal>
9736
+ </span>
9737
+ `
9738
+ };
9739
+
9473
9740
  // src/utils/sanitize-html.js
9474
9741
  var TAGS = /* @__PURE__ */ new Set([
9475
9742
  "p",
@@ -9508,7 +9775,18 @@ Minimum version required to store current data is: ` + i + `.
9508
9775
  "th",
9509
9776
  "td"
9510
9777
  ]);
9511
- var GLOBAL_ATTRS = /* @__PURE__ */ new Set(["class", "title"]);
9778
+ var GLOBAL_ATTRS = /* @__PURE__ */ new Set(["class", "title", "style"]);
9779
+ var STYLE_PROPS = /* @__PURE__ */ new Set([
9780
+ "color",
9781
+ "background-color",
9782
+ "font-size",
9783
+ "font-weight",
9784
+ "font-style",
9785
+ "text-decoration",
9786
+ "text-align",
9787
+ "text-indent",
9788
+ "line-height"
9789
+ ]);
9512
9790
  var TAG_ATTRS = {
9513
9791
  a: /* @__PURE__ */ new Set(["href", "target", "rel"]),
9514
9792
  img: /* @__PURE__ */ new Set(["src", "alt", "width", "height"]),
@@ -9516,11 +9794,29 @@ Minimum version required to store current data is: ` + i + `.
9516
9794
  th: /* @__PURE__ */ new Set(["colspan", "rowspan", "scope"]),
9517
9795
  td: /* @__PURE__ */ new Set(["colspan", "rowspan"])
9518
9796
  };
9797
+ function cleanStyle(value) {
9798
+ return String(value || "").split(";").map((item) => {
9799
+ const index = item.indexOf(":");
9800
+ if (index === -1) return "";
9801
+ const name = item.slice(0, index).trim().toLowerCase();
9802
+ const val = item.slice(index + 1).trim();
9803
+ if (!STYLE_PROPS.has(name)) return "";
9804
+ if (/url\s*\(|expression\s*\(|javascript:/i.test(val)) return "";
9805
+ return name + ": " + val;
9806
+ }).filter(Boolean).join("; ");
9807
+ }
9808
+ var SAFE_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
9519
9809
  function safeUrl(value, tag) {
9520
- const url = String(value || "").replace(/[\u0000-\u0020]+/g, "").toLowerCase();
9521
- if (url.startsWith("javascript:") || url.startsWith("vbscript:")) return false;
9522
- if (url.startsWith("data:")) return tag === "img" && url.startsWith("data:image/");
9523
- return true;
9810
+ const url = String(value || "").replace(/[\u0000-\u0020]+/g, "");
9811
+ const scheme = url.match(/^([a-z][a-z0-9+.-]*):/i);
9812
+ if (!scheme) return true;
9813
+ const name = scheme[1].toLowerCase();
9814
+ if (SAFE_SCHEMES.has(name)) return true;
9815
+ if (tag === "img") {
9816
+ if (name === "blob") return true;
9817
+ if (name === "data" && /^data:image\//i.test(url)) return true;
9818
+ }
9819
+ return false;
9524
9820
  }
9525
9821
  function cleanNode(node) {
9526
9822
  const children = [...node.childNodes];
@@ -9543,6 +9839,12 @@ Minimum version required to store current data is: ` + i + `.
9543
9839
  child.removeAttribute(attr.name);
9544
9840
  return;
9545
9841
  }
9842
+ if (name === "style") {
9843
+ const style = cleanStyle(attr.value);
9844
+ if (style) child.setAttribute("style", style);
9845
+ else child.removeAttribute("style");
9846
+ return;
9847
+ }
9546
9848
  if ((name === "href" || name === "src") && !safeUrl(attr.value, tag)) {
9547
9849
  child.removeAttribute(attr.name);
9548
9850
  }
@@ -9563,78 +9865,474 @@ Minimum version required to store current data is: ` + i + `.
9563
9865
  }
9564
9866
 
9565
9867
  // src/components/rich-editor/index.js
9868
+ var BLOCKS = [
9869
+ { value: "p", label: "paragraph" },
9870
+ { value: "h1", label: "heading1" },
9871
+ { value: "h2", label: "heading2" },
9872
+ { value: "h3", label: "heading3" },
9873
+ { value: "blockquote", label: "quote" },
9874
+ { value: "pre", label: "codeBlock" }
9875
+ ];
9876
+ var SIZES = [
9877
+ { value: "", label: "defaultSize" },
9878
+ { value: "12px", label: "12px" },
9879
+ { value: "14px", label: "14px" },
9880
+ { value: "16px", label: "16px" },
9881
+ { value: "18px", label: "18px" },
9882
+ { value: "24px", label: "24px" }
9883
+ ];
9884
+ var COLORS = ["#1f2d3d", "#5a6b7d", "#2b86d9", "#2fa36b", "#d97706", "#dc2626", "#7c3aed", "#ffffff"];
9885
+ var TOOLBAR_PRESETS = {
9886
+ simple: ["history", "inline", "list", "insert"],
9887
+ basic: ["history", "format", "inline", "align", "list", "insert", "view"],
9888
+ full: ["history", "format", "inline", "color", "align", "list", "insert", "view"]
9889
+ };
9890
+ function isSafeImageUrl(url) {
9891
+ const value = String(url || "").replace(/[\u0000-\u0020]+/g, "");
9892
+ if (!value) return false;
9893
+ const scheme = value.match(/^([a-z][a-z0-9+.-]*):/i);
9894
+ if (!scheme) return true;
9895
+ const name = scheme[1].toLowerCase();
9896
+ if (name === "http" || name === "https" || name === "blob") return true;
9897
+ return name === "data" && /^data:image\//i.test(value);
9898
+ }
9899
+ function isSafeLink(url) {
9900
+ const value = String(url || "").trim();
9901
+ if (!value) return false;
9902
+ const scheme = value.replace(/[\u0000-\u0020]+/g, "").match(/^([a-z][a-z0-9+.-]*):/i);
9903
+ if (!scheme) return true;
9904
+ return ["http", "https", "mailto", "tel"].indexOf(scheme[1].toLowerCase()) > -1;
9905
+ }
9566
9906
  var rich_editor_default = {
9567
9907
  name: "DjmRichEditor",
9568
- components: { Icon: icon_default },
9908
+ components: { Icon: icon_default, MediaPicker: media_picker_default },
9569
9909
  props: {
9570
9910
  modelValue: { type: String, default: "" },
9571
9911
  placeholder: { type: String, default: "" },
9572
9912
  minHeight: { type: Number, default: 200 },
9573
- // 开启后对外输出的 HTML 会走白名单清洗
9574
- sanitize: { type: Boolean, default: false },
9575
- disabled: { type: Boolean, default: false }
9913
+ // 默认开启:对外输出的 HTML 走白名单清洗,内容里不会带出脚本
9914
+ sanitize: { type: Boolean, default: true },
9915
+ disabled: { type: Boolean, default: false },
9916
+ // 工具条:simple / basic / full,或传分组数组(history / format / inline / color / align / list / insert / view)
9917
+ toolbar: { type: [String, Array], default: "full" },
9918
+ // 自定义上传:接收 File,返回图片地址(或 { url }),不传则转成 dataURL 本地预览
9919
+ uploadImage: { type: Function, default: null },
9920
+ // 已上传图片,提供后工具栏出现「从素材库选择」:字符串或 { url, name }
9921
+ media: { type: Array, default: () => [] },
9922
+ accept: { type: String, default: "image/*" },
9923
+ // 单张图片大小上限(MB)
9924
+ maxImageSize: { type: Number, default: 5 },
9925
+ // 是否允许一次选多张
9926
+ multiple: { type: Boolean, default: true },
9927
+ showCount: { type: Boolean, default: true }
9576
9928
  },
9577
- emits: ["update:modelValue"],
9929
+ emits: ["update:modelValue", "upload-error"],
9578
9930
  setup(props, { emit }) {
9579
9931
  const editor = ref(null);
9932
+ const fileInput = ref(null);
9580
9933
  const sourceMode = ref(false);
9581
9934
  const source = ref(props.modelValue);
9582
- const formats = ref({ bold: false, italic: false, underline: false, strike: false });
9935
+ const fullscreen = ref(false);
9936
+ const panel = ref("");
9937
+ const uploadCount = ref(0);
9938
+ const textColor = ref(COLORS[0]);
9939
+ const bgColor = ref("#fdf3c4");
9940
+ const fontSize = ref("");
9941
+ const count2 = ref(0);
9942
+ const formats = ref({
9943
+ bold: false,
9944
+ italic: false,
9945
+ underline: false,
9946
+ strike: false,
9947
+ code: false,
9948
+ block: "p",
9949
+ align: "",
9950
+ list: ""
9951
+ });
9952
+ let savedRange = null;
9953
+ const ready = computed(() => !props.disabled && !sourceMode.value);
9954
+ const toolbarGroups = computed(() => {
9955
+ const list = Array.isArray(props.toolbar) ? props.toolbar : TOOLBAR_PRESETS[props.toolbar] || TOOLBAR_PRESETS.full;
9956
+ return new Set(list);
9957
+ });
9958
+ function has(group) {
9959
+ return toolbarGroups.value.has(group);
9960
+ }
9961
+ const mediaLibrary = computed(
9962
+ () => (props.media || []).map((item, index) => {
9963
+ if (typeof item === "string") {
9964
+ return { id: "media-" + index, url: item, name: "", type: "image" };
9965
+ }
9966
+ return {
9967
+ id: item && item.id !== void 0 ? String(item.id) : "media-" + index,
9968
+ url: item && (item.url || item.src) || "",
9969
+ name: item && (item.name || item.alt) || "",
9970
+ type: "image"
9971
+ };
9972
+ }).filter((item) => !!item.url)
9973
+ );
9583
9974
  function clean(html) {
9584
9975
  return props.sanitize ? sanitizeHtml(html) : html;
9585
9976
  }
9977
+ function plainText(html) {
9978
+ return String(html || "").replace(/<[^>]*>/g, "").replace(/&nbsp;/g, " ").replace(/&[a-z]+;/gi, " ");
9979
+ }
9586
9980
  function syncFromDom() {
9587
9981
  if (!editor.value) return;
9588
- emit("update:modelValue", clean(editor.value.innerHTML));
9982
+ const html = clean(editor.value.innerHTML);
9983
+ count2.value = plainText(html).trim().length;
9984
+ emit("update:modelValue", html);
9589
9985
  }
9590
- function onPaste(event) {
9591
- const data = event.clipboardData;
9592
- const html = data && data.getData("text/html");
9593
- if (!html) return;
9594
- event.preventDefault();
9595
- document.execCommand("insertHTML", false, sanitizeHtml(html));
9986
+ function currentRange() {
9987
+ const selection = window.getSelection();
9988
+ if (!selection || !selection.rangeCount || !editor.value) return null;
9989
+ const range = selection.getRangeAt(0);
9990
+ return editor.value.contains(range.commonAncestorContainer) ? range : null;
9991
+ }
9992
+ function rememberRange() {
9993
+ const range = currentRange();
9994
+ savedRange = range ? range.cloneRange() : null;
9995
+ }
9996
+ function focusAt(range) {
9997
+ if (!editor.value) return;
9998
+ editor.value.focus();
9999
+ const selection = window.getSelection();
10000
+ selection.removeAllRanges();
10001
+ if (range) {
10002
+ selection.addRange(range);
10003
+ return;
10004
+ }
10005
+ const end = document.createRange();
10006
+ end.selectNodeContents(editor.value);
10007
+ end.collapse(false);
10008
+ selection.addRange(end);
10009
+ }
10010
+ function insertNodeAt(range, node) {
10011
+ if (!editor.value) return;
10012
+ focusAt(range);
10013
+ const selection = window.getSelection();
10014
+ if (selection.rangeCount) selection.getRangeAt(0).insertNode(node);
10015
+ else editor.value.appendChild(node);
9596
10016
  syncFromDom();
10017
+ syncFormats();
10018
+ }
10019
+ function ensureRange() {
10020
+ const range = currentRange();
10021
+ if (range) return range;
10022
+ if (!savedRange) return null;
10023
+ focusAt(savedRange);
10024
+ return currentRange();
9597
10025
  }
9598
10026
  function exec(command, value = null) {
9599
- if (props.disabled || sourceMode.value) return;
10027
+ if (!ready.value) return;
10028
+ if (typeof document.execCommand !== "function") return;
10029
+ ensureRange();
10030
+ document.execCommand("styleWithCSS", false, true);
9600
10031
  document.execCommand(command, false, value);
9601
10032
  if (editor.value) editor.value.focus();
9602
10033
  syncFromDom();
9603
10034
  syncFormats();
9604
10035
  }
9605
- function formatBlock(tag) {
9606
- exec("formatBlock", tag);
10036
+ function setBlock(value) {
10037
+ panel.value = "";
10038
+ exec("formatBlock", "<" + value + ">");
10039
+ }
10040
+ function setFontSize(size) {
10041
+ panel.value = "";
10042
+ if (!ready.value || !editor.value) return;
10043
+ fontSize.value = size;
10044
+ const range = ensureRange();
10045
+ if (!range || range.collapsed) return;
10046
+ if (!size) {
10047
+ const walker = document.createTreeWalker(editor.value, NodeFilter.SHOW_ELEMENT);
10048
+ const targets = [];
10049
+ while (walker.nextNode()) {
10050
+ const el = walker.currentNode;
10051
+ if (el.style && el.style.fontSize && range.intersectsNode(el)) targets.push(el);
10052
+ }
10053
+ targets.forEach((el) => {
10054
+ el.style.fontSize = "";
10055
+ });
10056
+ syncFromDom();
10057
+ return;
10058
+ }
10059
+ const span = document.createElement("span");
10060
+ span.style.fontSize = size;
10061
+ span.appendChild(range.extractContents());
10062
+ range.insertNode(span);
10063
+ const selection = window.getSelection();
10064
+ const after = document.createRange();
10065
+ after.setStartAfter(span);
10066
+ after.collapse(true);
10067
+ selection.removeAllRanges();
10068
+ selection.addRange(after);
10069
+ syncFromDom();
10070
+ syncFormats();
10071
+ }
10072
+ function applyColor(kind, color) {
10073
+ panel.value = "";
10074
+ if (kind === "text") textColor.value = color;
10075
+ else bgColor.value = color;
10076
+ if (!ready.value) return;
10077
+ if (typeof document.execCommand !== "function") return;
10078
+ document.execCommand("styleWithCSS", false, true);
10079
+ const command = kind === "text" ? "foreColor" : "hiliteColor";
10080
+ if (!document.execCommand(command, false, color) && kind === "bg") {
10081
+ document.execCommand("backColor", false, color);
10082
+ }
10083
+ if (editor.value) editor.value.focus();
10084
+ syncFromDom();
10085
+ syncFormats();
10086
+ }
10087
+ function togglePanel(name) {
10088
+ if (!ready.value) return;
10089
+ panel.value = panel.value === name ? "" : name;
10090
+ }
10091
+ function toggleInlineCode() {
10092
+ if (!ready.value || !editor.value) return;
10093
+ const range = currentRange();
10094
+ if (!range) return;
10095
+ const anchor = range.commonAncestorContainer;
10096
+ const node = anchor.nodeType === 1 ? anchor : anchor.parentElement;
10097
+ const code = node && node.closest ? node.closest("code") : null;
10098
+ if (code && editor.value.contains(code)) {
10099
+ const parent = code.parentNode;
10100
+ while (code.firstChild) parent.insertBefore(code.firstChild, code);
10101
+ parent.removeChild(code);
10102
+ syncFromDom();
10103
+ return;
10104
+ }
10105
+ if (range.collapsed) return;
10106
+ const wrapper = document.createElement("code");
10107
+ wrapper.appendChild(range.extractContents());
10108
+ range.insertNode(wrapper);
10109
+ const selection = window.getSelection();
10110
+ const after = document.createRange();
10111
+ after.setStartAfter(wrapper);
10112
+ after.collapse(true);
10113
+ selection.removeAllRanges();
10114
+ selection.addRange(after);
10115
+ syncFromDom();
10116
+ syncFormats();
9607
10117
  }
9608
10118
  function addLink() {
10119
+ if (!ready.value) return;
9609
10120
  const url = window.prompt(t("richEditor.linkPrompt"), "https://");
9610
- if (url) exec("createLink", url);
10121
+ if (!url) return;
10122
+ if (!isSafeLink(url)) {
10123
+ window.alert(t("richEditor.linkInvalid"));
10124
+ return;
10125
+ }
10126
+ const range = currentRange();
10127
+ if (range && range.collapsed) {
10128
+ const link = document.createElement("a");
10129
+ link.href = url;
10130
+ link.target = "_blank";
10131
+ link.rel = "noopener noreferrer";
10132
+ link.textContent = url;
10133
+ insertNodeAt(range, link);
10134
+ return;
10135
+ }
10136
+ exec("createLink", url);
10137
+ if (editor.value) {
10138
+ editor.value.querySelectorAll("a[href]").forEach((link) => {
10139
+ if (!link.getAttribute("target")) link.setAttribute("target", "_blank");
10140
+ link.setAttribute("rel", "noopener noreferrer");
10141
+ });
10142
+ }
10143
+ syncFromDom();
10144
+ }
10145
+ function readAsDataUrl(file) {
10146
+ return new Promise((resolve, reject) => {
10147
+ const reader = new FileReader();
10148
+ reader.onload = () => resolve(reader.result);
10149
+ reader.onerror = () => reject(reader.error || new Error("read failed"));
10150
+ reader.readAsDataURL(file);
10151
+ });
10152
+ }
10153
+ async function uploadOne(file) {
10154
+ if (props.uploadImage) {
10155
+ const result = await props.uploadImage(file);
10156
+ const url = typeof result === "string" ? result : result && result.url;
10157
+ if (!url) throw new Error(t("richEditor.imageUrlInvalid"));
10158
+ if (!isSafeImageUrl(url)) throw new Error(t("richEditor.imageUrlInvalid"));
10159
+ return String(url).trim();
10160
+ }
10161
+ return readAsDataUrl(file);
10162
+ }
10163
+ function isImage(file) {
10164
+ return file && /^image\//.test(file.type);
10165
+ }
10166
+ function pickImage(allowMultiple) {
10167
+ if (!ready.value || !fileInput.value) return;
10168
+ rememberRange();
10169
+ fileInput.value.multiple = !!allowMultiple && props.multiple;
10170
+ fileInput.value.click();
10171
+ }
10172
+ function insertMedia(ids) {
10173
+ if (!ready.value) return;
10174
+ const picked = (Array.isArray(ids) ? ids : [ids]).map((id) => mediaLibrary.value.find((item) => item.id === String(id))).filter(Boolean);
10175
+ const safe = picked.filter((item) => {
10176
+ if (isSafeImageUrl(item.url)) return true;
10177
+ emit("upload-error", { file: null, message: t("richEditor.imageUrlInvalid") });
10178
+ return false;
10179
+ });
10180
+ if (!safe.length) return;
10181
+ focusAt(savedRange);
10182
+ const fragment = document.createDocumentFragment();
10183
+ safe.forEach((item) => {
10184
+ const img = document.createElement("img");
10185
+ img.src = item.url;
10186
+ img.alt = item.name || "image";
10187
+ img.className = "djm-rich-editor-img";
10188
+ fragment.appendChild(img);
10189
+ });
10190
+ const selection = window.getSelection();
10191
+ if (selection.rangeCount) selection.getRangeAt(0).insertNode(fragment);
10192
+ else editor.value.appendChild(fragment);
10193
+ syncFromDom();
10194
+ syncFormats();
10195
+ }
10196
+ function insertImages(files) {
10197
+ if (!files.length || !editor.value) return;
10198
+ uploadCount.value = files.length;
10199
+ const holder = document.createElement("span");
10200
+ holder.className = "djm-rich-editor-uploading";
10201
+ holder.textContent = t("richEditor.uploading", { count: files.length });
10202
+ const anchor = savedRange && editor.value.contains(savedRange.commonAncestorContainer) ? savedRange : null;
10203
+ focusAt(anchor);
10204
+ const selection = window.getSelection();
10205
+ if (selection.rangeCount) selection.getRangeAt(0).insertNode(holder);
10206
+ else editor.value.appendChild(holder);
10207
+ Promise.all(files.map(async (file) => {
10208
+ if (!isImage(file)) return { file, error: t("richEditor.imageTypeError") };
10209
+ if (props.maxImageSize > 0 && file.size > props.maxImageSize * 1024 * 1024) {
10210
+ return { file, error: t("richEditor.imageSizeError", { size: props.maxImageSize }) };
10211
+ }
10212
+ try {
10213
+ return { file, url: await uploadOne(file) };
10214
+ } catch (error) {
10215
+ return { file, error: error && error.message || t("richEditor.uploadFailed") };
10216
+ }
10217
+ })).then((results) => {
10218
+ uploadCount.value = 0;
10219
+ const done = results.filter((item) => item.url);
10220
+ const failed = results.filter((item) => item.error);
10221
+ const alive = editor.value && editor.value.contains(holder);
10222
+ if (done.length && editor.value) {
10223
+ done.forEach((item) => {
10224
+ const img = document.createElement("img");
10225
+ img.src = item.url;
10226
+ img.alt = item.file.name || "image";
10227
+ img.className = "djm-rich-editor-img";
10228
+ if (alive) holder.parentNode.insertBefore(img, holder);
10229
+ else editor.value.appendChild(img);
10230
+ });
10231
+ }
10232
+ if (alive) holder.remove();
10233
+ failed.forEach((item) => emit("upload-error", { file: item.file, message: item.error }));
10234
+ syncFromDom();
10235
+ });
10236
+ }
10237
+ function onFilesChange(event) {
10238
+ const files = Array.from(event.target.files || []);
10239
+ event.target.value = "";
10240
+ insertImages(files);
10241
+ }
10242
+ function onPaste(event) {
10243
+ const data = event.clipboardData;
10244
+ if (!data) return;
10245
+ const images = Array.from(data.files || []).filter(isImage);
10246
+ if (images.length) {
10247
+ event.preventDefault();
10248
+ insertImages(images);
10249
+ return;
10250
+ }
10251
+ const html = data.getData("text/html");
10252
+ if (!html) return;
10253
+ event.preventDefault();
10254
+ document.execCommand("insertHTML", false, sanitizeHtml(html));
10255
+ syncFromDom();
10256
+ }
10257
+ function onDragOver(event) {
10258
+ if (!ready.value) return;
10259
+ if (event.dataTransfer && Array.from(event.dataTransfer.types || []).includes("Files")) {
10260
+ event.preventDefault();
10261
+ }
10262
+ }
10263
+ function onDrop(event) {
10264
+ if (!ready.value) return;
10265
+ const files = Array.from(event.dataTransfer && event.dataTransfer.files || []).filter(isImage);
10266
+ if (!files.length) return;
10267
+ event.preventDefault();
10268
+ rememberRange();
10269
+ insertImages(files);
10270
+ }
10271
+ function closestTag(node, tag) {
10272
+ const el = node && (node.nodeType === 1 ? node : node.parentElement);
10273
+ return el && el.closest ? el.closest(tag) : null;
10274
+ }
10275
+ function detectBlock() {
10276
+ const selection = window.getSelection();
10277
+ if (!selection || !selection.anchorNode) return "p";
10278
+ let node = selection.anchorNode;
10279
+ if (node.nodeType !== 1) node = node.parentElement;
10280
+ while (node && node !== editor.value) {
10281
+ const tag = node.tagName.toLowerCase();
10282
+ if (["p", "h1", "h2", "h3", "blockquote", "pre"].includes(tag)) return tag;
10283
+ node = node.parentElement;
10284
+ }
10285
+ return "p";
9611
10286
  }
9612
10287
  function syncFormats() {
9613
- if (props.disabled || sourceMode.value) return;
9614
- if (!editor.value || !editor.value.contains(document.activeElement)) return;
10288
+ if (props.disabled || sourceMode.value || !editor.value) return;
10289
+ if (!editor.value.contains(document.activeElement)) return;
10290
+ const selection = window.getSelection();
9615
10291
  formats.value = {
9616
10292
  bold: document.queryCommandState("bold"),
9617
10293
  italic: document.queryCommandState("italic"),
9618
10294
  underline: document.queryCommandState("underline"),
9619
- strike: document.queryCommandState("strikeThrough")
10295
+ strike: document.queryCommandState("strikeThrough"),
10296
+ code: !!closestTag(selection.anchorNode, "code"),
10297
+ block: detectBlock(),
10298
+ align: document.queryCommandState("justifyCenter") ? "center" : document.queryCommandState("justifyRight") ? "right" : document.queryCommandState("justifyFull") ? "justify" : "left",
10299
+ list: document.queryCommandState("insertOrderedList") ? "ol" : document.queryCommandState("insertUnorderedList") ? "ul" : ""
9620
10300
  };
9621
10301
  }
9622
10302
  function onSourceInput() {
10303
+ count2.value = plainText(source.value).trim().length;
9623
10304
  emit("update:modelValue", clean(source.value));
9624
10305
  }
9625
10306
  function toggleSource() {
9626
10307
  if (props.disabled) return;
10308
+ panel.value = "";
9627
10309
  if (sourceMode.value) {
9628
10310
  emit("update:modelValue", clean(source.value));
9629
10311
  sourceMode.value = false;
9630
10312
  requestAnimationFrame(() => {
9631
10313
  if (editor.value) editor.value.innerHTML = clean(source.value);
10314
+ syncFromDom();
9632
10315
  });
9633
10316
  } else {
9634
10317
  source.value = editor.value ? editor.value.innerHTML : props.modelValue;
9635
10318
  sourceMode.value = true;
9636
10319
  }
9637
10320
  }
10321
+ function toggleFullscreen() {
10322
+ if (props.disabled) return;
10323
+ fullscreen.value = !fullscreen.value;
10324
+ panel.value = "";
10325
+ }
10326
+ function onDocumentClick(event) {
10327
+ if (!panel.value) return;
10328
+ if (!(event.target.closest && event.target.closest(".djm-rich-editor-color"))) panel.value = "";
10329
+ }
10330
+ function onKeydown(event) {
10331
+ if (event.key === "Escape" && fullscreen.value) {
10332
+ fullscreen.value = false;
10333
+ panel.value = "";
10334
+ }
10335
+ }
9638
10336
  watch(
9639
10337
  () => props.modelValue,
9640
10338
  (value) => {
@@ -9644,40 +10342,117 @@ Minimum version required to store current data is: ` + i + `.
9644
10342
  }
9645
10343
  const next = clean(value);
9646
10344
  if (editor.value && editor.value.innerHTML !== next) editor.value.innerHTML = next;
10345
+ count2.value = plainText(next).trim().length;
9647
10346
  }
9648
10347
  );
9649
10348
  onMounted(() => {
9650
10349
  if (editor.value) editor.value.innerHTML = clean(props.modelValue);
10350
+ count2.value = plainText(props.modelValue).trim().length;
9651
10351
  document.addEventListener("selectionchange", syncFormats);
10352
+ document.addEventListener("click", onDocumentClick);
10353
+ document.addEventListener("keydown", onKeydown);
10354
+ });
10355
+ onBeforeUnmount(() => {
10356
+ document.removeEventListener("selectionchange", syncFormats);
10357
+ document.removeEventListener("click", onDocumentClick);
10358
+ document.removeEventListener("keydown", onKeydown);
9652
10359
  });
9653
- onBeforeUnmount(() => document.removeEventListener("selectionchange", syncFormats));
9654
10360
  return {
9655
10361
  editor,
10362
+ fileInput,
9656
10363
  sourceMode,
9657
10364
  source,
10365
+ fullscreen,
10366
+ panel,
10367
+ uploadCount,
10368
+ textColor,
10369
+ bgColor,
10370
+ fontSize,
10371
+ count: count2,
9658
10372
  formats,
10373
+ blocks: BLOCKS,
10374
+ sizes: SIZES,
10375
+ colors: COLORS,
10376
+ mediaLibrary,
10377
+ has,
9659
10378
  exec,
9660
- formatBlock,
10379
+ setBlock,
10380
+ setFontSize,
10381
+ applyColor,
10382
+ togglePanel,
10383
+ toggleInlineCode,
9661
10384
  addLink,
9662
- onSourceInput,
10385
+ pickImage,
10386
+ insertMedia,
10387
+ onFilesChange,
9663
10388
  onPaste,
10389
+ onDragOver,
10390
+ onDrop,
10391
+ rememberRange,
10392
+ onSourceInput,
9664
10393
  toggleSource,
10394
+ toggleFullscreen,
9665
10395
  syncFromDom,
9666
10396
  syncFormats,
9667
10397
  t
9668
10398
  };
9669
10399
  },
9670
10400
  template: `
9671
- <div class="djm-rich-editor" :class="{ 'djm-is-disabled': disabled }">
9672
- <div class="djm-rich-editor-toolbar">
9673
- <div class="djm-rich-editor-group">
10401
+ <div
10402
+ class="djm-rich-editor"
10403
+ :class="{ 'djm-is-disabled': disabled, 'djm-is-fullscreen': fullscreen }"
10404
+ >
10405
+ <div class="djm-rich-editor-toolbar" role="toolbar" :aria-label="t('richEditor.toolbar')" @mousedown="rememberRange">
10406
+ <div class="djm-rich-editor-group" v-if="has('history')">
10407
+ <button
10408
+ class="djm-rich-editor-btn"
10409
+ type="button"
10410
+ :title="t('richEditor.undo')"
10411
+ :aria-label="t('richEditor.undo')"
10412
+ :disabled="disabled || sourceMode"
10413
+ @mousedown.prevent
10414
+ @click="exec('undo')"
10415
+ ><Icon name="undo" :size="13" /></button>
10416
+ <button
10417
+ class="djm-rich-editor-btn"
10418
+ type="button"
10419
+ :title="t('richEditor.redo')"
10420
+ :aria-label="t('richEditor.redo')"
10421
+ :disabled="disabled || sourceMode"
10422
+ @mousedown.prevent
10423
+ @click="exec('redo')"
10424
+ ><Icon name="redo" :size="13" /></button>
10425
+ </div>
10426
+
10427
+ <div class="djm-rich-editor-group" v-if="has('format')">
10428
+ <select
10429
+ class="djm-rich-editor-select"
10430
+ :value="formats.block"
10431
+ :aria-label="t('richEditor.block')"
10432
+ :disabled="disabled || sourceMode"
10433
+ @change="setBlock($event.target.value)"
10434
+ >
10435
+ <option v-for="item in blocks" :key="item.value" :value="item.value">{{ t('richEditor.' + item.label) }}</option>
10436
+ </select>
10437
+ <select
10438
+ class="djm-rich-editor-select djm-rich-editor-select-size"
10439
+ :value="fontSize"
10440
+ :aria-label="t('richEditor.fontSize')"
10441
+ :disabled="disabled || sourceMode"
10442
+ @change="setFontSize($event.target.value)"
10443
+ >
10444
+ <option v-for="item in sizes" :key="item.value" :value="item.value">{{ t('richEditor.' + item.label) }}</option>
10445
+ </select>
10446
+ </div>
10447
+
10448
+ <div class="djm-rich-editor-group" v-if="has('inline')">
9674
10449
  <button
9675
10450
  class="djm-rich-editor-btn djm-rich-editor-btn-bold"
9676
10451
  type="button"
9677
10452
  :title="t('richEditor.bold')"
9678
10453
  :aria-label="t('richEditor.bold')"
9679
10454
  :aria-pressed="String(formats.bold)"
9680
- :disabled="disabled"
10455
+ :disabled="disabled || sourceMode"
9681
10456
  @mousedown.prevent
9682
10457
  @click="exec('bold')"
9683
10458
  >B</button>
@@ -9687,7 +10462,7 @@ Minimum version required to store current data is: ` + i + `.
9687
10462
  :title="t('richEditor.italic')"
9688
10463
  :aria-label="t('richEditor.italic')"
9689
10464
  :aria-pressed="String(formats.italic)"
9690
- :disabled="disabled"
10465
+ :disabled="disabled || sourceMode"
9691
10466
  @mousedown.prevent
9692
10467
  @click="exec('italic')"
9693
10468
  >I</button>
@@ -9697,7 +10472,7 @@ Minimum version required to store current data is: ` + i + `.
9697
10472
  :title="t('richEditor.underline')"
9698
10473
  :aria-label="t('richEditor.underline')"
9699
10474
  :aria-pressed="String(formats.underline)"
9700
- :disabled="disabled"
10475
+ :disabled="disabled || sourceMode"
9701
10476
  @mousedown.prevent
9702
10477
  @click="exec('underline')"
9703
10478
  >U</button>
@@ -9707,46 +10482,130 @@ Minimum version required to store current data is: ` + i + `.
9707
10482
  :title="t('richEditor.strike')"
9708
10483
  :aria-label="t('richEditor.strike')"
9709
10484
  :aria-pressed="String(formats.strike)"
9710
- :disabled="disabled"
10485
+ :disabled="disabled || sourceMode"
9711
10486
  @mousedown.prevent
9712
10487
  @click="exec('strikeThrough')"
9713
10488
  >S</button>
10489
+ <button
10490
+ class="djm-rich-editor-btn"
10491
+ type="button"
10492
+ :title="t('richEditor.inlineCode')"
10493
+ :aria-label="t('richEditor.inlineCode')"
10494
+ :aria-pressed="String(formats.code)"
10495
+ :disabled="disabled || sourceMode"
10496
+ @mousedown.prevent
10497
+ @click="toggleInlineCode"
10498
+ ><Icon name="code" :size="13" /></button>
9714
10499
  </div>
9715
10500
 
9716
- <div class="djm-rich-editor-group">
9717
- <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.heading')" :disabled="disabled" @mousedown.prevent @click="formatBlock('h2')">H2</button>
9718
- <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.paragraph')" :disabled="disabled" @mousedown.prevent @click="formatBlock('p')">{{ t('richEditor.paragraph') }}</button>
9719
- </div>
9720
-
9721
- <div class="djm-rich-editor-group">
9722
- <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.bulletList')" :disabled="disabled" @mousedown.prevent @click="exec('insertUnorderedList')">
9723
- <span class="djm-rich-editor-glyph">\u2022 \u2261</span>
9724
- </button>
9725
- <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.numberList')" :disabled="disabled" @mousedown.prevent @click="exec('insertOrderedList')">
9726
- <span class="djm-rich-editor-glyph">1. \u2261</span>
10501
+ <div class="djm-rich-editor-group djm-rich-editor-color" v-if="has('color')">
10502
+ <button
10503
+ class="djm-rich-editor-btn"
10504
+ type="button"
10505
+ :title="t('richEditor.textColor')"
10506
+ :aria-label="t('richEditor.textColor')"
10507
+ :aria-expanded="String(panel === 'text')"
10508
+ :disabled="disabled || sourceMode"
10509
+ @mousedown.prevent
10510
+ @click.stop="togglePanel('text')"
10511
+ >
10512
+ <span class="djm-rich-editor-letter" :style="{ borderBottomColor: textColor }">A</span>
9727
10513
  </button>
9728
- <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.link')" :aria-label="t('richEditor.link')" :disabled="disabled" @mousedown.prevent @click="addLink">
9729
- <Icon name="link" :size="13" />
10514
+ <button
10515
+ class="djm-rich-editor-btn"
10516
+ type="button"
10517
+ :title="t('richEditor.bgColor')"
10518
+ :aria-label="t('richEditor.bgColor')"
10519
+ :aria-expanded="String(panel === 'bg')"
10520
+ :disabled="disabled || sourceMode"
10521
+ @mousedown.prevent
10522
+ @click.stop="togglePanel('bg')"
10523
+ >
10524
+ <span class="djm-rich-editor-letter djm-rich-editor-letter-bg" :style="{ background: bgColor }">A</span>
9730
10525
  </button>
10526
+ <div v-if="panel === 'text' || panel === 'bg'" class="djm-rich-editor-panel" role="group" :aria-label="t('richEditor.textColor')">
10527
+ <button
10528
+ v-for="color in colors"
10529
+ :key="color"
10530
+ class="djm-rich-editor-swatch"
10531
+ type="button"
10532
+ :title="color"
10533
+ :aria-label="color"
10534
+ :style="{ background: color }"
10535
+ @mousedown.prevent
10536
+ @click="applyColor(panel, color)"
10537
+ ></button>
10538
+ </div>
9731
10539
  </div>
9732
10540
 
9733
- <div class="djm-rich-editor-group">
9734
- <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.clearFormat')" :disabled="disabled" @mousedown.prevent @click="exec('removeFormat')">{{ t('richEditor.clearFormat') }}</button>
9735
- <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.undo')" :aria-label="t('richEditor.undo')" :disabled="disabled" @mousedown.prevent @click="exec('undo')">\u21B6</button>
9736
- <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.redo')" :aria-label="t('richEditor.redo')" :disabled="disabled" @mousedown.prevent @click="exec('redo')">\u21B7</button>
10541
+ <div class="djm-rich-editor-group" v-if="has('align')">
10542
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.alignLeft')" :aria-label="t('richEditor.alignLeft')" :aria-pressed="String(formats.align === 'left')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('justifyLeft')"><Icon name="align-left" :size="13" /></button>
10543
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.alignCenter')" :aria-label="t('richEditor.alignCenter')" :aria-pressed="String(formats.align === 'center')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('justifyCenter')"><Icon name="align-center" :size="13" /></button>
10544
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.alignRight')" :aria-label="t('richEditor.alignRight')" :aria-pressed="String(formats.align === 'right')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('justifyRight')"><Icon name="align-right" :size="13" /></button>
10545
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.alignJustify')" :aria-label="t('richEditor.alignJustify')" :aria-pressed="String(formats.align === 'justify')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('justifyFull')"><Icon name="align-justify" :size="13" /></button>
9737
10546
  </div>
9738
10547
 
9739
- <div class="djm-rich-editor-spacer"></div>
9740
- <button
9741
- class="djm-rich-editor-btn"
9742
- :class="{ 'djm-is-active': sourceMode }"
9743
- type="button"
9744
- :aria-pressed="String(sourceMode)"
9745
- :disabled="disabled"
9746
- @click="toggleSource"
9747
- >
9748
- <Icon name="code" :size="13" />{{ sourceMode ? t('richEditor.visual') : t('richEditor.source') }}
9749
- </button>
10548
+ <div class="djm-rich-editor-group" v-if="has('list')">
10549
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.bulletList')" :aria-label="t('richEditor.bulletList')" :aria-pressed="String(formats.list === 'ul')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('insertUnorderedList')"><Icon name="list" :size="13" /></button>
10550
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.numberList')" :aria-label="t('richEditor.numberList')" :aria-pressed="String(formats.list === 'ol')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('insertOrderedList')"><Icon name="list-ordered" :size="13" /></button>
10551
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.outdent')" :aria-label="t('richEditor.outdent')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('outdent')"><Icon name="outdent" :size="13" /></button>
10552
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.indent')" :aria-label="t('richEditor.indent')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('indent')"><Icon name="indent" :size="13" /></button>
10553
+ </div>
10554
+
10555
+ <div class="djm-rich-editor-group" v-if="has('insert')">
10556
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.link')" :aria-label="t('richEditor.link')" :disabled="disabled || sourceMode" @mousedown.prevent @click="addLink"><Icon name="link" :size="13" /></button>
10557
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.unlink')" :aria-label="t('richEditor.unlink')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('unlink')"><Icon name="unlink" :size="13" /></button>
10558
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.singleImage')" :aria-label="t('richEditor.singleImage')" :disabled="disabled || sourceMode" @mousedown.prevent @click="pickImage(false)"><Icon name="image" :size="13" /></button>
10559
+ <button v-if="multiple" class="djm-rich-editor-btn" type="button" :title="t('richEditor.multiImage')" :aria-label="t('richEditor.multiImage')" :disabled="disabled || sourceMode" @mousedown.prevent @click="pickImage(true)"><Icon name="images" :size="13" /></button>
10560
+ <MediaPicker
10561
+ v-if="mediaLibrary.length"
10562
+ class="djm-rich-editor-picker"
10563
+ multiple
10564
+ accept="image"
10565
+ :items="mediaLibrary"
10566
+ :title="t('richEditor.mediaLibrary')"
10567
+ :disabled="disabled || sourceMode"
10568
+ @change="insertMedia"
10569
+ >
10570
+ <template #trigger="{ open }">
10571
+ <button
10572
+ class="djm-rich-editor-btn"
10573
+ type="button"
10574
+ :title="t('richEditor.mediaLibrary')"
10575
+ :aria-label="t('richEditor.mediaLibrary')"
10576
+ :disabled="disabled || sourceMode"
10577
+ @mousedown.prevent="rememberRange"
10578
+ @click="open"
10579
+ ><Icon name="grid" :size="13" /></button>
10580
+ </template>
10581
+ </MediaPicker>
10582
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.hr')" :aria-label="t('richEditor.hr')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('insertHorizontalRule')">\u2014</button>
10583
+ <button class="djm-rich-editor-btn" type="button" :title="t('richEditor.clearFormat')" :aria-label="t('richEditor.clearFormat')" :disabled="disabled || sourceMode" @mousedown.prevent @click="exec('removeFormat')"><span class="djm-rich-editor-glyph">T\xD7</span></button>
10584
+ </div>
10585
+
10586
+ <div class="djm-rich-editor-spacer" v-if="has('view')"></div>
10587
+ <div class="djm-rich-editor-group djm-rich-editor-group-end" v-if="has('view')">
10588
+ <button
10589
+ class="djm-rich-editor-btn"
10590
+ :class="{ 'djm-is-active': sourceMode }"
10591
+ type="button"
10592
+ :title="sourceMode ? t('richEditor.visual') : t('richEditor.source')"
10593
+ :aria-label="sourceMode ? t('richEditor.visual') : t('richEditor.source')"
10594
+ :aria-pressed="String(sourceMode)"
10595
+ :disabled="disabled"
10596
+ @click="toggleSource"
10597
+ ><Icon name="code" :size="13" /></button>
10598
+ <button
10599
+ class="djm-rich-editor-btn"
10600
+ :class="{ 'djm-is-active': fullscreen }"
10601
+ type="button"
10602
+ :title="fullscreen ? t('richEditor.exitFullscreen') : t('richEditor.fullscreen')"
10603
+ :aria-label="fullscreen ? t('richEditor.exitFullscreen') : t('richEditor.fullscreen')"
10604
+ :aria-pressed="String(fullscreen)"
10605
+ :disabled="disabled"
10606
+ @click="toggleFullscreen"
10607
+ ><Icon :name="fullscreen ? 'minimize' : 'maximize'" :size="13" /></button>
10608
+ </div>
9750
10609
  </div>
9751
10610
 
9752
10611
  <div
@@ -9762,6 +10621,8 @@ Minimum version required to store current data is: ` + i + `.
9762
10621
  :style="{ minHeight: minHeight + 'px' }"
9763
10622
  @input="syncFromDom"
9764
10623
  @paste="onPaste"
10624
+ @dragover="onDragOver"
10625
+ @drop="onDrop"
9765
10626
  ></div>
9766
10627
 
9767
10628
  <textarea
@@ -9774,6 +10635,21 @@ Minimum version required to store current data is: ` + i + `.
9774
10635
  spellcheck="false"
9775
10636
  @input="onSourceInput"
9776
10637
  ></textarea>
10638
+
10639
+ <div v-if="showCount" class="djm-rich-editor-foot">
10640
+ <span v-if="uploadCount" class="djm-rich-editor-foot-item">{{ t('richEditor.uploading', { count: uploadCount }) }}</span>
10641
+ <span class="djm-rich-editor-foot-item">{{ t('richEditor.wordCount', { count }) }}</span>
10642
+ </div>
10643
+
10644
+ <input
10645
+ ref="fileInput"
10646
+ class="djm-rich-editor-file"
10647
+ type="file"
10648
+ :accept="accept"
10649
+ tabindex="-1"
10650
+ aria-hidden="true"
10651
+ @change="onFilesChange"
10652
+ />
9777
10653
  </div>
9778
10654
  `
9779
10655
  };
@@ -11837,197 +12713,6 @@ Minimum version required to store current data is: ` + i + `.
11837
12713
  `
11838
12714
  };
11839
12715
 
11840
- // src/components/media-picker/index.js
11841
- var media_picker_default = {
11842
- name: "DjmMediaPicker",
11843
- components: { DjmModal: modal_default, DjmPagination: pagination_default, DjmIcon: icon_default },
11844
- props: {
11845
- modelValue: { type: [Array, String, Number], default: "" },
11846
- items: { type: Array, default: () => [] },
11847
- multiple: { type: Boolean, default: false },
11848
- // 最多选几个,0 表示不限
11849
- limit: { type: Number, default: 0 },
11850
- // image 只显示图片,file 只显示非图片,空串不过滤
11851
- accept: { type: String, default: "" },
11852
- title: { type: String, default: "" },
11853
- triggerText: { type: String, default: "" },
11854
- pageSize: { type: Number, default: 12 },
11855
- disabled: { type: Boolean, default: false }
11856
- },
11857
- emits: ["update:modelValue", "change"],
11858
- setup(props, { emit }) {
11859
- const open = ref(false);
11860
- const keyword = ref("");
11861
- const page = ref(1);
11862
- const temp = ref([]);
11863
- const selectedIds = computed(() => {
11864
- if (props.multiple) return Array.isArray(props.modelValue) ? props.modelValue : [];
11865
- if (props.modelValue === "" || props.modelValue === null || props.modelValue === void 0) return [];
11866
- return [props.modelValue];
11867
- });
11868
- function isImage(item) {
11869
- if (item.type) return item.type === "image";
11870
- return /\.(png|jpe?g|gif|webp|svg|avif)$/i.test(item.url || "");
11871
- }
11872
- const filtered = computed(() => {
11873
- const key = keyword.value.trim().toLowerCase();
11874
- return props.items.filter((item) => {
11875
- if (props.accept === "image" && !isImage(item)) return false;
11876
- if (props.accept === "file" && isImage(item)) return false;
11877
- if (key && !String(item.name || "").toLowerCase().includes(key)) return false;
11878
- return true;
11879
- });
11880
- });
11881
- const paged = computed(() => {
11882
- const start = (page.value - 1) * props.pageSize;
11883
- return filtered.value.slice(start, start + props.pageSize);
11884
- });
11885
- watch(keyword, () => {
11886
- page.value = 1;
11887
- });
11888
- watch(
11889
- () => filtered.value.length,
11890
- () => {
11891
- const max = Math.max(1, Math.ceil(filtered.value.length / props.pageSize));
11892
- if (page.value > max) page.value = max;
11893
- }
11894
- );
11895
- function isChecked(id) {
11896
- return temp.value.includes(id);
11897
- }
11898
- function atLimit() {
11899
- return props.limit > 0 && temp.value.length >= props.limit;
11900
- }
11901
- function toggle(item) {
11902
- if (props.multiple) {
11903
- if (isChecked(item.id)) {
11904
- temp.value = temp.value.filter((id) => id !== item.id);
11905
- return;
11906
- }
11907
- if (atLimit()) return;
11908
- temp.value = [...temp.value, item.id];
11909
- return;
11910
- }
11911
- temp.value = [item.id];
11912
- }
11913
- function openPanel() {
11914
- if (props.disabled) return;
11915
- temp.value = [...selectedIds.value];
11916
- keyword.value = "";
11917
- page.value = 1;
11918
- open.value = true;
11919
- }
11920
- function commit() {
11921
- const next = props.multiple ? [...temp.value] : temp.value[0] !== void 0 ? temp.value[0] : "";
11922
- emit("update:modelValue", next);
11923
- emit("change", next);
11924
- open.value = false;
11925
- }
11926
- function cancel() {
11927
- temp.value = [...selectedIds.value];
11928
- open.value = false;
11929
- }
11930
- return {
11931
- open,
11932
- keyword,
11933
- page,
11934
- temp,
11935
- selectedIds,
11936
- filtered,
11937
- paged,
11938
- isImage,
11939
- isChecked,
11940
- atLimit,
11941
- toggle,
11942
- openPanel,
11943
- commit,
11944
- cancel,
11945
- t
11946
- };
11947
- },
11948
- template: `
11949
- <span class="djm-media-picker">
11950
- <slot name="trigger" :open="openPanel" :count="selectedIds.length">
11951
- <button
11952
- class="djm-btn"
11953
- type="button"
11954
- :disabled="disabled"
11955
- @click="openPanel"
11956
- >{{ triggerText || t('mediaPicker.select') }}<span v-if="selectedIds.length" class="djm-media-picker-count">{{ selectedIds.length }}</span></button>
11957
- </slot>
11958
-
11959
- <DjmModal
11960
- :model-value="open"
11961
- :title="title || t('mediaPicker.title')"
11962
- width="760px"
11963
- @update:model-value="cancel"
11964
- >
11965
- <div class="djm-media-toolbar">
11966
- <label class="djm-media-search">
11967
- <DjmIcon name="search" :size="13" />
11968
- <input
11969
- v-model="keyword"
11970
- class="djm-media-search-input"
11971
- type="text"
11972
- :placeholder="t('mediaPicker.search')"
11973
- :aria-label="t('mediaPicker.search')"
11974
- />
11975
- </label>
11976
- <span class="djm-media-toolbar-slot">
11977
- <slot name="toolbar"></slot>
11978
- </span>
11979
- </div>
11980
-
11981
- <div class="djm-media-grid" role="listbox" :aria-multiselectable="multiple ? 'true' : 'false'">
11982
- <div
11983
- v-for="item in paged"
11984
- :key="item.id"
11985
- class="djm-media-item"
11986
- :class="{
11987
- 'djm-is-checked': isChecked(item.id),
11988
- 'djm-is-disabled': multiple && atLimit() && !isChecked(item.id)
11989
- }"
11990
- role="option"
11991
- tabindex="0"
11992
- :aria-selected="String(isChecked(item.id))"
11993
- @click="toggle(item)"
11994
- @keydown.enter.prevent="toggle(item)"
11995
- @keydown.space.prevent="toggle(item)"
11996
- >
11997
- <div class="djm-media-thumb">
11998
- <img v-if="isImage(item)" :src="item.url" :alt="item.name" loading="lazy" />
11999
- <DjmIcon v-else name="folder" :size="26" />
12000
- <span v-if="isChecked(item.id)" class="djm-media-check">
12001
- <DjmIcon name="check" :size="11" />
12002
- </span>
12003
- </div>
12004
- <div class="djm-media-name" :title="item.name">{{ item.name }}</div>
12005
- </div>
12006
- <div v-if="!paged.length" class="djm-media-empty">{{ t('mediaPicker.empty') }}</div>
12007
- </div>
12008
-
12009
- <div class="djm-media-foot">
12010
- <span class="djm-media-total">
12011
- {{ t('mediaPicker.total', { total: filtered.length }) }}
12012
- <em v-if="multiple && limit > 0">{{ t('mediaPicker.limit', { limit }) }}</em>
12013
- </span>
12014
- <DjmPagination
12015
- v-if="filtered.length > pageSize"
12016
- v-model:page="page"
12017
- :page-size="pageSize"
12018
- :total="filtered.length"
12019
- />
12020
- </div>
12021
-
12022
- <template #footer>
12023
- <button class="djm-btn" type="button" @click="cancel">{{ t('common.cancel') }}</button>
12024
- <button class="djm-btn djm-btn-primary" type="button" @click="commit">{{ t('common.confirm') }}</button>
12025
- </template>
12026
- </DjmModal>
12027
- </span>
12028
- `
12029
- };
12030
-
12031
12716
  // src/components/kanban/index.js
12032
12717
  function cloneColumns(list) {
12033
12718
  return (list || []).map((col) => ({ ...col, cards: (col.cards || []).slice() }));
@@ -15346,7 +16031,7 @@ Minimum version required to store current data is: ` + i + `.
15346
16031
  org_chart_default,
15347
16032
  page_tabs_default
15348
16033
  ];
15349
- var version = "0.67.4";
16034
+ var version = "0.72.0";
15350
16035
  function install(app, options = {}) {
15351
16036
  if (options.locale) setLocale(options.locale);
15352
16037
  components.forEach((component) => app.component(component.name, component));