@veltra/desktop 1.8.2 → 1.8.3

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.
@@ -7,33 +7,41 @@ function nextImageId() {
7
7
  /**
8
8
  * 图片节点:插入时 src 为本地 objectURL、__file 持有原始文件,提交前由宿主调用
9
9
  * uploadImages 换成服务器地址。__file 不参与序列化,仅会话内有效。
10
+ * width / height 为显示尺寸(px),0 表示未设置、按图片自然尺寸(受 max-width 约束)。
10
11
  * 本仓库为手写 Vue 绑定(无 React 适配层),不走 decorate 渲染管道,
11
- * createDOM 直接返回 <img> 元素。
12
+ * createDOM 返回 wrapper:`<span><img><span 手柄/></span>`,选中与拖拽缩放
13
+ * 由组件层(use-image-resize)基于 data-rte-image-key 驱动。
12
14
  */
13
15
  var ImageNode = class ImageNode extends DecoratorNode {
14
16
  __src;
15
17
  __alt;
16
18
  __id;
17
19
  __file;
20
+ __width;
21
+ __height;
18
22
  static getType() {
19
23
  return "u-image";
20
24
  }
21
25
  static clone(node) {
22
- return new ImageNode(node.__src, node.__alt, node.__id, node.__file, node.__key);
26
+ return new ImageNode(node.__src, node.__alt, node.__id, node.__file, node.__width, node.__height, node.__key);
23
27
  }
24
28
  static importJSON(serializedNode) {
25
29
  return $createImageNode({
26
30
  src: serializedNode.src,
27
31
  alt: serializedNode.alt,
28
- id: serializedNode.id
32
+ id: serializedNode.id,
33
+ width: serializedNode.width,
34
+ height: serializedNode.height
29
35
  });
30
36
  }
31
- constructor(src, alt = "", id = nextImageId(), file = null, key) {
37
+ constructor(src, alt = "", id = nextImageId(), file = null, width = 0, height = 0, key) {
32
38
  super(key);
33
39
  this.__src = src;
34
40
  this.__alt = alt;
35
41
  this.__id = id;
36
42
  this.__file = file;
43
+ this.__width = width;
44
+ this.__height = height;
37
45
  }
38
46
  static importDOM() {
39
47
  return { img: () => ({
@@ -47,6 +55,8 @@ var ImageNode = class ImageNode extends DecoratorNode {
47
55
  element.setAttribute("class", "u-rte-image");
48
56
  element.setAttribute("alt", this.__alt);
49
57
  element.setAttribute("data-rte-image-id", this.__id);
58
+ if (this.__width) element.setAttribute("width", String(this.__width));
59
+ if (this.__height) element.setAttribute("height", String(this.__height));
50
60
  return { element };
51
61
  }
52
62
  exportJSON() {
@@ -55,21 +65,42 @@ var ImageNode = class ImageNode extends DecoratorNode {
55
65
  version: 1,
56
66
  src: this.__src,
57
67
  alt: this.__alt,
58
- id: this.__id
68
+ id: this.__id,
69
+ width: this.__width,
70
+ height: this.__height
59
71
  };
60
72
  }
61
73
  createDOM() {
62
- const element = document.createElement("img");
63
- element.className = "u-rte-image";
64
- element.src = this.__src;
65
- element.alt = this.__alt;
66
- element.draggable = false;
67
- return element;
74
+ const wrapper = document.createElement("span");
75
+ wrapper.className = "u-rte-image-wrap";
76
+ wrapper.contentEditable = "false";
77
+ wrapper.setAttribute("data-rte-image-key", this.__key);
78
+ const img = document.createElement("img");
79
+ img.className = "u-rte-image";
80
+ img.src = this.__src;
81
+ img.alt = this.__alt;
82
+ img.draggable = false;
83
+ if (this.__width) img.style.width = `${this.__width}px`;
84
+ if (this.__height) img.style.height = `${this.__height}px`;
85
+ const handle = document.createElement("span");
86
+ handle.className = "u-rte-image-resizer";
87
+ handle.setAttribute("aria-hidden", "true");
88
+ wrapper.append(img, handle);
89
+ return wrapper;
68
90
  }
69
91
  updateDOM(prevNode, element) {
70
- const img = element;
92
+ const img = element.querySelector("img");
93
+ if (!img) return false;
71
94
  if (prevNode.__src !== this.__src) img.src = this.__src;
72
95
  if (prevNode.__alt !== this.__alt) img.alt = this.__alt;
96
+ if (prevNode.__width !== this.__width) {
97
+ if (this.__width) img.style.width = `${this.__width}px`;
98
+ else img.style.removeProperty("width");
99
+ }
100
+ if (prevNode.__height !== this.__height) {
101
+ if (this.__height) img.style.height = `${this.__height}px`;
102
+ else img.style.removeProperty("height");
103
+ }
73
104
  return false;
74
105
  }
75
106
  /** Vue 环境无 React 渲染管道,装饰内容即 createDOM 返回的元素本身 */
@@ -88,6 +119,12 @@ var ImageNode = class ImageNode extends DecoratorNode {
88
119
  self.__src = src;
89
120
  self.__file = null;
90
121
  }
122
+ /** 写入显示尺寸(拖拽缩放结束时调用),px,0 表示恢复自然尺寸 */
123
+ setWidthAndHeight(width, height) {
124
+ const self = this.getWritable();
125
+ self.__width = width;
126
+ self.__height = height;
127
+ }
91
128
  };
92
129
  function convertImageElement(domNode) {
93
130
  const src = domNode.getAttribute("src");
@@ -95,11 +132,13 @@ function convertImageElement(domNode) {
95
132
  return { node: $createImageNode({
96
133
  src,
97
134
  alt: domNode.getAttribute("alt") ?? "",
98
- id: domNode.getAttribute("data-rte-image-id") ?? void 0
135
+ id: domNode.getAttribute("data-rte-image-id") ?? void 0,
136
+ width: parseInt(domNode.getAttribute("width") ?? "") || 0,
137
+ height: parseInt(domNode.getAttribute("height") ?? "") || 0
99
138
  }) };
100
139
  }
101
140
  function $createImageNode(options) {
102
- return new ImageNode(options.src, options.alt ?? "", options.id ?? nextImageId(), options.file ?? null);
141
+ return new ImageNode(options.src, options.alt ?? "", options.id ?? nextImageId(), options.file ?? null, options.width ?? 0, options.height ?? 0);
103
142
  }
104
143
  function $isImageNode(node) {
105
144
  return node instanceof ImageNode;
@@ -1 +1 @@
1
- {"version":3,"file":"image-node.js","names":[],"sources":["../../../src/components/rich-text-editor/image-node.ts"],"sourcesContent":["import {\n $insertNodes,\n DecoratorNode,\n type DOMConversionMap,\n type DOMConversionOutput,\n type DOMExportOutput,\n type LexicalEditor,\n type LexicalNode,\n type NodeKey,\n type SerializedLexicalNode,\n type Spread\n} from 'lexical'\n\nlet imageUid = 0\n\nfunction nextImageId(): string {\n return `rte-img-${++imageUid}`\n}\n\nexport interface SerializedImageNode extends Spread<\n { src: string; alt: string; id: string },\n SerializedLexicalNode\n> {}\n\n/**\n * 图片节点:插入时 src 为本地 objectURL、__file 持有原始文件,提交前由宿主调用\n * uploadImages 换成服务器地址。__file 不参与序列化,仅会话内有效。\n * 本仓库为手写 Vue 绑定(无 React 适配层),不走 decorate 渲染管道,\n * createDOM 直接返回 <img> 元素。\n */\nexport class ImageNode extends DecoratorNode<void> {\n __src: string\n __alt: string\n __id: string\n __file: File | null\n\n static override getType(): string {\n return 'u-image'\n }\n\n static override clone(node: ImageNode): ImageNode {\n return new ImageNode(node.__src, node.__alt, node.__id, node.__file, node.__key)\n }\n\n static override importJSON(serializedNode: SerializedImageNode): ImageNode {\n return $createImageNode({\n src: serializedNode.src,\n alt: serializedNode.alt,\n id: serializedNode.id\n })\n }\n\n constructor(src: string, alt = '', id = nextImageId(), file: File | null = null, key?: NodeKey) {\n super(key)\n this.__src = src\n this.__alt = alt\n this.__id = id\n this.__file = file\n }\n\n static override importDOM(): DOMConversionMap | null {\n return { img: () => ({ conversion: convertImageElement, priority: 0 }) }\n }\n\n override exportDOM(): DOMExportOutput {\n const element = document.createElement('img')\n element.setAttribute('src', this.__src)\n element.setAttribute('class', 'u-rte-image')\n element.setAttribute('alt', this.__alt)\n element.setAttribute('data-rte-image-id', this.__id)\n return { element }\n }\n\n override exportJSON(): SerializedImageNode {\n return { type: 'u-image', version: 1, src: this.__src, alt: this.__alt, id: this.__id }\n }\n\n override createDOM(): HTMLElement {\n const element = document.createElement('img')\n element.className = 'u-rte-image'\n element.src = this.__src\n element.alt = this.__alt\n element.draggable = false\n return element\n }\n\n override updateDOM(prevNode: ImageNode, element: HTMLElement): boolean {\n const img = element as HTMLImageElement\n if (prevNode.__src !== this.__src) img.src = this.__src\n if (prevNode.__alt !== this.__alt) img.alt = this.__alt\n return false\n }\n\n /** Vue 环境无 React 渲染管道,装饰内容即 createDOM 返回的元素本身 */\n override decorate(): null {\n return null\n }\n\n getSrc(): string {\n return this.__src\n }\n\n getFile(): File | null {\n return this.__file\n }\n\n /** 替换为服务器地址,同时清除待上传标记(已上传的节点不再重复上传) */\n setSrc(src: string): void {\n const self = this.getWritable()\n self.__src = src\n self.__file = null\n }\n}\n\nfunction convertImageElement(domNode: HTMLElement): DOMConversionOutput {\n const src = domNode.getAttribute('src')\n if (!src) return { node: null }\n return {\n node: $createImageNode({\n src,\n alt: domNode.getAttribute('alt') ?? '',\n id: domNode.getAttribute('data-rte-image-id') ?? undefined\n })\n }\n}\n\nexport function $createImageNode(options: {\n src: string\n alt?: string\n id?: string\n file?: File | null\n}): ImageNode {\n return new ImageNode(\n options.src,\n options.alt ?? '',\n options.id ?? nextImageId(),\n options.file ?? null\n )\n}\n\nexport function $isImageNode(node: LexicalNode | null | undefined): node is ImageNode {\n return node instanceof ImageNode\n}\n\n// 各编辑器实例创建的图片 objectURL,用于节点删除 / 组件卸载时回收\nconst objectUrls = new WeakMap<LexicalEditor, Set<string>>()\n\nfunction getImageObjectUrls(editor: LexicalEditor): Set<string> {\n let urls = objectUrls.get(editor)\n if (!urls) {\n urls = new Set()\n objectUrls.set(editor, urls)\n }\n return urls\n}\n\n/** 插入图片文件:objectURL 即时预览,File 挂在节点上等待提交时上传。返回插入的图片数量 */\nexport function insertImageFiles(editor: LexicalEditor, files: File[] | FileList): number {\n const images = Array.from(files).filter((file) => file.type.startsWith('image/'))\n if (!images.length) return 0\n\n const urls = getImageObjectUrls(editor)\n\n editor.update(() => {\n const nodes = images.map((file) => {\n const url = URL.createObjectURL(file)\n urls.add(url)\n return $createImageNode({ src: url, alt: file.name, file })\n })\n $insertNodes(nodes)\n })\n\n return images.length\n}\n\n/** 回收已从内容中移除的图片 objectURL,aliveUrls 为当前内容中仍存活的图片地址 */\nexport function gcImageObjectUrls(editor: LexicalEditor, aliveUrls: Set<string>): void {\n const urls = objectUrls.get(editor)\n if (!urls?.size) return\n urls.forEach((url) => {\n if (!aliveUrls.has(url)) {\n URL.revokeObjectURL(url)\n urls.delete(url)\n }\n })\n}\n\n/** 回收该编辑器创建的全部图片 objectURL(组件卸载时调用) */\nexport function revokeImageObjectUrls(editor: LexicalEditor): void {\n const urls = objectUrls.get(editor)\n urls?.forEach((url) => URL.revokeObjectURL(url))\n objectUrls.delete(editor)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"image-node.js","names":[],"sources":["../../../src/components/rich-text-editor/image-node.ts"],"sourcesContent":["import {\n $insertNodes,\n DecoratorNode,\n type DOMConversionMap,\n type DOMConversionOutput,\n type DOMExportOutput,\n type LexicalEditor,\n type LexicalNode,\n type NodeKey,\n type SerializedLexicalNode,\n type Spread\n} from 'lexical'\n\nlet imageUid = 0\n\nfunction nextImageId(): string {\n return `rte-img-${++imageUid}`\n}\n\nexport interface SerializedImageNode extends Spread<\n { src: string; alt: string; id: string; width: number; height: number },\n SerializedLexicalNode\n> {}\n\n/**\n * 图片节点:插入时 src 为本地 objectURL、__file 持有原始文件,提交前由宿主调用\n * uploadImages 换成服务器地址。__file 不参与序列化,仅会话内有效。\n * width / height 为显示尺寸(px),0 表示未设置、按图片自然尺寸(受 max-width 约束)。\n * 本仓库为手写 Vue 绑定(无 React 适配层),不走 decorate 渲染管道,\n * createDOM 返回 wrapper:`<span><img><span 手柄/></span>`,选中与拖拽缩放\n * 由组件层(use-image-resize)基于 data-rte-image-key 驱动。\n */\nexport class ImageNode extends DecoratorNode<void> {\n __src: string\n __alt: string\n __id: string\n __file: File | null\n __width: number\n __height: number\n\n static override getType(): string {\n return 'u-image'\n }\n\n static override clone(node: ImageNode): ImageNode {\n return new ImageNode(\n node.__src,\n node.__alt,\n node.__id,\n node.__file,\n node.__width,\n node.__height,\n node.__key\n )\n }\n\n static override importJSON(serializedNode: SerializedImageNode): ImageNode {\n return $createImageNode({\n src: serializedNode.src,\n alt: serializedNode.alt,\n id: serializedNode.id,\n width: serializedNode.width,\n height: serializedNode.height\n })\n }\n\n constructor(\n src: string,\n alt = '',\n id = nextImageId(),\n file: File | null = null,\n width = 0,\n height = 0,\n key?: NodeKey\n ) {\n super(key)\n this.__src = src\n this.__alt = alt\n this.__id = id\n this.__file = file\n this.__width = width\n this.__height = height\n }\n\n static override importDOM(): DOMConversionMap | null {\n return { img: () => ({ conversion: convertImageElement, priority: 0 }) }\n }\n\n override exportDOM(): DOMExportOutput {\n const element = document.createElement('img')\n element.setAttribute('src', this.__src)\n element.setAttribute('class', 'u-rte-image')\n element.setAttribute('alt', this.__alt)\n element.setAttribute('data-rte-image-id', this.__id)\n if (this.__width) element.setAttribute('width', String(this.__width))\n if (this.__height) element.setAttribute('height', String(this.__height))\n return { element }\n }\n\n override exportJSON(): SerializedImageNode {\n return {\n type: 'u-image',\n version: 1,\n src: this.__src,\n alt: this.__alt,\n id: this.__id,\n width: this.__width,\n height: this.__height\n }\n }\n\n override createDOM(): HTMLElement {\n const wrapper = document.createElement('span')\n wrapper.className = 'u-rte-image-wrap'\n wrapper.contentEditable = 'false'\n wrapper.setAttribute('data-rte-image-key', this.__key)\n\n const img = document.createElement('img')\n img.className = 'u-rte-image'\n img.src = this.__src\n img.alt = this.__alt\n img.draggable = false\n if (this.__width) img.style.width = `${this.__width}px`\n if (this.__height) img.style.height = `${this.__height}px`\n\n const handle = document.createElement('span')\n handle.className = 'u-rte-image-resizer'\n handle.setAttribute('aria-hidden', 'true')\n\n wrapper.append(img, handle)\n return wrapper\n }\n\n override updateDOM(prevNode: ImageNode, element: HTMLElement): boolean {\n const img = element.querySelector('img')\n if (!img) return false\n if (prevNode.__src !== this.__src) img.src = this.__src\n if (prevNode.__alt !== this.__alt) img.alt = this.__alt\n if (prevNode.__width !== this.__width) {\n if (this.__width) img.style.width = `${this.__width}px`\n else img.style.removeProperty('width')\n }\n if (prevNode.__height !== this.__height) {\n if (this.__height) img.style.height = `${this.__height}px`\n else img.style.removeProperty('height')\n }\n return false\n }\n\n /** Vue 环境无 React 渲染管道,装饰内容即 createDOM 返回的元素本身 */\n override decorate(): null {\n return null\n }\n\n getSrc(): string {\n return this.__src\n }\n\n getFile(): File | null {\n return this.__file\n }\n\n /** 替换为服务器地址,同时清除待上传标记(已上传的节点不再重复上传) */\n setSrc(src: string): void {\n const self = this.getWritable()\n self.__src = src\n self.__file = null\n }\n\n /** 写入显示尺寸(拖拽缩放结束时调用),px,0 表示恢复自然尺寸 */\n setWidthAndHeight(width: number, height: number): void {\n const self = this.getWritable()\n self.__width = width\n self.__height = height\n }\n}\n\nfunction convertImageElement(domNode: HTMLElement): DOMConversionOutput {\n const src = domNode.getAttribute('src')\n if (!src) return { node: null }\n return {\n node: $createImageNode({\n src,\n alt: domNode.getAttribute('alt') ?? '',\n id: domNode.getAttribute('data-rte-image-id') ?? undefined,\n width: parseInt(domNode.getAttribute('width') ?? '') || 0,\n height: parseInt(domNode.getAttribute('height') ?? '') || 0\n })\n }\n}\n\nexport function $createImageNode(options: {\n src: string\n alt?: string\n id?: string\n file?: File | null\n width?: number\n height?: number\n}): ImageNode {\n return new ImageNode(\n options.src,\n options.alt ?? '',\n options.id ?? nextImageId(),\n options.file ?? null,\n options.width ?? 0,\n options.height ?? 0\n )\n}\n\nexport function $isImageNode(node: LexicalNode | null | undefined): node is ImageNode {\n return node instanceof ImageNode\n}\n\n// 各编辑器实例创建的图片 objectURL,用于节点删除 / 组件卸载时回收\nconst objectUrls = new WeakMap<LexicalEditor, Set<string>>()\n\nfunction getImageObjectUrls(editor: LexicalEditor): Set<string> {\n let urls = objectUrls.get(editor)\n if (!urls) {\n urls = new Set()\n objectUrls.set(editor, urls)\n }\n return urls\n}\n\n/** 插入图片文件:objectURL 即时预览,File 挂在节点上等待提交时上传。返回插入的图片数量 */\nexport function insertImageFiles(editor: LexicalEditor, files: File[] | FileList): number {\n const images = Array.from(files).filter((file) => file.type.startsWith('image/'))\n if (!images.length) return 0\n\n const urls = getImageObjectUrls(editor)\n\n editor.update(() => {\n const nodes = images.map((file) => {\n const url = URL.createObjectURL(file)\n urls.add(url)\n return $createImageNode({ src: url, alt: file.name, file })\n })\n $insertNodes(nodes)\n })\n\n return images.length\n}\n\n/** 回收已从内容中移除的图片 objectURL,aliveUrls 为当前内容中仍存活的图片地址 */\nexport function gcImageObjectUrls(editor: LexicalEditor, aliveUrls: Set<string>): void {\n const urls = objectUrls.get(editor)\n if (!urls?.size) return\n urls.forEach((url) => {\n if (!aliveUrls.has(url)) {\n URL.revokeObjectURL(url)\n urls.delete(url)\n }\n })\n}\n\n/** 回收该编辑器创建的全部图片 objectURL(组件卸载时调用) */\nexport function revokeImageObjectUrls(editor: LexicalEditor): void {\n const urls = objectUrls.get(editor)\n urls?.forEach((url) => URL.revokeObjectURL(url))\n objectUrls.delete(editor)\n}\n"],"mappings":";;AAaA,IAAI,WAAW;AAEf,SAAS,cAAsB;CAC7B,OAAO,WAAW,EAAE;AACtB;;;;;;;;;AAeA,IAAa,YAAb,MAAa,kBAAkB,cAAoB;CACjD;CACA;CACA;CACA;CACA;CACA;CAEA,OAAgB,UAAkB;EAChC,OAAO;CACT;CAEA,OAAgB,MAAM,MAA4B;EAChD,OAAO,IAAI,UACT,KAAK,OACL,KAAK,OACL,KAAK,MACL,KAAK,QACL,KAAK,SACL,KAAK,UACL,KAAK,KACP;CACF;CAEA,OAAgB,WAAW,gBAAgD;EACzE,OAAO,iBAAiB;GACtB,KAAK,eAAe;GACpB,KAAK,eAAe;GACpB,IAAI,eAAe;GACnB,OAAO,eAAe;GACtB,QAAQ,eAAe;EACzB,CAAC;CACH;CAEA,YACE,KACA,MAAM,IACN,KAAK,YAAY,GACjB,OAAoB,MACpB,QAAQ,GACR,SAAS,GACT,KACA;EACA,MAAM,GAAG;EACT,KAAK,QAAQ;EACb,KAAK,QAAQ;EACb,KAAK,OAAO;EACZ,KAAK,SAAS;EACd,KAAK,UAAU;EACf,KAAK,WAAW;CAClB;CAEA,OAAgB,YAAqC;EACnD,OAAO,EAAE,YAAY;GAAE,YAAY;GAAqB,UAAU;EAAE,GAAG;CACzE;CAEA,YAAsC;EACpC,MAAM,UAAU,SAAS,cAAc,KAAK;EAC5C,QAAQ,aAAa,OAAO,KAAK,KAAK;EACtC,QAAQ,aAAa,SAAS,aAAa;EAC3C,QAAQ,aAAa,OAAO,KAAK,KAAK;EACtC,QAAQ,aAAa,qBAAqB,KAAK,IAAI;EACnD,IAAI,KAAK,SAAS,QAAQ,aAAa,SAAS,OAAO,KAAK,OAAO,CAAC;EACpE,IAAI,KAAK,UAAU,QAAQ,aAAa,UAAU,OAAO,KAAK,QAAQ,CAAC;EACvE,OAAO,EAAE,QAAQ;CACnB;CAEA,aAA2C;EACzC,OAAO;GACL,MAAM;GACN,SAAS;GACT,KAAK,KAAK;GACV,KAAK,KAAK;GACV,IAAI,KAAK;GACT,OAAO,KAAK;GACZ,QAAQ,KAAK;EACf;CACF;CAEA,YAAkC;EAChC,MAAM,UAAU,SAAS,cAAc,MAAM;EAC7C,QAAQ,YAAY;EACpB,QAAQ,kBAAkB;EAC1B,QAAQ,aAAa,sBAAsB,KAAK,KAAK;EAErD,MAAM,MAAM,SAAS,cAAc,KAAK;EACxC,IAAI,YAAY;EAChB,IAAI,MAAM,KAAK;EACf,IAAI,MAAM,KAAK;EACf,IAAI,YAAY;EAChB,IAAI,KAAK,SAAS,IAAI,MAAM,QAAQ,GAAG,KAAK,QAAQ;EACpD,IAAI,KAAK,UAAU,IAAI,MAAM,SAAS,GAAG,KAAK,SAAS;EAEvD,MAAM,SAAS,SAAS,cAAc,MAAM;EAC5C,OAAO,YAAY;EACnB,OAAO,aAAa,eAAe,MAAM;EAEzC,QAAQ,OAAO,KAAK,MAAM;EAC1B,OAAO;CACT;CAEA,UAAmB,UAAqB,SAA+B;EACrE,MAAM,MAAM,QAAQ,cAAc,KAAK;EACvC,IAAI,CAAC,KAAK,OAAO;EACjB,IAAI,SAAS,UAAU,KAAK,OAAO,IAAI,MAAM,KAAK;EAClD,IAAI,SAAS,UAAU,KAAK,OAAO,IAAI,MAAM,KAAK;EAClD,IAAI,SAAS,YAAY,KAAK,SAAS;GACrC,IAAI,KAAK,SAAS,IAAI,MAAM,QAAQ,GAAG,KAAK,QAAQ;QAC/C,IAAI,MAAM,eAAe,OAAO;EACvC;EACA,IAAI,SAAS,aAAa,KAAK,UAAU;GACvC,IAAI,KAAK,UAAU,IAAI,MAAM,SAAS,GAAG,KAAK,SAAS;QAClD,IAAI,MAAM,eAAe,QAAQ;EACxC;EACA,OAAO;CACT;;CAGA,WAA0B;EACxB,OAAO;CACT;CAEA,SAAiB;EACf,OAAO,KAAK;CACd;CAEA,UAAuB;EACrB,OAAO,KAAK;CACd;;CAGA,OAAO,KAAmB;EACxB,MAAM,OAAO,KAAK,YAAY;EAC9B,KAAK,QAAQ;EACb,KAAK,SAAS;CAChB;;CAGA,kBAAkB,OAAe,QAAsB;EACrD,MAAM,OAAO,KAAK,YAAY;EAC9B,KAAK,UAAU;EACf,KAAK,WAAW;CAClB;AACF;AAEA,SAAS,oBAAoB,SAA2C;CACtE,MAAM,MAAM,QAAQ,aAAa,KAAK;CACtC,IAAI,CAAC,KAAK,OAAO,EAAE,MAAM,KAAK;CAC9B,OAAO,EACL,MAAM,iBAAiB;EACrB;EACA,KAAK,QAAQ,aAAa,KAAK,KAAK;EACpC,IAAI,QAAQ,aAAa,mBAAmB,KAAK,KAAA;EACjD,OAAO,SAAS,QAAQ,aAAa,OAAO,KAAK,EAAE,KAAK;EACxD,QAAQ,SAAS,QAAQ,aAAa,QAAQ,KAAK,EAAE,KAAK;CAC5D,CAAC,EACH;AACF;AAEA,SAAgB,iBAAiB,SAOnB;CACZ,OAAO,IAAI,UACT,QAAQ,KACR,QAAQ,OAAO,IACf,QAAQ,MAAM,YAAY,GAC1B,QAAQ,QAAQ,MAChB,QAAQ,SAAS,GACjB,QAAQ,UAAU,CACpB;AACF;AAEA,SAAgB,aAAa,MAAyD;CACpF,OAAO,gBAAgB;AACzB;AAGA,MAAM,6BAAa,IAAI,QAAoC;AAE3D,SAAS,mBAAmB,QAAoC;CAC9D,IAAI,OAAO,WAAW,IAAI,MAAM;CAChC,IAAI,CAAC,MAAM;EACT,uBAAO,IAAI,IAAI;EACf,WAAW,IAAI,QAAQ,IAAI;CAC7B;CACA,OAAO;AACT;;AAGA,SAAgB,iBAAiB,QAAuB,OAAkC;CACxF,MAAM,SAAS,MAAM,KAAK,KAAK,CAAC,CAAC,QAAQ,SAAS,KAAK,KAAK,WAAW,QAAQ,CAAC;CAChF,IAAI,CAAC,OAAO,QAAQ,OAAO;CAE3B,MAAM,OAAO,mBAAmB,MAAM;CAEtC,OAAO,aAAa;EAClB,MAAM,QAAQ,OAAO,KAAK,SAAS;GACjC,MAAM,MAAM,IAAI,gBAAgB,IAAI;GACpC,KAAK,IAAI,GAAG;GACZ,OAAO,iBAAiB;IAAE,KAAK;IAAK,KAAK,KAAK;IAAM;GAAK,CAAC;EAC5D,CAAC;EACD,aAAa,KAAK;CACpB,CAAC;CAED,OAAO,OAAO;AAChB;;AAGA,SAAgB,kBAAkB,QAAuB,WAA8B;CACrF,MAAM,OAAO,WAAW,IAAI,MAAM;CAClC,IAAI,CAAC,MAAM,MAAM;CACjB,KAAK,SAAS,QAAQ;EACpB,IAAI,CAAC,UAAU,IAAI,GAAG,GAAG;GACvB,IAAI,gBAAgB,GAAG;GACvB,KAAK,OAAO,GAAG;EACjB;CACF,CAAC;AACH;;AAGA,SAAgB,sBAAsB,QAA6B;CAEjE,WADwB,IAAI,MACzB,CAAC,EAAE,SAAS,QAAQ,IAAI,gBAAgB,GAAG,CAAC;CAC/C,WAAW,OAAO,MAAM;AAC1B"}
@@ -1,5 +1,6 @@
1
1
  import { $isImageNode, ImageNode, gcImageObjectUrls, insertImageFiles, revokeImageObjectUrls } from "./image-node.js";
2
2
  import _sfc_main$1 from "./toolbar.js";
3
+ import { useImageResize } from "./use-image-resize.js";
3
4
  import { computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, defineComponent, mergeModels, normalizeClass, onBeforeUnmount, onMounted, openBlock, ref, shallowRef, toDisplayString, unref, useModel, useTemplateRef, watch } from "vue";
4
5
  import { useFormFallbackProps } from "@veltra/compositions";
5
6
  import { bem, injectFormContext } from "@veltra/utils";
@@ -8,7 +9,7 @@ import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html";
8
9
  import { AutoLinkNode, LinkNode } from "@lexical/link";
9
10
  import { ListItemNode, ListNode, registerList } from "@lexical/list";
10
11
  import { HeadingNode, QuoteNode, registerRichText } from "@lexical/rich-text";
11
- import { $createParagraphNode, $createRangeSelection, $getNodeByKey, $getRoot, $nodesOfType, $setSelection, COMMAND_PRIORITY_HIGH, DROP_COMMAND, PASTE_COMMAND, createEditor } from "lexical";
12
+ import { $createParagraphNode, $createRangeSelection, $getNodeByKey, $getRoot, $getSelection, $isNodeSelection, $nodesOfType, $setSelection, COMMAND_PRIORITY_HIGH, DROP_COMMAND, PASTE_COMMAND, createEditor } from "lexical";
12
13
  //#region src/components/rich-text-editor/rich-text-editor.vue
13
14
  const _hoisted_1 = ["contenteditable"];
14
15
  const LEXICAL_THEME = {
@@ -112,6 +113,8 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
112
113
  const showPlaceholder = ref(true);
113
114
  let isComposing = false;
114
115
  let cleanupFns = [];
116
+ const acceptsImage = () => props.image && !isDisabled.value && !isReadonly.value;
117
+ useImageResize(editor, acceptsImage);
115
118
  function initEditor() {
116
119
  if (!editorContainer.value) return;
117
120
  const editorInstance = createEditor({
@@ -135,7 +138,6 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
135
138
  cleanupFns.push(registerRichText(editorInstance));
136
139
  cleanupFns.push(registerList(editorInstance));
137
140
  cleanupFns.push(registerHistory(editorInstance, createEmptyHistoryState(), 300));
138
- const acceptsImage = () => props.image && !isDisabled.value && !isReadonly.value;
139
141
  cleanupFns.push(editorInstance.registerCommand(PASTE_COMMAND, (event) => {
140
142
  if (!acceptsImage()) return false;
141
143
  if (insertImageFiles(editorInstance, event.clipboardData?.files ?? []) > 0) {
@@ -252,7 +254,11 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
252
254
  }
253
255
  });
254
256
  watch([isDisabled, isReadonly], () => {
255
- if (editor.value) editor.value.setEditable(!isDisabled.value && !isReadonly.value);
257
+ if (!editor.value) return;
258
+ editor.value.setEditable(!isDisabled.value && !isReadonly.value);
259
+ if (isDisabled.value || isReadonly.value) editor.value.update(() => {
260
+ if ($isNodeSelection($getSelection())) $setSelection(null);
261
+ });
256
262
  });
257
263
  onMounted(() => {
258
264
  initEditor();
@@ -1 +1 @@
1
- {"version":3,"file":"rich-text-editor.js","names":[],"sources":["../../../src/components/rich-text-editor/rich-text-editor.vue"],"sourcesContent":["<template>\n <div :class=\"className\">\n <toolbar\n v-if=\"!isReadonly && toolbarItems.length > 0\"\n :editor=\"editor\"\n :toolbar=\"toolbarItems\"\n :disabled=\"isDisabled\"\n />\n <div :class=\"cls.e('content')\">\n <div\n ref=\"editorContainer\"\n :class=\"cls.e('editable')\"\n :contenteditable=\"!isDisabled && !isReadonly\"\n ></div>\n <div v-if=\"showPlaceholder\" :class=\"cls.e('placeholder')\">\n {{ placeholder }}\n </div>\n </div>\n </div>\n</template>\n\n<script lang=\"ts\">\nimport type { ToolbarItem } from '../../types'\n\nconst LEXICAL_THEME = {\n paragraph: 'u-rte-paragraph',\n heading: {\n h1: 'u-rte-h1',\n h2: 'u-rte-h2',\n h3: 'u-rte-h3',\n h4: 'u-rte-h4',\n h5: 'u-rte-h5',\n h6: 'u-rte-h6'\n },\n text: {\n bold: 'u-rte-bold',\n italic: 'u-rte-italic',\n underline: 'u-rte-underline',\n strikethrough: 'u-rte-strikethrough'\n },\n list: {\n ul: 'u-rte-ul',\n ol: 'u-rte-ol',\n listitem: 'u-rte-li',\n nested: { listitem: 'u-rte-li--nested' }\n },\n quote: 'u-rte-blockquote',\n\n link: 'u-rte-link',\n image: 'u-rte-image'\n}\n\nconst DEFAULT_TOOLBAR: ToolbarItem[] = [\n 'undo',\n 'redo',\n '|',\n 'heading',\n '|',\n 'bold',\n 'italic',\n 'underline',\n 'strikethrough',\n '|',\n 'bullet-list',\n 'ordered-list',\n '|',\n 'blockquote',\n '|',\n 'link',\n '|',\n 'image'\n]\n</script>\n\n<script lang=\"ts\" setup>\nimport { registerHistory, createEmptyHistoryState } from '@lexical/history'\nimport { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html'\nimport { LinkNode, AutoLinkNode } from '@lexical/link'\nimport { ListNode, ListItemNode, registerList } from '@lexical/list'\nimport { registerRichText } from '@lexical/rich-text'\nimport { HeadingNode, QuoteNode } from '@lexical/rich-text'\nimport { useFormFallbackProps } from '@veltra/compositions'\nimport { bem } from '@veltra/utils'\nimport { injectFormContext } from '@veltra/utils'\nimport {\n $createRangeSelection,\n $getNodeByKey,\n $getRoot,\n $createParagraphNode,\n $nodesOfType,\n $setSelection,\n COMMAND_PRIORITY_HIGH,\n DROP_COMMAND,\n PASTE_COMMAND,\n createEditor,\n type LexicalEditor\n} from 'lexical'\nimport { computed, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef, watch } from 'vue'\n\nimport type {\n RichTextEditorProps,\n RichTextImageUploader,\n _RichTextEditorExposed\n} from '../../types'\nimport {\n ImageNode,\n $isImageNode,\n gcImageObjectUrls,\n insertImageFiles,\n revokeImageObjectUrls\n} from './image-node'\nimport Toolbar from './toolbar.vue'\n\nconst props = withDefaults(defineProps<RichTextEditorProps>(), {\n disabled: undefined,\n readonly: undefined,\n format: 'html',\n toolbar: () => DEFAULT_TOOLBAR,\n placeholder: '',\n image: true\n})\n\nconst model = defineModel<string>()\n\nconst cls = bem('rich-text-editor')\n\nconst { formProps } = injectFormContext()\n\nconst { size, disabled, readonly } = useFormFallbackProps([formProps ?? {}, props])\n\nconst isDisabled = computed(() => !!disabled.value)\nconst isReadonly = computed(() => !!readonly.value)\n\nconst className = computed<string[]>(() => {\n return [\n cls.b,\n cls.m(size.value),\n bem.is('disabled', isDisabled.value),\n bem.is('readonly', isReadonly.value)\n ]\n})\n\nconst toolbarItems = computed<ToolbarItem[]>(() => {\n const items = props.toolbar ?? DEFAULT_TOOLBAR\n return props.image ? items : items.filter((item) => item !== 'image')\n})\n\nconst editorContainer = useTemplateRef('editorContainer')\nconst editor = shallowRef<LexicalEditor | null>(null)\nconst showPlaceholder = ref(true)\nlet isComposing = false\nlet cleanupFns: (() => void)[] = []\n\nfunction initEditor() {\n if (!editorContainer.value) return\n\n const editorInstance = createEditor({\n namespace: 'URichTextEditor',\n theme: LEXICAL_THEME,\n nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode, ImageNode],\n editable: !isDisabled.value && !isReadonly.value,\n onError: (error: Error) => {\n console.error('Lexical error:', error)\n }\n })\n\n editorInstance.setRootElement(editorContainer.value)\n\n // Register plugins\n cleanupFns.push(registerRichText(editorInstance))\n cleanupFns.push(registerList(editorInstance))\n cleanupFns.push(registerHistory(editorInstance, createEmptyHistoryState(), 300))\n\n // Image input: paste & drop\n const acceptsImage = () => props.image && !isDisabled.value && !isReadonly.value\n\n cleanupFns.push(\n editorInstance.registerCommand(\n PASTE_COMMAND,\n (event) => {\n if (!acceptsImage()) return false\n if (insertImageFiles(editorInstance, event.clipboardData?.files ?? []) > 0) {\n event.preventDefault()\n return true\n }\n return false\n },\n COMMAND_PRIORITY_HIGH\n ),\n editorInstance.registerCommand(\n DROP_COMMAND,\n (event) => {\n if (!acceptsImage()) return false\n const files = event.dataTransfer?.files\n if (!files?.length) return false\n // 先把光标落到落点,再插入\n const range = document.caretRangeFromPoint(event.clientX, event.clientY)\n if (range) {\n editorInstance.update(() => {\n const selection = $createRangeSelection()\n selection.applyDOMRange(range)\n $setSelection(selection)\n })\n }\n if (insertImageFiles(editorInstance, files) > 0) {\n event.preventDefault()\n return true\n }\n return false\n },\n COMMAND_PRIORITY_HIGH\n )\n )\n\n // Set initial value\n if (model.value) {\n setEditorContent(editorInstance, model.value)\n }\n\n // Listen for content changes\n cleanupFns.push(\n editorInstance.registerUpdateListener(({ editorState }) => {\n editorState.read(\n () => {\n const root = $getRoot()\n const imageNodes = $nodesOfType(ImageNode)\n showPlaceholder.value =\n root.getTextContent().trim().length === 0 && imageNodes.length === 0\n\n // 回收已从内容中移除的图片 objectURL\n gcImageObjectUrls(editorInstance, new Set(imageNodes.map((node) => node.getSrc())))\n\n if (isComposing) return\n\n const value = serializeContent(editorInstance)\n\n // Avoid circular updates: only emit if content really changed\n if (value !== model.value) {\n isComposing = true\n model.value = value\n // Use microtask to reset after Vue processes the update\n queueMicrotask(() => {\n isComposing = false\n })\n }\n },\n { editor: editorInstance }\n )\n })\n )\n\n // Composing event listeners for IME\n const rootElement = editorInstance.getRootElement()\n if (rootElement) {\n const onCompositionStart = () => {\n isComposing = true\n }\n const onCompositionEnd = () => {\n isComposing = false\n }\n rootElement.addEventListener('compositionstart', onCompositionStart)\n rootElement.addEventListener('compositionend', onCompositionEnd)\n cleanupFns.push(() => {\n rootElement.removeEventListener('compositionstart', onCompositionStart)\n rootElement.removeEventListener('compositionend', onCompositionEnd)\n })\n }\n\n editor.value = editorInstance\n}\n\nfunction setEditorContent(editorInstance: LexicalEditor, value: string) {\n editorInstance.update(() => {\n const root = $getRoot()\n root.clear()\n\n if (props.format === 'json') {\n try {\n const parsedState = editorInstance.parseEditorState(value)\n editorInstance.setEditorState(parsedState)\n } catch {\n // If JSON parse fails, treat as empty\n root.append($createParagraphNode())\n }\n } else {\n // HTML format\n const parser = new DOMParser()\n const dom = parser.parseFromString(value, 'text/html')\n const nodes = $generateNodesFromDOM(editorInstance, dom)\n root.append(...nodes)\n }\n })\n}\n\nfunction serializeContent(editorInstance: LexicalEditor): string {\n return editorInstance.read(() =>\n props.format === 'json'\n ? JSON.stringify(editorInstance.getEditorState().toJSON())\n : $generateHtmlFromNodes(editorInstance)\n )\n}\n\nlet isUploadingImages = false\n\nasync function uploadImages(upload: RichTextImageUploader): Promise<string> {\n const editorInstance = editor.value\n if (!editorInstance) return model.value ?? ''\n if (isUploadingImages) throw new Error('图片正在上传中')\n\n const pending = editorInstance.getEditorState().read(() =>\n $nodesOfType(ImageNode)\n .filter((node) => node.getFile())\n .map((node) => ({ key: node.getKey(), file: node.getFile() as File }))\n )\n\n if (pending.length) {\n isUploadingImages = true\n try {\n const urls = await Promise.all(pending.map(({ file }) => upload(file)))\n editorInstance.update(() => {\n pending.forEach(({ key }, index) => {\n const node = $getNodeByKey(key)\n if ($isImageNode(node)) node.setSrc(urls[index])\n })\n })\n } finally {\n isUploadingImages = false\n }\n }\n\n return serializeContent(editorInstance)\n}\n\ndefineExpose<_RichTextEditorExposed>({ uploadImages })\n\n// Watch external model changes\nwatch(model, (newVal) => {\n if (!editor.value || isComposing) return\n\n const editorInstance = editor.value\n\n // Compare current content to avoid circular updates\n const currentValue = serializeContent(editorInstance)\n\n if (currentValue !== newVal) {\n isComposing = true\n setEditorContent(editorInstance, newVal ?? '')\n queueMicrotask(() => {\n isComposing = false\n })\n }\n})\n\n// Watch disabled/readonly changes\nwatch([isDisabled, isReadonly], () => {\n if (editor.value) {\n editor.value.setEditable(!isDisabled.value && !isReadonly.value)\n }\n})\n\nonMounted(() => {\n initEditor()\n})\n\nonBeforeUnmount(() => {\n cleanupFns.forEach((fn) => fn())\n cleanupFns = []\n if (editor.value) revokeImageObjectUrls(editor.value)\n editor.value = null\n})\n</script>\n"],"mappings":"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"}
1
+ {"version":3,"file":"rich-text-editor.js","names":[],"sources":["../../../src/components/rich-text-editor/rich-text-editor.vue"],"sourcesContent":["<template>\n <div :class=\"className\">\n <toolbar\n v-if=\"!isReadonly && toolbarItems.length > 0\"\n :editor=\"editor\"\n :toolbar=\"toolbarItems\"\n :disabled=\"isDisabled\"\n />\n <div :class=\"cls.e('content')\">\n <div\n ref=\"editorContainer\"\n :class=\"cls.e('editable')\"\n :contenteditable=\"!isDisabled && !isReadonly\"\n ></div>\n <div v-if=\"showPlaceholder\" :class=\"cls.e('placeholder')\">\n {{ placeholder }}\n </div>\n </div>\n </div>\n</template>\n\n<script lang=\"ts\">\nimport type { ToolbarItem } from '../../types'\n\nconst LEXICAL_THEME = {\n paragraph: 'u-rte-paragraph',\n heading: {\n h1: 'u-rte-h1',\n h2: 'u-rte-h2',\n h3: 'u-rte-h3',\n h4: 'u-rte-h4',\n h5: 'u-rte-h5',\n h6: 'u-rte-h6'\n },\n text: {\n bold: 'u-rte-bold',\n italic: 'u-rte-italic',\n underline: 'u-rte-underline',\n strikethrough: 'u-rte-strikethrough'\n },\n list: {\n ul: 'u-rte-ul',\n ol: 'u-rte-ol',\n listitem: 'u-rte-li',\n nested: { listitem: 'u-rte-li--nested' }\n },\n quote: 'u-rte-blockquote',\n\n link: 'u-rte-link',\n image: 'u-rte-image'\n}\n\nconst DEFAULT_TOOLBAR: ToolbarItem[] = [\n 'undo',\n 'redo',\n '|',\n 'heading',\n '|',\n 'bold',\n 'italic',\n 'underline',\n 'strikethrough',\n '|',\n 'bullet-list',\n 'ordered-list',\n '|',\n 'blockquote',\n '|',\n 'link',\n '|',\n 'image'\n]\n</script>\n\n<script lang=\"ts\" setup>\nimport { registerHistory, createEmptyHistoryState } from '@lexical/history'\nimport { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html'\nimport { LinkNode, AutoLinkNode } from '@lexical/link'\nimport { ListNode, ListItemNode, registerList } from '@lexical/list'\nimport { registerRichText } from '@lexical/rich-text'\nimport { HeadingNode, QuoteNode } from '@lexical/rich-text'\nimport { useFormFallbackProps } from '@veltra/compositions'\nimport { bem } from '@veltra/utils'\nimport { injectFormContext } from '@veltra/utils'\nimport {\n $createRangeSelection,\n $getNodeByKey,\n $getRoot,\n $createParagraphNode,\n $nodesOfType,\n $getSelection,\n $isNodeSelection,\n $setSelection,\n COMMAND_PRIORITY_HIGH,\n DROP_COMMAND,\n PASTE_COMMAND,\n createEditor,\n type LexicalEditor\n} from 'lexical'\nimport { computed, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef, watch } from 'vue'\n\nimport type {\n RichTextEditorProps,\n RichTextImageUploader,\n _RichTextEditorExposed\n} from '../../types'\nimport {\n ImageNode,\n $isImageNode,\n gcImageObjectUrls,\n insertImageFiles,\n revokeImageObjectUrls\n} from './image-node'\nimport Toolbar from './toolbar.vue'\nimport { useImageResize } from './use-image-resize'\n\nconst props = withDefaults(defineProps<RichTextEditorProps>(), {\n disabled: undefined,\n readonly: undefined,\n format: 'html',\n toolbar: () => DEFAULT_TOOLBAR,\n placeholder: '',\n image: true\n})\n\nconst model = defineModel<string>()\n\nconst cls = bem('rich-text-editor')\n\nconst { formProps } = injectFormContext()\n\nconst { size, disabled, readonly } = useFormFallbackProps([formProps ?? {}, props])\n\nconst isDisabled = computed(() => !!disabled.value)\nconst isReadonly = computed(() => !!readonly.value)\n\nconst className = computed<string[]>(() => {\n return [\n cls.b,\n cls.m(size.value),\n bem.is('disabled', isDisabled.value),\n bem.is('readonly', isReadonly.value)\n ]\n})\n\nconst toolbarItems = computed<ToolbarItem[]>(() => {\n const items = props.toolbar ?? DEFAULT_TOOLBAR\n return props.image ? items : items.filter((item) => item !== 'image')\n})\n\nconst editorContainer = useTemplateRef('editorContainer')\nconst editor = shallowRef<LexicalEditor | null>(null)\nconst showPlaceholder = ref(true)\nlet isComposing = false\nlet cleanupFns: (() => void)[] = []\n\nconst acceptsImage = () => props.image && !isDisabled.value && !isReadonly.value\n\n// 图片选中 / 拖拽缩放交互\nuseImageResize(editor, acceptsImage)\n\nfunction initEditor() {\n if (!editorContainer.value) return\n\n const editorInstance = createEditor({\n namespace: 'URichTextEditor',\n theme: LEXICAL_THEME,\n nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, AutoLinkNode, ImageNode],\n editable: !isDisabled.value && !isReadonly.value,\n onError: (error: Error) => {\n console.error('Lexical error:', error)\n }\n })\n\n editorInstance.setRootElement(editorContainer.value)\n\n // Register plugins\n cleanupFns.push(registerRichText(editorInstance))\n cleanupFns.push(registerList(editorInstance))\n cleanupFns.push(registerHistory(editorInstance, createEmptyHistoryState(), 300))\n\n // Image input: paste & drop\n cleanupFns.push(\n editorInstance.registerCommand(\n PASTE_COMMAND,\n (event) => {\n if (!acceptsImage()) return false\n if (insertImageFiles(editorInstance, event.clipboardData?.files ?? []) > 0) {\n event.preventDefault()\n return true\n }\n return false\n },\n COMMAND_PRIORITY_HIGH\n ),\n editorInstance.registerCommand(\n DROP_COMMAND,\n (event) => {\n if (!acceptsImage()) return false\n const files = event.dataTransfer?.files\n if (!files?.length) return false\n // 先把光标落到落点,再插入\n const range = document.caretRangeFromPoint(event.clientX, event.clientY)\n if (range) {\n editorInstance.update(() => {\n const selection = $createRangeSelection()\n selection.applyDOMRange(range)\n $setSelection(selection)\n })\n }\n if (insertImageFiles(editorInstance, files) > 0) {\n event.preventDefault()\n return true\n }\n return false\n },\n COMMAND_PRIORITY_HIGH\n )\n )\n\n // Set initial value\n if (model.value) {\n setEditorContent(editorInstance, model.value)\n }\n\n // Listen for content changes\n cleanupFns.push(\n editorInstance.registerUpdateListener(({ editorState }) => {\n editorState.read(\n () => {\n const root = $getRoot()\n const imageNodes = $nodesOfType(ImageNode)\n showPlaceholder.value =\n root.getTextContent().trim().length === 0 && imageNodes.length === 0\n\n // 回收已从内容中移除的图片 objectURL\n gcImageObjectUrls(editorInstance, new Set(imageNodes.map((node) => node.getSrc())))\n\n if (isComposing) return\n\n const value = serializeContent(editorInstance)\n\n // Avoid circular updates: only emit if content really changed\n if (value !== model.value) {\n isComposing = true\n model.value = value\n // Use microtask to reset after Vue processes the update\n queueMicrotask(() => {\n isComposing = false\n })\n }\n },\n { editor: editorInstance }\n )\n })\n )\n\n // Composing event listeners for IME\n const rootElement = editorInstance.getRootElement()\n if (rootElement) {\n const onCompositionStart = () => {\n isComposing = true\n }\n const onCompositionEnd = () => {\n isComposing = false\n }\n rootElement.addEventListener('compositionstart', onCompositionStart)\n rootElement.addEventListener('compositionend', onCompositionEnd)\n cleanupFns.push(() => {\n rootElement.removeEventListener('compositionstart', onCompositionStart)\n rootElement.removeEventListener('compositionend', onCompositionEnd)\n })\n }\n\n editor.value = editorInstance\n}\n\nfunction setEditorContent(editorInstance: LexicalEditor, value: string) {\n editorInstance.update(() => {\n const root = $getRoot()\n root.clear()\n\n if (props.format === 'json') {\n try {\n const parsedState = editorInstance.parseEditorState(value)\n editorInstance.setEditorState(parsedState)\n } catch {\n // If JSON parse fails, treat as empty\n root.append($createParagraphNode())\n }\n } else {\n // HTML format\n const parser = new DOMParser()\n const dom = parser.parseFromString(value, 'text/html')\n const nodes = $generateNodesFromDOM(editorInstance, dom)\n root.append(...nodes)\n }\n })\n}\n\nfunction serializeContent(editorInstance: LexicalEditor): string {\n return editorInstance.read(() =>\n props.format === 'json'\n ? JSON.stringify(editorInstance.getEditorState().toJSON())\n : $generateHtmlFromNodes(editorInstance)\n )\n}\n\nlet isUploadingImages = false\n\nasync function uploadImages(upload: RichTextImageUploader): Promise<string> {\n const editorInstance = editor.value\n if (!editorInstance) return model.value ?? ''\n if (isUploadingImages) throw new Error('图片正在上传中')\n\n const pending = editorInstance.getEditorState().read(() =>\n $nodesOfType(ImageNode)\n .filter((node) => node.getFile())\n .map((node) => ({ key: node.getKey(), file: node.getFile() as File }))\n )\n\n if (pending.length) {\n isUploadingImages = true\n try {\n const urls = await Promise.all(pending.map(({ file }) => upload(file)))\n editorInstance.update(() => {\n pending.forEach(({ key }, index) => {\n const node = $getNodeByKey(key)\n if ($isImageNode(node)) node.setSrc(urls[index])\n })\n })\n } finally {\n isUploadingImages = false\n }\n }\n\n return serializeContent(editorInstance)\n}\n\ndefineExpose<_RichTextEditorExposed>({ uploadImages })\n\n// Watch external model changes\nwatch(model, (newVal) => {\n if (!editor.value || isComposing) return\n\n const editorInstance = editor.value\n\n // Compare current content to avoid circular updates\n const currentValue = serializeContent(editorInstance)\n\n if (currentValue !== newVal) {\n isComposing = true\n setEditorContent(editorInstance, newVal ?? '')\n queueMicrotask(() => {\n isComposing = false\n })\n }\n})\n\n// Watch disabled/readonly changes\nwatch([isDisabled, isReadonly], () => {\n if (!editor.value) return\n editor.value.setEditable(!isDisabled.value && !isReadonly.value)\n if (isDisabled.value || isReadonly.value) {\n // 切出可编辑态时取消可能残留的图片选中\n editor.value.update(() => {\n if ($isNodeSelection($getSelection())) $setSelection(null)\n })\n }\n})\n\nonMounted(() => {\n initEditor()\n})\n\nonBeforeUnmount(() => {\n cleanupFns.forEach((fn) => fn())\n cleanupFns = []\n if (editor.value) revokeImageObjectUrls(editor.value)\n editor.value = null\n})\n</script>\n"],"mappings":"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"}
@@ -342,3 +342,34 @@
342
342
  height: auto;
343
343
  vertical-align: bottom;
344
344
  }
345
+
346
+ .u-rte-image-wrap {
347
+ position: relative;
348
+ display: inline-block;
349
+ max-width: 100%;
350
+ vertical-align: bottom;
351
+ line-height: 0;
352
+ }
353
+ .u-rte-image-wrap .u-rte-image {
354
+ display: block;
355
+ }
356
+ .u-rte-image-wrap.is-selected .u-rte-image {
357
+ outline: 2px solid var(--u-color-primary);
358
+ border-radius: 2px;
359
+ }
360
+
361
+ .u-rte-image-resizer {
362
+ position: absolute;
363
+ right: -5px;
364
+ bottom: -5px;
365
+ display: none;
366
+ width: 10px;
367
+ height: 10px;
368
+ border-radius: 50%;
369
+ background-color: var(--u-bg-color-top);
370
+ border: 2px solid var(--u-color-primary);
371
+ cursor: nwse-resize;
372
+ }
373
+ .u-rte-image-wrap.is-selected .u-rte-image-resizer {
374
+ display: block;
375
+ }
@@ -0,0 +1,118 @@
1
+ import { $isImageNode } from "./image-node.js";
2
+ import { onBeforeUnmount, watch } from "vue";
3
+ import { $createNodeSelection, $getNodeByKey, $getSelection, $isNodeSelection, $setSelection, CLICK_COMMAND, COMMAND_PRIORITY_HIGH, KEY_ESCAPE_COMMAND } from "lexical";
4
+ //#region src/components/rich-text-editor/use-image-resize.ts
5
+ const WRAP_SELECTOR = "[data-rte-image-key]";
6
+ const HANDLE_SELECTOR = ".u-rte-image-resizer";
7
+ const MIN_WIDTH = 24;
8
+ const captureOptions = { capture: true };
9
+ function swallowClick(event) {
10
+ event.preventDefault();
11
+ event.stopPropagation();
12
+ }
13
+ /**
14
+ * 图片选中与拖拽缩放(参考 Lexical playground ImagesPlugin / ImageResizer):
15
+ * - 点击图片设置 NodeSelection 并标记选中态(wrapper 加 is-selected)
16
+ * - 拖拽右下角手柄等比缩放,实时改写 img 内联尺寸,松手时写入节点
17
+ * - Esc 取消选中;Backspace / Delete 删除走 Lexical 原生 NodeSelection 通路
18
+ * 选中态在每次 editor 更新后重放,避免撤销 / 内容重设后 class 失效。
19
+ */
20
+ function useImageResize(editor, canEdit) {
21
+ let selectedKey = null;
22
+ let cleanupFns = [];
23
+ function syncSelectionClass(ed) {
24
+ const root = ed.getRootElement();
25
+ if (!root) return;
26
+ root.querySelectorAll(".u-rte-image-wrap.is-selected").forEach((element) => {
27
+ element.classList.remove("is-selected");
28
+ });
29
+ if (selectedKey) ed.getElementByKey(selectedKey)?.classList.add("is-selected");
30
+ }
31
+ function setup(ed) {
32
+ cleanupFns.push(ed.registerCommand(CLICK_COMMAND, (event) => {
33
+ if (!canEdit()) return false;
34
+ const key = event.target.closest?.(WRAP_SELECTOR)?.getAttribute("data-rte-image-key");
35
+ if (!key) return false;
36
+ event.preventDefault();
37
+ ed.update(() => {
38
+ if (!$isImageNode($getNodeByKey(key))) return;
39
+ const selection = $createNodeSelection();
40
+ selection.add(key);
41
+ $setSelection(selection);
42
+ });
43
+ return true;
44
+ }, COMMAND_PRIORITY_HIGH), ed.registerCommand(KEY_ESCAPE_COMMAND, () => {
45
+ if (!selectedKey) return false;
46
+ ed.update(() => {
47
+ if ($isNodeSelection($getSelection())) $setSelection(null);
48
+ });
49
+ return true;
50
+ }, COMMAND_PRIORITY_HIGH), ed.registerUpdateListener(({ editorState }) => {
51
+ editorState.read(() => {
52
+ const selection = $getSelection();
53
+ selectedKey = $isNodeSelection(selection) ? selection.getNodes().find($isImageNode)?.getKey() ?? null : null;
54
+ });
55
+ syncSelectionClass(ed);
56
+ }));
57
+ const root = ed.getRootElement();
58
+ if (!root) return;
59
+ const onPointerDown = (event) => {
60
+ if (!canEdit() || event.button !== 0) return;
61
+ const wrapper = (event.target.closest?.(HANDLE_SELECTOR))?.closest(WRAP_SELECTOR);
62
+ const img = wrapper?.querySelector("img");
63
+ if (!wrapper || !img) return;
64
+ event.preventDefault();
65
+ const key = wrapper.getAttribute("data-rte-image-key");
66
+ const style = getComputedStyle(root);
67
+ const maxWidth = root.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
68
+ const ratio = img.clientHeight / img.clientWidth;
69
+ const startX = event.clientX;
70
+ const startWidth = img.clientWidth;
71
+ try {
72
+ root.setPointerCapture(event.pointerId);
73
+ } catch {}
74
+ const onPointerMove = (moveEvent) => {
75
+ const width = Math.min(maxWidth, Math.max(MIN_WIDTH, Math.round(startWidth + moveEvent.clientX - startX)));
76
+ img.style.width = `${width}px`;
77
+ img.style.height = `${Math.round(width * ratio)}px`;
78
+ };
79
+ const finish = (upEvent) => {
80
+ root.removeEventListener("pointermove", onPointerMove);
81
+ root.removeEventListener("pointerup", finish);
82
+ root.removeEventListener("pointercancel", finish);
83
+ if (root.hasPointerCapture(upEvent.pointerId)) root.releasePointerCapture(upEvent.pointerId);
84
+ root.addEventListener("click", swallowClick, {
85
+ capture: true,
86
+ once: true
87
+ });
88
+ setTimeout(() => root.removeEventListener("click", swallowClick, captureOptions), 0);
89
+ if (!key) return;
90
+ ed.update(() => {
91
+ const node = $getNodeByKey(key);
92
+ if (!$isImageNode(node)) return;
93
+ node.setWidthAndHeight(img.clientWidth, img.clientHeight);
94
+ const selection = $createNodeSelection();
95
+ selection.add(key);
96
+ $setSelection(selection);
97
+ });
98
+ };
99
+ root.addEventListener("pointermove", onPointerMove);
100
+ root.addEventListener("pointerup", finish);
101
+ root.addEventListener("pointercancel", finish);
102
+ };
103
+ root.addEventListener("pointerdown", onPointerDown);
104
+ cleanupFns.push(() => root.removeEventListener("pointerdown", onPointerDown));
105
+ }
106
+ watch(editor, (ed) => {
107
+ if (ed) setup(ed);
108
+ }, { immediate: true });
109
+ onBeforeUnmount(() => {
110
+ cleanupFns.forEach((fn) => fn());
111
+ cleanupFns = [];
112
+ selectedKey = null;
113
+ });
114
+ }
115
+ //#endregion
116
+ export { useImageResize };
117
+
118
+ //# sourceMappingURL=use-image-resize.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-image-resize.js","names":[],"sources":["../../../src/components/rich-text-editor/use-image-resize.ts"],"sourcesContent":["import {\n $createNodeSelection,\n $getNodeByKey,\n $getSelection,\n $isNodeSelection,\n $setSelection,\n CLICK_COMMAND,\n COMMAND_PRIORITY_HIGH,\n KEY_ESCAPE_COMMAND,\n type LexicalEditor\n} from 'lexical'\nimport { onBeforeUnmount, type ShallowRef, watch } from 'vue'\n\nimport { $isImageNode } from './image-node'\n\nconst WRAP_SELECTOR = '[data-rte-image-key]'\nconst HANDLE_SELECTOR = '.u-rte-image-resizer'\nconst MIN_WIDTH = 24\n\nconst captureOptions = { capture: true } as AddEventListenerOptions\n\nfunction swallowClick(event: Event): void {\n event.preventDefault()\n event.stopPropagation()\n}\n\n/**\n * 图片选中与拖拽缩放(参考 Lexical playground ImagesPlugin / ImageResizer):\n * - 点击图片设置 NodeSelection 并标记选中态(wrapper 加 is-selected)\n * - 拖拽右下角手柄等比缩放,实时改写 img 内联尺寸,松手时写入节点\n * - Esc 取消选中;Backspace / Delete 删除走 Lexical 原生 NodeSelection 通路\n * 选中态在每次 editor 更新后重放,避免撤销 / 内容重设后 class 失效。\n */\nexport function useImageResize(\n editor: ShallowRef<LexicalEditor | null>,\n canEdit: () => boolean\n): void {\n let selectedKey: string | null = null\n let cleanupFns: (() => void)[] = []\n\n function syncSelectionClass(ed: LexicalEditor) {\n const root = ed.getRootElement()\n if (!root) return\n root.querySelectorAll('.u-rte-image-wrap.is-selected').forEach((element) => {\n element.classList.remove('is-selected')\n })\n if (selectedKey) ed.getElementByKey(selectedKey)?.classList.add('is-selected')\n }\n\n function setup(ed: LexicalEditor) {\n cleanupFns.push(\n ed.registerCommand(\n CLICK_COMMAND,\n (event) => {\n if (!canEdit()) return false\n const key = (event.target as HTMLElement)\n .closest?.(WRAP_SELECTOR)\n ?.getAttribute('data-rte-image-key')\n if (!key) return false\n event.preventDefault()\n ed.update(() => {\n if (!$isImageNode($getNodeByKey(key))) return\n const selection = $createNodeSelection()\n selection.add(key)\n $setSelection(selection)\n })\n return true\n },\n COMMAND_PRIORITY_HIGH\n ),\n ed.registerCommand(\n KEY_ESCAPE_COMMAND,\n () => {\n if (!selectedKey) return false\n ed.update(() => {\n if ($isNodeSelection($getSelection())) $setSelection(null)\n })\n return true\n },\n COMMAND_PRIORITY_HIGH\n ),\n ed.registerUpdateListener(({ editorState }) => {\n editorState.read(() => {\n const selection = $getSelection()\n selectedKey = $isNodeSelection(selection)\n ? (selection.getNodes().find($isImageNode)?.getKey() ?? null)\n : null\n })\n syncSelectionClass(ed)\n })\n )\n\n const root = ed.getRootElement()\n if (!root) return\n\n const onPointerDown = (event: PointerEvent) => {\n if (!canEdit() || event.button !== 0) return\n const handle = (event.target as HTMLElement).closest?.(HANDLE_SELECTOR)\n const wrapper = handle?.closest(WRAP_SELECTOR)\n const img = wrapper?.querySelector('img')\n if (!wrapper || !img) return\n event.preventDefault()\n\n const key = wrapper.getAttribute('data-rte-image-key')\n const style = getComputedStyle(root)\n const maxWidth =\n root.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)\n const ratio = img.clientHeight / img.clientWidth\n const startX = event.clientX\n const startWidth = img.clientWidth\n // 真实输入下捕获指针保证拖出编辑区仍可跟踪;合成事件(自动化测试)无活跃\n // 指针,setPointerCapture 会抛 NotFoundError,降级为仅监听 root 内移动\n try {\n root.setPointerCapture(event.pointerId)\n } catch {\n // ignore\n }\n\n const onPointerMove = (moveEvent: PointerEvent) => {\n const width = Math.min(\n maxWidth,\n Math.max(MIN_WIDTH, Math.round(startWidth + moveEvent.clientX - startX))\n )\n img.style.width = `${width}px`\n img.style.height = `${Math.round(width * ratio)}px`\n }\n const finish = (upEvent: PointerEvent) => {\n root.removeEventListener('pointermove', onPointerMove)\n root.removeEventListener('pointerup', finish)\n root.removeEventListener('pointercancel', finish)\n if (root.hasPointerCapture(upEvent.pointerId)) root.releasePointerCapture(upEvent.pointerId)\n // pointer capture 使松手 target 落在编辑区内,浏览器随派发的 click 会把\n // 图片选中换成文本光标;吞掉这次拖拽尾随的 click\n root.addEventListener('click', swallowClick, { capture: true, once: true })\n setTimeout(() => root.removeEventListener('click', swallowClick, captureOptions), 0)\n if (!key) return\n ed.update(() => {\n const node = $getNodeByKey(key)\n if (!$isImageNode(node)) return\n node.setWidthAndHeight(img.clientWidth, img.clientHeight)\n // 松手后保持图片选中,方便继续微调或删除\n const selection = $createNodeSelection()\n selection.add(key)\n $setSelection(selection)\n })\n }\n root.addEventListener('pointermove', onPointerMove)\n root.addEventListener('pointerup', finish)\n root.addEventListener('pointercancel', finish)\n }\n root.addEventListener('pointerdown', onPointerDown)\n cleanupFns.push(() => root.removeEventListener('pointerdown', onPointerDown))\n }\n\n watch(\n editor,\n (ed) => {\n if (ed) setup(ed)\n },\n { immediate: true }\n )\n\n onBeforeUnmount(() => {\n cleanupFns.forEach((fn) => fn())\n cleanupFns = []\n selectedKey = null\n })\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veltra/desktop",
3
- "version": "1.8.2",
3
+ "version": "1.8.3",
4
4
  "homepage": "https://github.com/cabinet-fe/ultra-ui#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/cabinet-fe/ultra-ui/issues"
@@ -0,0 +1,140 @@
1
+ import { createEditor, type LexicalEditor } from 'lexical'
2
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
3
+
4
+ import { $createImageNode, $isImageNode, ImageNode } from '../image-node'
5
+
6
+ let editor: LexicalEditor
7
+ let container: HTMLDivElement
8
+
9
+ beforeAll(() => {
10
+ container = document.createElement('div')
11
+ document.body.appendChild(container)
12
+ editor = createEditor({
13
+ namespace: 'test',
14
+ nodes: [ImageNode],
15
+ onError: (error) => {
16
+ throw error
17
+ }
18
+ })
19
+ editor.setRootElement(container)
20
+ })
21
+
22
+ afterAll(() => {
23
+ editor.setRootElement(null)
24
+ container.remove()
25
+ })
26
+
27
+ function createImage(options: { src?: string; width?: number; height?: number } = {}) {
28
+ return $createImageNode({
29
+ src: options.src ?? 'https://cdn.example.com/a.png',
30
+ alt: '示例图',
31
+ width: options.width,
32
+ height: options.height
33
+ })
34
+ }
35
+
36
+ /** 节点构造与写方法要求可写上下文(Lexical 0.50 在 read 回调内禁止 new 节点) */
37
+ function inUpdate<T>(fn: () => T): T {
38
+ let result!: T
39
+ editor.update(() => {
40
+ result = fn()
41
+ })
42
+ return result
43
+ }
44
+
45
+ describe('ImageNode', () => {
46
+ it('createDOM 返回 wrapper + img + 缩放手柄,未设置尺寸时无内联宽高', () => {
47
+ const { wrapper, key } = inUpdate(() => {
48
+ const node = createImage()
49
+ return { wrapper: node.createDOM(), key: node.getKey() }
50
+ })
51
+
52
+ expect(wrapper.className).toBe('u-rte-image-wrap')
53
+ expect(wrapper.getAttribute('data-rte-image-key')).toBe(key)
54
+
55
+ const img = wrapper.querySelector('img')
56
+ expect(img?.className).toBe('u-rte-image')
57
+ expect(img?.getAttribute('src')).toBe('https://cdn.example.com/a.png')
58
+ expect(img?.getAttribute('alt')).toBe('示例图')
59
+ expect(img?.style.width).toBe('')
60
+ expect(img?.style.height).toBe('')
61
+
62
+ expect(wrapper.querySelector('.u-rte-image-resizer')).not.toBeNull()
63
+ })
64
+
65
+ it('createDOM 应用节点上的显示尺寸', () => {
66
+ const img = inUpdate(() =>
67
+ createImage({ width: 320, height: 180 }).createDOM().querySelector('img')!
68
+ )
69
+
70
+ expect(img.style.width).toBe('320px')
71
+ expect(img.style.height).toBe('180px')
72
+ })
73
+
74
+ it('updateDOM 在尺寸变化时同步 img 内联样式', () => {
75
+ const { img } = inUpdate(() => {
76
+ const node = createImage({ width: 320, height: 180 })
77
+ const wrapper = node.createDOM()
78
+ const resized = createImage({ width: 480, height: 270 })
79
+ resized.updateDOM(node, wrapper)
80
+ return { img: wrapper.querySelector('img')! }
81
+ })
82
+
83
+ expect(img.style.width).toBe('480px')
84
+ expect(img.style.height).toBe('270px')
85
+ })
86
+
87
+ it('exportDOM 仅在设置尺寸时输出 width / height 属性', () => {
88
+ const { sizedEl, unsizedEl } = inUpdate(() => ({
89
+ sizedEl: createImage({ width: 480, height: 270 }).exportDOM().element as HTMLImageElement,
90
+ unsizedEl: createImage().exportDOM().element as HTMLImageElement
91
+ }))
92
+
93
+ expect(sizedEl.getAttribute('width')).toBe('480')
94
+ expect(sizedEl.getAttribute('height')).toBe('270')
95
+ expect(sizedEl.className).toBe('u-rte-image')
96
+
97
+ expect(unsizedEl.hasAttribute('width')).toBe(false)
98
+ expect(unsizedEl.hasAttribute('height')).toBe(false)
99
+ })
100
+
101
+ it('importDOM 读取 img 的 width / height 属性,缺失时为 0', () => {
102
+ const { sized, unsized } = inUpdate(() => {
103
+ const conversion = ImageNode.importDOM()!.img().conversion
104
+ const dom = document.createElement('img')
105
+ dom.setAttribute('src', 'https://cdn.example.com/a.png')
106
+ dom.setAttribute('width', '640')
107
+ dom.setAttribute('height', '360')
108
+
109
+ const noSizeDom = document.createElement('img')
110
+ noSizeDom.setAttribute('src', 'https://cdn.example.com/b.png')
111
+
112
+ return { sized: conversion(dom).node, unsized: conversion(noSizeDom).node }
113
+ })
114
+
115
+ expect($isImageNode(sized)).toBe(true)
116
+ expect(sized!.exportJSON()).toMatchObject({
117
+ src: 'https://cdn.example.com/a.png',
118
+ width: 640,
119
+ height: 360
120
+ })
121
+ expect(unsized!.exportJSON()).toMatchObject({ width: 0, height: 0 })
122
+ })
123
+
124
+ it('exportJSON / importJSON roundtrip 保留尺寸', () => {
125
+ const json = inUpdate(() => createImage({ width: 480, height: 270 }).exportJSON())
126
+
127
+ const restored = inUpdate(() => ImageNode.importJSON(json).exportJSON())
128
+ expect(restored).toEqual(json)
129
+ })
130
+
131
+ it('editor update 中 setWidthAndHeight 写入尺寸', () => {
132
+ const json = inUpdate(() => {
133
+ const node = createImage()
134
+ node.setWidthAndHeight(200, 100)
135
+ return node.exportJSON()
136
+ })
137
+
138
+ expect(json).toMatchObject({ width: 200, height: 100 })
139
+ })
140
+ })
@@ -18,44 +18,68 @@ function nextImageId(): string {
18
18
  }
19
19
 
20
20
  export interface SerializedImageNode extends Spread<
21
- { src: string; alt: string; id: string },
21
+ { src: string; alt: string; id: string; width: number; height: number },
22
22
  SerializedLexicalNode
23
23
  > {}
24
24
 
25
25
  /**
26
26
  * 图片节点:插入时 src 为本地 objectURL、__file 持有原始文件,提交前由宿主调用
27
27
  * uploadImages 换成服务器地址。__file 不参与序列化,仅会话内有效。
28
+ * width / height 为显示尺寸(px),0 表示未设置、按图片自然尺寸(受 max-width 约束)。
28
29
  * 本仓库为手写 Vue 绑定(无 React 适配层),不走 decorate 渲染管道,
29
- * createDOM 直接返回 <img> 元素。
30
+ * createDOM 返回 wrapper:`<span><img><span 手柄/></span>`,选中与拖拽缩放
31
+ * 由组件层(use-image-resize)基于 data-rte-image-key 驱动。
30
32
  */
31
33
  export class ImageNode extends DecoratorNode<void> {
32
34
  __src: string
33
35
  __alt: string
34
36
  __id: string
35
37
  __file: File | null
38
+ __width: number
39
+ __height: number
36
40
 
37
41
  static override getType(): string {
38
42
  return 'u-image'
39
43
  }
40
44
 
41
45
  static override clone(node: ImageNode): ImageNode {
42
- return new ImageNode(node.__src, node.__alt, node.__id, node.__file, node.__key)
46
+ return new ImageNode(
47
+ node.__src,
48
+ node.__alt,
49
+ node.__id,
50
+ node.__file,
51
+ node.__width,
52
+ node.__height,
53
+ node.__key
54
+ )
43
55
  }
44
56
 
45
57
  static override importJSON(serializedNode: SerializedImageNode): ImageNode {
46
58
  return $createImageNode({
47
59
  src: serializedNode.src,
48
60
  alt: serializedNode.alt,
49
- id: serializedNode.id
61
+ id: serializedNode.id,
62
+ width: serializedNode.width,
63
+ height: serializedNode.height
50
64
  })
51
65
  }
52
66
 
53
- constructor(src: string, alt = '', id = nextImageId(), file: File | null = null, key?: NodeKey) {
67
+ constructor(
68
+ src: string,
69
+ alt = '',
70
+ id = nextImageId(),
71
+ file: File | null = null,
72
+ width = 0,
73
+ height = 0,
74
+ key?: NodeKey
75
+ ) {
54
76
  super(key)
55
77
  this.__src = src
56
78
  this.__alt = alt
57
79
  this.__id = id
58
80
  this.__file = file
81
+ this.__width = width
82
+ this.__height = height
59
83
  }
60
84
 
61
85
  static override importDOM(): DOMConversionMap | null {
@@ -68,26 +92,58 @@ export class ImageNode extends DecoratorNode<void> {
68
92
  element.setAttribute('class', 'u-rte-image')
69
93
  element.setAttribute('alt', this.__alt)
70
94
  element.setAttribute('data-rte-image-id', this.__id)
95
+ if (this.__width) element.setAttribute('width', String(this.__width))
96
+ if (this.__height) element.setAttribute('height', String(this.__height))
71
97
  return { element }
72
98
  }
73
99
 
74
100
  override exportJSON(): SerializedImageNode {
75
- return { type: 'u-image', version: 1, src: this.__src, alt: this.__alt, id: this.__id }
101
+ return {
102
+ type: 'u-image',
103
+ version: 1,
104
+ src: this.__src,
105
+ alt: this.__alt,
106
+ id: this.__id,
107
+ width: this.__width,
108
+ height: this.__height
109
+ }
76
110
  }
77
111
 
78
112
  override createDOM(): HTMLElement {
79
- const element = document.createElement('img')
80
- element.className = 'u-rte-image'
81
- element.src = this.__src
82
- element.alt = this.__alt
83
- element.draggable = false
84
- return element
113
+ const wrapper = document.createElement('span')
114
+ wrapper.className = 'u-rte-image-wrap'
115
+ wrapper.contentEditable = 'false'
116
+ wrapper.setAttribute('data-rte-image-key', this.__key)
117
+
118
+ const img = document.createElement('img')
119
+ img.className = 'u-rte-image'
120
+ img.src = this.__src
121
+ img.alt = this.__alt
122
+ img.draggable = false
123
+ if (this.__width) img.style.width = `${this.__width}px`
124
+ if (this.__height) img.style.height = `${this.__height}px`
125
+
126
+ const handle = document.createElement('span')
127
+ handle.className = 'u-rte-image-resizer'
128
+ handle.setAttribute('aria-hidden', 'true')
129
+
130
+ wrapper.append(img, handle)
131
+ return wrapper
85
132
  }
86
133
 
87
134
  override updateDOM(prevNode: ImageNode, element: HTMLElement): boolean {
88
- const img = element as HTMLImageElement
135
+ const img = element.querySelector('img')
136
+ if (!img) return false
89
137
  if (prevNode.__src !== this.__src) img.src = this.__src
90
138
  if (prevNode.__alt !== this.__alt) img.alt = this.__alt
139
+ if (prevNode.__width !== this.__width) {
140
+ if (this.__width) img.style.width = `${this.__width}px`
141
+ else img.style.removeProperty('width')
142
+ }
143
+ if (prevNode.__height !== this.__height) {
144
+ if (this.__height) img.style.height = `${this.__height}px`
145
+ else img.style.removeProperty('height')
146
+ }
91
147
  return false
92
148
  }
93
149
 
@@ -110,6 +166,13 @@ export class ImageNode extends DecoratorNode<void> {
110
166
  self.__src = src
111
167
  self.__file = null
112
168
  }
169
+
170
+ /** 写入显示尺寸(拖拽缩放结束时调用),px,0 表示恢复自然尺寸 */
171
+ setWidthAndHeight(width: number, height: number): void {
172
+ const self = this.getWritable()
173
+ self.__width = width
174
+ self.__height = height
175
+ }
113
176
  }
114
177
 
115
178
  function convertImageElement(domNode: HTMLElement): DOMConversionOutput {
@@ -119,7 +182,9 @@ function convertImageElement(domNode: HTMLElement): DOMConversionOutput {
119
182
  node: $createImageNode({
120
183
  src,
121
184
  alt: domNode.getAttribute('alt') ?? '',
122
- id: domNode.getAttribute('data-rte-image-id') ?? undefined
185
+ id: domNode.getAttribute('data-rte-image-id') ?? undefined,
186
+ width: parseInt(domNode.getAttribute('width') ?? '') || 0,
187
+ height: parseInt(domNode.getAttribute('height') ?? '') || 0
123
188
  })
124
189
  }
125
190
  }
@@ -129,12 +194,16 @@ export function $createImageNode(options: {
129
194
  alt?: string
130
195
  id?: string
131
196
  file?: File | null
197
+ width?: number
198
+ height?: number
132
199
  }): ImageNode {
133
200
  return new ImageNode(
134
201
  options.src,
135
202
  options.alt ?? '',
136
203
  options.id ?? nextImageId(),
137
- options.file ?? null
204
+ options.file ?? null,
205
+ options.width ?? 0,
206
+ options.height ?? 0
138
207
  )
139
208
  }
140
209
 
@@ -88,6 +88,8 @@ import {
88
88
  $getRoot,
89
89
  $createParagraphNode,
90
90
  $nodesOfType,
91
+ $getSelection,
92
+ $isNodeSelection,
91
93
  $setSelection,
92
94
  COMMAND_PRIORITY_HIGH,
93
95
  DROP_COMMAND,
@@ -110,6 +112,7 @@ import {
110
112
  revokeImageObjectUrls
111
113
  } from './image-node'
112
114
  import Toolbar from './toolbar.vue'
115
+ import { useImageResize } from './use-image-resize'
113
116
 
114
117
  const props = withDefaults(defineProps<RichTextEditorProps>(), {
115
118
  disabled: undefined,
@@ -151,6 +154,11 @@ const showPlaceholder = ref(true)
151
154
  let isComposing = false
152
155
  let cleanupFns: (() => void)[] = []
153
156
 
157
+ const acceptsImage = () => props.image && !isDisabled.value && !isReadonly.value
158
+
159
+ // 图片选中 / 拖拽缩放交互
160
+ useImageResize(editor, acceptsImage)
161
+
154
162
  function initEditor() {
155
163
  if (!editorContainer.value) return
156
164
 
@@ -172,8 +180,6 @@ function initEditor() {
172
180
  cleanupFns.push(registerHistory(editorInstance, createEmptyHistoryState(), 300))
173
181
 
174
182
  // Image input: paste & drop
175
- const acceptsImage = () => props.image && !isDisabled.value && !isReadonly.value
176
-
177
183
  cleanupFns.push(
178
184
  editorInstance.registerCommand(
179
185
  PASTE_COMMAND,
@@ -353,8 +359,13 @@ watch(model, (newVal) => {
353
359
 
354
360
  // Watch disabled/readonly changes
355
361
  watch([isDisabled, isReadonly], () => {
356
- if (editor.value) {
357
- editor.value.setEditable(!isDisabled.value && !isReadonly.value)
362
+ if (!editor.value) return
363
+ editor.value.setEditable(!isDisabled.value && !isReadonly.value)
364
+ if (isDisabled.value || isReadonly.value) {
365
+ // 切出可编辑态时取消可能残留的图片选中
366
+ editor.value.update(() => {
367
+ if ($isNodeSelection($getSelection())) $setSelection(null)
368
+ })
358
369
  }
359
370
  })
360
371
 
@@ -388,3 +388,38 @@ $root-name: rich-text-editor;
388
388
  height: auto;
389
389
  vertical-align: bottom;
390
390
  }
391
+
392
+ // 编辑器内图片 wrapper:承载选中态描边与缩放手柄(宿主直接渲染导出 HTML 时无此结构)
393
+ .u-rte-image-wrap {
394
+ position: relative;
395
+ display: inline-block;
396
+ max-width: 100%;
397
+ vertical-align: bottom;
398
+ line-height: 0;
399
+
400
+ .u-rte-image {
401
+ display: block;
402
+ }
403
+
404
+ &.is-selected .u-rte-image {
405
+ outline: 2px solid fn.use-var(color, primary);
406
+ border-radius: 2px;
407
+ }
408
+ }
409
+
410
+ .u-rte-image-resizer {
411
+ position: absolute;
412
+ right: -5px;
413
+ bottom: -5px;
414
+ display: none;
415
+ width: 10px;
416
+ height: 10px;
417
+ border-radius: 50%;
418
+ background-color: fn.use-var(bg-color, top);
419
+ border: 2px solid fn.use-var(color, primary);
420
+ cursor: nwse-resize;
421
+
422
+ .u-rte-image-wrap.is-selected & {
423
+ display: block;
424
+ }
425
+ }
@@ -0,0 +1,168 @@
1
+ import {
2
+ $createNodeSelection,
3
+ $getNodeByKey,
4
+ $getSelection,
5
+ $isNodeSelection,
6
+ $setSelection,
7
+ CLICK_COMMAND,
8
+ COMMAND_PRIORITY_HIGH,
9
+ KEY_ESCAPE_COMMAND,
10
+ type LexicalEditor
11
+ } from 'lexical'
12
+ import { onBeforeUnmount, type ShallowRef, watch } from 'vue'
13
+
14
+ import { $isImageNode } from './image-node'
15
+
16
+ const WRAP_SELECTOR = '[data-rte-image-key]'
17
+ const HANDLE_SELECTOR = '.u-rte-image-resizer'
18
+ const MIN_WIDTH = 24
19
+
20
+ const captureOptions = { capture: true } as AddEventListenerOptions
21
+
22
+ function swallowClick(event: Event): void {
23
+ event.preventDefault()
24
+ event.stopPropagation()
25
+ }
26
+
27
+ /**
28
+ * 图片选中与拖拽缩放(参考 Lexical playground ImagesPlugin / ImageResizer):
29
+ * - 点击图片设置 NodeSelection 并标记选中态(wrapper 加 is-selected)
30
+ * - 拖拽右下角手柄等比缩放,实时改写 img 内联尺寸,松手时写入节点
31
+ * - Esc 取消选中;Backspace / Delete 删除走 Lexical 原生 NodeSelection 通路
32
+ * 选中态在每次 editor 更新后重放,避免撤销 / 内容重设后 class 失效。
33
+ */
34
+ export function useImageResize(
35
+ editor: ShallowRef<LexicalEditor | null>,
36
+ canEdit: () => boolean
37
+ ): void {
38
+ let selectedKey: string | null = null
39
+ let cleanupFns: (() => void)[] = []
40
+
41
+ function syncSelectionClass(ed: LexicalEditor) {
42
+ const root = ed.getRootElement()
43
+ if (!root) return
44
+ root.querySelectorAll('.u-rte-image-wrap.is-selected').forEach((element) => {
45
+ element.classList.remove('is-selected')
46
+ })
47
+ if (selectedKey) ed.getElementByKey(selectedKey)?.classList.add('is-selected')
48
+ }
49
+
50
+ function setup(ed: LexicalEditor) {
51
+ cleanupFns.push(
52
+ ed.registerCommand(
53
+ CLICK_COMMAND,
54
+ (event) => {
55
+ if (!canEdit()) return false
56
+ const key = (event.target as HTMLElement)
57
+ .closest?.(WRAP_SELECTOR)
58
+ ?.getAttribute('data-rte-image-key')
59
+ if (!key) return false
60
+ event.preventDefault()
61
+ ed.update(() => {
62
+ if (!$isImageNode($getNodeByKey(key))) return
63
+ const selection = $createNodeSelection()
64
+ selection.add(key)
65
+ $setSelection(selection)
66
+ })
67
+ return true
68
+ },
69
+ COMMAND_PRIORITY_HIGH
70
+ ),
71
+ ed.registerCommand(
72
+ KEY_ESCAPE_COMMAND,
73
+ () => {
74
+ if (!selectedKey) return false
75
+ ed.update(() => {
76
+ if ($isNodeSelection($getSelection())) $setSelection(null)
77
+ })
78
+ return true
79
+ },
80
+ COMMAND_PRIORITY_HIGH
81
+ ),
82
+ ed.registerUpdateListener(({ editorState }) => {
83
+ editorState.read(() => {
84
+ const selection = $getSelection()
85
+ selectedKey = $isNodeSelection(selection)
86
+ ? (selection.getNodes().find($isImageNode)?.getKey() ?? null)
87
+ : null
88
+ })
89
+ syncSelectionClass(ed)
90
+ })
91
+ )
92
+
93
+ const root = ed.getRootElement()
94
+ if (!root) return
95
+
96
+ const onPointerDown = (event: PointerEvent) => {
97
+ if (!canEdit() || event.button !== 0) return
98
+ const handle = (event.target as HTMLElement).closest?.(HANDLE_SELECTOR)
99
+ const wrapper = handle?.closest(WRAP_SELECTOR)
100
+ const img = wrapper?.querySelector('img')
101
+ if (!wrapper || !img) return
102
+ event.preventDefault()
103
+
104
+ const key = wrapper.getAttribute('data-rte-image-key')
105
+ const style = getComputedStyle(root)
106
+ const maxWidth =
107
+ root.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)
108
+ const ratio = img.clientHeight / img.clientWidth
109
+ const startX = event.clientX
110
+ const startWidth = img.clientWidth
111
+ // 真实输入下捕获指针保证拖出编辑区仍可跟踪;合成事件(自动化测试)无活跃
112
+ // 指针,setPointerCapture 会抛 NotFoundError,降级为仅监听 root 内移动
113
+ try {
114
+ root.setPointerCapture(event.pointerId)
115
+ } catch {
116
+ // ignore
117
+ }
118
+
119
+ const onPointerMove = (moveEvent: PointerEvent) => {
120
+ const width = Math.min(
121
+ maxWidth,
122
+ Math.max(MIN_WIDTH, Math.round(startWidth + moveEvent.clientX - startX))
123
+ )
124
+ img.style.width = `${width}px`
125
+ img.style.height = `${Math.round(width * ratio)}px`
126
+ }
127
+ const finish = (upEvent: PointerEvent) => {
128
+ root.removeEventListener('pointermove', onPointerMove)
129
+ root.removeEventListener('pointerup', finish)
130
+ root.removeEventListener('pointercancel', finish)
131
+ if (root.hasPointerCapture(upEvent.pointerId)) root.releasePointerCapture(upEvent.pointerId)
132
+ // pointer capture 使松手 target 落在编辑区内,浏览器随派发的 click 会把
133
+ // 图片选中换成文本光标;吞掉这次拖拽尾随的 click
134
+ root.addEventListener('click', swallowClick, { capture: true, once: true })
135
+ setTimeout(() => root.removeEventListener('click', swallowClick, captureOptions), 0)
136
+ if (!key) return
137
+ ed.update(() => {
138
+ const node = $getNodeByKey(key)
139
+ if (!$isImageNode(node)) return
140
+ node.setWidthAndHeight(img.clientWidth, img.clientHeight)
141
+ // 松手后保持图片选中,方便继续微调或删除
142
+ const selection = $createNodeSelection()
143
+ selection.add(key)
144
+ $setSelection(selection)
145
+ })
146
+ }
147
+ root.addEventListener('pointermove', onPointerMove)
148
+ root.addEventListener('pointerup', finish)
149
+ root.addEventListener('pointercancel', finish)
150
+ }
151
+ root.addEventListener('pointerdown', onPointerDown)
152
+ cleanupFns.push(() => root.removeEventListener('pointerdown', onPointerDown))
153
+ }
154
+
155
+ watch(
156
+ editor,
157
+ (ed) => {
158
+ if (ed) setup(ed)
159
+ },
160
+ { immediate: true }
161
+ )
162
+
163
+ onBeforeUnmount(() => {
164
+ cleanupFns.forEach((fn) => fn())
165
+ cleanupFns = []
166
+ selectedKey = null
167
+ })
168
+ }