@atri-editor/core 0.3.0 → 0.4.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 (72) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/dist/AtriEditor.d.ts +7 -0
  3. package/dist/AtriEditor.d.ts.map +1 -1
  4. package/dist/AtriEditorElement.d.ts +14 -1
  5. package/dist/AtriEditorElement.d.ts.map +1 -1
  6. package/dist/ai/AICommandMenu.d.ts +1 -0
  7. package/dist/ai/AICommandMenu.d.ts.map +1 -1
  8. package/dist/ai/AIInterceptor.d.ts.map +1 -1
  9. package/dist/ai/AIService.d.ts.map +1 -1
  10. package/dist/core/CoreEditor.d.ts +9 -0
  11. package/dist/core/CoreEditor.d.ts.map +1 -1
  12. package/dist/core/I18nManager.d.ts +25 -0
  13. package/dist/core/I18nManager.d.ts.map +1 -1
  14. package/dist/core/LinkPanel.d.ts +1 -0
  15. package/dist/core/LinkPanel.d.ts.map +1 -1
  16. package/dist/core/StyleOptionPanel.d.ts +73 -0
  17. package/dist/core/StyleOptionPanel.d.ts.map +1 -0
  18. package/dist/core/TableGridPanel.d.ts +1 -0
  19. package/dist/core/TableGridPanel.d.ts.map +1 -1
  20. package/dist/core/TableMenu.d.ts +1 -0
  21. package/dist/core/TableMenu.d.ts.map +1 -1
  22. package/dist/core/ThemeManager.d.ts.map +1 -1
  23. package/dist/core/ToolbarManager.d.ts +50 -1
  24. package/dist/core/ToolbarManager.d.ts.map +1 -1
  25. package/dist/core/ToolbarMenuPanel.d.ts +58 -0
  26. package/dist/core/ToolbarMenuPanel.d.ts.map +1 -0
  27. package/dist/core/bubble-toolbar.d.ts +8 -2
  28. package/dist/core/bubble-toolbar.d.ts.map +1 -1
  29. package/dist/core/floating-panel.d.ts +24 -1
  30. package/dist/core/floating-panel.d.ts.map +1 -1
  31. package/dist/core/icons.d.ts.map +1 -1
  32. package/dist/core/style-presets.d.ts +47 -0
  33. package/dist/core/style-presets.d.ts.map +1 -0
  34. package/dist/extensions/Attachment.d.ts.map +1 -1
  35. package/dist/extensions/AttachmentLink.d.ts.map +1 -1
  36. package/dist/extensions/attachment-attrs.d.ts.map +1 -1
  37. package/dist/extensions/formatting.d.ts +24 -0
  38. package/dist/extensions/formatting.d.ts.map +1 -0
  39. package/dist/extensions/markdown-inline-html.d.ts +73 -0
  40. package/dist/extensions/markdown-inline-html.d.ts.map +1 -0
  41. package/dist/index.d.ts +2 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +1381 -198
  44. package/dist/index.js.map +1 -1
  45. package/dist/media/InsertPanel.d.ts +1 -0
  46. package/dist/media/InsertPanel.d.ts.map +1 -1
  47. package/dist/media/MediaRuntime.d.ts +13 -0
  48. package/dist/media/MediaRuntime.d.ts.map +1 -1
  49. package/dist/media/MediaStatusStrip.d.ts.map +1 -1
  50. package/dist/media/Uploader.d.ts.map +1 -1
  51. package/dist/style.css +186 -0
  52. package/dist/types/ai.d.ts +5 -0
  53. package/dist/types/ai.d.ts.map +1 -1
  54. package/dist/types/editor.d.ts +18 -1
  55. package/dist/types/editor.d.ts.map +1 -1
  56. package/dist/types/markdown.d.ts +14 -0
  57. package/dist/types/markdown.d.ts.map +1 -1
  58. package/dist/types/media.d.ts +5 -0
  59. package/dist/types/media.d.ts.map +1 -1
  60. package/dist/utils/escape.d.ts +30 -0
  61. package/dist/utils/escape.d.ts.map +1 -0
  62. package/dist/utils/formatting.d.ts +18 -0
  63. package/dist/utils/formatting.d.ts.map +1 -0
  64. package/dist/utils/index.d.ts +3 -0
  65. package/dist/utils/index.d.ts.map +1 -1
  66. package/dist/utils/markdown.d.ts +5 -3
  67. package/dist/utils/markdown.d.ts.map +1 -1
  68. package/dist/utils/safe-call.d.ts +15 -0
  69. package/dist/utils/safe-call.d.ts.map +1 -0
  70. package/dist/utils/url.d.ts +20 -0
  71. package/dist/utils/url.d.ts.map +1 -0
  72. package/package.json +6 -1
package/dist/index.js CHANGED
@@ -8,9 +8,13 @@ import { TaskList, TaskItem } from "@tiptap/extension-list";
8
8
  import { TableRow, TableHeader, TableCell, Table } from "@tiptap/extension-table";
9
9
  import Image from "@tiptap/extension-image";
10
10
  import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state";
11
+ import Highlight from "@tiptap/extension-highlight";
12
+ import Subscript from "@tiptap/extension-subscript";
13
+ import Superscript from "@tiptap/extension-superscript";
14
+ import { TextStyle, Color, BackgroundColor, FontFamily, FontSize } from "@tiptap/extension-text-style";
11
15
  import { Node as Node$1, DOMParser } from "@tiptap/pm/model";
12
16
  import i18next from "i18next";
13
- import { autoUpdate, computePosition, offset, flip, shift } from "@floating-ui/dom";
17
+ import { offset, flip, shift, autoUpdate, computePosition } from "@floating-ui/dom";
14
18
  function isBuiltinTableActive(editor) {
15
19
  var _a;
16
20
  const spec = (_a = editor.schema.nodes.table) == null ? void 0 : _a.spec;
@@ -126,6 +130,32 @@ function validateFile(file, options) {
126
130
  function isImageFile(file) {
127
131
  return (file.type || "").toLowerCase().startsWith("image/");
128
132
  }
133
+ const HREF_SCHEMES = ["http", "https", "mailto", "tel", "ftp", "ftps"];
134
+ const MEDIA_SCHEMES = ["http", "https", "data", "blob"];
135
+ function stripUrlNoise(value) {
136
+ const collapsed = value.replace(/[\t\n\r]/g, "");
137
+ let start = 0;
138
+ let end = collapsed.length;
139
+ while (start < end && isStripped(collapsed.charCodeAt(start))) start += 1;
140
+ while (end > start && isStripped(collapsed.charCodeAt(end - 1))) end -= 1;
141
+ return collapsed.slice(start, end);
142
+ }
143
+ function isStripped(code) {
144
+ return code <= 32 || code === 127;
145
+ }
146
+ const SCHEME_PREFIX = /^([a-z][a-z0-9+.-]*):/i;
147
+ function safeHref(value, schemes = HREF_SCHEMES) {
148
+ var _a;
149
+ if (typeof value !== "string") return null;
150
+ const url = stripUrlNoise(value);
151
+ if (!url) return null;
152
+ const scheme = (_a = SCHEME_PREFIX.exec(url)) == null ? void 0 : _a[1];
153
+ if (!scheme) return url;
154
+ return schemes.includes(scheme.toLowerCase()) ? url : null;
155
+ }
156
+ function safeMediaSrc(value) {
157
+ return safeHref(value, MEDIA_SCHEMES);
158
+ }
129
159
  function isAttachmentForm(name) {
130
160
  return name === "attachment" || name === "attachmentLink";
131
161
  }
@@ -150,6 +180,12 @@ function insertAttachmentContent(props, typeName, options) {
150
180
  const { state, commands, tr, dispatch } = props;
151
181
  const target = mediaInsertTarget(state.selection);
152
182
  const anchor = typeof target === "number" ? target : target.to;
183
+ if (safeHref(options.src) === null) {
184
+ console.warn(
185
+ `[Atri Editor] insertAttachment rejected: "${options.src}" is not a clickable address.`
186
+ );
187
+ return false;
188
+ }
153
189
  if (!commands.insertContentAt(target, {
154
190
  type: typeName,
155
191
  attrs: {
@@ -182,7 +218,10 @@ function attachmentAttributes() {
182
218
  return {
183
219
  src: {
184
220
  default: null,
185
- parseHTML: (element) => element.getAttribute("data-src"),
221
+ // 粘贴进来的 data-src 也洗一遍:不洗的话它绕开 AttachmentLink 那处 getAttrs,
222
+ // 直接落进节点属性,再由渲染层的 safeHref 挡掉——属性里仍留着地址,
223
+ // 而 setNodeMarkup / JSON 回灌都不经过渲染层
224
+ parseHTML: (element) => safeMediaSrc(element.getAttribute("data-src")),
186
225
  renderHTML: (attributes) => dataAttr("data-src", attributes.src)
187
226
  },
188
227
  name: {
@@ -332,7 +371,19 @@ const Attachment = Node.create({
332
371
  ["span", { class: "atri-attachment-icon", "aria-hidden": "true" }]
333
372
  ];
334
373
  const body = [
335
- ["a", { class: "atri-attachment-name", href: src, rel: "noopener noreferrer" }, label]
374
+ // src 有五条来路(宿主 insertAttachment、上传响应、外部粘贴、setNodeMarkup、
375
+ // setJSON 回灌),逐条在入口校验拦不全,渲染层是唯一都经过的地方。
376
+ // 不过 dataAttr 的"空值即省略键"是关键:传 null 会被 mergeAttributes 留下,
377
+ // 序列化出来就成了 href="null",反而多一枚假链接
378
+ [
379
+ "a",
380
+ {
381
+ class: "atri-attachment-name",
382
+ ...dataAttr("href", safeHref(src)),
383
+ rel: "noopener noreferrer"
384
+ },
385
+ label
386
+ ]
336
387
  ];
337
388
  if (size) {
338
389
  body.push(["span", { class: "atri-attachment-size" }, formatFileSize(size)]);
@@ -444,8 +495,9 @@ const AttachmentLink = Node.create({
444
495
  {
445
496
  tag: "a[data-atri-attachment-link]",
446
497
  priority: 100,
447
- // 手写或从别处粘来的 <a> 只带 href,没有 data-src:认下来,别留一枚空链接
448
- getAttrs: (element) => element.getAttribute("data-src") ? {} : { src: element.getAttribute("href") ?? null }
498
+ // 手写或从别处粘来的 <a> 只带 href,没有 data-src:认下来,别留一枚空链接。
499
+ // data-src 那条来路在 attachmentAttributes().src.parseHTML 里已经洗过
500
+ getAttrs: (element) => element.getAttribute("data-src") ? {} : { src: safeHref(element.getAttribute("href")) }
449
501
  }
450
502
  ];
451
503
  },
@@ -456,9 +508,11 @@ const AttachmentLink = Node.create({
456
508
  mergeAttributes(HTMLAttributes, {
457
509
  "data-atri-attachment-link": "",
458
510
  class: "atri-attachment-link",
459
- href: src,
511
+ // 协议不合法就整枚 href 都不写,退化成一枚没有地址的文字,
512
+ // 而不是留着 href="javascript:…" 等用户点
513
+ ...dataAttr("href", safeHref(src)),
460
514
  // download 让同源地址点击即下载(跨域浏览器会退化成导航);
461
- // mergeAttributes 会丢掉 null,没名字就只剩普通链接
515
+ // 值为 null 时 PM 序列化会跳过,没名字就只剩普通链接
462
516
  download: name,
463
517
  rel: "noopener noreferrer"
464
518
  }),
@@ -562,6 +616,204 @@ function createMediaExtensions(media, runtime) {
562
616
  MediaFiles.configure({ runtime: runtime ?? null })
563
617
  ];
564
618
  }
619
+ const TEXT_CHARS = {
620
+ "&": "&amp;",
621
+ "<": "&lt;",
622
+ ">": "&gt;"
623
+ };
624
+ const QUOTE_CHARS = {
625
+ '"': "&quot;",
626
+ "'": "&#39;"
627
+ };
628
+ function replaceAll(value, table, pattern) {
629
+ return value.replace(pattern, (char) => table[char] ?? char);
630
+ }
631
+ function escapeHtml(value) {
632
+ return replaceAll(value, TEXT_CHARS, /[&<>]/g);
633
+ }
634
+ function escapeQuotes(value) {
635
+ return replaceAll(value, QUOTE_CHARS, /["']/g);
636
+ }
637
+ const DECODED = {
638
+ "&amp;": "&",
639
+ "&lt;": "<",
640
+ "&gt;": ">",
641
+ "&quot;": '"',
642
+ "&#39;": "'"
643
+ };
644
+ function decodeEntities(value) {
645
+ return value.replace(/&(?:amp|lt|gt|quot|#39);/g, (entity) => DECODED[entity] ?? entity);
646
+ }
647
+ const STYLE_TO_ATTR = {
648
+ color: "color",
649
+ "background-color": "backgroundColor",
650
+ "font-size": "fontSize",
651
+ "font-family": "fontFamily"
652
+ };
653
+ const ATTR_TO_STYLE = {
654
+ color: "color",
655
+ backgroundColor: "background-color",
656
+ fontSize: "font-size",
657
+ fontFamily: "font-family"
658
+ };
659
+ function hasValue(value) {
660
+ return typeof value === "string" && value.trim() !== "";
661
+ }
662
+ function styleAttrToAttrs(styleText) {
663
+ const attrs = {};
664
+ for (const declaration of styleText.split(";")) {
665
+ const separator = declaration.indexOf(":");
666
+ if (separator < 1) continue;
667
+ const prop = declaration.slice(0, separator).trim().toLowerCase();
668
+ const value = declaration.slice(separator + 1).trim();
669
+ const mapped = STYLE_TO_ATTR[prop];
670
+ if (mapped && hasValue(value)) attrs[mapped] = value;
671
+ }
672
+ return attrs;
673
+ }
674
+ function attrsToStyleCss(attrs = {}) {
675
+ const declarations = [];
676
+ for (const attr of Object.keys(ATTR_TO_STYLE)) {
677
+ const value = attrs[attr];
678
+ if (hasValue(value)) declarations.push(`${ATTR_TO_STYLE[attr]}: ${value}`);
679
+ }
680
+ return declarations.join("; ");
681
+ }
682
+ function makeInlineHtmlReader(options) {
683
+ const { tokenName, pattern, begins, resolve } = options;
684
+ return {
685
+ markdownTokenName: tokenName,
686
+ markdownTokenizer: {
687
+ name: tokenName,
688
+ level: "inline",
689
+ start: (src) => {
690
+ let earliest = -1;
691
+ for (const needle of begins) {
692
+ const at = src.indexOf(needle);
693
+ if (at >= 0 && (earliest < 0 || at < earliest)) earliest = at;
694
+ }
695
+ return earliest;
696
+ },
697
+ tokenize: (src, _tokens, lexer) => {
698
+ const match = pattern.exec(src);
699
+ if (!match) return void 0;
700
+ return {
701
+ type: tokenName,
702
+ raw: match[0],
703
+ text: match[2] ?? "",
704
+ openAttrs: match[1] ?? "",
705
+ // 这一步是整个设计的关键:不在这里重分词,内层的 **粗体** 与转义就会
706
+ // 在 parse 阶段被当成字面文本,进而每导出一次转义翻一倍
707
+ tokens: lexer.inlineTokens(match[2] ?? "")
708
+ };
709
+ }
710
+ },
711
+ /**
712
+ * token.tokens 已经是分好词的内层,直接 parseInline。
713
+ *
714
+ * resolve 认不出属性时(`<span class="x">` 这种)照样解析内层、只是不套 mark——
715
+ * 把整个 token 丢给 parseInline 会让里面的文字一起消失。
716
+ */
717
+ parseMarkdown: (token, helpers) => {
718
+ const content = helpers.parseInline(token.tokens ?? []);
719
+ const resolved = resolve(token);
720
+ return resolved ? helpers.applyMark(resolved.markName, content, resolved.attrs) : content;
721
+ }
722
+ };
723
+ }
724
+ function emitAttrValue(value) {
725
+ return escapeQuotes(escapeHtml(value));
726
+ }
727
+ function renderStyleSpan(node, inner) {
728
+ const css = attrsToStyleCss(node.attrs);
729
+ if (!css) return inner;
730
+ return `<span style="${emitAttrValue(css)}">${inner}</span>`;
731
+ }
732
+ const StyledTextStyle = TextStyle.extend({
733
+ renderMarkdown: (node, helpers) => renderStyleSpan(node, helpers.renderChildren(node))
734
+ });
735
+ const StyledSubscript = Subscript.extend({
736
+ renderMarkdown: (_node, helpers) => `<sub>${helpers.renderChildren(_node)}</sub>`
737
+ });
738
+ const StyledSuperscript = Superscript.extend({
739
+ renderMarkdown: (_node, helpers) => `<sup>${helpers.renderChildren(_node)}</sup>`
740
+ });
741
+ const StyledHighlight = Highlight.configure({ multicolor: true }).extend({
742
+ renderMarkdown: (node, helpers) => {
743
+ var _a;
744
+ const inner = helpers.renderChildren(node);
745
+ const color = (_a = node.attrs) == null ? void 0 : _a.color;
746
+ if (typeof color !== "string" || color.trim() === "") return `==${inner}==`;
747
+ return `<mark data-color="${emitAttrValue(color)}" style="background-color: ${emitAttrValue(color)}; color: inherit">${inner}</mark>`;
748
+ }
749
+ });
750
+ const StyledSpanReader = Extension.create({
751
+ // Reader 也得有名有姓:Extension.create 不给 name 就会落成空串,
752
+ // 三个匿名扩展同名会撞上 tiptap 的 Duplicate extension names 警告
753
+ name: "atriStyledSpanReader",
754
+ ...makeInlineHtmlReader({
755
+ tokenName: "atriStyledSpan",
756
+ // 非贪婪:我们的发出侧不会产出血缘嵌套的 span(同一个 mark 只出一个标签),
757
+ // 遇到外来的嵌套就退回内层文本,不抛
758
+ pattern: /^<span([^>]*)>([\s\S]*?)<\/span>/,
759
+ begins: ["<span"],
760
+ resolve: (token) => {
761
+ var _a;
762
+ const style = (_a = /(?:^|\s)style="([^"]*)"/i.exec(token.openAttrs ?? "")) == null ? void 0 : _a[1];
763
+ if (!style) return null;
764
+ const attrs = styleAttrToAttrs(decodeEntities(style));
765
+ return Object.keys(attrs).length > 0 ? { markName: "textStyle", attrs } : null;
766
+ }
767
+ })
768
+ });
769
+ const SubSupReader = Extension.create({
770
+ name: "atriInlineTagReader",
771
+ ...makeInlineHtmlReader({
772
+ tokenName: "atriInlineTag",
773
+ pattern: /^<(sub|sup)>([\s\S]*?)<\/\1>/,
774
+ begins: ["<sub>", "<sup>"],
775
+ resolve: (token) => {
776
+ const tag = (token.openAttrs ?? "").toLowerCase();
777
+ if (tag !== "sub" && tag !== "sup") return null;
778
+ return { markName: tag === "sup" ? "superscript" : "subscript", attrs: {} };
779
+ }
780
+ })
781
+ });
782
+ const HighlightReader = Extension.create({
783
+ name: "atriStyledMarkReader",
784
+ ...makeInlineHtmlReader({
785
+ tokenName: "atriStyledMark",
786
+ pattern: /^<mark([^>]*)>([\s\S]*?)<\/mark>/,
787
+ begins: ["<mark"],
788
+ resolve: (token) => {
789
+ var _a;
790
+ const color = (_a = /data-color="([^"]*)"/i.exec(token.openAttrs ?? "")) == null ? void 0 : _a[1];
791
+ if (!color) return null;
792
+ return { markName: "highlight", attrs: { color: decodeEntities(color) } };
793
+ }
794
+ })
795
+ });
796
+ function createFormattingExtensions(options = {}) {
797
+ const emit = options.inlineStyles !== "drop";
798
+ return [
799
+ emit ? StyledTextStyle : TextStyle,
800
+ Color,
801
+ BackgroundColor,
802
+ FontFamily,
803
+ FontSize,
804
+ emit ? StyledHighlight : Highlight.configure({ multicolor: true }),
805
+ emit ? StyledSubscript : Subscript,
806
+ emit ? StyledSuperscript : Superscript,
807
+ StyledSpanReader,
808
+ SubSupReader,
809
+ HighlightReader
810
+ ];
811
+ }
812
+ function usableUrl(candidate) {
813
+ const value = candidate.trim();
814
+ if (!value || /[<>\n\r]/.test(value)) return null;
815
+ return safeMediaSrc(value);
816
+ }
565
817
  function pickUrl(body) {
566
818
  if (!body || typeof body !== "object") return void 0;
567
819
  const record = body;
@@ -605,10 +857,29 @@ function uploadViaEndpoint(config, file, context) {
605
857
  body = xhr.responseText;
606
858
  }
607
859
  if (config.transformResult) {
608
- resolve(config.transformResult(body, file));
860
+ let transformed;
861
+ try {
862
+ transformed = config.transformResult(body, file);
863
+ } catch (cause) {
864
+ reject(new Error(`上传失败:upload.transformResult 抛出了异常(${String(cause)})`));
865
+ return;
866
+ }
867
+ const result = transformed;
868
+ const picked = result && typeof result === "object" ? result.url : void 0;
869
+ const url2 = typeof picked === "string" ? usableUrl(picked) : null;
870
+ if (!url2) {
871
+ reject(
872
+ new Error(
873
+ `上传失败:upload.transformResult 没有返回带可用 url 的 UploadResult(拿到 ${JSON.stringify(picked)})`
874
+ )
875
+ );
876
+ return;
877
+ }
878
+ resolve({ ...result, url: url2 });
609
879
  return;
610
880
  }
611
- const url = typeof body === "string" ? body : pickUrl(body);
881
+ const candidate = typeof body === "string" ? body : pickUrl(body);
882
+ const url = candidate ? usableUrl(candidate) : null;
612
883
  if (!url) {
613
884
  reject(new Error("上传失败:响应里找不到文件地址,请用 upload.transformResult 指定"));
614
885
  return;
@@ -617,6 +888,8 @@ function uploadViaEndpoint(config, file, context) {
617
888
  };
618
889
  xhr.onerror = () => reject(new Error("上传失败:网络错误"));
619
890
  xhr.onabort = () => reject(new DOMException("Aborted", "AbortError"));
891
+ xhr.ontimeout = () => reject(new Error(`上传失败:超过 ${config.timeout}ms 未响应`));
892
+ xhr.timeout = config.timeout ?? 0;
620
893
  if (context.signal.aborted) {
621
894
  xhr.abort();
622
895
  return;
@@ -635,6 +908,17 @@ function createUploader(upload) {
635
908
  if (!upload.endpoint) return null;
636
909
  return { upload: (file, context) => uploadViaEndpoint(upload, file, context) };
637
910
  }
911
+ const reported = /* @__PURE__ */ new WeakSet();
912
+ function runSafely(callback, label, args = []) {
913
+ if (typeof callback !== "function") return;
914
+ try {
915
+ callback(...args);
916
+ } catch (error) {
917
+ if (reported.has(callback)) return;
918
+ reported.add(callback);
919
+ console.error(`[Atri Editor] The "${label}" callback threw and was skipped:`, error);
920
+ }
921
+ }
638
922
  function readFileAsDataUrl(file) {
639
923
  return new Promise((resolve, reject) => {
640
924
  const reader = new FileReader();
@@ -975,14 +1259,7 @@ class MediaRuntime {
975
1259
  findPosition(task) {
976
1260
  const editor = this.editor;
977
1261
  if (!editor) return null;
978
- let found = null;
979
- editor.state.doc.descendants((node, position) => {
980
- if (found !== null) return;
981
- if (node.attrs.uploadId === task.id || node.attrs.src === task.previewUrl) {
982
- found = position;
983
- }
984
- });
985
- return found;
1262
+ return findTaskPosition(editor.state.doc, task);
986
1263
  }
987
1264
  taskByPreviewUrl(src) {
988
1265
  if (typeof src !== "string" || !src) return null;
@@ -1002,7 +1279,7 @@ class MediaRuntime {
1002
1279
  }
1003
1280
  reject(file, reason, cause) {
1004
1281
  if (this.config.onError) {
1005
- this.config.onError({ file, reason, cause });
1282
+ runSafely(this.config.onError, "media.onError", [{ file, reason, cause }]);
1006
1283
  return;
1007
1284
  }
1008
1285
  console.warn(`[Atri Editor] File "${file.name}" was rejected (${reason}).`);
@@ -1016,6 +1293,17 @@ class MediaRuntime {
1016
1293
  function clampPercent(percent) {
1017
1294
  return Math.round(Math.min(100, Math.max(0, percent)));
1018
1295
  }
1296
+ function findTaskPosition(doc, task) {
1297
+ let byUploadId = null;
1298
+ let byPreviewUrl = null;
1299
+ doc.descendants((node, position) => {
1300
+ if (byUploadId === null && node.attrs.uploadId === task.id) byUploadId = position;
1301
+ if (byPreviewUrl === null && task.previewUrl !== null && node.attrs.src === task.previewUrl) {
1302
+ byPreviewUrl = position;
1303
+ }
1304
+ });
1305
+ return byUploadId ?? byPreviewUrl;
1306
+ }
1019
1307
  function createPreviewUrl(file) {
1020
1308
  if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") return null;
1021
1309
  try {
@@ -1031,7 +1319,16 @@ function revokePreviewUrl(task) {
1031
1319
  URL.revokeObjectURL(previewUrl);
1032
1320
  task.previewUrl = null;
1033
1321
  }
1034
- const BUBBLE_TEXT_ITEMS = ["bold", "italic", "underline", "strike", "code"];
1322
+ const BUBBLE_TEXT_ITEMS = [
1323
+ "bold",
1324
+ "italic",
1325
+ "underline",
1326
+ "strike",
1327
+ "code",
1328
+ "highlight",
1329
+ "subscript",
1330
+ "superscript"
1331
+ ];
1035
1332
  const BUBBLE_NODE_ITEMS = ["attachmentDisplay", "delete"];
1036
1333
  const BUBBLE_SELECTABLE_NODES = ["image", "attachment", "attachmentLink"];
1037
1334
  function bubbleNodeNameFor(state) {
@@ -1068,13 +1365,15 @@ const LINK_HTML_ATTRIBUTES = {
1068
1365
  };
1069
1366
  class CoreEditor {
1070
1367
  constructor(config) {
1368
+ this.ownsMediaRuntime = false;
1071
1369
  this.config = config;
1072
1370
  this.placeholderText = config.placeholder ?? "";
1073
1371
  this.mediaRuntime = config.media === false ? null : config.mediaRuntime ?? new MediaRuntime(config.media);
1372
+ this.ownsMediaRuntime = this.mediaRuntime !== null && !config.mediaRuntime;
1074
1373
  this.editor = this.createEditor();
1075
1374
  }
1076
1375
  createEditor() {
1077
- var _a, _b, _c, _d, _e, _f, _g, _h;
1376
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i;
1078
1377
  const { element, content, contentFormat, editable, extensions } = this.config;
1079
1378
  const editorExtensions = [
1080
1379
  StarterKit.configure({
@@ -1089,7 +1388,14 @@ class CoreEditor {
1089
1388
  ...LINK_HTML_ATTRIBUTES,
1090
1389
  target: ((_c = this.config.link) == null ? void 0 : _c.target) ?? LINK_HTML_ATTRIBUTES.target
1091
1390
  }
1092
- }
1391
+ },
1392
+ // 关掉内置代码块,让接入方(通常就是 @atri-editor/code-highlight)的实现独占这个节点名。
1393
+ // 为什么提供一个开关而不是让宿主直接传同名扩展顶掉:实测同名确实赢(后注册的赢,
1394
+ // 节点与标记都是),所以不关也能工作——但 tiptap 会打一句
1395
+ // "Duplicate extension names found: ['codeBlock']" 的 warning,而且这件事靠的是
1396
+ // ExtensionManager 的去重顺序这个内部行为,不是对外承诺。
1397
+ // 多一个包就多一处"看起来正常但踩在实现细节上"的配置,不如把意图写成显式的一档
1398
+ codeBlock: this.config.codeBlock === false ? false : void 0
1093
1399
  })
1094
1400
  ];
1095
1401
  editorExtensions.push(
@@ -1134,7 +1440,12 @@ class CoreEditor {
1134
1440
  );
1135
1441
  }
1136
1442
  editorExtensions.push(TaskList, TaskItem);
1137
- const enableInputRules = ((_g = this.config.markdown) == null ? void 0 : _g.shortcuts) !== false;
1443
+ if (this.config.formatting !== false) {
1444
+ editorExtensions.push(
1445
+ ...createFormattingExtensions({ inlineStyles: (_g = this.config.markdown) == null ? void 0 : _g.inlineStyles })
1446
+ );
1447
+ }
1448
+ const enableInputRules = ((_h = this.config.markdown) == null ? void 0 : _h.shortcuts) !== false;
1138
1449
  if (extensions) {
1139
1450
  editorExtensions.push(...extensions);
1140
1451
  }
@@ -1151,27 +1462,22 @@ class CoreEditor {
1151
1462
  }
1152
1463
  },
1153
1464
  onCreate: () => {
1154
- var _a2, _b2;
1155
- (_b2 = (_a2 = this.config).onCreate) == null ? void 0 : _b2.call(_a2);
1465
+ runSafely(this.config.onCreate, "onCreate");
1156
1466
  },
1157
1467
  onUpdate: () => {
1158
- var _a2, _b2;
1159
- (_b2 = (_a2 = this.config).onUpdate) == null ? void 0 : _b2.call(_a2);
1468
+ runSafely(this.config.onUpdate, "onUpdate");
1160
1469
  },
1161
1470
  onFocus: () => {
1162
- var _a2, _b2;
1163
- (_b2 = (_a2 = this.config).onFocus) == null ? void 0 : _b2.call(_a2);
1471
+ runSafely(this.config.onFocus, "onFocus");
1164
1472
  },
1165
1473
  onBlur: () => {
1166
- var _a2, _b2;
1167
- (_b2 = (_a2 = this.config).onBlur) == null ? void 0 : _b2.call(_a2);
1474
+ runSafely(this.config.onBlur, "onBlur");
1168
1475
  },
1169
1476
  onDestroy: () => {
1170
- var _a2, _b2;
1171
- (_b2 = (_a2 = this.config).onDestroy) == null ? void 0 : _b2.call(_a2);
1477
+ runSafely(this.config.onDestroy, "onDestroy");
1172
1478
  }
1173
1479
  });
1174
- (_h = this.mediaRuntime) == null ? void 0 : _h.bind(editor);
1480
+ (_i = this.mediaRuntime) == null ? void 0 : _i.bind(editor);
1175
1481
  return editor;
1176
1482
  }
1177
1483
  /**
@@ -1271,11 +1577,17 @@ class CoreEditor {
1271
1577
  * 销毁编辑器
1272
1578
  */
1273
1579
  destroy() {
1580
+ var _a;
1274
1581
  this.editor.destroy();
1582
+ if (this.ownsMediaRuntime) (_a = this.mediaRuntime) == null ? void 0 : _a.destroy();
1583
+ this.mediaRuntime = null;
1275
1584
  }
1276
1585
  }
1277
1586
  function simpleMarkdownToHtml(markdown2) {
1278
- let html = markdown2;
1587
+ let html = escapeHtml(markdown2);
1588
+ html = html.replace(/^###### (.+)$/gm, "<h6>$1</h6>");
1589
+ html = html.replace(/^##### (.+)$/gm, "<h5>$1</h5>");
1590
+ html = html.replace(/^#### (.+)$/gm, "<h4>$1</h4>");
1279
1591
  html = html.replace(/^### (.+)$/gm, "<h3>$1</h3>");
1280
1592
  html = html.replace(/^## (.+)$/gm, "<h2>$1</h2>");
1281
1593
  html = html.replace(/^# (.+)$/gm, "<h1>$1</h1>");
@@ -1286,9 +1598,21 @@ function simpleMarkdownToHtml(markdown2) {
1286
1598
  html = html.replace(/`(.+?)`/g, "<code>$1</code>");
1287
1599
  html = html.replace(
1288
1600
  /!\[([^\]]*)\]\(\s*([^\s)]+)(?:\s+"([^"]*)")?\s*\)/g,
1289
- (_all, alt, src, title) => `<img src="${src}" alt="${alt}"${title ? ` title="${title}"` : ""}>`
1601
+ (_all, alt, src, title) => {
1602
+ const url = safeMediaSrc(src);
1603
+ if (url === null) return alt;
1604
+ const titleAttr = title ? ` title="${escapeQuotes(title)}"` : "";
1605
+ return `<img src="${escapeQuotes(url)}" alt="${escapeQuotes(alt)}"${titleAttr}>`;
1606
+ }
1607
+ );
1608
+ html = html.replace(
1609
+ /\[([^\]]*)\]\(\s*([^\s)]+)(?:\s+"[^"]*")?\s*\)/g,
1610
+ (_all, text, href) => {
1611
+ const url = safeHref(href);
1612
+ if (url === null) return text;
1613
+ return `<a href="${escapeQuotes(url)}">${text}</a>`;
1614
+ }
1290
1615
  );
1291
- html = html.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>');
1292
1616
  html = html.replace(/^\* (.+)$/gm, "<li>$1</li>");
1293
1617
  html = html.replace(/^- (.+)$/gm, "<li>$1</li>");
1294
1618
  html = html.replace(/^(\d+)\. (.+)$/gm, "<li>$2</li>");
@@ -1308,6 +1632,9 @@ function simpleHtmlToMarkdown(html) {
1308
1632
  md = md.replace(/<h1[^>]*>(.+?)<\/h1>/gi, "# $1\n\n");
1309
1633
  md = md.replace(/<h2[^>]*>(.+?)<\/h2>/gi, "## $1\n\n");
1310
1634
  md = md.replace(/<h3[^>]*>(.+?)<\/h3>/gi, "### $1\n\n");
1635
+ md = md.replace(/<h4[^>]*>(.+?)<\/h4>/gi, "#### $1\n\n");
1636
+ md = md.replace(/<h5[^>]*>(.+?)<\/h5>/gi, "##### $1\n\n");
1637
+ md = md.replace(/<h6[^>]*>(.+?)<\/h6>/gi, "###### $1\n\n");
1311
1638
  md = md.replace(/<strong[^>]*>(.+?)<\/strong>/gi, "**$1**");
1312
1639
  md = md.replace(/<b[^>]*>(.+?)<\/b>/gi, "**$1**");
1313
1640
  md = md.replace(/<em[^>]*>(.+?)<\/em>/gi, "*$1*");
@@ -1426,6 +1753,7 @@ class MarkdownService {
1426
1753
  return this.config.enabled !== false;
1427
1754
  }
1428
1755
  }
1756
+ const THEME_PREFIX$1 = "atri-theme-";
1429
1757
  class ThemeManager {
1430
1758
  constructor(container, theme = "light") {
1431
1759
  this.container = container;
@@ -1436,8 +1764,8 @@ class ThemeManager {
1436
1764
  * 应用当前主题:内置主题与自定义主题走同一条 class 规则,只是名字不同
1437
1765
  */
1438
1766
  applyTheme() {
1439
- this.container.classList.remove("atri-theme-light", "atri-theme-dark");
1440
- this.container.classList.add(`atri-theme-${this.currentTheme}`);
1767
+ Array.from(this.container.classList).filter((name) => name.startsWith(THEME_PREFIX$1)).forEach((name) => this.container.classList.remove(name));
1768
+ this.container.classList.add(`${THEME_PREFIX$1}${this.currentTheme}`);
1441
1769
  this.container.setAttribute("data-atri-theme", this.currentTheme);
1442
1770
  }
1443
1771
  /**
@@ -1472,6 +1800,9 @@ const zhResources = {
1472
1800
  heading1: "标题 1",
1473
1801
  heading2: "标题 2",
1474
1802
  heading3: "标题 3",
1803
+ heading4: "标题 4",
1804
+ heading5: "标题 5",
1805
+ heading6: "标题 6",
1475
1806
  paragraph: "正文",
1476
1807
  bulletList: "无序列表",
1477
1808
  orderedList: "有序列表",
@@ -1488,10 +1819,27 @@ const zhResources = {
1488
1819
  table: "表格",
1489
1820
  undo: "撤销",
1490
1821
  redo: "重做",
1822
+ align: "对齐",
1491
1823
  alignLeft: "左对齐",
1492
1824
  alignCenter: "居中",
1493
1825
  alignRight: "右对齐",
1494
- alignJustify: "两端对齐"
1826
+ alignJustify: "两端对齐",
1827
+ textColor: "文字颜色",
1828
+ highlight: "高亮",
1829
+ highlightColor: "高亮色",
1830
+ fontSize: "字号",
1831
+ fontFamily: "字体",
1832
+ subscript: "下标",
1833
+ superscript: "上标",
1834
+ clearFormat: "清除格式"
1835
+ },
1836
+ formatting: {
1837
+ customColor: "其他颜色…",
1838
+ noColor: "无",
1839
+ defaultValue: "默认",
1840
+ fontSans: "无衬线",
1841
+ fontSerif: "衬线",
1842
+ fontMono: "等宽"
1495
1843
  },
1496
1844
  media: {
1497
1845
  uploading: "上传中 {{files}} 个 · {{percent}}%",
@@ -1549,6 +1897,9 @@ const enResources = {
1549
1897
  heading1: "Heading 1",
1550
1898
  heading2: "Heading 2",
1551
1899
  heading3: "Heading 3",
1900
+ heading4: "Heading 4",
1901
+ heading5: "Heading 5",
1902
+ heading6: "Heading 6",
1552
1903
  paragraph: "Paragraph",
1553
1904
  bulletList: "Bullet List",
1554
1905
  orderedList: "Ordered List",
@@ -1565,10 +1916,27 @@ const enResources = {
1565
1916
  table: "Table",
1566
1917
  undo: "Undo",
1567
1918
  redo: "Redo",
1919
+ align: "Align",
1568
1920
  alignLeft: "Align Left",
1569
1921
  alignCenter: "Align Center",
1570
1922
  alignRight: "Align Right",
1571
- alignJustify: "Justify"
1923
+ alignJustify: "Justify",
1924
+ textColor: "Text Color",
1925
+ highlight: "Highlight",
1926
+ highlightColor: "Highlight Color",
1927
+ fontSize: "Font Size",
1928
+ fontFamily: "Font",
1929
+ subscript: "Subscript",
1930
+ superscript: "Superscript",
1931
+ clearFormat: "Clear Formatting"
1932
+ },
1933
+ formatting: {
1934
+ customColor: "Custom color…",
1935
+ noColor: "None",
1936
+ defaultValue: "Default",
1937
+ fontSans: "Sans",
1938
+ fontSerif: "Serif",
1939
+ fontMono: "Monospace"
1572
1940
  },
1573
1941
  media: {
1574
1942
  uploading: "Uploading {{files}} · {{percent}}%",
@@ -1614,6 +1982,9 @@ const enResources = {
1614
1982
  error: "AI Error"
1615
1983
  }
1616
1984
  };
1985
+ function cloneResources(value) {
1986
+ return JSON.parse(JSON.stringify(value));
1987
+ }
1617
1988
  class I18nManager {
1618
1989
  constructor(lang = "zh") {
1619
1990
  this.instance = i18next.createInstance();
@@ -1622,8 +1993,8 @@ class I18nManager {
1622
1993
  fallbackLng: "zh",
1623
1994
  initImmediate: false,
1624
1995
  resources: {
1625
- zh: { translation: zhResources },
1626
- en: { translation: enResources }
1996
+ zh: { translation: cloneResources(zhResources) },
1997
+ en: { translation: cloneResources(enResources) }
1627
1998
  }
1628
1999
  });
1629
2000
  }
@@ -1646,6 +2017,30 @@ class I18nManager {
1646
2017
  async changeLanguage(lang) {
1647
2018
  await this.instance.changeLanguage(lang);
1648
2019
  }
2020
+ /**
2021
+ * 注册语言包:给已存在的语言补词条,或引入一门内置清单之外的新语言。
2022
+ *
2023
+ * 内置词条是源码里的两个字面量对象,接入方改不到,于是 lang: 'ja' 只能静默回退中文
2024
+ * ——看起来像"支持可扩展",实际没有入口。这里补上那个入口。
2025
+ *
2026
+ * overwrite 传 false:同名 key 以先注册的一方为准,防止后加载的包把内置文案悄悄换掉。
2027
+ * 要自定义措辞走 toolbar.items 的 tooltip,那本来就是优先级更高的显式入口。
2028
+ * 注册当前语言时补发一次 languageChanged,让已挂载的界面重取文案;
2029
+ * 不是当前语言就不发,白重刷一遍界面没有意义。
2030
+ */
2031
+ addResources(lang, resources) {
2032
+ this.instance.addResourceBundle(lang, "translation", resources, true, false);
2033
+ if (lang === this.instance.language) {
2034
+ this.instance.emit("languageChanged", lang);
2035
+ }
2036
+ }
2037
+ /**
2038
+ * 该语言是否已有语言包。未注册的语言不会抛错,只会按 fallbackLng 回退,
2039
+ * 所以想知道有没有真的生效得问这里。
2040
+ */
2041
+ hasLanguage(lang) {
2042
+ return this.instance.hasResourceBundle(lang, "translation");
2043
+ }
1649
2044
  /**
1650
2045
  * 监听语言变更,返回取消订阅的函数
1651
2046
  */
@@ -1663,6 +2058,13 @@ class I18nManager {
1663
2058
  function tOrFallback(i18n, key, fallback) {
1664
2059
  return (i18n == null ? void 0 : i18n.tOr(key, fallback)) ?? fallback;
1665
2060
  }
2061
+ function zhText(key) {
2062
+ const found = key.split(".").reduce(
2063
+ (node, part) => node && typeof node === "object" ? node[part] : void 0,
2064
+ zhResources
2065
+ );
2066
+ return typeof found === "string" ? found : "";
2067
+ }
1666
2068
  class ExtensionManager {
1667
2069
  constructor() {
1668
2070
  this.extensions = /* @__PURE__ */ new Map();
@@ -1742,6 +2144,16 @@ class ExtensionManager {
1742
2144
  return this.extensions.has(name);
1743
2145
  }
1744
2146
  }
2147
+ const PANEL_OFFSET = 6;
2148
+ const PANEL_SHIFT_PADDING = 8;
2149
+ function panelMiddleware() {
2150
+ return [offset(PANEL_OFFSET), flip(), shift({ padding: PANEL_SHIFT_PADDING })];
2151
+ }
2152
+ function placePanel(panel, x, y) {
2153
+ panel.style.position = "fixed";
2154
+ panel.style.left = `${x}px`;
2155
+ panel.style.top = `${y}px`;
2156
+ }
1745
2157
  function positionPanel(trigger, panel, isCurrent) {
1746
2158
  let stopAutoUpdate = null;
1747
2159
  const update = async () => {
@@ -1749,12 +2161,10 @@ function positionPanel(trigger, panel, isCurrent) {
1749
2161
  try {
1750
2162
  const { x, y } = await computePosition(trigger, panel, {
1751
2163
  placement: "bottom-start",
1752
- middleware: [offset(6), flip(), shift({ padding: 8 })]
2164
+ middleware: panelMiddleware()
1753
2165
  });
1754
2166
  if (!isCurrent()) return;
1755
- panel.style.position = "fixed";
1756
- panel.style.left = `${x}px`;
1757
- panel.style.top = `${y}px`;
2167
+ placePanel(panel, x, y);
1758
2168
  } catch {
1759
2169
  }
1760
2170
  };
@@ -1789,10 +2199,20 @@ function listenPanelDismiss(trigger, panel, onClose) {
1789
2199
  document.removeEventListener("keydown", onKeyDown, true);
1790
2200
  };
1791
2201
  }
1792
- function copyThemeClasses(source, panel) {
1793
- const root = source == null ? void 0 : source.closest(".atri-editor");
1794
- const themes = Array.from((root == null ? void 0 : root.classList) ?? []).filter((name) => name.startsWith("atri-theme-"));
1795
- panel.classList.add(...themes);
2202
+ const THEME_PREFIX = "atri-theme-";
2203
+ function syncThemeClasses(target, root) {
2204
+ Array.from(target.classList).filter((name) => name.startsWith(THEME_PREFIX)).forEach((name) => target.classList.remove(name));
2205
+ const themes = Array.from(root.classList).filter((name) => name.startsWith(THEME_PREFIX));
2206
+ target.classList.add(...themes);
2207
+ }
2208
+ function followThemeClasses(source, panel) {
2209
+ const root = (source == null ? void 0 : source.closest(".atri-editor")) ?? null;
2210
+ if (root) syncThemeClasses(panel, root);
2211
+ if (!root || typeof MutationObserver === "undefined") return () => {
2212
+ };
2213
+ const observer = new MutationObserver(() => syncThemeClasses(panel, root));
2214
+ observer.observe(root, { attributes: true, attributeFilter: ["class"] });
2215
+ return () => observer.disconnect();
1796
2216
  }
1797
2217
  const FIELDS = {
1798
2218
  image: { url: true, alt: true, kind: "image" },
@@ -1805,6 +2225,7 @@ class InsertPanel {
1805
2225
  this.mode = null;
1806
2226
  this.floating = null;
1807
2227
  this.detachDismiss = null;
2228
+ this.detachTheme = null;
1808
2229
  this.labelled = [];
1809
2230
  this.editor = options.editor;
1810
2231
  this.runtime = options.runtime;
@@ -1832,17 +2253,19 @@ class InsertPanel {
1832
2253
  (_a = this.onOpenChange) == null ? void 0 : _a.call(this, mode);
1833
2254
  }
1834
2255
  close() {
1835
- var _a, _b, _c, _d;
2256
+ var _a, _b, _c, _d, _e;
1836
2257
  (_a = this.detachDismiss) == null ? void 0 : _a.call(this);
1837
2258
  this.detachDismiss = null;
1838
- (_b = this.floating) == null ? void 0 : _b.stop();
2259
+ (_b = this.detachTheme) == null ? void 0 : _b.call(this);
2260
+ this.detachTheme = null;
2261
+ (_c = this.floating) == null ? void 0 : _c.stop();
1839
2262
  this.floating = null;
1840
- (_c = this.element) == null ? void 0 : _c.remove();
2263
+ (_d = this.element) == null ? void 0 : _d.remove();
1841
2264
  this.element = null;
1842
2265
  this.labelled = [];
1843
2266
  if (this.mode === null) return;
1844
2267
  this.mode = null;
1845
- (_d = this.onOpenChange) == null ? void 0 : _d.call(this, null);
2268
+ (_e = this.onOpenChange) == null ? void 0 : _e.call(this, null);
1846
2269
  }
1847
2270
  destroy() {
1848
2271
  var _a;
@@ -1937,7 +2360,7 @@ class InsertPanel {
1937
2360
  }
1938
2361
  document.body.appendChild(panel);
1939
2362
  this.element = panel;
1940
- copyThemeClasses(this.editor.view.dom, panel);
2363
+ this.detachTheme = followThemeClasses(this.editor.view.dom, panel);
1941
2364
  (urlInput ?? altInput ?? browse).focus();
1942
2365
  }
1943
2366
  /**
@@ -1968,6 +2391,232 @@ class InsertPanel {
1968
2391
  this.detachDismiss = listenPanelDismiss(anchor, panel, () => this.close());
1969
2392
  }
1970
2393
  }
2394
+ class StyleOptionPanel {
2395
+ constructor(options) {
2396
+ this.element = null;
2397
+ this.floating = null;
2398
+ this.detachDismiss = null;
2399
+ this.detachTheme = null;
2400
+ this.anchor = null;
2401
+ this.currentKey = null;
2402
+ this.rows = [];
2403
+ this.editor = options.editor;
2404
+ this.i18n = options.i18n;
2405
+ this.onOpenChange = options.onOpenChange;
2406
+ }
2407
+ get isOpen() {
2408
+ return this.element !== null;
2409
+ }
2410
+ get openKey() {
2411
+ return this.currentKey;
2412
+ }
2413
+ /** 面板是否正开在某个指定触发器上——工具栏据此决定按钮的按下态给谁 */
2414
+ isOpenFor(anchor) {
2415
+ return this.isOpen && this.anchor === anchor;
2416
+ }
2417
+ toggle(anchor, spec) {
2418
+ if (this.isOpen) {
2419
+ const switching = this.anchor !== anchor;
2420
+ this.close();
2421
+ if (switching) this.open(anchor, spec);
2422
+ return;
2423
+ }
2424
+ this.open(anchor, spec);
2425
+ }
2426
+ close() {
2427
+ var _a, _b, _c, _d;
2428
+ if (!this.element) return;
2429
+ (_a = this.detachDismiss) == null ? void 0 : _a.call(this);
2430
+ this.detachDismiss = null;
2431
+ (_b = this.detachTheme) == null ? void 0 : _b.call(this);
2432
+ this.detachTheme = null;
2433
+ (_c = this.floating) == null ? void 0 : _c.stop();
2434
+ this.floating = null;
2435
+ this.element.remove();
2436
+ this.element = null;
2437
+ this.anchor = null;
2438
+ this.currentKey = null;
2439
+ this.rows = [];
2440
+ (_d = this.onOpenChange) == null ? void 0 : _d.call(this, false);
2441
+ }
2442
+ destroy() {
2443
+ this.close();
2444
+ }
2445
+ open(anchor, spec) {
2446
+ var _a;
2447
+ const panel = this.build(spec);
2448
+ document.body.appendChild(panel);
2449
+ this.detachTheme = followThemeClasses(this.editor.view.dom, panel);
2450
+ this.element = panel;
2451
+ this.anchor = anchor;
2452
+ this.currentKey = spec.key;
2453
+ this.floating = positionPanel(anchor, panel, () => this.element === panel);
2454
+ this.detachDismiss = listenPanelDismiss(anchor, panel, () => this.close());
2455
+ (_a = this.onOpenChange) == null ? void 0 : _a.call(this, true);
2456
+ panel.focus();
2457
+ }
2458
+ labelOf(option) {
2459
+ if (!option.i18nKey) return option.label;
2460
+ return tOrFallback(this.i18n, option.i18nKey, option.label);
2461
+ }
2462
+ build(spec) {
2463
+ const panel = document.createElement("div");
2464
+ panel.className = `atri-style-panel atri-style-panel--${spec.shape}`;
2465
+ panel.setAttribute("role", "menu");
2466
+ panel.setAttribute("aria-label", spec.label);
2467
+ panel.tabIndex = -1;
2468
+ panel.addEventListener("keydown", (event) => this.onKeyDown(event));
2469
+ const body = document.createElement("div");
2470
+ body.className = "atri-style-panel-body";
2471
+ this.rows = [];
2472
+ spec.options.forEach((option) => {
2473
+ body.appendChild(this.makeRow(option, spec));
2474
+ });
2475
+ if (spec.customColorLabel) {
2476
+ body.appendChild(this.makeCustomColorRow(spec));
2477
+ }
2478
+ if (spec.clearLabel) {
2479
+ const clear = {
2480
+ value: "",
2481
+ label: spec.clearLabel,
2482
+ isClear: true
2483
+ };
2484
+ body.appendChild(this.makeRow(clear, spec));
2485
+ }
2486
+ panel.appendChild(body);
2487
+ return panel;
2488
+ }
2489
+ makeRow(option, spec) {
2490
+ const row = document.createElement("button");
2491
+ row.type = "button";
2492
+ row.className = "atri-style-panel-row";
2493
+ row.setAttribute("role", "menuitem");
2494
+ row.dataset.styleValue = option.value;
2495
+ if (option.isClear) row.classList.add("atri-style-panel-row--clear");
2496
+ const label = this.labelOf(option);
2497
+ row.title = label;
2498
+ row.setAttribute("aria-label", label);
2499
+ if (option.swatch) {
2500
+ const swatch = document.createElement("span");
2501
+ swatch.className = "atri-style-panel-swatch";
2502
+ swatch.style.backgroundColor = option.swatch;
2503
+ swatch.setAttribute("aria-hidden", "true");
2504
+ row.appendChild(swatch);
2505
+ if (spec.shape === "grid") row.classList.add("atri-style-panel-row--swatch-only");
2506
+ }
2507
+ if (spec.shape === "list" || !option.swatch) {
2508
+ const text = document.createElement("span");
2509
+ text.className = "atri-style-panel-text";
2510
+ text.textContent = label;
2511
+ if (option.preview) text.style.fontFamily = option.preview;
2512
+ row.appendChild(text);
2513
+ }
2514
+ row.addEventListener("click", () => {
2515
+ if (option.isClear) {
2516
+ spec.onPick("");
2517
+ } else {
2518
+ spec.onPick(option.value);
2519
+ }
2520
+ this.close();
2521
+ });
2522
+ this.rows.push(row);
2523
+ return row;
2524
+ }
2525
+ /**
2526
+ * 原生取色器:不在预设色板里时用它。
2527
+ *
2528
+ * 监听 change 而不是 input——input 在拖动取色器的过程中每移一像素就触发一次,
2529
+ * 每一次都是一条进撤销历史的事务,用户按一次撤销只能退一格,得按上百次才回到起点。
2530
+ */
2531
+ makeCustomColorRow(spec) {
2532
+ const row = document.createElement("label");
2533
+ row.className = "atri-style-panel-custom";
2534
+ const input = document.createElement("input");
2535
+ input.type = "color";
2536
+ input.className = "atri-style-panel-custom-input";
2537
+ input.value = "#1890ff";
2538
+ input.addEventListener("change", () => {
2539
+ spec.onPick(input.value);
2540
+ this.close();
2541
+ });
2542
+ const text = document.createElement("span");
2543
+ text.textContent = spec.customColorLabel ?? "";
2544
+ row.appendChild(input);
2545
+ row.appendChild(text);
2546
+ return row;
2547
+ }
2548
+ onKeyDown(event) {
2549
+ const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key];
2550
+ if (event.key === "Enter" || event.key === " ") return;
2551
+ if (step === void 0) return;
2552
+ event.preventDefault();
2553
+ if (this.rows.length === 0) return;
2554
+ const focused = document.activeElement;
2555
+ const current = this.rows.indexOf(focused);
2556
+ const next = current < 0 ? step > 0 ? 0 : this.rows.length - 1 : (current + step + this.rows.length) % this.rows.length;
2557
+ this.rows[next].focus();
2558
+ }
2559
+ }
2560
+ const TEXT_COLORS = [
2561
+ "rgb(31, 31, 31)",
2562
+ "rgb(89, 89, 89)",
2563
+ "rgb(24, 144, 255)",
2564
+ "rgb(82, 196, 26)",
2565
+ "rgb(250, 173, 20)",
2566
+ "rgb(245, 34, 45)",
2567
+ "rgb(114, 46, 209)",
2568
+ "rgb(235, 47, 150)",
2569
+ "rgb(19, 194, 194)",
2570
+ "rgb(47, 84, 235)",
2571
+ "rgb(160, 97, 42)",
2572
+ "rgb(120, 120, 120)"
2573
+ ].map((value) => ({ value, label: value, swatch: value }));
2574
+ const HIGHLIGHT_COLORS = [
2575
+ "rgb(255, 241, 184)",
2576
+ "rgb(200, 250, 170)",
2577
+ "rgb(168, 223, 255)",
2578
+ "rgb(255, 192, 203)",
2579
+ "rgb(223, 190, 255)",
2580
+ "rgb(255, 214, 168)",
2581
+ "rgb(178, 235, 242)",
2582
+ "rgb(230, 230, 230)"
2583
+ ].map((value) => ({ value, label: value, swatch: value }));
2584
+ const FONT_SIZES = [
2585
+ "12px",
2586
+ "14px",
2587
+ "16px",
2588
+ "18px",
2589
+ "20px",
2590
+ "24px",
2591
+ "28px",
2592
+ "32px",
2593
+ "40px",
2594
+ "48px"
2595
+ ].map((value) => ({ value, label: value }));
2596
+ const FONT_FAMILIES = [
2597
+ {
2598
+ value: '-apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif',
2599
+ label: "Sans",
2600
+ i18nKey: "formatting.fontSans",
2601
+ preview: "sans-serif"
2602
+ },
2603
+ {
2604
+ value: 'Georgia, "Times New Roman", "Songti SC", SimSun, serif',
2605
+ label: "Serif",
2606
+ i18nKey: "formatting.fontSerif",
2607
+ preview: "serif"
2608
+ },
2609
+ {
2610
+ value: '"SFMono-Regular", Consolas, Menlo, monospace',
2611
+ label: "Monospace",
2612
+ i18nKey: "formatting.fontMono",
2613
+ preview: "monospace"
2614
+ },
2615
+ { value: '"PingFang SC", "Microsoft YaHei", sans-serif', label: "PingFang / YaHei" },
2616
+ { value: 'SimSun, "Songti SC", serif', label: "SimSun" },
2617
+ { value: 'SimHei, "Heiti SC", sans-serif', label: "SimHei" },
2618
+ { value: 'KaiTi, "Kaiti SC", serif', label: "KaiTi" }
2619
+ ];
1971
2620
  const MAX_COLS = 8;
1972
2621
  const MAX_ROWS = 6;
1973
2622
  class TableGridPanel {
@@ -1975,6 +2624,7 @@ class TableGridPanel {
1975
2624
  this.element = null;
1976
2625
  this.floating = null;
1977
2626
  this.detachDismiss = null;
2627
+ this.detachTheme = null;
1978
2628
  this.cursorRow = -1;
1979
2629
  this.cursorCol = -1;
1980
2630
  this.cells = [];
@@ -1999,18 +2649,20 @@ class TableGridPanel {
1999
2649
  (_a = this.onOpenChange) == null ? void 0 : _a.call(this, true);
2000
2650
  }
2001
2651
  close() {
2002
- var _a, _b, _c;
2652
+ var _a, _b, _c, _d;
2003
2653
  if (!this.element) return;
2004
2654
  (_a = this.detachDismiss) == null ? void 0 : _a.call(this);
2005
2655
  this.detachDismiss = null;
2006
- (_b = this.floating) == null ? void 0 : _b.stop();
2656
+ (_b = this.detachTheme) == null ? void 0 : _b.call(this);
2657
+ this.detachTheme = null;
2658
+ (_c = this.floating) == null ? void 0 : _c.stop();
2007
2659
  this.floating = null;
2008
2660
  this.element.remove();
2009
2661
  this.element = null;
2010
2662
  this.cells = [];
2011
2663
  this.cursorRow = -1;
2012
2664
  this.cursorCol = -1;
2013
- (_c = this.onOpenChange) == null ? void 0 : _c.call(this, false);
2665
+ (_d = this.onOpenChange) == null ? void 0 : _d.call(this, false);
2014
2666
  }
2015
2667
  destroy() {
2016
2668
  this.close();
@@ -2048,7 +2700,7 @@ class TableGridPanel {
2048
2700
  panel.appendChild(hint);
2049
2701
  document.body.appendChild(panel);
2050
2702
  this.element = panel;
2051
- copyThemeClasses(this.editor.view.dom, panel);
2703
+ this.detachTheme = followThemeClasses(this.editor.view.dom, panel);
2052
2704
  panel.focus();
2053
2705
  }
2054
2706
  /**
@@ -2101,6 +2753,127 @@ class TableGridPanel {
2101
2753
  this.highlight(row + 1, col + 1);
2102
2754
  }
2103
2755
  }
2756
+ class ToolbarMenuPanel {
2757
+ constructor(options) {
2758
+ this.element = null;
2759
+ this.floating = null;
2760
+ this.detachDismiss = null;
2761
+ this.detachTheme = null;
2762
+ this.anchor = null;
2763
+ this.currentKey = null;
2764
+ this.rows = [];
2765
+ this.editor = options.editor;
2766
+ this.onOpenChange = options.onOpenChange;
2767
+ }
2768
+ get isOpen() {
2769
+ return this.element !== null;
2770
+ }
2771
+ get openKey() {
2772
+ return this.currentKey;
2773
+ }
2774
+ toggle(anchor, spec) {
2775
+ if (this.isOpen) {
2776
+ const switching = this.anchor !== anchor;
2777
+ this.close();
2778
+ if (switching) this.open(anchor, spec);
2779
+ return;
2780
+ }
2781
+ this.open(anchor, spec);
2782
+ }
2783
+ close() {
2784
+ var _a, _b, _c, _d;
2785
+ if (!this.element) return;
2786
+ (_a = this.detachDismiss) == null ? void 0 : _a.call(this);
2787
+ this.detachDismiss = null;
2788
+ (_b = this.detachTheme) == null ? void 0 : _b.call(this);
2789
+ this.detachTheme = null;
2790
+ (_c = this.floating) == null ? void 0 : _c.stop();
2791
+ this.floating = null;
2792
+ this.element.remove();
2793
+ this.element = null;
2794
+ this.anchor = null;
2795
+ this.currentKey = null;
2796
+ this.rows = [];
2797
+ (_d = this.onOpenChange) == null ? void 0 : _d.call(this, false);
2798
+ }
2799
+ destroy() {
2800
+ this.close();
2801
+ }
2802
+ open(anchor, spec) {
2803
+ var _a;
2804
+ const panel = this.build(spec);
2805
+ document.body.appendChild(panel);
2806
+ this.detachTheme = followThemeClasses(this.editorViewDom(anchor), panel);
2807
+ this.element = panel;
2808
+ this.anchor = anchor;
2809
+ this.currentKey = spec.key;
2810
+ this.floating = positionPanel(anchor, panel, () => this.element === panel);
2811
+ this.detachDismiss = listenPanelDismiss(anchor, panel, () => this.close());
2812
+ (_a = this.onOpenChange) == null ? void 0 : _a.call(this, true);
2813
+ panel.focus();
2814
+ }
2815
+ /**
2816
+ * 主题类要从编辑器根上取,而这里只拿得到一个锚在工具栏上的按钮:
2817
+ * 沿 DOM 往上找 .atri-editor 比让调用方再多传一个参数省事,且工具栏就在那个根里面
2818
+ */
2819
+ editorViewDom(anchor) {
2820
+ return anchor.closest(".atri-editor");
2821
+ }
2822
+ build(spec) {
2823
+ const panel = document.createElement("div");
2824
+ panel.className = "atri-toolbar-menu";
2825
+ panel.setAttribute("role", "menu");
2826
+ panel.setAttribute("aria-label", spec.label);
2827
+ panel.tabIndex = -1;
2828
+ panel.addEventListener("keydown", (event) => this.onKeyDown(event));
2829
+ this.rows = [];
2830
+ spec.rows.forEach((row) => panel.appendChild(this.makeRow(row)));
2831
+ return panel;
2832
+ }
2833
+ makeRow(row) {
2834
+ const button = document.createElement("button");
2835
+ button.type = "button";
2836
+ button.className = "atri-toolbar-menu-item";
2837
+ button.setAttribute("role", "menuitemradio");
2838
+ button.setAttribute("aria-checked", String(row.active));
2839
+ button.dataset.menuItem = row.id;
2840
+ button.textContent = row.label;
2841
+ button.disabled = row.disabled;
2842
+ if (row.active) button.classList.add("active");
2843
+ button.addEventListener("click", () => {
2844
+ row.run();
2845
+ this.close();
2846
+ this.editor.commands.focus();
2847
+ });
2848
+ this.rows.push(button);
2849
+ return button;
2850
+ }
2851
+ onKeyDown(event) {
2852
+ if (event.key === "Enter" || event.key === " ") return;
2853
+ const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key];
2854
+ if (step === void 0) return;
2855
+ event.preventDefault();
2856
+ if (this.rows.length === 0) return;
2857
+ const current = this.rows.indexOf(document.activeElement);
2858
+ const next = current < 0 ? step > 0 ? 0 : this.rows.length - 1 : (current + step + this.rows.length) % this.rows.length;
2859
+ this.rows[next].focus();
2860
+ }
2861
+ }
2862
+ const SET_COMMAND = {
2863
+ color: "setColor",
2864
+ backgroundColor: "setBackgroundColor",
2865
+ fontSize: "setFontSize",
2866
+ fontFamily: "setFontFamily"
2867
+ };
2868
+ function canUseTextStyle(editor, attr) {
2869
+ var _a, _b;
2870
+ const command = editor.commands[SET_COMMAND[attr]];
2871
+ if (typeof command !== "function") return false;
2872
+ return !!((_b = (_a = editor.schema.marks.textStyle) == null ? void 0 : _a.spec.attrs) == null ? void 0 : _b[attr]);
2873
+ }
2874
+ function hasMark(editor, name) {
2875
+ return !!editor.schema.marks[name];
2876
+ }
2104
2877
  const icons = {
2105
2878
  undo: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>',
2106
2879
  redo: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3L21 13"/></svg>',
@@ -2139,7 +2912,20 @@ const icons = {
2139
2912
  tableColumnDelete: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="M15 3v18"/><path d="M10.5 12h3"/></svg>',
2140
2913
  tableMergeCells: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M8 12h8"/><path d="m10.5 9.5-2.5 2.5 2.5 2.5"/><path d="m13.5 9.5 2.5 2.5-2.5 2.5"/></svg>',
2141
2914
  tableSplitCell: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M12 3v18"/><path d="M9 12H6"/><path d="M15 12h3"/></svg>',
2142
- tableHeaderRow: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M7 6h4"/></svg>'
2915
+ tableHeaderRow: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M7 6h4"/></svg>',
2916
+ textColor: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.436-.652-.436-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2Z"/></svg>',
2917
+ highlight: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/></svg>',
2918
+ highlightColor: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m14 7 3-3a2.12 2.12 0 0 1 3 3l-3 3"/><path d="m13 6 5 5"/><path d="M14.7 8.3 8 15v4h4l6.7-6.7a2 2 0 0 0-4-4"/></svg>',
2919
+ fontSize: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 18 4.5-11L11 18"/><path d="M3.6 14h5.8"/><path d="m14 18 3-7 3 7"/><path d="M14.9 15.6h4.2"/></svg>',
2920
+ fontFamily: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" x2="15" y1="20" y2="20"/><line x1="12" x2="12" y1="4" y2="20"/></svg>',
2921
+ subscript: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 5 6 8"/><path d="m10 5-6 8"/><path d="M14 14.5c.7-1.4 2.8-1.4 3.4 0 .5 1.2-.5 2-1.7 2.6-1.4.7-2.2 1.4-2.2 2.4h4.5"/></svg>',
2922
+ superscript: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 9 6 8"/><path d="m10 9-6 8"/><path d="M14 5.5c.7-1.4 2.8-1.4 3.4 0 .5 1.2-.5 2-1.7 2.6-1.4.7-2.2 1.4-2.2 2.4h4.5"/></svg>',
2923
+ clearFormat: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>',
2924
+ // 下拉触发器用的通用字形:标题菜单与对齐菜单在"当前没有选中任何一级/一种对齐"时用它,
2925
+ // 有当前值时触发器换成那一项自己的图标,所见即所得
2926
+ heading: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4v16"/><path d="M18 4v16"/><path d="M6 12h12"/></svg>',
2927
+ align: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16"/><path d="M4 10h10"/><path d="M4 14h16"/><path d="M4 18h8"/></svg>',
2928
+ chevronDown: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>'
2143
2929
  };
2144
2930
  const TOOLTIP_KEYS = {
2145
2931
  undo: "editor.undo",
@@ -2147,6 +2933,13 @@ const TOOLTIP_KEYS = {
2147
2933
  heading1: "editor.heading1",
2148
2934
  heading2: "editor.heading2",
2149
2935
  heading3: "editor.heading3",
2936
+ heading4: "editor.heading4",
2937
+ heading5: "editor.heading5",
2938
+ heading6: "editor.heading6",
2939
+ // 两个下拉触发器。'editor.heading' 这条词条躺了很久、背后没有任何功能,
2940
+ // 这批终于有了真正读它的人
2941
+ heading: "editor.heading",
2942
+ align: "editor.align",
2150
2943
  paragraph: "editor.paragraph",
2151
2944
  bold: "editor.bold",
2152
2945
  italic: "editor.italic",
@@ -2168,7 +2961,15 @@ const TOOLTIP_KEYS = {
2168
2961
  attachmentDisplay: "editor.attachmentDisplay",
2169
2962
  insertTable: "editor.table",
2170
2963
  insertLink: "editor.link",
2171
- delete: "editor.delete"
2964
+ delete: "editor.delete",
2965
+ textColor: "editor.textColor",
2966
+ highlight: "editor.highlight",
2967
+ highlightColor: "editor.highlightColor",
2968
+ fontSize: "editor.fontSize",
2969
+ fontFamily: "editor.fontFamily",
2970
+ subscript: "editor.subscript",
2971
+ superscript: "editor.superscript",
2972
+ clearFormat: "editor.clearFormat"
2172
2973
  };
2173
2974
  class ToolbarManager {
2174
2975
  constructor(editor, container, config, i18n, mediaRuntime, linkPanel) {
@@ -2178,6 +2979,8 @@ class ToolbarManager {
2178
2979
  this.createdElements = [];
2179
2980
  this.panel = null;
2180
2981
  this.tablePanel = null;
2982
+ this.stylePanel = null;
2983
+ this.menuPanel = null;
2181
2984
  this.editor = editor;
2182
2985
  this.container = container;
2183
2986
  this.config = config;
@@ -2256,6 +3059,150 @@ class ToolbarManager {
2256
3059
  get tableExtensionRegistered() {
2257
3060
  return canInsertTable(this.editor);
2258
3061
  }
3062
+ /** 格式面板按需创建:没点开过就不该有一个实例挂在编辑器上 */
3063
+ get styleOptionPanel() {
3064
+ this.stylePanel ?? (this.stylePanel = new StyleOptionPanel({
3065
+ editor: this.editor,
3066
+ i18n: this.i18n,
3067
+ // 开合不改文档,等不到 transaction,得主动刷一次按钮状态
3068
+ onOpenChange: () => this.updateButtonStates()
3069
+ }));
3070
+ return this.stylePanel;
3071
+ }
3072
+ tOr(key, fallback) {
3073
+ var _a;
3074
+ return ((_a = this.i18n) == null ? void 0 : _a.tOr(key, fallback)) ?? fallback;
3075
+ }
3076
+ /** 下拉菜单面板按需创建:没点开过就不该有一个实例挂在编辑器上 */
3077
+ get menu() {
3078
+ this.menuPanel ?? (this.menuPanel = new ToolbarMenuPanel({
3079
+ editor: this.editor,
3080
+ // 开合不改文档,等不到 transaction,得主动刷一次按钮状态
3081
+ onOpenChange: () => this.updateButtonStates()
3082
+ }));
3083
+ return this.menuPanel;
3084
+ }
3085
+ /**
3086
+ * 把一个项做成下拉触发器。
3087
+ * 子项走的仍是同一张 itemDefs 表,所以它们和顶栏项一样受能力门控——
3088
+ * 表格被顶掉时 insertTable 就不会出现在菜单里,而不是摆一行按了没反应的
3089
+ */
3090
+ menuItem(key, icon, children) {
3091
+ const def = {
3092
+ id: key,
3093
+ icon,
3094
+ popup: "menu",
3095
+ children,
3096
+ command: (_editor, button) => {
3097
+ var _a;
3098
+ const live = ((_a = this.itemDefs.get(key)) == null ? void 0 : _a.children) ?? children;
3099
+ this.menu.toggle(button, this.menuSpec(key, live, button));
3100
+ },
3101
+ isActive: () => {
3102
+ var _a;
3103
+ return ((_a = this.menuPanel) == null ? void 0 : _a.openKey) === key;
3104
+ },
3105
+ isDisabled: (editor) => !editor.isEditable
3106
+ };
3107
+ return def;
3108
+ }
3109
+ /** 当前命中的子项:触发器的图标与提示都跟着它走,用户不必点开就知道现在的级别 */
3110
+ activeChild(def) {
3111
+ for (const childId of def.children ?? []) {
3112
+ const child = this.itemDefs.get(childId);
3113
+ if (child && !child.popup && child.isActive(this.editor) && !child.isDisabled(this.editor)) {
3114
+ return child;
3115
+ }
3116
+ }
3117
+ return null;
3118
+ }
3119
+ /** 子项词条 key;没有映射的(宿主的自定义项)退回定义里的兜底文案 */
3120
+ rowLabel(childId, child) {
3121
+ const key = TOOLTIP_KEYS[childId];
3122
+ return key ? this.tOr(key, zhText(key)) : child.tooltip ?? childId;
3123
+ }
3124
+ /**
3125
+ * 菜单里带浮层的子项一律不收:子项被点中后菜单就关了,
3126
+ * 而面板要锚在触发器上——锚一个即将从文档里消失的行没有意义。
3127
+ * 这与 attachBubbleToolbar 拒绝浮层项是同一条理由。
3128
+ */
3129
+ menuSpec(key, children, trigger) {
3130
+ const menuKey = TOOLTIP_KEYS[key];
3131
+ return {
3132
+ key,
3133
+ label: menuKey ? this.tOr(menuKey, zhText(menuKey)) : key,
3134
+ rows: children.reduce((rows, childId) => {
3135
+ const child = this.itemDefs.get(childId);
3136
+ if (!child) {
3137
+ console.warn(`[Atri Editor] Menu "${key}" declares unknown item "${childId}".`);
3138
+ return rows;
3139
+ }
3140
+ if (child.popup) {
3141
+ console.warn(
3142
+ `[Atri Editor] Menu item "${childId}" opens a panel, which menus cannot host.`
3143
+ );
3144
+ return rows;
3145
+ }
3146
+ rows.push({
3147
+ id: childId,
3148
+ label: this.rowLabel(childId, child),
3149
+ active: child.isActive(this.editor),
3150
+ disabled: child.isDisabled(this.editor),
3151
+ // 子项自己不需要触发器元素(只有浮层项会用),传过去是为了共用同一份定义
3152
+ run: () => child.command(this.editor, trigger)
3153
+ });
3154
+ return rows;
3155
+ }, [])
3156
+ };
3157
+ }
3158
+ /**
3159
+ * 面板文案在这里现取,不在建项时算好存下来:
3160
+ * 语言切换后重开面板要拿到新词条,而项定义只在建栏时求值一次
3161
+ */
3162
+ styleSpec(key, parts) {
3163
+ return {
3164
+ key,
3165
+ label: this.tOr(`editor.${key}`, key),
3166
+ shape: parts.shape,
3167
+ options: parts.options,
3168
+ // 色板才有"其他颜色"这一行;字号字体用原生控件选不出任意值
3169
+ customColorLabel: parts.swatch ? this.tOr("formatting.customColor", "其他颜色…") : void 0,
3170
+ // 清除行的措辞按形状分:颜色去掉是"无",字号字体退回的是"默认"
3171
+ clearLabel: parts.swatch ? this.tOr("formatting.noColor", "无") : this.tOr("formatting.defaultValue", "默认"),
3172
+ onPick: (value) => this.applyStyle(parts.attr, value)
3173
+ };
3174
+ }
3175
+ /**
3176
+ * 空串是"清除"而不是"设成空值":设成空串会留下一个属性为空串的 mark,
3177
+ * 而 textStyle 的 renderHTML 对空值照样会吐出一个 `<span style="">`,
3178
+ * 那种节点既看不见又会在往返里产噪音(见 extensions/formatting.ts 的发出侧)
3179
+ */
3180
+ applyStyle(attr, value) {
3181
+ const chain = this.editor.chain().focus();
3182
+ if (attr === "highlight") {
3183
+ if (value === "") chain.unsetHighlight().run();
3184
+ else chain.setHighlight({ color: value }).run();
3185
+ return;
3186
+ }
3187
+ switch (attr) {
3188
+ case "color":
3189
+ if (value === "") chain.unsetColor().run();
3190
+ else chain.setColor(value).run();
3191
+ return;
3192
+ case "backgroundColor":
3193
+ if (value === "") chain.unsetBackgroundColor().run();
3194
+ else chain.setBackgroundColor(value).run();
3195
+ return;
3196
+ case "fontSize":
3197
+ if (value === "") chain.unsetFontSize().run();
3198
+ else chain.setFontSize(value).run();
3199
+ return;
3200
+ case "fontFamily":
3201
+ if (value === "") chain.unsetFontFamily().run();
3202
+ else chain.setFontFamily(value).run();
3203
+ return;
3204
+ }
3205
+ }
2259
3206
  /**
2260
3207
  * 创建工具栏 DOM - 直接将按钮插入 container
2261
3208
  */
@@ -2275,18 +3222,23 @@ class ToolbarManager {
2275
3222
  }
2276
3223
  const layout = [
2277
3224
  ["undo", "redo"],
2278
- ["heading1", "heading2", "heading3", "paragraph"],
3225
+ // 标题收成一下拉:一到六级加正文七个取值平铺要占七格,而一次只用得上一个
3226
+ ["heading"],
2279
3227
  // 链接跟着行内格式走:它也是一段文字上的标记,只是要填地址所以带浮层
2280
3228
  ["bold", "italic", "underline", "strike", "code", "insertLink"],
3229
+ // 颜色与字号字体自成一组:都是"这段文字长什么样",且除 highlight 外都带浮层
3230
+ ["textColor", "highlight", "highlightColor", "fontSize", "fontFamily"],
2281
3231
  ["bulletList", "orderedList", "taskList", "blockquote", "codeBlock"],
2282
- ["alignLeft", "alignCenter", "alignRight", "alignJustify"],
2283
- // 分割线与表格自成一组:都是"往正文里插一块"的动作,没有激活态。
2284
- // 表格节点没注册时(table:false 或被顶掉)这项不存在,组里只剩分割线,不会吊出空组
2285
- ["horizontalRule", ...this.itemDefs.has("insertTable") ? ["insertTable"] : []],
2286
- // 「附件样式」不再常驻:切换形态改由选中附件时的浮动工具栏承接(bubble 默认开)
2287
- ...this.itemDefs.has("insertImage") ? [["insertImage", "insertAttachment"]] : []
3232
+ // 对齐同样收成一下拉
3233
+ ["align"],
3234
+ // 分割线与表格是一组:都是"往正文里插一块"的动作,没有激活态
3235
+ ["horizontalRule", "insertTable"],
3236
+ // 上下标与清除格式:作用于选区已有的格式,而不是加一种新格式
3237
+ ["subscript", "superscript", "clearFormat"],
3238
+ ["insertImage", "insertAttachment"]
2288
3239
  ];
2289
- layout.forEach((group, groupIndex) => {
3240
+ const groups = layout.filter((group) => group.some((itemId) => this.itemDefs.has(itemId)));
3241
+ groups.forEach((group, groupIndex) => {
2290
3242
  if (groupIndex > 0) {
2291
3243
  const separator = document.createElement("div");
2292
3244
  separator.className = "atri-editor-toolbar-separator";
@@ -2318,7 +3270,22 @@ class ToolbarManager {
2318
3270
  return { id, def };
2319
3271
  }
2320
3272
  if ((_a = item.children) == null ? void 0 : _a.length) {
2321
- console.warn(`[Atri Editor] Toolbar item "${id}" declares children, which is not supported.`);
3273
+ if (def.popup !== "menu") {
3274
+ console.warn(
3275
+ `[Atri Editor] Toolbar item "${id}" declares children, but it is not a dropdown; only the built-in dropdown items (heading, align) accept children.`
3276
+ );
3277
+ } else {
3278
+ const nested = item.children.filter((child) => {
3279
+ var _a2;
3280
+ return (_a2 = child.children) == null ? void 0 : _a2.length;
3281
+ });
3282
+ if (nested.length) {
3283
+ console.warn(
3284
+ `[Atri Editor] Nested children on "${nested.map((child) => child.id).join(", ")}" are not supported and were ignored.`
3285
+ );
3286
+ }
3287
+ def.children = item.children.map((child) => child.id);
3288
+ }
2322
3289
  }
2323
3290
  return { id, def, overrides: item };
2324
3291
  }
@@ -2338,9 +3305,14 @@ class ToolbarManager {
2338
3305
  this.customTooltips.set(itemId, overrides.tooltip);
2339
3306
  }
2340
3307
  if (itemDef.popup) {
2341
- button.setAttribute("aria-haspopup", "dialog");
3308
+ button.setAttribute("aria-haspopup", itemDef.popup === "menu" ? "menu" : "dialog");
2342
3309
  button.setAttribute("aria-expanded", "false");
2343
3310
  }
3311
+ if (itemDef.popup === "menu") {
3312
+ button.classList.add("atri-editor-toolbar-btn--menu");
3313
+ button.dataset.menuIcon = button.innerHTML;
3314
+ button.innerHTML += icons.chevronDown;
3315
+ }
2344
3316
  button.addEventListener("click", (e) => {
2345
3317
  e.preventDefault();
2346
3318
  e.stopPropagation();
@@ -2360,12 +3332,9 @@ class ToolbarManager {
2360
3332
  */
2361
3333
  applyTooltips() {
2362
3334
  this.buttons.forEach((buttons, itemId) => {
2363
- var _a;
2364
3335
  const itemDef = this.itemDefs.get(itemId);
2365
3336
  if (!itemDef) return;
2366
- const custom = this.customTooltips.get(itemId);
2367
- const key = TOOLTIP_KEYS[itemId];
2368
- const title = custom ?? (key ? ((_a = this.i18n) == null ? void 0 : _a.tOr(key, itemDef.tooltip)) ?? itemDef.tooltip : itemDef.tooltip);
3337
+ const title = this.itemTitle(itemId, itemDef);
2369
3338
  buttons.forEach((button) => {
2370
3339
  button.title = title;
2371
3340
  });
@@ -2380,7 +3349,6 @@ class ToolbarManager {
2380
3349
  items.set("insertImage", {
2381
3350
  id: "insertImage",
2382
3351
  icon: icons.image,
2383
- tooltip: "图片",
2384
3352
  popup: true,
2385
3353
  command: (_editor, button) => this.openInsert("image", button),
2386
3354
  isActive: () => {
@@ -2392,7 +3360,6 @@ class ToolbarManager {
2392
3360
  items.set("insertAttachment", {
2393
3361
  id: "insertAttachment",
2394
3362
  icon: icons.paperclip,
2395
- tooltip: "附件",
2396
3363
  popup: true,
2397
3364
  command: (_editor, button) => this.openInsert("attachment", button),
2398
3365
  isActive: () => {
@@ -2404,7 +3371,6 @@ class ToolbarManager {
2404
3371
  items.set("attachmentDisplay", {
2405
3372
  id: "attachmentDisplay",
2406
3373
  icon: icons.link,
2407
- tooltip: "附件样式",
2408
3374
  command: (editor) => editor.chain().focus().toggleAttachmentDisplay().run(),
2409
3375
  isActive: (editor) => editor.isActive("attachment") || editor.isActive("attachmentLink"),
2410
3376
  isDisabled: (editor) => !editor.isActive("attachment") && !editor.isActive("attachmentLink")
@@ -2414,7 +3380,6 @@ class ToolbarManager {
2414
3380
  items.set("insertTable", {
2415
3381
  id: "insertTable",
2416
3382
  icon: icons.table,
2417
- tooltip: "表格",
2418
3383
  popup: true,
2419
3384
  command: (_editor, button) => {
2420
3385
  var _a;
@@ -2431,7 +3396,6 @@ class ToolbarManager {
2431
3396
  items.set("insertLink", {
2432
3397
  id: "insertLink",
2433
3398
  icon: icons.link,
2434
- tooltip: "链接",
2435
3399
  popup: true,
2436
3400
  command: (_editor, button) => {
2437
3401
  var _a;
@@ -2445,10 +3409,127 @@ class ToolbarManager {
2445
3409
  isDisabled: (editor) => !editor.isEditable
2446
3410
  });
2447
3411
  }
3412
+ const textStyleReady = (attr) => canUseTextStyle(this.editor, attr);
3413
+ if (textStyleReady("color")) {
3414
+ items.set("textColor", {
3415
+ id: "textColor",
3416
+ icon: icons.textColor,
3417
+ popup: true,
3418
+ command: (_editor, button) => this.styleOptionPanel.toggle(
3419
+ button,
3420
+ this.styleSpec("textColor", {
3421
+ shape: "grid",
3422
+ options: TEXT_COLORS,
3423
+ swatch: true,
3424
+ attr: "color"
3425
+ })
3426
+ ),
3427
+ // 浮层项的按下态只跟浮层走:选区里有没有颜色是文档事实,不是"这个按钮正按着"
3428
+ isActive: () => {
3429
+ var _a;
3430
+ return ((_a = this.stylePanel) == null ? void 0 : _a.openKey) === "textColor";
3431
+ },
3432
+ isDisabled: (editor) => !editor.isEditable
3433
+ });
3434
+ }
3435
+ if (hasMark(this.editor, "highlight")) {
3436
+ items.set("highlight", {
3437
+ id: "highlight",
3438
+ icon: icons.highlight,
3439
+ command: (editor) => editor.chain().focus().toggleHighlight().run(),
3440
+ isActive: (editor) => editor.isActive("highlight"),
3441
+ isDisabled: (editor) => !editor.isEditable
3442
+ });
3443
+ items.set("highlightColor", {
3444
+ id: "highlightColor",
3445
+ icon: icons.highlightColor,
3446
+ popup: true,
3447
+ command: (_editor, button) => this.styleOptionPanel.toggle(
3448
+ button,
3449
+ this.styleSpec("highlightColor", {
3450
+ shape: "grid",
3451
+ options: HIGHLIGHT_COLORS,
3452
+ swatch: true,
3453
+ attr: "highlight"
3454
+ })
3455
+ ),
3456
+ isActive: () => {
3457
+ var _a;
3458
+ return ((_a = this.stylePanel) == null ? void 0 : _a.openKey) === "highlightColor";
3459
+ },
3460
+ isDisabled: (editor) => !editor.isEditable
3461
+ });
3462
+ }
3463
+ if (textStyleReady("fontSize")) {
3464
+ items.set("fontSize", {
3465
+ id: "fontSize",
3466
+ icon: icons.fontSize,
3467
+ popup: true,
3468
+ command: (_editor, button) => this.styleOptionPanel.toggle(
3469
+ button,
3470
+ this.styleSpec("fontSize", {
3471
+ shape: "list",
3472
+ options: FONT_SIZES,
3473
+ swatch: false,
3474
+ attr: "fontSize"
3475
+ })
3476
+ ),
3477
+ isActive: () => {
3478
+ var _a;
3479
+ return ((_a = this.stylePanel) == null ? void 0 : _a.openKey) === "fontSize";
3480
+ },
3481
+ isDisabled: (editor) => !editor.isEditable
3482
+ });
3483
+ }
3484
+ if (textStyleReady("fontFamily")) {
3485
+ items.set("fontFamily", {
3486
+ id: "fontFamily",
3487
+ icon: icons.fontFamily,
3488
+ popup: true,
3489
+ command: (_editor, button) => this.styleOptionPanel.toggle(
3490
+ button,
3491
+ this.styleSpec("fontFamily", {
3492
+ shape: "list",
3493
+ options: FONT_FAMILIES,
3494
+ swatch: false,
3495
+ attr: "fontFamily"
3496
+ })
3497
+ ),
3498
+ isActive: () => {
3499
+ var _a;
3500
+ return ((_a = this.stylePanel) == null ? void 0 : _a.openKey) === "fontFamily";
3501
+ },
3502
+ isDisabled: (editor) => !editor.isEditable
3503
+ });
3504
+ }
3505
+ if (hasMark(this.editor, "subscript")) {
3506
+ items.set("subscript", {
3507
+ id: "subscript",
3508
+ icon: icons.subscript,
3509
+ command: (editor) => editor.chain().focus().toggleSubscript().run(),
3510
+ isActive: (editor) => editor.isActive("subscript"),
3511
+ isDisabled: (editor) => !editor.isEditable
3512
+ });
3513
+ }
3514
+ if (hasMark(this.editor, "superscript")) {
3515
+ items.set("superscript", {
3516
+ id: "superscript",
3517
+ icon: icons.superscript,
3518
+ command: (editor) => editor.chain().focus().toggleSuperscript().run(),
3519
+ isActive: (editor) => editor.isActive("superscript"),
3520
+ isDisabled: (editor) => !editor.isEditable
3521
+ });
3522
+ }
3523
+ items.set("clearFormat", {
3524
+ id: "clearFormat",
3525
+ icon: icons.clearFormat,
3526
+ command: (editor) => editor.chain().focus().unsetAllMarks().clearNodes().run(),
3527
+ isActive: () => false,
3528
+ isDisabled: (editor) => !editor.isEditable
3529
+ });
2448
3530
  items.set("delete", {
2449
3531
  id: "delete",
2450
3532
  icon: icons.trash,
2451
- tooltip: "删除",
2452
3533
  command: (editor) => editor.chain().deleteSelection().run(),
2453
3534
  isActive: () => false,
2454
3535
  isDisabled: (editor) => !isNodeSelection(editor.state.selection)
@@ -2456,7 +3537,6 @@ class ToolbarManager {
2456
3537
  items.set("undo", {
2457
3538
  id: "undo",
2458
3539
  icon: icons.undo,
2459
- tooltip: "撤销",
2460
3540
  command: (editor) => editor.chain().undo().run(),
2461
3541
  isActive: () => false,
2462
3542
  isDisabled: (editor) => !editor.can().undo()
@@ -2464,39 +3544,23 @@ class ToolbarManager {
2464
3544
  items.set("redo", {
2465
3545
  id: "redo",
2466
3546
  icon: icons.redo,
2467
- tooltip: "重做",
2468
3547
  command: (editor) => editor.chain().redo().run(),
2469
3548
  isActive: () => false,
2470
3549
  isDisabled: (editor) => !editor.can().redo()
2471
3550
  });
2472
- items.set("heading1", {
2473
- id: "heading1",
2474
- icon: icons.heading1,
2475
- tooltip: "标题 1",
2476
- command: (editor) => editor.chain().toggleHeading({ level: 1 }).run(),
2477
- isActive: (editor) => editor.isActive("heading", { level: 1 }),
2478
- isDisabled: (editor) => !editor.can().toggleHeading({ level: 1 })
2479
- });
2480
- items.set("heading2", {
2481
- id: "heading2",
2482
- icon: icons.heading2,
2483
- tooltip: "标题 2",
2484
- command: (editor) => editor.chain().toggleHeading({ level: 2 }).run(),
2485
- isActive: (editor) => editor.isActive("heading", { level: 2 }),
2486
- isDisabled: (editor) => !editor.can().toggleHeading({ level: 2 })
2487
- });
2488
- items.set("heading3", {
2489
- id: "heading3",
2490
- icon: icons.heading3,
2491
- tooltip: "标题 3",
2492
- command: (editor) => editor.chain().toggleHeading({ level: 3 }).run(),
2493
- isActive: (editor) => editor.isActive("heading", { level: 3 }),
2494
- isDisabled: (editor) => !editor.can().toggleHeading({ level: 3 })
3551
+ [1, 2, 3, 4, 5, 6].forEach((level) => {
3552
+ const id = `heading${level}`;
3553
+ items.set(id, {
3554
+ id,
3555
+ icon: level <= 3 ? icons[id] : icons.heading,
3556
+ command: (editor) => editor.chain().toggleHeading({ level }).run(),
3557
+ isActive: (editor) => editor.isActive("heading", { level }),
3558
+ isDisabled: (editor) => !editor.can().toggleHeading({ level })
3559
+ });
2495
3560
  });
2496
3561
  items.set("paragraph", {
2497
3562
  id: "paragraph",
2498
3563
  icon: icons.paragraph,
2499
- tooltip: "正文",
2500
3564
  command: (editor) => editor.chain().setParagraph().run(),
2501
3565
  isActive: (editor) => editor.isActive("paragraph"),
2502
3566
  isDisabled: (editor) => !editor.can().setParagraph()
@@ -2504,7 +3568,6 @@ class ToolbarManager {
2504
3568
  items.set("bold", {
2505
3569
  id: "bold",
2506
3570
  icon: icons.bold,
2507
- tooltip: "加粗",
2508
3571
  command: (editor) => editor.chain().toggleBold().run(),
2509
3572
  isActive: (editor) => editor.isActive("bold"),
2510
3573
  isDisabled: (editor) => !editor.can().toggleBold()
@@ -2512,7 +3575,6 @@ class ToolbarManager {
2512
3575
  items.set("italic", {
2513
3576
  id: "italic",
2514
3577
  icon: icons.italic,
2515
- tooltip: "斜体",
2516
3578
  command: (editor) => editor.chain().toggleItalic().run(),
2517
3579
  isActive: (editor) => editor.isActive("italic"),
2518
3580
  isDisabled: (editor) => !editor.can().toggleItalic()
@@ -2520,7 +3582,6 @@ class ToolbarManager {
2520
3582
  items.set("underline", {
2521
3583
  id: "underline",
2522
3584
  icon: icons.underline,
2523
- tooltip: "下划线",
2524
3585
  command: (editor) => editor.chain().toggleUnderline().run(),
2525
3586
  isActive: (editor) => editor.isActive("underline"),
2526
3587
  isDisabled: (editor) => !editor.can().toggleUnderline()
@@ -2528,7 +3589,6 @@ class ToolbarManager {
2528
3589
  items.set("strike", {
2529
3590
  id: "strike",
2530
3591
  icon: icons.strikethrough,
2531
- tooltip: "删除线",
2532
3592
  command: (editor) => editor.chain().toggleStrike().run(),
2533
3593
  isActive: (editor) => editor.isActive("strike"),
2534
3594
  isDisabled: (editor) => !editor.can().toggleStrike()
@@ -2536,7 +3596,6 @@ class ToolbarManager {
2536
3596
  items.set("code", {
2537
3597
  id: "code",
2538
3598
  icon: icons.code,
2539
- tooltip: "行内代码",
2540
3599
  command: (editor) => editor.chain().toggleCode().run(),
2541
3600
  isActive: (editor) => editor.isActive("code"),
2542
3601
  isDisabled: (editor) => !editor.can().toggleCode()
@@ -2544,7 +3603,6 @@ class ToolbarManager {
2544
3603
  items.set("bulletList", {
2545
3604
  id: "bulletList",
2546
3605
  icon: icons.list,
2547
- tooltip: "无序列表",
2548
3606
  command: (editor) => editor.chain().toggleBulletList().run(),
2549
3607
  isActive: (editor) => editor.isActive("bulletList"),
2550
3608
  isDisabled: (editor) => !editor.can().toggleBulletList()
@@ -2552,7 +3610,6 @@ class ToolbarManager {
2552
3610
  items.set("orderedList", {
2553
3611
  id: "orderedList",
2554
3612
  icon: icons.listOrdered,
2555
- tooltip: "有序列表",
2556
3613
  command: (editor) => editor.chain().toggleOrderedList().run(),
2557
3614
  isActive: (editor) => editor.isActive("orderedList"),
2558
3615
  isDisabled: (editor) => !editor.can().toggleOrderedList()
@@ -2560,7 +3617,6 @@ class ToolbarManager {
2560
3617
  items.set("taskList", {
2561
3618
  id: "taskList",
2562
3619
  icon: icons.listChecks,
2563
- tooltip: "任务列表",
2564
3620
  command: (editor) => editor.chain().toggleTaskList().run(),
2565
3621
  isActive: (editor) => editor.isActive("taskList"),
2566
3622
  isDisabled: (editor) => !editor.can().toggleTaskList()
@@ -2568,23 +3624,22 @@ class ToolbarManager {
2568
3624
  items.set("blockquote", {
2569
3625
  id: "blockquote",
2570
3626
  icon: icons.quote,
2571
- tooltip: "引用",
2572
3627
  command: (editor) => editor.chain().toggleBlockquote().run(),
2573
3628
  isActive: (editor) => editor.isActive("blockquote"),
2574
3629
  isDisabled: (editor) => !editor.can().toggleBlockquote()
2575
3630
  });
2576
- items.set("codeBlock", {
2577
- id: "codeBlock",
2578
- icon: icons.codeBlock,
2579
- tooltip: "代码块",
2580
- command: (editor) => editor.chain().toggleCodeBlock().run(),
2581
- isActive: (editor) => editor.isActive("codeBlock"),
2582
- isDisabled: (editor) => !editor.can().toggleCodeBlock()
2583
- });
3631
+ if (this.editor.schema.nodes.codeBlock) {
3632
+ items.set("codeBlock", {
3633
+ id: "codeBlock",
3634
+ icon: icons.codeBlock,
3635
+ command: (editor) => editor.chain().toggleCodeBlock().run(),
3636
+ isActive: (editor) => editor.isActive("codeBlock"),
3637
+ isDisabled: (editor) => !editor.can().toggleCodeBlock()
3638
+ });
3639
+ }
2584
3640
  items.set("horizontalRule", {
2585
3641
  id: "horizontalRule",
2586
3642
  icon: icons.horizontalRule,
2587
- tooltip: "分割线",
2588
3643
  command: (editor) => editor.chain().setHorizontalRule().run(),
2589
3644
  isActive: () => false,
2590
3645
  isDisabled: (editor) => !editor.can().setHorizontalRule()
@@ -2592,7 +3647,6 @@ class ToolbarManager {
2592
3647
  items.set("alignLeft", {
2593
3648
  id: "alignLeft",
2594
3649
  icon: icons.alignLeft,
2595
- tooltip: "左对齐",
2596
3650
  command: (editor) => editor.chain().setTextAlign("left").run(),
2597
3651
  isActive: (editor) => editor.isActive({ textAlign: "left" }),
2598
3652
  isDisabled: (editor) => !editor.can().setTextAlign("left")
@@ -2600,7 +3654,6 @@ class ToolbarManager {
2600
3654
  items.set("alignCenter", {
2601
3655
  id: "alignCenter",
2602
3656
  icon: icons.alignCenter,
2603
- tooltip: "居中",
2604
3657
  command: (editor) => editor.chain().setTextAlign("center").run(),
2605
3658
  isActive: (editor) => editor.isActive({ textAlign: "center" }),
2606
3659
  isDisabled: (editor) => !editor.can().setTextAlign("center")
@@ -2608,7 +3661,6 @@ class ToolbarManager {
2608
3661
  items.set("alignRight", {
2609
3662
  id: "alignRight",
2610
3663
  icon: icons.alignRight,
2611
- tooltip: "右对齐",
2612
3664
  command: (editor) => editor.chain().setTextAlign("right").run(),
2613
3665
  isActive: (editor) => editor.isActive({ textAlign: "right" }),
2614
3666
  isDisabled: (editor) => !editor.can().setTextAlign("right")
@@ -2616,23 +3668,44 @@ class ToolbarManager {
2616
3668
  items.set("alignJustify", {
2617
3669
  id: "alignJustify",
2618
3670
  icon: icons.alignJustify,
2619
- tooltip: "两端对齐",
2620
3671
  command: (editor) => editor.chain().setTextAlign("justify").run(),
2621
3672
  isActive: (editor) => editor.isActive({ textAlign: "justify" }),
2622
3673
  isDisabled: (editor) => !editor.can().setTextAlign("justify")
2623
3674
  });
3675
+ items.set(
3676
+ "heading",
3677
+ this.menuItem("heading", icons.heading, [
3678
+ "heading1",
3679
+ "heading2",
3680
+ "heading3",
3681
+ "heading4",
3682
+ "heading5",
3683
+ "heading6",
3684
+ "paragraph"
3685
+ ])
3686
+ );
3687
+ items.set(
3688
+ "align",
3689
+ this.menuItem("align", icons.align, [
3690
+ "alignLeft",
3691
+ "alignCenter",
3692
+ "alignRight",
3693
+ "alignJustify"
3694
+ ])
3695
+ );
2624
3696
  return items;
2625
3697
  }
2626
3698
  /**
2627
3699
  * 绑定编辑器事件,更新按钮状态
2628
3700
  */
2629
3701
  bindEditorEvents() {
2630
- this.editor.on("transaction", () => {
2631
- this.updateButtonStates();
2632
- });
2633
- this.editor.on("selectionUpdate", () => {
2634
- this.updateButtonStates();
2635
- });
3702
+ const refresh = () => this.updateButtonStates();
3703
+ this.editor.on("transaction", refresh);
3704
+ this.editor.on("selectionUpdate", refresh);
3705
+ this.unsubscribeEditor = () => {
3706
+ this.editor.off("transaction", refresh);
3707
+ this.editor.off("selectionUpdate", refresh);
3708
+ };
2636
3709
  this.updateButtonStates();
2637
3710
  }
2638
3711
  /**
@@ -2651,20 +3724,57 @@ class ToolbarManager {
2651
3724
  button.classList.toggle("active", active);
2652
3725
  if (itemDef.popup) button.setAttribute("aria-expanded", String(active));
2653
3726
  button.disabled = disabled;
3727
+ if (itemDef.popup === "menu") this.refreshMenuTrigger(itemDef, button);
2654
3728
  });
2655
3729
  });
2656
3730
  }
3731
+ /**
3732
+ * 一项当前该显示的文字。下拉触发器跟着命中的子项走(现在是三级标题就写「标题 3」),
3733
+ * 宿主显式写的 tooltip 优先级最高。
3734
+ * 抽出来是因为图标与提示必须由同一份判定得出:只换其中一个就会出现
3735
+ * "按钮画着 H3、悬停写标题"这种一半新一半旧的界面
3736
+ */
3737
+ itemTitle(itemId, def) {
3738
+ var _a;
3739
+ const custom = this.customTooltips.get(itemId);
3740
+ if (custom) return custom;
3741
+ let key = TOOLTIP_KEYS[itemId];
3742
+ if (def.popup === "menu") {
3743
+ const activeKey = TOOLTIP_KEYS[((_a = this.activeChild(def)) == null ? void 0 : _a.id) ?? ""];
3744
+ if (activeKey) key = activeKey;
3745
+ }
3746
+ const fallback = key ? zhText(key) : def.tooltip ?? "";
3747
+ return key ? this.tOr(key, fallback) : fallback;
3748
+ }
3749
+ /**
3750
+ * 下拉触发器的图标与提示跟着当前值走:现在是二级标题就显示 H2,
3751
+ * 一个都不匹配才退回通用图标。只有菜单项会走到这里,不是每次刷新对所有按钮都做一遍
3752
+ */
3753
+ refreshMenuTrigger(def, button) {
3754
+ var _a;
3755
+ const base = button.dataset.menuIcon ?? def.icon;
3756
+ const next = `${((_a = this.activeChild(def)) == null ? void 0 : _a.icon) ?? base}${icons.chevronDown}`;
3757
+ if (button.innerHTML !== next) button.innerHTML = next;
3758
+ const title = this.itemTitle(def.id, def);
3759
+ if (button.title !== title) button.title = title;
3760
+ }
2657
3761
  /**
2658
3762
  * 销毁工具栏
2659
3763
  */
2660
3764
  destroy() {
2661
- var _a, _b, _c;
3765
+ var _a, _b, _c, _d, _e, _f;
2662
3766
  (_a = this.unsubscribeLanguage) == null ? void 0 : _a.call(this);
2663
3767
  this.unsubscribeLanguage = void 0;
2664
- (_b = this.panel) == null ? void 0 : _b.destroy();
3768
+ (_b = this.unsubscribeEditor) == null ? void 0 : _b.call(this);
3769
+ this.unsubscribeEditor = void 0;
3770
+ (_c = this.panel) == null ? void 0 : _c.destroy();
2665
3771
  this.panel = null;
2666
- (_c = this.tablePanel) == null ? void 0 : _c.destroy();
3772
+ (_d = this.tablePanel) == null ? void 0 : _d.destroy();
2667
3773
  this.tablePanel = null;
3774
+ (_e = this.stylePanel) == null ? void 0 : _e.destroy();
3775
+ this.stylePanel = null;
3776
+ (_f = this.menuPanel) == null ? void 0 : _f.destroy();
3777
+ this.menuPanel = null;
2668
3778
  this.buttons.clear();
2669
3779
  this.customTooltips.clear();
2670
3780
  this.createdElements.forEach((el) => el.remove());
@@ -2683,6 +3793,7 @@ class LinkPanel {
2683
3793
  this.element = null;
2684
3794
  this.floating = null;
2685
3795
  this.detachDismiss = null;
3796
+ this.detachTheme = null;
2686
3797
  this.labelled = [];
2687
3798
  this.urlInput = null;
2688
3799
  this.targetInput = null;
@@ -2728,19 +3839,21 @@ class LinkPanel {
2728
3839
  (_a = this.onOpenChange) == null ? void 0 : _a.call(this, true);
2729
3840
  }
2730
3841
  close() {
2731
- var _a, _b, _c, _d;
3842
+ var _a, _b, _c, _d, _e;
2732
3843
  const wasOpen = this.element !== null;
2733
3844
  (_a = this.detachDismiss) == null ? void 0 : _a.call(this);
2734
3845
  this.detachDismiss = null;
2735
- (_b = this.floating) == null ? void 0 : _b.stop();
3846
+ (_b = this.detachTheme) == null ? void 0 : _b.call(this);
3847
+ this.detachTheme = null;
3848
+ (_c = this.floating) == null ? void 0 : _c.stop();
2736
3849
  this.floating = null;
2737
- (_c = this.element) == null ? void 0 : _c.remove();
3850
+ (_d = this.element) == null ? void 0 : _d.remove();
2738
3851
  this.element = null;
2739
3852
  this.urlInput = null;
2740
3853
  this.targetInput = null;
2741
3854
  this.labelled = [];
2742
3855
  this.editing = false;
2743
- if (wasOpen) (_d = this.onOpenChange) == null ? void 0 : _d.call(this, false);
3856
+ if (wasOpen) (_e = this.onOpenChange) == null ? void 0 : _e.call(this, false);
2744
3857
  }
2745
3858
  destroy() {
2746
3859
  var _a;
@@ -2812,7 +3925,7 @@ class LinkPanel {
2812
3925
  panel.appendChild(actions);
2813
3926
  document.body.appendChild(panel);
2814
3927
  this.element = panel;
2815
- copyThemeClasses(this.root, panel);
3928
+ this.detachTheme = followThemeClasses(this.root, panel);
2816
3929
  url.focus();
2817
3930
  url.select();
2818
3931
  }
@@ -2859,7 +3972,6 @@ function buildItems() {
2859
3972
  id: "addRowBefore",
2860
3973
  icon: icons.tableRowBefore,
2861
3974
  labelKey: "table.rowBefore",
2862
- label: "上方插入行",
2863
3975
  group: 0,
2864
3976
  command: (editor) => editor.chain().focus().addRowBefore().run()
2865
3977
  },
@@ -2867,7 +3979,6 @@ function buildItems() {
2867
3979
  id: "addRowAfter",
2868
3980
  icon: icons.tableRowAfter,
2869
3981
  labelKey: "table.rowAfter",
2870
- label: "下方插入行",
2871
3982
  group: 0,
2872
3983
  command: (editor) => editor.chain().focus().addRowAfter().run()
2873
3984
  },
@@ -2875,7 +3986,6 @@ function buildItems() {
2875
3986
  id: "deleteRow",
2876
3987
  icon: icons.tableRowDelete,
2877
3988
  labelKey: "table.deleteRow",
2878
- label: "删除行",
2879
3989
  group: 0,
2880
3990
  command: (editor) => editor.chain().focus().deleteRow().run()
2881
3991
  },
@@ -2883,7 +3993,6 @@ function buildItems() {
2883
3993
  id: "addColumnBefore",
2884
3994
  icon: icons.tableColumnBefore,
2885
3995
  labelKey: "table.columnBefore",
2886
- label: "左侧插入列",
2887
3996
  group: 1,
2888
3997
  command: (editor) => editor.chain().focus().addColumnBefore().run()
2889
3998
  },
@@ -2891,7 +4000,6 @@ function buildItems() {
2891
4000
  id: "addColumnAfter",
2892
4001
  icon: icons.tableColumnAfter,
2893
4002
  labelKey: "table.columnAfter",
2894
- label: "右侧插入列",
2895
4003
  group: 1,
2896
4004
  command: (editor) => editor.chain().focus().addColumnAfter().run()
2897
4005
  },
@@ -2899,7 +4007,6 @@ function buildItems() {
2899
4007
  id: "deleteColumn",
2900
4008
  icon: icons.tableColumnDelete,
2901
4009
  labelKey: "table.deleteColumn",
2902
- label: "删除列",
2903
4010
  group: 1,
2904
4011
  command: (editor) => editor.chain().focus().deleteColumn().run()
2905
4012
  },
@@ -2908,7 +4015,6 @@ function buildItems() {
2908
4015
  id: "mergeCells",
2909
4016
  icon: icons.tableMergeCells,
2910
4017
  labelKey: "table.mergeCells",
2911
- label: "合并单元格",
2912
4018
  group: 2,
2913
4019
  command: (editor) => editor.chain().focus().mergeCells().run(),
2914
4020
  isDisabled: (editor) => !editor.can().mergeCells()
@@ -2917,7 +4023,6 @@ function buildItems() {
2917
4023
  id: "splitCell",
2918
4024
  icon: icons.tableSplitCell,
2919
4025
  labelKey: "table.splitCell",
2920
- label: "拆分单元格",
2921
4026
  group: 2,
2922
4027
  command: (editor) => editor.chain().focus().splitCell().run(),
2923
4028
  isDisabled: (editor) => !editor.can().splitCell()
@@ -2926,24 +4031,22 @@ function buildItems() {
2926
4031
  id: "toggleHeaderRow",
2927
4032
  icon: icons.tableHeaderRow,
2928
4033
  labelKey: "table.toggleHeaderRow",
2929
- label: "切换表头行",
2930
4034
  group: 2,
2931
4035
  command: (editor) => editor.chain().focus().toggleHeaderRow().run(),
2932
4036
  isActive: (editor) => editor.isActive("tableHeader")
2933
4037
  }
2934
4038
  ];
2935
4039
  const alignments = [
2936
- ["left", "table.alignLeft", "单元格左对齐"],
2937
- ["center", "table.alignCenter", "单元格居中"],
2938
- ["right", "table.alignRight", "单元格右对齐"]
4040
+ ["left", "table.alignLeft"],
4041
+ ["center", "table.alignCenter"],
4042
+ ["right", "table.alignRight"]
2939
4043
  ];
2940
- alignments.forEach(([align, labelKey, label]) => {
4044
+ alignments.forEach(([align, labelKey]) => {
2941
4045
  const suffix = `${align[0].toUpperCase()}${align.slice(1)}`;
2942
4046
  items.push({
2943
4047
  id: `cellAlign${suffix}`,
2944
4048
  icon: icons[`align${suffix}`],
2945
4049
  labelKey,
2946
- label,
2947
4050
  group: 3,
2948
4051
  command: (editor) => editor.chain().focus().setCellAttribute("align", align).run(),
2949
4052
  isActive: cellAlignmentActive(align)
@@ -2953,7 +4056,6 @@ function buildItems() {
2953
4056
  id: "deleteTable",
2954
4057
  icon: icons.trash,
2955
4058
  labelKey: "table.deleteTable",
2956
- label: "删除表格",
2957
4059
  group: 4,
2958
4060
  command: (editor) => editor.chain().focus().deleteTable().run()
2959
4061
  });
@@ -3013,8 +4115,8 @@ class TableMenu {
3013
4115
  this.unsubscribeLanguage = void 0;
3014
4116
  this.hide();
3015
4117
  }
3016
- t(key, fallback) {
3017
- return tOrFallback(this.i18n, key, fallback);
4118
+ t(key) {
4119
+ return tOrFallback(this.i18n, key, zhText(key));
3018
4120
  }
3019
4121
  show() {
3020
4122
  if (!this.element) this.build();
@@ -3022,9 +4124,11 @@ class TableMenu {
3022
4124
  void this.position();
3023
4125
  }
3024
4126
  hide() {
3025
- var _a;
4127
+ var _a, _b;
3026
4128
  (_a = this.detachEscape) == null ? void 0 : _a.call(this);
3027
4129
  this.detachEscape = void 0;
4130
+ (_b = this.detachTheme) == null ? void 0 : _b.call(this);
4131
+ this.detachTheme = void 0;
3028
4132
  if (!this.element) return;
3029
4133
  this.element.remove();
3030
4134
  this.element = null;
@@ -3047,7 +4151,7 @@ class TableMenu {
3047
4151
  const panel = document.createElement("div");
3048
4152
  panel.className = "atri-table-menu";
3049
4153
  panel.setAttribute("role", "toolbar");
3050
- panel.setAttribute("aria-label", this.t("table.menuLabel", "表格操作"));
4154
+ panel.setAttribute("aria-label", this.t("table.menuLabel"));
3051
4155
  panel.addEventListener("mousedown", (event) => event.preventDefault());
3052
4156
  let lastGroup = null;
3053
4157
  this.items.forEach((item) => {
@@ -3061,7 +4165,7 @@ class TableMenu {
3061
4165
  button.type = "button";
3062
4166
  button.className = "atri-editor-toolbar-btn";
3063
4167
  button.setAttribute("data-table-item", item.id);
3064
- button.title = this.t(item.labelKey, item.label);
4168
+ button.title = this.t(item.labelKey);
3065
4169
  button.innerHTML = item.icon;
3066
4170
  button.addEventListener("click", (event) => {
3067
4171
  event.preventDefault();
@@ -3072,16 +4176,16 @@ class TableMenu {
3072
4176
  });
3073
4177
  document.body.appendChild(panel);
3074
4178
  this.element = panel;
3075
- copyThemeClasses(this.root, panel);
4179
+ this.detachTheme = followThemeClasses(this.root, panel);
3076
4180
  this.detachEscape = this.listenEscape();
3077
4181
  }
3078
4182
  applyLabels() {
3079
4183
  const panel = this.element;
3080
4184
  if (!panel) return;
3081
- panel.setAttribute("aria-label", this.t("table.menuLabel", "表格操作"));
4185
+ panel.setAttribute("aria-label", this.t("table.menuLabel"));
3082
4186
  this.items.forEach((item) => {
3083
4187
  const button = panel.querySelector(`[data-table-item="${item.id}"]`);
3084
- if (button) button.title = this.t(item.labelKey, item.label);
4188
+ if (button) button.title = this.t(item.labelKey);
3085
4189
  });
3086
4190
  }
3087
4191
  updateStates() {
@@ -3133,15 +4237,13 @@ class TableMenu {
3133
4237
  ({ x, y } = await computePosition(reference, panel, {
3134
4238
  // 贴在单元格上沿:菜单跟着当前格走,长表格里也不会跑到视口外
3135
4239
  placement: "top-start",
3136
- middleware: [offset(6), flip(), shift({ padding: 8 })]
4240
+ middleware: panelMiddleware()
3137
4241
  }));
3138
4242
  } catch {
3139
4243
  return;
3140
4244
  }
3141
4245
  if (this.element !== panel) return;
3142
- panel.style.position = "fixed";
3143
- panel.style.left = `${x}px`;
3144
- panel.style.top = `${y}px`;
4246
+ placePanel(panel, x, y);
3145
4247
  }
3146
4248
  }
3147
4249
  class MediaStatusStrip {
@@ -3163,7 +4265,11 @@ class MediaStatusStrip {
3163
4265
  this.retry.type = "button";
3164
4266
  this.retry.className = "atri-media-status-retry";
3165
4267
  this.retry.hidden = true;
3166
- this.retry.addEventListener("click", () => void runtime.retryFailed());
4268
+ this.retry.addEventListener("click", () => {
4269
+ runtime.retryFailed().catch((error) => {
4270
+ console.error("[Atri Editor] Retrying the failed uploads did not work:", error);
4271
+ });
4272
+ });
3167
4273
  this.element.append(this.text, this.retry);
3168
4274
  container.appendChild(this.element);
3169
4275
  this.unsubscribe = runtime.subscribe((state) => this.render(state));
@@ -3195,6 +4301,15 @@ class MediaStatusStrip {
3195
4301
  this.retry.textContent = this.labels.retry();
3196
4302
  }
3197
4303
  }
4304
+ function requireResponse(response, stage) {
4305
+ if (!response || typeof response !== "object") {
4306
+ throw new TypeError(`AI ${stage} must resolve to an AIResponse object.`);
4307
+ }
4308
+ if (typeof response.content !== "string") {
4309
+ throw new TypeError(`AI ${stage} resolved with a non-string content.`);
4310
+ }
4311
+ return response;
4312
+ }
3198
4313
  class AIInterceptor {
3199
4314
  constructor(interceptors = {}, markdownService = null, autoTranslateMarkdown = true) {
3200
4315
  this.interceptors = interceptors;
@@ -3265,8 +4380,11 @@ class AIInterceptor {
3265
4380
  if (processedContext === false) {
3266
4381
  return;
3267
4382
  }
3268
- let response = await requestEndpoint(processedContext);
3269
- response = await this.runAfterPost(response, processedContext);
4383
+ let response = requireResponse(
4384
+ await requestEndpoint(processedContext),
4385
+ `requestEndpoint("${processedContext.functionId}")`
4386
+ );
4387
+ response = requireResponse(await this.runAfterPost(response, processedContext), "afterPost");
3270
4388
  let content = await this.runBeforeRender(response.content, processedContext);
3271
4389
  content = await this.runAutoTranslateMarkdown(content, response);
3272
4390
  insertCallback(content);
@@ -3353,22 +4471,42 @@ class AIService {
3353
4471
  * 执行 AI 功能
3354
4472
  */
3355
4473
  async execute(functionId) {
3356
- var _a, _b;
3357
4474
  const func = this.getFunction(functionId);
3358
4475
  if (!func) {
3359
4476
  console.error(`AI function "${functionId}" not found.`);
3360
4477
  return;
3361
4478
  }
3362
4479
  const context = this.buildContext(func);
4480
+ const timeout = this.config.timeout ?? 0;
4481
+ let timedOut = false;
4482
+ let timer;
4483
+ const chain = this.interceptor.executeChain(context, this.config.requestEndpoint, (content) => {
4484
+ if (timedOut) return;
4485
+ this.insertContent(content, func.outputMode || "insert");
4486
+ });
3363
4487
  try {
3364
- await this.interceptor.executeChain(
3365
- context,
3366
- this.config.requestEndpoint,
3367
- (content) => this.insertContent(content, func.outputMode || "insert")
3368
- );
4488
+ if (timeout > 0) {
4489
+ await Promise.race([
4490
+ chain,
4491
+ new Promise((_resolve, reject) => {
4492
+ timer = setTimeout(() => {
4493
+ timedOut = true;
4494
+ reject(new Error(`AI function "${functionId}" timed out after ${timeout}ms.`));
4495
+ }, timeout);
4496
+ })
4497
+ ]);
4498
+ } else {
4499
+ await chain;
4500
+ }
3369
4501
  } catch (error) {
3370
4502
  const err = error instanceof Error ? error : new Error(String(error));
3371
- (_b = (_a = this.config).onError) == null ? void 0 : _b.call(_a, err, context);
4503
+ if (this.config.onError) {
4504
+ runSafely(this.config.onError, "ai.onError", [err, context]);
4505
+ } else {
4506
+ console.error(`[Atri Editor] AI function "${functionId}" failed:`, err);
4507
+ }
4508
+ } finally {
4509
+ if (timer !== void 0) clearTimeout(timer);
3372
4510
  }
3373
4511
  }
3374
4512
  /**
@@ -3443,14 +4581,16 @@ class AICommandMenuManager {
3443
4581
  this.detachKeyListener = () => document.removeEventListener("keydown", handler, true);
3444
4582
  }
3445
4583
  setupKeyboardListener() {
3446
- this.editor.on("transaction", ({ transaction }) => {
4584
+ const handler = ({ transaction }) => {
3447
4585
  if (transaction.docChanged) {
3448
4586
  const lastChar = this.getLastTypedChar();
3449
4587
  if (lastChar === this.triggerChar && !this.isVisible) {
3450
4588
  this.show();
3451
4589
  }
3452
4590
  }
3453
- });
4591
+ };
4592
+ this.editor.on("transaction", handler);
4593
+ this.detachTransactionListener = () => this.editor.off("transaction", handler);
3454
4594
  }
3455
4595
  getLastTypedChar() {
3456
4596
  const { doc, selection } = this.editor.state;
@@ -3461,7 +4601,7 @@ class AICommandMenuManager {
3461
4601
  * 显示菜单
3462
4602
  */
3463
4603
  show() {
3464
- if (this.isVisible) return;
4604
+ if (this.isVisible || !this.functions.length) return;
3465
4605
  this.isVisible = true;
3466
4606
  this.selectedIndex = 0;
3467
4607
  this.createMenuElement();
@@ -3525,15 +4665,13 @@ class AICommandMenuManager {
3525
4665
  };
3526
4666
  ({ x, y } = await computePosition(virtualReference, menuElement, {
3527
4667
  placement: "bottom-start",
3528
- middleware: [offset(8), flip(), shift()]
4668
+ middleware: panelMiddleware()
3529
4669
  }));
3530
4670
  } catch {
3531
4671
  return;
3532
4672
  }
3533
4673
  if (this.menuElement !== menuElement) return;
3534
- menuElement.style.position = "fixed";
3535
- menuElement.style.left = `${x}px`;
3536
- menuElement.style.top = `${y}px`;
4674
+ placePanel(menuElement, x, y);
3537
4675
  }
3538
4676
  selectItem(index) {
3539
4677
  const func = this.functions[index];
@@ -3598,9 +4736,11 @@ class AICommandMenuManager {
3598
4736
  * 销毁
3599
4737
  */
3600
4738
  destroy() {
3601
- var _a;
4739
+ var _a, _b;
3602
4740
  (_a = this.detachKeyListener) == null ? void 0 : _a.call(this);
3603
4741
  this.detachKeyListener = void 0;
4742
+ (_b = this.detachTransactionListener) == null ? void 0 : _b.call(this);
4743
+ this.detachTransactionListener = void 0;
3604
4744
  this.hide();
3605
4745
  }
3606
4746
  }
@@ -3615,6 +4755,7 @@ class AtriEditor {
3615
4755
  this.mediaStatus = null;
3616
4756
  this.pendingViewState = null;
3617
4757
  this.bubbleElement = null;
4758
+ this.destroyed = false;
3618
4759
  this.options = options;
3619
4760
  const rootElement = resolveElement(options.element);
3620
4761
  this.container = createContainer("atri-editor");
@@ -3647,7 +4788,7 @@ class AtriEditor {
3647
4788
  return this.bubbleElement;
3648
4789
  }
3649
4790
  onEditorCreated() {
3650
- var _a, _b, _c;
4791
+ var _a, _b, _c, _d;
3651
4792
  if (this.pendingViewState) {
3652
4793
  const { from, to } = this.pendingViewState;
3653
4794
  this.pendingViewState = null;
@@ -3657,7 +4798,7 @@ class AtriEditor {
3657
4798
  to: Math.min(to, limit)
3658
4799
  });
3659
4800
  }
3660
- if ((_a = this.options.ai) == null ? void 0 : _a.functions) {
4801
+ if ((_b = (_a = this.options.ai) == null ? void 0 : _a.functions) == null ? void 0 : _b.length) {
3661
4802
  this.aiMenuManager = new AICommandMenuManager(
3662
4803
  this.editor,
3663
4804
  this.options.ai.functions,
@@ -3668,7 +4809,7 @@ class AtriEditor {
3668
4809
  }
3669
4810
  );
3670
4811
  }
3671
- (_c = (_b = this.options).onCreate) == null ? void 0 : _c.call(_b, this);
4812
+ (_d = (_c = this.options).onCreate) == null ? void 0 : _d.call(_c, this);
3672
4813
  }
3673
4814
  initAI(config) {
3674
4815
  this.aiService = new AIService(this.editor, config, this.markdownService);
@@ -3690,6 +4831,8 @@ class AtriEditor {
3690
4831
  media: this.options.media,
3691
4832
  table: this.options.table,
3692
4833
  link: this.options.link,
4834
+ formatting: this.options.formatting,
4835
+ codeBlock: this.options.codeBlock,
3693
4836
  mediaRuntime: this.mediaRuntime ?? void 0,
3694
4837
  bubbleElement: this.ensureBubbleElement(this.options.toolbar),
3695
4838
  onCreate: () => {
@@ -4023,6 +5166,7 @@ class AtriEditor {
4023
5166
  * 连续注册多个组件请改用 registerNodeViews,只需重建一次
4024
5167
  */
4025
5168
  registerNodeView(config) {
5169
+ if (this.refuseAfterDestroy("registerNodeView")) return;
4026
5170
  this.extensionManager.registerNodeView(config);
4027
5171
  if (this.coreEditor) {
4028
5172
  this.recreateEditor();
@@ -4033,11 +5177,21 @@ class AtriEditor {
4033
5177
  * 注意:如果编辑器已创建,会重新创建编辑器以更新 schema
4034
5178
  */
4035
5179
  registerNodeViews(configs) {
5180
+ if (this.refuseAfterDestroy("registerNodeViews")) return;
4036
5181
  this.extensionManager.registerNodeViews(configs);
4037
5182
  if (this.coreEditor) {
4038
5183
  this.recreateEditor();
4039
5184
  }
4040
5185
  }
5186
+ /**
5187
+ * 销毁后重建类 API 的统一守卫:判在改状态之前,
5188
+ * 否则配置进了 extensionManager 却没有对应的编辑器,看起来就像"注册了但没生效"
5189
+ */
5190
+ refuseAfterDestroy(api) {
5191
+ if (!this.destroyed) return false;
5192
+ console.warn(`[Atri Editor] ${api}() was called on a destroyed editor and did nothing.`);
5193
+ return true;
5194
+ }
4041
5195
  /**
4042
5196
  * 获取所有已注册的 NodeView
4043
5197
  */
@@ -4049,6 +5203,8 @@ class AtriEditor {
4049
5203
  */
4050
5204
  destroy() {
4051
5205
  var _a, _b, _c, _d, _e, _f;
5206
+ if (this.destroyed) return;
5207
+ this.destroyed = true;
4052
5208
  (_a = this.toolbarManager) == null ? void 0 : _a.destroy();
4053
5209
  (_b = this.tableMenu) == null ? void 0 : _b.destroy();
4054
5210
  (_c = this.linkPanel) == null ? void 0 : _c.destroy();
@@ -4077,15 +5233,41 @@ class AtriEditorElement extends AtriEditorBase {
4077
5233
  super();
4078
5234
  this.editor = null;
4079
5235
  this._options = {};
5236
+ this.teardownToken = 0;
5237
+ this.detachedContent = null;
5238
+ this.destroyed = false;
4080
5239
  }
4081
5240
  static get observedAttributes() {
4082
5241
  return ["theme", "editable", "lang", "placeholder"];
4083
5242
  }
4084
5243
  connectedCallback() {
5244
+ this.teardownToken += 1;
5245
+ if (this.destroyed) return;
4085
5246
  this.initEditor();
4086
5247
  }
4087
5248
  disconnectedCallback() {
4088
- this.destroyEditor();
5249
+ const token = this.teardownToken;
5250
+ queueMicrotask(() => {
5251
+ if (token === this.teardownToken && !this.isConnected) this.teardownEditor();
5252
+ });
5253
+ }
5254
+ /**
5255
+ * 显式销毁:之后重挂不再自动重建,内容快照也一并丢掉
5256
+ *
5257
+ * 框架里对应 unmount 钩子(Vue 的 unmounted、React effect 的 cleanup)。
5258
+ * 断连本身已经会延迟拆除,这个方法的意义在于"别接回去了"
5259
+ */
5260
+ destroy() {
5261
+ this.destroyed = true;
5262
+ this.teardownToken += 1;
5263
+ this.detachedContent = null;
5264
+ this.teardownEditor();
5265
+ }
5266
+ teardownEditor() {
5267
+ if (!this.editor) return;
5268
+ if (!this.destroyed) this.detachedContent = this.editor.getHTML();
5269
+ this.editor.destroy();
5270
+ this.editor = null;
4089
5271
  }
4090
5272
  attributeChangedCallback(name, oldValue, newValue) {
4091
5273
  if (oldValue === newValue) return;
@@ -4117,6 +5299,7 @@ class AtriEditorElement extends AtriEditorBase {
4117
5299
  */
4118
5300
  setOptions(options) {
4119
5301
  this._options = { ...this._options, ...options };
5302
+ if ("content" in options) this.detachedContent = null;
4120
5303
  }
4121
5304
  initEditor() {
4122
5305
  if (this.editor) return;
@@ -4136,6 +5319,11 @@ class AtriEditorElement extends AtriEditorBase {
4136
5319
  if (content) options.content = content;
4137
5320
  const contentFormat = this.getAttribute("data-content-format");
4138
5321
  if (contentFormat) options.contentFormat = contentFormat;
5322
+ if (this.detachedContent !== null) {
5323
+ options.content = this.detachedContent;
5324
+ options.contentFormat = "html";
5325
+ this.detachedContent = null;
5326
+ }
4139
5327
  this.editor = new AtriEditor(options);
4140
5328
  this.dispatchEvent(
4141
5329
  new CustomEvent("editor-created", {
@@ -4144,12 +5332,6 @@ class AtriEditorElement extends AtriEditorBase {
4144
5332
  })
4145
5333
  );
4146
5334
  }
4147
- destroyEditor() {
4148
- if (this.editor) {
4149
- this.editor.destroy();
4150
- this.editor = null;
4151
- }
4152
- }
4153
5335
  /**
4154
5336
  * 获取编辑器实例
4155
5337
  */
@@ -4258,6 +5440,7 @@ export {
4258
5440
  ThemeManager,
4259
5441
  ToolbarManager,
4260
5442
  createContainer,
5443
+ createFormattingExtensions,
4261
5444
  createMediaExtensions,
4262
5445
  getCursorContext,
4263
5446
  getDocumentText,