@flogeez/angular-tiptap-editor 3.7.0 → 3.8.1
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/CHANGELOG.md +18 -0
- package/esm2022/lib/components/editor/angular-tiptap-editor.component.mjs +30 -20
- package/esm2022/lib/components/slash-commands/ate-slash-commands.component.mjs +5 -7
- package/esm2022/lib/services/ate-image.service.mjs +83 -36
- package/fesm2022/flogeez-angular-tiptap-editor.mjs +114 -60
- package/fesm2022/flogeez-angular-tiptap-editor.mjs.map +1 -1
- package/lib/components/editor/angular-tiptap-editor.component.d.ts +7 -0
- package/lib/services/ate-image.service.d.ts +15 -1
- package/package.json +12 -12
|
@@ -4109,6 +4109,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
4109
4109
|
args: [{ selector: "ate-separator", standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, template: `<div class="ate-separator"></div>`, styles: [".ate-separator{width:1px;height:24px;background-color:var(--ate-border, #e2e8f0);margin:0 var(--ate-menu-gap, 2px);flex-shrink:0}\n"] }]
|
|
4110
4110
|
}] });
|
|
4111
4111
|
|
|
4112
|
+
/** Whether `pos` holds an image node (`NodeSelection.create` throws on an empty position) */
|
|
4113
|
+
function isImageAt(doc, pos) {
|
|
4114
|
+
return pos >= 0 && pos < doc.content.size && doc.nodeAt(pos)?.type.name === "resizableImage";
|
|
4115
|
+
}
|
|
4112
4116
|
class AteImageService {
|
|
4113
4117
|
constructor() {
|
|
4114
4118
|
/** Signals for image state */
|
|
@@ -4155,11 +4159,15 @@ class AteImageService {
|
|
|
4155
4159
|
.chain()
|
|
4156
4160
|
.focus()
|
|
4157
4161
|
.setResizableImage(imageData)
|
|
4158
|
-
.command(({ tr
|
|
4162
|
+
.command(({ tr }) => {
|
|
4159
4163
|
// Selection is usually after the node. Node is at tr.selection.from - 1.
|
|
4164
|
+
// Only select a position that holds an image: NodeSelection throws on an empty position.
|
|
4160
4165
|
const { from } = tr.selection;
|
|
4161
|
-
|
|
4162
|
-
|
|
4166
|
+
const imagePos = [from, from - 1].find(pos => isImageAt(tr.doc, pos));
|
|
4167
|
+
if (imagePos !== undefined) {
|
|
4168
|
+
tr.setSelection(NodeSelection.create(tr.doc, imagePos));
|
|
4169
|
+
}
|
|
4170
|
+
return true;
|
|
4163
4171
|
})
|
|
4164
4172
|
.run();
|
|
4165
4173
|
}
|
|
@@ -4523,42 +4531,80 @@ class AteImageService {
|
|
|
4523
4531
|
}
|
|
4524
4532
|
/** Select file and replace currently selected image */
|
|
4525
4533
|
async selectAndReplaceImage(editor, options) {
|
|
4526
|
-
//
|
|
4527
|
-
|
|
4528
|
-
|
|
4534
|
+
// Remember the image now: opening the file picker can move the selection (e.g. on WebKit)
|
|
4535
|
+
const target = this.trackSelectedImage(editor);
|
|
4536
|
+
try {
|
|
4537
|
+
await this.selectFileAndProcess(editor, (ed, file, opts, source) => this.uploadAndReplaceImage(ed, file, opts, source, target), options);
|
|
4538
|
+
}
|
|
4539
|
+
finally {
|
|
4540
|
+
target.stop();
|
|
4541
|
+
}
|
|
4529
4542
|
}
|
|
4530
4543
|
/** Internal helper used by replacement logic */
|
|
4531
|
-
async uploadAndReplaceImage(editor, file, options, source = "api") {
|
|
4532
|
-
//
|
|
4533
|
-
const
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
4544
|
+
async uploadAndReplaceImage(editor, file, options, source = "api", target) {
|
|
4545
|
+
// The image to replace is the one selected when the replacement started, wherever the selection is after the upload
|
|
4546
|
+
const tracked = target ?? this.trackSelectedImage(editor);
|
|
4547
|
+
try {
|
|
4548
|
+
await this.uploadImageWithProgress(editor, file, (ed, result) => {
|
|
4549
|
+
const imageData = {
|
|
4550
|
+
src: result.src,
|
|
4551
|
+
alt: result.name,
|
|
4552
|
+
title: `${result.name} (${result.width}×${result.height})`,
|
|
4553
|
+
width: result.width,
|
|
4554
|
+
height: result.height,
|
|
4555
|
+
};
|
|
4556
|
+
if (this.replaceImageAt(ed, tracked.pos(), imageData)) {
|
|
4557
|
+
this.updateSelectedImage(imageData);
|
|
4558
|
+
}
|
|
4559
|
+
else {
|
|
4560
|
+
// No image to replace (none was selected, or it was removed during the upload): insert at cursor
|
|
4561
|
+
this.insertImage(ed, imageData);
|
|
4562
|
+
}
|
|
4563
|
+
// After replacement, ensure the image is tracked in our signals
|
|
4564
|
+
this.selectImage(ed);
|
|
4565
|
+
}, this.t().replacingImage, options, source);
|
|
4566
|
+
}
|
|
4567
|
+
finally {
|
|
4568
|
+
if (!target) {
|
|
4569
|
+
tracked.stop();
|
|
4554
4570
|
}
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4571
|
+
}
|
|
4572
|
+
}
|
|
4573
|
+
/**
|
|
4574
|
+
* Remembers the selected image and follows its position through the transactions
|
|
4575
|
+
* dispatched until `stop()` (e.g. while the file picker is open and the image uploads).
|
|
4576
|
+
*/
|
|
4577
|
+
trackSelectedImage(editor) {
|
|
4578
|
+
const { selection } = editor.state;
|
|
4579
|
+
let pos = selection instanceof NodeSelection && isImageAt(editor.state.doc, selection.from)
|
|
4580
|
+
? selection.from
|
|
4581
|
+
: null;
|
|
4582
|
+
const onTransaction = ({ transaction }) => {
|
|
4583
|
+
if (pos !== null && transaction.docChanged) {
|
|
4584
|
+
pos = transaction.mapping.map(pos);
|
|
4558
4585
|
}
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4586
|
+
};
|
|
4587
|
+
editor.on("transaction", onTransaction);
|
|
4588
|
+
return {
|
|
4589
|
+
pos: () => pos,
|
|
4590
|
+
stop: () => editor.off("transaction", onTransaction),
|
|
4591
|
+
};
|
|
4592
|
+
}
|
|
4593
|
+
/** Replaces the image at `pos` with `imageData` (keeping its other attributes) and selects it */
|
|
4594
|
+
replaceImageAt(editor, pos, imageData) {
|
|
4595
|
+
if (pos === null || !isImageAt(editor.state.doc, pos)) {
|
|
4596
|
+
return false;
|
|
4597
|
+
}
|
|
4598
|
+
return editor
|
|
4599
|
+
.chain()
|
|
4600
|
+
.focus()
|
|
4601
|
+
.command(({ tr }) => {
|
|
4602
|
+
const node = tr.doc.nodeAt(pos);
|
|
4603
|
+
tr.setNodeMarkup(pos, undefined, { ...node.attrs, ...imageData });
|
|
4604
|
+
tr.setSelection(NodeSelection.create(tr.doc, pos));
|
|
4605
|
+
return true;
|
|
4606
|
+
})
|
|
4607
|
+
.run();
|
|
4562
4608
|
}
|
|
4563
4609
|
/**
|
|
4564
4610
|
* Handle image drop event.
|
|
@@ -8816,12 +8862,10 @@ class AteSlashCommandsComponent {
|
|
|
8816
8862
|
// Hide menu immediately
|
|
8817
8863
|
this.hideTippy();
|
|
8818
8864
|
this.isActive = false;
|
|
8819
|
-
// Give focus back to editor and execute command
|
|
8820
|
-
//
|
|
8821
|
-
|
|
8822
|
-
|
|
8823
|
-
command.command(ed);
|
|
8824
|
-
}, 10);
|
|
8865
|
+
// Give focus back to editor and execute command right away: ProseMirror updates the DOM synchronously
|
|
8866
|
+
// on dispatch, and any delay lets keys typed in the meantime land in the paragraph the "/" was typed in
|
|
8867
|
+
ed.commands.focus();
|
|
8868
|
+
command.command(ed);
|
|
8825
8869
|
}
|
|
8826
8870
|
addKeyboardPlugin(ed) {
|
|
8827
8871
|
// Add a ProseMirror plugin to intercept keyboard events
|
|
@@ -9958,6 +10002,8 @@ class AngularTiptapEditorComponent {
|
|
|
9958
10002
|
this._wordCount = signal(0);
|
|
9959
10003
|
this._isDragOver = signal(false);
|
|
9960
10004
|
this._editorFullyInitialized = signal(false);
|
|
10005
|
+
/** Technical config the current editor was created with, see `technicalConfig()` */
|
|
10006
|
+
this.editorTechnicalConfig = null;
|
|
9961
10007
|
this._hoveredBlock = signal(null);
|
|
9962
10008
|
// Anti-echo: track last emitted HTML to prevent cursor reset on parent echo
|
|
9963
10009
|
this.lastEmittedHtml = null;
|
|
@@ -10246,22 +10292,19 @@ class AngularTiptapEditorComponent {
|
|
|
10246
10292
|
}, { allowSignalWrites: true });
|
|
10247
10293
|
// Effect to re-initialize editor when technical configuration changes
|
|
10248
10294
|
effect(() => {
|
|
10249
|
-
|
|
10250
|
-
this.finalTiptapExtensions();
|
|
10251
|
-
this.finalTiptapOptions();
|
|
10252
|
-
this.finalStarterKitOptions();
|
|
10253
|
-
this.finalAngularNodesConfig();
|
|
10254
|
-
this.finalBlockControls();
|
|
10295
|
+
const technicalConfig = this.technicalConfig();
|
|
10255
10296
|
untracked(() => {
|
|
10256
|
-
|
|
10257
|
-
|
|
10258
|
-
|
|
10259
|
-
|
|
10260
|
-
|
|
10261
|
-
|
|
10262
|
-
|
|
10263
|
-
}
|
|
10297
|
+
const currentEditor = this.editor();
|
|
10298
|
+
// Before the first initEditor (AfterViewInit) there is nothing to re-create: it reads the latest config.
|
|
10299
|
+
// Compare with the config the editor was created with, so a change is never missed, however soon it comes.
|
|
10300
|
+
if (!currentEditor ||
|
|
10301
|
+
!this.editorTechnicalConfig ||
|
|
10302
|
+
technicalConfig.every((value, index) => value === this.editorTechnicalConfig[index])) {
|
|
10303
|
+
return;
|
|
10264
10304
|
}
|
|
10305
|
+
currentEditor.destroy();
|
|
10306
|
+
this._editorFullyInitialized.set(false);
|
|
10307
|
+
this.initEditor();
|
|
10265
10308
|
});
|
|
10266
10309
|
}, { allowSignalWrites: true });
|
|
10267
10310
|
this.editorCommandsService.onImageUploaded = result => {
|
|
@@ -10290,7 +10333,21 @@ class AngularTiptapEditorComponent {
|
|
|
10290
10333
|
this.editorRegistry.unregister(id);
|
|
10291
10334
|
}
|
|
10292
10335
|
}
|
|
10336
|
+
/**
|
|
10337
|
+
* Settings that require re-creating the editor when they change.
|
|
10338
|
+
* Each computed compares by value, so a new reference means a real change.
|
|
10339
|
+
*/
|
|
10340
|
+
technicalConfig() {
|
|
10341
|
+
return [
|
|
10342
|
+
this.finalTiptapExtensions(),
|
|
10343
|
+
this.finalTiptapOptions(),
|
|
10344
|
+
this.finalStarterKitOptions(),
|
|
10345
|
+
this.finalAngularNodesConfig(),
|
|
10346
|
+
this.finalBlockControls(),
|
|
10347
|
+
];
|
|
10348
|
+
}
|
|
10293
10349
|
initEditor() {
|
|
10350
|
+
this.editorTechnicalConfig = this.technicalConfig();
|
|
10294
10351
|
this.editorCommandsService.srcResolver = this.finalImageSrcResolver() ?? null;
|
|
10295
10352
|
const starterKitOptions = this.finalStarterKitOptions();
|
|
10296
10353
|
const customExtensions = this.finalTiptapExtensions();
|
|
@@ -10528,11 +10585,8 @@ class AngularTiptapEditorComponent {
|
|
|
10528
10585
|
onCreate: ({ editor }) => {
|
|
10529
10586
|
this.editorCreated.emit(editor);
|
|
10530
10587
|
this.updateCharacterCount(editor);
|
|
10531
|
-
//
|
|
10532
|
-
|
|
10533
|
-
setTimeout(() => {
|
|
10534
|
-
this._editorFullyInitialized.set(true);
|
|
10535
|
-
}, 100);
|
|
10588
|
+
// Tiptap emits create once every extension and plugin is mounted: the menus can show
|
|
10589
|
+
this._editorFullyInitialized.set(true);
|
|
10536
10590
|
},
|
|
10537
10591
|
onFocus: ({ editor, event }) => {
|
|
10538
10592
|
const id = this.registeredId();
|