Skip to content

Commit 9401b13

Browse files
feat(text-editor): add metadata event for notifying about metadata changes (images and links)
Deprecated the `imageRemoved`. Use the `metadataChange` event instead to track image removals.
1 parent 1d04ded commit 9401b13

9 files changed

Lines changed: 593 additions & 99 deletions

File tree

etc/lime-elements.api.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1723,6 +1723,8 @@ export namespace JSX {
17231723
// @alpha
17241724
"onImageRemoved"?: (event: LimelProsemirrorAdapterCustomEvent<ImageInfo>) => void;
17251725
// @alpha
1726+
"onMetadataChange"?: (event: LimelProsemirrorAdapterCustomEvent<MetadataInfo>) => void;
1727+
// @alpha
17261728
"triggerCharacters"?: TriggerCharacter[];
17271729
"ui"?: EditorUiType;
17281730
"value"?: string;
@@ -1846,9 +1848,11 @@ export namespace JSX {
18461848
"onChange"?: (event: LimelTextEditorCustomEvent<string>) => void;
18471849
// @alpha
18481850
"onImagePasted"?: (event: LimelTextEditorCustomEvent<ImageInserter>) => void;
1849-
// @alpha
1851+
// @alpha @deprecated
18501852
"onImageRemoved"?: (event: LimelTextEditorCustomEvent<ImageInfo>) => void;
18511853
// @alpha
1854+
"onMetadataChange"?: (event: LimelTextEditorCustomEvent<MetadataInfo>) => void;
1855+
// @alpha
18521856
"onTriggerChange"?: (event: LimelTextEditorCustomEvent<TriggerEventDetail>) => void;
18531857
// @alpha
18541858
"onTriggerStart"?: (event: LimelTextEditorCustomEvent<TriggerEventDetail>) => void;
@@ -2368,6 +2372,12 @@ export interface Link {
23682372
title?: string;
23692373
}
23702374

2375+
// @alpha (undocumented)
2376+
export interface LinkInfo {
2377+
href: string;
2378+
text: string;
2379+
}
2380+
23712381
// @public
23722382
export interface ListComponent {
23732383
name: string;
@@ -2425,6 +2435,12 @@ export type MenuLoader = (item: MenuItem) => Promise<Array<MenuItem | ListSepara
24252435
// @public
24262436
export type MenuSearcher = (query: string) => Promise<Array<MenuItem | ListSeparator>>;
24272437

2438+
// @alpha
2439+
export interface MetadataInfo {
2440+
images: ImageInfo[];
2441+
links: LinkInfo[];
2442+
}
2443+
24282444
// @public (undocumented)
24292445
export type OfficeViewer = 'microsoft-office' | 'google-drive';
24302446

src/components/text-editor/examples/text-editor-with-inline-images-file-storage.tsx

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
ImageInfo,
66
LimelTextEditorCustomEvent,
77
LimelCheckboxCustomEvent,
8+
MetadataInfo,
9+
ImageState,
810
} from '@limetech/lime-elements';
911
/**
1012
* Handling inline images (with external file storage)
@@ -41,14 +43,16 @@ export class TextEditorWithInlineImagesExample {
4143
@State()
4244
private uploadImageFails = false;
4345

46+
private metadata: MetadataInfo = { links: [], images: [] };
47+
4448
public render() {
4549
return (
4650
<Host>
4751
<limel-text-editor
4852
value={this.value}
4953
onChange={this.handleChange}
5054
onImagePasted={this.handleImagePasted}
51-
onImageRemoved={this.handleImageRemoved}
55+
onMetadataChange={this.handleMetadataChange}
5256
/>
5357
<limel-checkbox
5458
label="Upload image fails - insert failed thumbnail"
@@ -109,19 +113,44 @@ export class TextEditorWithInlineImagesExample {
109113
}
110114
};
111115

112-
private handleImageRemoved = (
113-
event: LimelTextEditorCustomEvent<ImageInfo>,
116+
private handleMetadataChange = (
117+
event: LimelTextEditorCustomEvent<MetadataInfo>,
114118
) => {
115-
const imageInfo = event.detail;
116-
console.log(`Image deleted: ${imageInfo.fileInfoId}`);
119+
const removedImages = this.getRemovedImages(
120+
this.metadata,
121+
event.detail,
122+
);
123+
124+
removedImages.forEach((image) => {
125+
console.log(`Image removed: ${image.fileInfoId}`);
126+
127+
if (image.state === ImageState.SUCCESS) {
128+
this.removeImage(image);
129+
}
130+
});
131+
132+
this.metadata = event.detail;
133+
};
134+
135+
private getRemovedImages(
136+
oldMetadata: MetadataInfo,
137+
newMetadata: MetadataInfo,
138+
): ImageInfo[] {
139+
return oldMetadata.images.filter(
140+
(oldImage) =>
141+
!newMetadata.images.some(
142+
(newImage) => newImage.fileInfoId === oldImage.fileInfoId,
143+
),
144+
);
145+
}
146+
147+
private removeImage(image: ImageInfo) {
148+
// Remove image from external file storage if desired.
117149

118150
try {
119151
throw new Error('Not implemented.');
120152
} catch (error) {
121-
console.error(
122-
`Failed to delete image ${imageInfo.fileInfoId}`,
123-
error,
124-
);
153+
console.error(`Failed to remove image ${image.fileInfoId}`, error);
125154
}
126-
};
155+
}
127156
}

src/components/text-editor/prosemirror-adapter/plugins/image/inserter.ts

Lines changed: 2 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,16 @@
1-
import { Plugin, PluginKey, Transaction, StateField } from 'prosemirror-state';
1+
import { Plugin, PluginKey } from 'prosemirror-state';
22
import { EditorView } from 'prosemirror-view';
33
import { createFileInfo } from '../../../../../util/files';
44
import { FileInfo } from '../../../../../global/shared-types/file.types';
5-
import {
6-
ImageInserter,
7-
ImageInfo,
8-
ImageState,
9-
} from '../../../text-editor.types';
5+
import { ImageInserter, ImageState } from '../../../text-editor.types';
106
import { Node, Slice, Fragment } from 'prosemirror-model';
11-
import { imageCache } from './node';
127

138
export const pluginKey = new PluginKey('imageInserterPlugin');
149

1510
type ImagePastedCallback = (data: ImageInserter) => CustomEvent<ImageInserter>;
1611

17-
type ImageRemovedCallback = (data: ImageInfo) => CustomEvent<ImageInfo>;
18-
19-
type PluginState = {
20-
insertedImages: Record<string, Node>;
21-
};
22-
2312
export const createImageInserterPlugin = (
2413
imagePastedCallback: ImagePastedCallback,
25-
imageRemovedCallback: ImageRemovedCallback,
2614
) => {
2715
return new Plugin({
2816
key: pluginKey,
@@ -36,57 +24,7 @@ export const createImageInserterPlugin = (
3624
},
3725
},
3826
},
39-
state: {
40-
init: (): PluginState => {
41-
return { insertedImages: {} };
42-
},
43-
apply: (tr, pluginState): PluginState => {
44-
const newState = { ...pluginState };
45-
46-
newState.insertedImages = getImagesFromTransaction(tr);
47-
findAndHandleRemovedImages(
48-
imageRemovedCallback,
49-
pluginState.insertedImages,
50-
newState.insertedImages,
51-
);
52-
53-
return newState;
54-
},
55-
} as StateField<PluginState>,
56-
});
57-
};
58-
59-
const getImagesFromTransaction = (tr: Transaction): Record<string, Node> => {
60-
const images: Record<string, Node> = {};
61-
tr.doc.descendants((node) => {
62-
if (node.type.name === 'image') {
63-
images[node.attrs.fileInfoId] = node;
64-
}
6527
});
66-
67-
return images;
68-
};
69-
70-
const findAndHandleRemovedImages = (
71-
imageRemovedCallback: ImageRemovedCallback,
72-
previousImages: Record<string, Node>,
73-
newImages: Record<string, Node>,
74-
) => {
75-
const removedKeys = Object.keys(previousImages).filter(
76-
(key) => !(key in newImages),
77-
);
78-
79-
for (const removedKey of removedKeys) {
80-
const removedImage = previousImages[removedKey];
81-
const imageInfo: ImageInfo = {
82-
fileInfoId: removedImage.attrs.fileInfoId,
83-
src: removedImage.attrs.src,
84-
state: removedImage.attrs.state,
85-
};
86-
imageRemovedCallback(imageInfo);
87-
88-
imageCache.delete(removedImage.attrs.fileInfoId);
89-
}
9028
};
9129

9230
export const imageInserterFactory = (

src/components/text-editor/prosemirror-adapter/plugins/link-plugin.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,12 @@ import { schema } from 'prosemirror-schema-basic';
44
import { Mark } from 'prosemirror-model';
55
import { isExternalLink, isValidUrl } from '../menu/menu-commands';
66
import { EditorMenuTypes, MouseButtons } from '../menu/types';
7+
import { LinkInfo } from '../../text-editor.types';
78

89
export const linkPluginKey = new PluginKey('linkPlugin');
910

1011
export type UpdateLinkCallback = (text: string, href: string) => void;
1112

12-
export interface EditorLinkMenuEventDetail {
13-
href: string;
14-
text: string;
15-
}
16-
1713
const updateLink = (
1814
view: EditorView,
1915
updateLinkCallback?: UpdateLinkCallback,
@@ -138,14 +134,11 @@ const processModClickEvent = (view: EditorView, event: MouseEvent): boolean => {
138134
};
139135

140136
const openLinkMenu = (view: EditorView, href: string, text: string) => {
141-
const event = new CustomEvent<EditorLinkMenuEventDetail>(
142-
'open-editor-link-menu',
143-
{
144-
detail: { href: href, text: text },
145-
bubbles: true,
146-
composed: true,
147-
},
148-
);
137+
const event = new CustomEvent<LinkInfo>('open-editor-link-menu', {
138+
detail: { href: href, text: text },
139+
bubbles: true,
140+
composed: true,
141+
});
149142
view.dom.dispatchEvent(event);
150143
};
151144

src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,7 @@ import { isItem } from 'src/components/action-bar/isItem';
3434
import { cloneDeep, debounce } from 'lodash-es';
3535
import { Languages } from '../../date-picker/date.types';
3636
import { strikethrough } from './menu/menu-schema-extender';
37-
import {
38-
EditorLinkMenuEventDetail,
39-
createLinkPlugin,
40-
} from './plugins/link-plugin';
37+
import { createLinkPlugin } from './plugins/link-plugin';
4138
import { createImageInserterPlugin } from './plugins/image/inserter';
4239
import { createImageViewPlugin } from './plugins/image/view';
4340
import { createMenuStateTrackingPlugin } from './plugins/menu-state-tracking-plugin';
@@ -49,10 +46,16 @@ import {
4946
TriggerCharacter,
5047
ImageInserter,
5148
ImageInfo,
49+
MetadataInfo,
50+
LinkInfo,
5251
} from '../text-editor.types';
5352
import { getTableNodes, getTableEditingPlugins } from './plugins/table-plugin';
5453
import { getImageNode, imageCache } from './plugins/image/node';
5554
import { EditorUiType } from '../types';
55+
import {
56+
getMetadataFromDoc,
57+
hasMetadataChanged,
58+
} from '../utils/metadata-utils';
5659

5760
const DEBOUNCE_TIMEOUT = 300;
5861

@@ -153,6 +156,8 @@ export class ProsemirrorAdapter {
153156
private lastEmittedValue: string;
154157
private changeWaiting = false;
155158

159+
private metadata: MetadataInfo = { images: [], links: [] };
160+
156161
/**
157162
* Used to stop change event emitting as result of getting updated value from consumer
158163
*/
@@ -182,6 +187,15 @@ export class ProsemirrorAdapter {
182187
@Event()
183188
private imageRemoved: EventEmitter<ImageInfo>;
184189

190+
/**
191+
* Dispatched when the metadata of the editor changes (images and links)
192+
*
193+
* @private
194+
* @alpha
195+
*/
196+
@Event()
197+
private metadataChange: EventEmitter<MetadataInfo>;
198+
185199
constructor() {
186200
this.portalId = createRandomString();
187201
}
@@ -404,10 +418,7 @@ export class ProsemirrorAdapter {
404418
this.contentConverter,
405419
),
406420
createLinkPlugin(this.handleNewLinkSelection),
407-
createImageInserterPlugin(
408-
this.imagePasted.emit,
409-
this.imageRemoved.emit,
410-
),
421+
createImageInserterPlugin(this.imagePasted.emit),
411422
createImageViewPlugin(this.language),
412423
createMenuStateTrackingPlugin(
413424
editorMenuTypesArray,
@@ -456,6 +467,10 @@ export class ProsemirrorAdapter {
456467
const tr = this.view.state.tr;
457468
tr.replaceWith(0, tr.doc.content.size, prosemirrorDoc.content);
458469
this.view.dispatch(tr);
470+
471+
const metadata = getMetadataFromDoc(this.view.state.doc);
472+
this.metadataEmitter(metadata);
473+
459474
this.suppressChangeEvent = false;
460475
}
461476

@@ -473,11 +488,39 @@ export class ProsemirrorAdapter {
473488
return;
474489
}
475490

491+
const metadata = getMetadataFromDoc(newState.doc);
492+
this.metadataEmitter(metadata);
493+
476494
this.lastEmittedValue = content;
477495
this.changeWaiting = true;
478496
this.changeEmitter(content);
479497
};
480498

499+
private metadataEmitter(metadata: MetadataInfo) {
500+
if (hasMetadataChanged(this.metadata, metadata)) {
501+
this.removeImagesFromCache(metadata, this.metadata);
502+
this.metadata = metadata;
503+
this.metadataChange.emit(metadata);
504+
}
505+
}
506+
507+
private removeImagesFromCache(
508+
newMetadata: MetadataInfo,
509+
oldMetadata: MetadataInfo,
510+
) {
511+
const removedImages = oldMetadata.images.filter(
512+
(oldImage) =>
513+
!newMetadata.images.some(
514+
(newImage) => newImage.fileInfoId === oldImage.fileInfoId,
515+
),
516+
);
517+
518+
removedImages.forEach((image) => {
519+
imageCache.delete(image.fileInfoId);
520+
this.imageRemoved.emit(image);
521+
});
522+
}
523+
481524
private handleActionBarItem = (
482525
event: CustomEvent<ActionBarItem<EditorMenuTypes>>,
483526
) => {
@@ -534,9 +577,7 @@ export class ProsemirrorAdapter {
534577
this.link.href = href || 'https://';
535578
};
536579

537-
private handleOpenLinkMenu = (
538-
event: CustomEvent<EditorLinkMenuEventDetail>,
539-
) => {
580+
private handleOpenLinkMenu = (event: CustomEvent<LinkInfo>) => {
540581
event.stopImmediatePropagation();
541582
const { href, text } = event.detail;
542583
this.link = { href: href, text: text };

0 commit comments

Comments
 (0)