]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/nodes/image.ts
Lexical: Added some level of img/media alignment
[bookstack] / resources / js / wysiwyg / nodes / image.ts
1 import {
2     DecoratorNode,
3     DOMConversion,
4     DOMConversionMap,
5     DOMConversionOutput,
6     LexicalEditor, LexicalNode,
7     SerializedLexicalNode,
8     Spread
9 } from "lexical";
10 import type {EditorConfig} from "lexical/LexicalEditor";
11 import {EditorDecoratorAdapter} from "../ui/framework/decorator";
12 import {el} from "../utils/dom";
13 import {CommonBlockAlignment, extractAlignmentFromElement} from "./_common";
14
15 export interface ImageNodeOptions {
16     alt?: string;
17     width?: number;
18     height?: number;
19 }
20
21 export type SerializedImageNode = Spread<{
22     src: string;
23     alt: string;
24     width: number;
25     height: number;
26     alignment: CommonBlockAlignment;
27 }, SerializedLexicalNode>
28
29 export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
30     __src: string = '';
31     __alt: string = '';
32     __width: number = 0;
33     __height: number = 0;
34     __alignment: CommonBlockAlignment = '';
35
36     static getType(): string {
37         return 'image';
38     }
39
40     static clone(node: ImageNode): ImageNode {
41         return new ImageNode(node.__src, {
42             alt: node.__alt,
43             width: node.__width,
44             height: node.__height,
45         });
46     }
47
48     constructor(src: string, options: ImageNodeOptions, key?: string) {
49         super(key);
50         this.__src = src;
51         if (options.alt) {
52             this.__alt = options.alt;
53         }
54         if (options.width) {
55             this.__width = options.width;
56         }
57         if (options.height) {
58             this.__height = options.height;
59         }
60     }
61
62     setSrc(src: string): void {
63         const self = this.getWritable();
64         self.__src = src;
65     }
66
67     getSrc(): string {
68         const self = this.getLatest();
69         return self.__src;
70     }
71
72     setAltText(altText: string): void {
73         const self = this.getWritable();
74         self.__alt = altText;
75     }
76
77     getAltText(): string {
78         const self = this.getLatest();
79         return self.__alt;
80     }
81
82     setHeight(height: number): void {
83         const self = this.getWritable();
84         self.__height = height;
85     }
86
87     getHeight(): number {
88         const self = this.getLatest();
89         return self.__height;
90     }
91
92     setWidth(width: number): void {
93         const self = this.getWritable();
94         self.__width = width;
95     }
96
97     getWidth(): number {
98         const self = this.getLatest();
99         return self.__width;
100     }
101
102     setAlignment(alignment: CommonBlockAlignment) {
103         const self = this.getWritable();
104         self.__alignment = alignment;
105     }
106
107     getAlignment(): CommonBlockAlignment {
108         const self = this.getLatest();
109         return self.__alignment;
110     }
111
112     isInline(): boolean {
113         return true;
114     }
115
116     decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter {
117         return {
118             type: 'image',
119             getNode: () => this,
120         };
121     }
122
123     createDOM(_config: EditorConfig, _editor: LexicalEditor) {
124         const element = document.createElement('img');
125         element.setAttribute('src', this.__src);
126
127         if (this.__width) {
128             element.setAttribute('width', String(this.__width));
129         }
130         if (this.__height) {
131             element.setAttribute('height', String(this.__height));
132         }
133         if (this.__alt) {
134             element.setAttribute('alt', this.__alt);
135         }
136
137         if (this.__alignment) {
138             element.classList.add('align-' + this.__alignment);
139         }
140
141         return el('span', {class: 'editor-image-wrap'}, [
142             element,
143         ]);
144     }
145
146     updateDOM(prevNode: ImageNode, dom: HTMLElement) {
147         const image = dom.querySelector('img');
148         if (!image) return false;
149
150         if (prevNode.__src !== this.__src) {
151             image.setAttribute('src', this.__src);
152         }
153
154         if (prevNode.__width !== this.__width) {
155             if (this.__width) {
156                 image.setAttribute('width', String(this.__width));
157             } else {
158                 image.removeAttribute('width');
159             }
160         }
161
162         if (prevNode.__height !== this.__height) {
163             if (this.__height) {
164                 image.setAttribute('height', String(this.__height));
165             } else {
166                 image.removeAttribute('height');
167             }
168         }
169
170         if (prevNode.__alt !== this.__alt) {
171             if (this.__alt) {
172                 image.setAttribute('alt', String(this.__alt));
173             } else {
174                 image.removeAttribute('alt');
175             }
176         }
177
178         if (prevNode.__alignment !== this.__alignment) {
179             if (prevNode.__alignment) {
180                 image.classList.remove('align-' + prevNode.__alignment);
181             }
182             if (this.__alignment) {
183                 image.classList.add('align-' + this.__alignment);
184             }
185         }
186
187         return false;
188     }
189
190     static importDOM(): DOMConversionMap|null {
191         return {
192             img(node: HTMLElement): DOMConversion|null {
193                 return {
194                     conversion: (element: HTMLElement): DOMConversionOutput|null => {
195
196                         const src = element.getAttribute('src') || '';
197                         const options: ImageNodeOptions = {
198                             alt: element.getAttribute('alt') || '',
199                             height: Number.parseInt(element.getAttribute('height') || '0'),
200                             width: Number.parseInt(element.getAttribute('width') || '0'),
201                         }
202
203                         const node = new ImageNode(src, options);
204                         node.setAlignment(extractAlignmentFromElement(element));
205
206                         return { node };
207                     },
208                     priority: 3,
209                 };
210             },
211         };
212     }
213
214     exportJSON(): SerializedImageNode {
215         return {
216             type: 'image',
217             version: 1,
218             src: this.__src,
219             alt: this.__alt,
220             height: this.__height,
221             width: this.__width,
222             alignment: this.__alignment,
223         };
224     }
225
226     static importJSON(serializedNode: SerializedImageNode): ImageNode {
227         const node = $createImageNode(serializedNode.src, {
228             alt: serializedNode.alt,
229             width: serializedNode.width,
230             height: serializedNode.height,
231         });
232         node.setAlignment(serializedNode.alignment);
233         return node;
234     }
235 }
236
237 export function $createImageNode(src: string, options: ImageNodeOptions = {}): ImageNode {
238     return new ImageNode(src, options);
239 }
240
241 export function $isImageNode(node: LexicalNode | null | undefined) {
242     return node instanceof ImageNode;
243 }