@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.
- package/dist/components/rich-text-editor/image-node.js +53 -14
- package/dist/components/rich-text-editor/image-node.js.map +1 -1
- package/dist/components/rich-text-editor/rich-text-editor.js +9 -3
- package/dist/components/rich-text-editor/rich-text-editor.js.map +1 -1
- package/dist/components/rich-text-editor/style2.css +31 -0
- package/dist/components/rich-text-editor/use-image-resize.js +118 -0
- package/dist/components/rich-text-editor/use-image-resize.js.map +1 -0
- package/package.json +1 -1
- package/src/components/rich-text-editor/__test__/image-node.test.ts +140 -0
- package/src/components/rich-text-editor/image-node.ts +84 -15
- package/src/components/rich-text-editor/rich-text-editor.vue +15 -4
- package/src/components/rich-text-editor/style.scss +35 -0
- package/src/components/rich-text-editor/use-image-resize.ts +168 -0
|
@@ -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
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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":"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"}
|
|
@@ -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)
|
|
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
|
@@ -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
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|