@flogeez/angular-tiptap-editor 3.8.0 → 3.9.0
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 +25 -0
- package/esm2022/lib/components/editor/angular-tiptap-editor.component.mjs +30 -20
- package/esm2022/lib/components/editor/ate-block-controls.component.mjs +67 -18
- package/esm2022/lib/components/slash-commands/ate-slash-commands.component.mjs +5 -7
- package/esm2022/lib/extensions/ate-block-controls.extension.mjs +222 -4
- package/esm2022/lib/services/ate-image.service.mjs +83 -36
- package/fesm2022/flogeez-angular-tiptap-editor.mjs +550 -231
- package/fesm2022/flogeez-angular-tiptap-editor.mjs.map +1 -1
- package/lib/components/editor/angular-tiptap-editor.component.d.ts +7 -0
- package/lib/components/editor/ate-block-controls.component.d.ts +9 -0
- package/lib/extensions/ate-block-controls.extension.d.ts +10 -0
- package/lib/services/ate-image.service.d.ts +15 -1
- package/package.json +1 -1
- package/src/lib/styles/index.css +13 -0
- package/styles/index.css +13 -0
|
@@ -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
|
|
@@ -8944,6 +8988,382 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
8944
8988
|
`, styles: [".slash-commands-menu{background:var(--ate-menu-bg);border:1px solid var(--ate-menu-border);border-radius:var(--ate-menu-border-radius, 12px);box-shadow:var(--ate-menu-shadow);padding:var(--ate-menu-padding);max-height:320px;overflow-y:auto;min-width:280px;outline:none;animation:slashMenuFadeIn .2s cubic-bezier(0,0,.2,1);scrollbar-width:thin;scrollbar-color:var(--ate-scrollbar-thumb) var(--ate-scrollbar-track)}.slash-commands-menu::-webkit-scrollbar{width:var(--ate-scrollbar-width)}.slash-commands-menu::-webkit-scrollbar-track{background:var(--ate-scrollbar-track)}.slash-commands-menu::-webkit-scrollbar-thumb{background:var(--ate-scrollbar-thumb);border:3px solid transparent;background-clip:content-box;border-radius:10px}.slash-commands-menu::-webkit-scrollbar-thumb:hover{background:var(--ate-scrollbar-thumb-hover);background-clip:content-box}@keyframes slashMenuFadeIn{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}.slash-command-item{display:flex;align-items:center;gap:12px;padding:var(--ate-menu-padding);border-radius:var(--ate-menu-border-radius, 8px);cursor:pointer;transition:all .15s ease;border:var(--ate-border-width, 1px) solid transparent;outline:none;margin-bottom:2px}.slash-command-item:last-child{margin-bottom:0}.slash-command-item:hover{background:var(--ate-surface-secondary)}.slash-command-item.selected{background:var(--ate-primary-light);border-color:var(--ate-primary-light-alpha)}.slash-command-icon{display:flex;align-items:center;justify-content:center;width:32px;height:32px;background:var(--ate-surface-tertiary);border-radius:var(--ate-sub-border-radius, 8px);color:var(--ate-primary);flex-shrink:0;transition:all .15s ease}.slash-command-item.selected .slash-command-icon{background:var(--ate-primary);color:var(--ate-primary-contrast, #ffffff)}.slash-command-icon .ate-icon{font-size:18px}.slash-command-content{flex:1;min-width:0}.slash-command-title{font-weight:500;color:var(--ate-text);font-size:14px;margin-bottom:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.slash-command-description{color:var(--ate-text-secondary);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n"] }]
|
|
8945
8989
|
}], ctorParameters: () => [] });
|
|
8946
8990
|
|
|
8991
|
+
const ATE_BLOCK_DRAG_MIME = "application/x-ate-block-drag";
|
|
8992
|
+
const DRAGGING_CLASS = "ate-block-dragging";
|
|
8993
|
+
const DROP_BEFORE_CLASS = "ate-block-drop-before";
|
|
8994
|
+
const DROP_AFTER_CLASS = "ate-block-drop-after";
|
|
8995
|
+
const blockDragKey = new PluginKey("ateBlockDrag");
|
|
8996
|
+
const blockDragResetters = new WeakMap();
|
|
8997
|
+
function endAteBlockDrag(view) {
|
|
8998
|
+
blockDragResetters.get(view)?.();
|
|
8999
|
+
}
|
|
9000
|
+
function ateMultiBlockSelection(state, blockPos) {
|
|
9001
|
+
const covered = coveredTopLevelRange(state.doc, state.selection);
|
|
9002
|
+
return covered && blockPos >= covered.from && blockPos < covered.to ? covered : null;
|
|
9003
|
+
}
|
|
9004
|
+
function coveredTopLevelRange(doc, selection) {
|
|
9005
|
+
const blocks = [];
|
|
9006
|
+
doc.forEach((node, offset) => {
|
|
9007
|
+
const end = offset + node.nodeSize;
|
|
9008
|
+
if (offset < selection.to && end > selection.from) {
|
|
9009
|
+
blocks.push({ from: offset, to: end });
|
|
9010
|
+
}
|
|
9011
|
+
});
|
|
9012
|
+
const last = blocks.at(-1);
|
|
9013
|
+
if (blocks.length < 2 || !last) {
|
|
9014
|
+
return null;
|
|
9015
|
+
}
|
|
9016
|
+
return { from: blocks[0].from, to: last.to };
|
|
9017
|
+
}
|
|
9018
|
+
function topLevelDropPos(view, point) {
|
|
9019
|
+
const hit = view.posAtCoords({ left: point.clientX, top: point.clientY });
|
|
9020
|
+
if (!hit) {
|
|
9021
|
+
return null;
|
|
9022
|
+
}
|
|
9023
|
+
const $pos = view.state.doc.resolve(hit.pos);
|
|
9024
|
+
if ($pos.depth === 0) {
|
|
9025
|
+
return hit.pos;
|
|
9026
|
+
}
|
|
9027
|
+
const start = $pos.before(1);
|
|
9028
|
+
const dom = view.nodeDOM(start);
|
|
9029
|
+
if (!(dom instanceof Element)) {
|
|
9030
|
+
return $pos.after(1);
|
|
9031
|
+
}
|
|
9032
|
+
const rect = dom.getBoundingClientRect();
|
|
9033
|
+
return point.clientY < rect.top + (rect.bottom - rect.top) / 2 ? start : $pos.after(1);
|
|
9034
|
+
}
|
|
9035
|
+
function dropIndicatorDecoration(doc, pos) {
|
|
9036
|
+
if (pos > doc.content.size || doc.resolve(pos).depth !== 0) {
|
|
9037
|
+
return null;
|
|
9038
|
+
}
|
|
9039
|
+
if (pos < doc.content.size) {
|
|
9040
|
+
const next = doc.nodeAt(pos);
|
|
9041
|
+
return next ? Decoration.node(pos, pos + next.nodeSize, { class: DROP_BEFORE_CLASS }) : null;
|
|
9042
|
+
}
|
|
9043
|
+
const last = doc.lastChild;
|
|
9044
|
+
return last ? Decoration.node(pos - last.nodeSize, pos, { class: DROP_AFTER_CLASS }) : null;
|
|
9045
|
+
}
|
|
9046
|
+
// Helper to get the DOM element properly
|
|
9047
|
+
function getDomElement(view, pos) {
|
|
9048
|
+
const dom = view.nodeDOM(pos);
|
|
9049
|
+
if (!dom) {
|
|
9050
|
+
return null;
|
|
9051
|
+
}
|
|
9052
|
+
if (dom instanceof HTMLElement) {
|
|
9053
|
+
return dom;
|
|
9054
|
+
}
|
|
9055
|
+
return dom.parentElement;
|
|
9056
|
+
}
|
|
9057
|
+
// VERTICAL SCAN (For the empty margin)
|
|
9058
|
+
function findBlockElementAtY(view, y) {
|
|
9059
|
+
const editorDom = view.dom;
|
|
9060
|
+
const children = Array.from(editorDom.children);
|
|
9061
|
+
// We look for the direct child of the editor (Level 1) that intercepts the Y line
|
|
9062
|
+
return (children.find(child => {
|
|
9063
|
+
if (getComputedStyle(child).position === "absolute") {
|
|
9064
|
+
return false;
|
|
9065
|
+
}
|
|
9066
|
+
const rect = child.getBoundingClientRect();
|
|
9067
|
+
// Tolérance de 5px
|
|
9068
|
+
return y >= rect.top - 5 && y <= rect.bottom + 5;
|
|
9069
|
+
}) || null);
|
|
9070
|
+
}
|
|
9071
|
+
function resolveBlockData(view, y, targetElement) {
|
|
9072
|
+
let blockEl = null;
|
|
9073
|
+
// 1. FAST PATH : Direct target
|
|
9074
|
+
// LEVEL 1: We look for the first parent element that is a direct child of the editor
|
|
9075
|
+
if (targetElement && !targetElement.classList.contains("ProseMirror")) {
|
|
9076
|
+
blockEl = targetElement.closest(".ProseMirror > *");
|
|
9077
|
+
}
|
|
9078
|
+
// 2. GUTTER PATH : Vertical scan
|
|
9079
|
+
if (!blockEl) {
|
|
9080
|
+
blockEl = findBlockElementAtY(view, y);
|
|
9081
|
+
}
|
|
9082
|
+
if (!blockEl) {
|
|
9083
|
+
return null;
|
|
9084
|
+
}
|
|
9085
|
+
// 3. PROSEMIRROR NODE RESOLUTION
|
|
9086
|
+
try {
|
|
9087
|
+
// We ask for the position at the beginning of this root block
|
|
9088
|
+
const pos = view.posAtDOM(blockEl, 0);
|
|
9089
|
+
const $pos = view.state.doc.resolve(pos);
|
|
9090
|
+
// CASE A : STANDARD BLOCKS (H1, P, Lists, Blockquotes...)
|
|
9091
|
+
// We make sure to take the node of depth 1 (Direct child of the Doc)
|
|
9092
|
+
if ($pos.depth >= 1) {
|
|
9093
|
+
const rootNode = $pos.node(1);
|
|
9094
|
+
const rootPos = $pos.before(1);
|
|
9095
|
+
const rootElement = getDomElement(view, rootPos);
|
|
9096
|
+
// Sécurité : on vérifie que l'élément DOM correspond bien
|
|
9097
|
+
if (rootNode && (rootElement || blockEl)) {
|
|
9098
|
+
return { node: rootNode, element: rootElement || blockEl, pos: rootPos };
|
|
9099
|
+
}
|
|
9100
|
+
}
|
|
9101
|
+
// CASE B : IMAGES / ATOMS (Depth 0)
|
|
9102
|
+
// Sometimes a root image is seen as Depth 0 by resolve()
|
|
9103
|
+
// We look at the node right after the position
|
|
9104
|
+
if ($pos.depth === 0) {
|
|
9105
|
+
const nodeAfter = $pos.nodeAfter;
|
|
9106
|
+
if (nodeAfter && (nodeAfter.isBlock || nodeAfter.isAtom)) {
|
|
9107
|
+
return { node: nodeAfter, element: blockEl, pos: pos };
|
|
9108
|
+
}
|
|
9109
|
+
}
|
|
9110
|
+
}
|
|
9111
|
+
catch (_e) {
|
|
9112
|
+
// console.warn(_e);
|
|
9113
|
+
}
|
|
9114
|
+
return null;
|
|
9115
|
+
}
|
|
9116
|
+
function createBlockDragPlugin() {
|
|
9117
|
+
let active = null;
|
|
9118
|
+
let marked = null;
|
|
9119
|
+
let stopListening = null;
|
|
9120
|
+
const setIndicator = (view, pos) => {
|
|
9121
|
+
if (blockDragKey.getState(view.state)?.pos !== pos) {
|
|
9122
|
+
view.dispatch(view.state.tr.setMeta(blockDragKey, pos));
|
|
9123
|
+
}
|
|
9124
|
+
};
|
|
9125
|
+
const reset = (view) => {
|
|
9126
|
+
if (active && view.dragging?.slice === active.slice) {
|
|
9127
|
+
view.dragging = null;
|
|
9128
|
+
}
|
|
9129
|
+
active = null;
|
|
9130
|
+
stopListening?.();
|
|
9131
|
+
stopListening = null;
|
|
9132
|
+
marked?.classList.remove(DRAGGING_CLASS);
|
|
9133
|
+
marked = null;
|
|
9134
|
+
if (!view.isDestroyed) {
|
|
9135
|
+
setIndicator(view, null);
|
|
9136
|
+
}
|
|
9137
|
+
};
|
|
9138
|
+
const listenForDragEnd = (view) => {
|
|
9139
|
+
stopListening?.();
|
|
9140
|
+
const onEnd = () => reset(view);
|
|
9141
|
+
const events = ["dragend", "drop"];
|
|
9142
|
+
events.forEach(name => window.addEventListener(name, onEnd));
|
|
9143
|
+
stopListening = () => events.forEach(name => window.removeEventListener(name, onEnd));
|
|
9144
|
+
};
|
|
9145
|
+
const resetOnForeignDrag = (view, event) => {
|
|
9146
|
+
if (active && !Array.from(event.dataTransfer?.types ?? []).includes(ATE_BLOCK_DRAG_MIME)) {
|
|
9147
|
+
reset(view);
|
|
9148
|
+
}
|
|
9149
|
+
};
|
|
9150
|
+
return new Plugin({
|
|
9151
|
+
key: blockDragKey,
|
|
9152
|
+
state: {
|
|
9153
|
+
init: () => ({ pos: null }),
|
|
9154
|
+
apply: (tr, value) => {
|
|
9155
|
+
const meta = tr.getMeta(blockDragKey);
|
|
9156
|
+
if (meta !== undefined) {
|
|
9157
|
+
return { pos: meta };
|
|
9158
|
+
}
|
|
9159
|
+
return tr.docChanged ? { pos: null } : value;
|
|
9160
|
+
},
|
|
9161
|
+
},
|
|
9162
|
+
view: view => {
|
|
9163
|
+
blockDragResetters.set(view, () => reset(view));
|
|
9164
|
+
return {
|
|
9165
|
+
destroy: () => {
|
|
9166
|
+
reset(view);
|
|
9167
|
+
blockDragResetters.delete(view);
|
|
9168
|
+
},
|
|
9169
|
+
};
|
|
9170
|
+
},
|
|
9171
|
+
props: {
|
|
9172
|
+
decorations: state => {
|
|
9173
|
+
const pos = blockDragKey.getState(state)?.pos;
|
|
9174
|
+
if (pos === null || pos === undefined) {
|
|
9175
|
+
return null;
|
|
9176
|
+
}
|
|
9177
|
+
const decoration = dropIndicatorDecoration(state.doc, pos);
|
|
9178
|
+
return decoration ? DecorationSet.create(state.doc, [decoration]) : null;
|
|
9179
|
+
},
|
|
9180
|
+
handleDOMEvents: {
|
|
9181
|
+
dragstart: (view, event) => {
|
|
9182
|
+
const transfer = event.dataTransfer;
|
|
9183
|
+
if (!view.editable ||
|
|
9184
|
+
!transfer ||
|
|
9185
|
+
!Array.from(transfer.types).includes(ATE_BLOCK_DRAG_MIME)) {
|
|
9186
|
+
return false;
|
|
9187
|
+
}
|
|
9188
|
+
reset(view);
|
|
9189
|
+
const { selection, doc } = view.state;
|
|
9190
|
+
const range = coveredTopLevelRange(doc, selection) ?? {
|
|
9191
|
+
from: selection.from,
|
|
9192
|
+
to: selection.to,
|
|
9193
|
+
};
|
|
9194
|
+
if (range.from === range.to) {
|
|
9195
|
+
return false;
|
|
9196
|
+
}
|
|
9197
|
+
const slice = doc.slice(range.from, range.to);
|
|
9198
|
+
active = { ...range, doc, slice };
|
|
9199
|
+
view.dragging = { slice, move: true };
|
|
9200
|
+
marked =
|
|
9201
|
+
view.dom.offsetParent instanceof HTMLElement
|
|
9202
|
+
? view.dom.offsetParent
|
|
9203
|
+
: view.dom.parentElement;
|
|
9204
|
+
marked?.classList.add(DRAGGING_CLASS);
|
|
9205
|
+
listenForDragEnd(view);
|
|
9206
|
+
const { dom, text } = view.serializeForClipboard(slice);
|
|
9207
|
+
transfer.clearData();
|
|
9208
|
+
transfer.setData("text/html", dom.innerHTML);
|
|
9209
|
+
transfer.setData("text/plain", text);
|
|
9210
|
+
transfer.setData(ATE_BLOCK_DRAG_MIME, "1");
|
|
9211
|
+
return true;
|
|
9212
|
+
},
|
|
9213
|
+
dragleave: (view, event) => {
|
|
9214
|
+
const related = event.relatedTarget;
|
|
9215
|
+
if (active && !(related instanceof globalThis.Node && view.dom.contains(related))) {
|
|
9216
|
+
setIndicator(view, null);
|
|
9217
|
+
}
|
|
9218
|
+
return false;
|
|
9219
|
+
},
|
|
9220
|
+
dragenter: (view, event) => {
|
|
9221
|
+
resetOnForeignDrag(view, event);
|
|
9222
|
+
return false;
|
|
9223
|
+
},
|
|
9224
|
+
dragover: (view, event) => {
|
|
9225
|
+
resetOnForeignDrag(view, event);
|
|
9226
|
+
if (active && !event.dataTransfer?.files?.length) {
|
|
9227
|
+
setIndicator(view, topLevelDropPos(view, event));
|
|
9228
|
+
}
|
|
9229
|
+
return false;
|
|
9230
|
+
},
|
|
9231
|
+
},
|
|
9232
|
+
handleDrop: (view, event) => {
|
|
9233
|
+
const drag = active;
|
|
9234
|
+
if (!drag || event.dataTransfer?.files?.length) {
|
|
9235
|
+
return false;
|
|
9236
|
+
}
|
|
9237
|
+
if (drag.doc !== view.state.doc) {
|
|
9238
|
+
event.preventDefault();
|
|
9239
|
+
reset(view);
|
|
9240
|
+
return true;
|
|
9241
|
+
}
|
|
9242
|
+
const target = topLevelDropPos(view, event);
|
|
9243
|
+
if (target === null) {
|
|
9244
|
+
return false;
|
|
9245
|
+
}
|
|
9246
|
+
event.preventDefault();
|
|
9247
|
+
if (target >= drag.from && target <= drag.to) {
|
|
9248
|
+
reset(view);
|
|
9249
|
+
return true;
|
|
9250
|
+
}
|
|
9251
|
+
const tr = view.state.tr.delete(drag.from, drag.to);
|
|
9252
|
+
const at = tr.mapping.map(target);
|
|
9253
|
+
tr.insert(at, drag.slice.content);
|
|
9254
|
+
const { content } = drag.slice;
|
|
9255
|
+
const only = content.childCount === 1 ? content.firstChild : null;
|
|
9256
|
+
tr.setSelection(only && NodeSelection.isSelectable(only)
|
|
9257
|
+
? NodeSelection.create(tr.doc, at)
|
|
9258
|
+
: TextSelection.between(tr.doc.resolve(at), tr.doc.resolve(at + content.size)));
|
|
9259
|
+
tr.setMeta(blockDragKey, null).setMeta("uiEvent", "drop");
|
|
9260
|
+
view.dispatch(tr.scrollIntoView());
|
|
9261
|
+
reset(view);
|
|
9262
|
+
return true;
|
|
9263
|
+
},
|
|
9264
|
+
},
|
|
9265
|
+
});
|
|
9266
|
+
}
|
|
9267
|
+
const AteBlockControlsExtension = Extension.create({
|
|
9268
|
+
name: "ateBlockControls",
|
|
9269
|
+
addOptions() {
|
|
9270
|
+
return { onHover: undefined };
|
|
9271
|
+
},
|
|
9272
|
+
addProseMirrorPlugins() {
|
|
9273
|
+
let lastHoveredElement = null;
|
|
9274
|
+
let lastHoveredPos = null;
|
|
9275
|
+
let rafId = null;
|
|
9276
|
+
let lastMouseX = 0;
|
|
9277
|
+
let lastMouseY = 0;
|
|
9278
|
+
// `force` re-resolves the hovered block even if the pointer is still over the last one (after a document change)
|
|
9279
|
+
const update = (view, force = false) => {
|
|
9280
|
+
if (!this.editor.isEditable) {
|
|
9281
|
+
return;
|
|
9282
|
+
}
|
|
9283
|
+
const editorRect = view.dom.getBoundingClientRect();
|
|
9284
|
+
const gutterWidth = 80;
|
|
9285
|
+
// Check Limites (Marge Gauche + Editeur)
|
|
9286
|
+
if (lastMouseY < editorRect.top ||
|
|
9287
|
+
lastMouseY > editorRect.bottom ||
|
|
9288
|
+
lastMouseX > editorRect.right ||
|
|
9289
|
+
lastMouseX < editorRect.left - gutterWidth) {
|
|
9290
|
+
if (lastHoveredElement) {
|
|
9291
|
+
reset();
|
|
9292
|
+
}
|
|
9293
|
+
return;
|
|
9294
|
+
}
|
|
9295
|
+
// STICKY ANTI-FLICKER
|
|
9296
|
+
// On garde le bloc tant qu'on est dans sa hauteur
|
|
9297
|
+
if (!force && lastHoveredElement && lastHoveredElement.isConnected) {
|
|
9298
|
+
const rect = lastHoveredElement.getBoundingClientRect();
|
|
9299
|
+
if (lastMouseY >= rect.top && lastMouseY <= rect.bottom) {
|
|
9300
|
+
return;
|
|
9301
|
+
}
|
|
9302
|
+
}
|
|
9303
|
+
const target = document.elementFromPoint(lastMouseX, lastMouseY);
|
|
9304
|
+
const data = resolveBlockData(view, lastMouseY, target);
|
|
9305
|
+
if (data && (data.element !== lastHoveredElement || data.pos !== lastHoveredPos)) {
|
|
9306
|
+
lastHoveredElement = data.element;
|
|
9307
|
+
lastHoveredPos = data.pos;
|
|
9308
|
+
this.options.onHover?.(data);
|
|
9309
|
+
}
|
|
9310
|
+
else if (!data) {
|
|
9311
|
+
if (!lastHoveredElement || !lastHoveredElement.isConnected) {
|
|
9312
|
+
reset();
|
|
9313
|
+
}
|
|
9314
|
+
}
|
|
9315
|
+
};
|
|
9316
|
+
const reset = () => {
|
|
9317
|
+
this.options.onHover?.(null);
|
|
9318
|
+
lastHoveredElement = null;
|
|
9319
|
+
lastHoveredPos = null;
|
|
9320
|
+
};
|
|
9321
|
+
return [
|
|
9322
|
+
new Plugin({
|
|
9323
|
+
key: new PluginKey("ateBlockControls"),
|
|
9324
|
+
// The hovered block can be removed, replaced or moved by an edit while the pointer stays still:
|
|
9325
|
+
// resolve it again so the controls never point at a detached element or a stale position
|
|
9326
|
+
view: () => ({
|
|
9327
|
+
update: (view, prevState) => {
|
|
9328
|
+
if (lastHoveredElement && !view.state.doc.eq(prevState.doc)) {
|
|
9329
|
+
update(view, true);
|
|
9330
|
+
}
|
|
9331
|
+
},
|
|
9332
|
+
}),
|
|
9333
|
+
props: {
|
|
9334
|
+
handleDOMEvents: {
|
|
9335
|
+
mousemove: (view, event) => {
|
|
9336
|
+
lastMouseX = event.clientX;
|
|
9337
|
+
lastMouseY = event.clientY;
|
|
9338
|
+
if (rafId === null) {
|
|
9339
|
+
rafId = requestAnimationFrame(() => {
|
|
9340
|
+
update(view);
|
|
9341
|
+
rafId = null;
|
|
9342
|
+
});
|
|
9343
|
+
}
|
|
9344
|
+
return false;
|
|
9345
|
+
},
|
|
9346
|
+
mouseleave: () => {
|
|
9347
|
+
if (rafId) {
|
|
9348
|
+
cancelAnimationFrame(rafId);
|
|
9349
|
+
rafId = null;
|
|
9350
|
+
}
|
|
9351
|
+
reset();
|
|
9352
|
+
return false;
|
|
9353
|
+
},
|
|
9354
|
+
},
|
|
9355
|
+
},
|
|
9356
|
+
}),
|
|
9357
|
+
createBlockDragPlugin(),
|
|
9358
|
+
];
|
|
9359
|
+
},
|
|
9360
|
+
});
|
|
9361
|
+
|
|
9362
|
+
// Glides from block to block; the strong ease-out covers most of the way at once, so it never trails the pointer
|
|
9363
|
+
const BLOCK_CONTROLS_MOVE_TRANSITION = "transform 200ms cubic-bezier(0.23, 1, 0.32, 1)";
|
|
9364
|
+
function prefersReducedMotion() {
|
|
9365
|
+
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
9366
|
+
}
|
|
8947
9367
|
/**
|
|
8948
9368
|
* Component providing Notion-like block controls (Plus button and Drag Handle).
|
|
8949
9369
|
*/
|
|
@@ -8958,23 +9378,37 @@ class AteBlockControlsComponent {
|
|
|
8958
9378
|
this.lastValidRect = null;
|
|
8959
9379
|
this.tippyInstance = null;
|
|
8960
9380
|
this.updateTimeout = null;
|
|
9381
|
+
this.glideFrame = null;
|
|
9382
|
+
/** While scrolling, the controls follow their block every frame: a glide would make them trail behind it */
|
|
9383
|
+
this.onScroll = () => {
|
|
9384
|
+
if (this.tippyInstance?.state.isVisible) {
|
|
9385
|
+
this.withoutGlide();
|
|
9386
|
+
}
|
|
9387
|
+
};
|
|
8961
9388
|
effect(() => {
|
|
8962
9389
|
const data = this.hoveredData();
|
|
8963
9390
|
const interacting = this.isInteracting();
|
|
8964
9391
|
if (data) {
|
|
8965
9392
|
this.lastValidData = data;
|
|
8966
|
-
|
|
9393
|
+
if (data.element.isConnected) {
|
|
9394
|
+
this.lastValidRect = data.element.getBoundingClientRect();
|
|
9395
|
+
}
|
|
8967
9396
|
}
|
|
8968
9397
|
this.updateMenu(!!data || interacting);
|
|
8969
9398
|
});
|
|
8970
9399
|
}
|
|
8971
9400
|
ngAfterViewInit() {
|
|
8972
9401
|
this.initTippy();
|
|
9402
|
+
window.addEventListener("scroll", this.onScroll, { capture: true, passive: true });
|
|
8973
9403
|
}
|
|
8974
9404
|
ngOnDestroy() {
|
|
9405
|
+
window.removeEventListener("scroll", this.onScroll, { capture: true });
|
|
8975
9406
|
if (this.updateTimeout) {
|
|
8976
9407
|
clearTimeout(this.updateTimeout);
|
|
8977
9408
|
}
|
|
9409
|
+
if (this.glideFrame !== null) {
|
|
9410
|
+
cancelAnimationFrame(this.glideFrame);
|
|
9411
|
+
}
|
|
8978
9412
|
if (this.tippyInstance) {
|
|
8979
9413
|
this.tippyInstance.destroy();
|
|
8980
9414
|
this.tippyInstance = null;
|
|
@@ -8998,7 +9432,8 @@ class AteBlockControlsComponent {
|
|
|
8998
9432
|
zIndex: 99,
|
|
8999
9433
|
plugins: [sticky],
|
|
9000
9434
|
sticky: "reference",
|
|
9001
|
-
|
|
9435
|
+
// The glide keeps the handle visibly the same one moving with the pointer (see withoutGlide for when it is skipped)
|
|
9436
|
+
moveTransition: prefersReducedMotion() ? "" : BLOCK_CONTROLS_MOVE_TRANSITION,
|
|
9002
9437
|
getReferenceClientRect: () => this.getSelectionRect(),
|
|
9003
9438
|
popperOptions: {
|
|
9004
9439
|
modifiers: [
|
|
@@ -9027,8 +9462,33 @@ class AteBlockControlsComponent {
|
|
|
9027
9462
|
}
|
|
9028
9463
|
}, delay);
|
|
9029
9464
|
}
|
|
9465
|
+
/**
|
|
9466
|
+
* Moves the controls without gliding until the position settles (two frames after the last call).
|
|
9467
|
+
* Tippy only skips the move transition on its very first show: the controls are never unmounted
|
|
9468
|
+
* afterwards, so they would otherwise glide in from wherever they were last shown.
|
|
9469
|
+
*/
|
|
9470
|
+
withoutGlide() {
|
|
9471
|
+
const popper = this.tippyInstance?.popper;
|
|
9472
|
+
if (!popper) {
|
|
9473
|
+
return;
|
|
9474
|
+
}
|
|
9475
|
+
popper.style.transition = "none";
|
|
9476
|
+
if (this.glideFrame !== null) {
|
|
9477
|
+
cancelAnimationFrame(this.glideFrame);
|
|
9478
|
+
}
|
|
9479
|
+
this.glideFrame = requestAnimationFrame(() => {
|
|
9480
|
+
this.glideFrame = requestAnimationFrame(() => {
|
|
9481
|
+
this.glideFrame = null;
|
|
9482
|
+
popper.style.transition = this.tippyInstance?.props.moveTransition ?? "";
|
|
9483
|
+
});
|
|
9484
|
+
});
|
|
9485
|
+
}
|
|
9030
9486
|
showTippy() {
|
|
9031
9487
|
if (this.tippyInstance) {
|
|
9488
|
+
// Appearing: show the controls in place, the glide is only for moving between blocks
|
|
9489
|
+
if (!this.tippyInstance.state.isVisible) {
|
|
9490
|
+
this.withoutGlide();
|
|
9491
|
+
}
|
|
9032
9492
|
this.tippyInstance.setProps({
|
|
9033
9493
|
getReferenceClientRect: () => this.getSelectionRect(),
|
|
9034
9494
|
});
|
|
@@ -9042,8 +9502,10 @@ class AteBlockControlsComponent {
|
|
|
9042
9502
|
}
|
|
9043
9503
|
getSelectionRect() {
|
|
9044
9504
|
const data = this.hoveredData();
|
|
9045
|
-
|
|
9046
|
-
|
|
9505
|
+
// A block removed by an edit is detached: its rect is (0, 0), which would send the controls to the editor's corner
|
|
9506
|
+
if (data?.element?.isConnected) {
|
|
9507
|
+
this.lastValidRect = data.element.getBoundingClientRect();
|
|
9508
|
+
return this.lastValidRect;
|
|
9047
9509
|
}
|
|
9048
9510
|
return this.lastValidRect || new DOMRect(-9999, -9999, 0, 0);
|
|
9049
9511
|
}
|
|
@@ -9068,7 +9530,7 @@ class AteBlockControlsComponent {
|
|
|
9068
9530
|
event.stopPropagation();
|
|
9069
9531
|
const ed = this.editor();
|
|
9070
9532
|
const data = this.hoveredData() || this.lastValidData;
|
|
9071
|
-
if (ed && data) {
|
|
9533
|
+
if (ed && data && !ateMultiBlockSelection(ed.state, data.pos)) {
|
|
9072
9534
|
// Force whole-node selection. This is crucial for atomic nodes like tables and images.
|
|
9073
9535
|
ed.commands.setNodeSelection(data.pos);
|
|
9074
9536
|
}
|
|
@@ -9107,32 +9569,33 @@ class AteBlockControlsComponent {
|
|
|
9107
9569
|
}
|
|
9108
9570
|
const { element, pos } = data;
|
|
9109
9571
|
const view = ed.view;
|
|
9110
|
-
// 1.
|
|
9572
|
+
// 1. Select the grabbed block, unless it belongs to a multi-block selection that is dragged as a whole
|
|
9111
9573
|
view.focus();
|
|
9112
|
-
ed.
|
|
9574
|
+
if (!ateMultiBlockSelection(ed.state, pos)) {
|
|
9575
|
+
ed.commands.setNodeSelection(pos);
|
|
9576
|
+
}
|
|
9113
9577
|
// 2. Browser Drag Image & metadata
|
|
9114
9578
|
event.dataTransfer.setDragImage(element, 0, 10);
|
|
9115
9579
|
event.dataTransfer.effectAllowed = "move";
|
|
9116
|
-
event.dataTransfer.setData(
|
|
9117
|
-
// 3.
|
|
9118
|
-
view.dragging = {
|
|
9119
|
-
slice: ed.state.selection.content(),
|
|
9120
|
-
move: true,
|
|
9121
|
-
};
|
|
9122
|
-
// 4. Dispatch virtual dragstart
|
|
9580
|
+
event.dataTransfer.setData(ATE_BLOCK_DRAG_MIME, "1");
|
|
9581
|
+
// 3. Dispatch virtual dragstart, handled by the block drag plugin of AteBlockControlsExtension
|
|
9123
9582
|
const pmEvent = new DragEvent("dragstart", {
|
|
9124
9583
|
dataTransfer: event.dataTransfer,
|
|
9125
9584
|
bubbles: true,
|
|
9126
9585
|
cancelable: true,
|
|
9127
9586
|
});
|
|
9128
9587
|
element.dispatchEvent(pmEvent);
|
|
9129
|
-
//
|
|
9588
|
+
// 4. Cleanup UI
|
|
9130
9589
|
setTimeout(() => {
|
|
9131
9590
|
this.hideTippy();
|
|
9132
9591
|
this.isInteracting.set(false);
|
|
9133
9592
|
}, 0);
|
|
9134
9593
|
}
|
|
9135
9594
|
onDragEnd() {
|
|
9595
|
+
const ed = this.editor();
|
|
9596
|
+
if (!ed.isDestroyed) {
|
|
9597
|
+
endAteBlockDrag(ed.view);
|
|
9598
|
+
}
|
|
9136
9599
|
this.isInteracting.set(false);
|
|
9137
9600
|
}
|
|
9138
9601
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AteBlockControlsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
@@ -9162,7 +9625,7 @@ class AteBlockControlsComponent {
|
|
|
9162
9625
|
<ate-icon name="blockDrag" />
|
|
9163
9626
|
</div>
|
|
9164
9627
|
</div>
|
|
9165
|
-
`, isInline: true, styles: [".ate-block-controls{display:flex;align-items:center;gap:2px;padding:4px;pointer-events:auto;will-change:transform}.ate-control-btn{display:flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:4px;border:none;background:transparent;color:var(--ate-text-secondary);cursor:pointer
|
|
9628
|
+
`, isInline: true, styles: [".ate-block-controls{display:flex;align-items:center;gap:2px;padding:4px;pointer-events:auto;will-change:transform}.ate-control-btn{display:flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:4px;border:none;background:transparent;color:var(--ate-text-secondary);cursor:pointer}@media (hover: hover) and (pointer: fine){.ate-control-btn{transition:background-color .12s ease-out,color .12s ease-out}.ate-control-btn:hover{background:var(--ate-surface-tertiary);color:var(--ate-text)}}.ate-control-btn .ate-icon{font-size:18px}@media (max-width: 768px){:host{display:none!important}}\n"], dependencies: [{ kind: "directive", type: AteTooltipDirective, selector: "[ateTooltip]", inputs: ["ateTooltip", "ateTooltipPlacement", "ateTooltipDelay", "ateTooltipDisabled"] }, { kind: "component", type: AteIconComponent, selector: "ate-icon", inputs: ["name"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
9166
9629
|
}
|
|
9167
9630
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AteBlockControlsComponent, decorators: [{
|
|
9168
9631
|
type: Component,
|
|
@@ -9192,7 +9655,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
9192
9655
|
<ate-icon name="blockDrag" />
|
|
9193
9656
|
</div>
|
|
9194
9657
|
</div>
|
|
9195
|
-
`, styles: [".ate-block-controls{display:flex;align-items:center;gap:2px;padding:4px;pointer-events:auto;will-change:transform}.ate-control-btn{display:flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:4px;border:none;background:transparent;color:var(--ate-text-secondary);cursor:pointer
|
|
9658
|
+
`, styles: [".ate-block-controls{display:flex;align-items:center;gap:2px;padding:4px;pointer-events:auto;will-change:transform}.ate-control-btn{display:flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:4px;border:none;background:transparent;color:var(--ate-text-secondary);cursor:pointer}@media (hover: hover) and (pointer: fine){.ate-control-btn{transition:background-color .12s ease-out,color .12s ease-out}.ate-control-btn:hover{background:var(--ate-surface-tertiary);color:var(--ate-text)}}.ate-control-btn .ate-icon{font-size:18px}@media (max-width: 768px){:host{display:none!important}}\n"] }]
|
|
9196
9659
|
}], ctorParameters: () => [] });
|
|
9197
9660
|
|
|
9198
9661
|
/**
|
|
@@ -9553,160 +10016,6 @@ const AteLinkClickBehavior = Extension.create({
|
|
|
9553
10016
|
},
|
|
9554
10017
|
});
|
|
9555
10018
|
|
|
9556
|
-
// Helper to get the DOM element properly
|
|
9557
|
-
function getDomElement(view, pos) {
|
|
9558
|
-
const dom = view.nodeDOM(pos);
|
|
9559
|
-
if (!dom) {
|
|
9560
|
-
return null;
|
|
9561
|
-
}
|
|
9562
|
-
if (dom instanceof HTMLElement) {
|
|
9563
|
-
return dom;
|
|
9564
|
-
}
|
|
9565
|
-
return dom.parentElement;
|
|
9566
|
-
}
|
|
9567
|
-
// VERTICAL SCAN (For the empty margin)
|
|
9568
|
-
function findBlockElementAtY(view, y) {
|
|
9569
|
-
const editorDom = view.dom;
|
|
9570
|
-
const children = Array.from(editorDom.children);
|
|
9571
|
-
// We look for the direct child of the editor (Level 1) that intercepts the Y line
|
|
9572
|
-
return (children.find(child => {
|
|
9573
|
-
if (getComputedStyle(child).position === "absolute") {
|
|
9574
|
-
return false;
|
|
9575
|
-
}
|
|
9576
|
-
const rect = child.getBoundingClientRect();
|
|
9577
|
-
// Tolérance de 5px
|
|
9578
|
-
return y >= rect.top - 5 && y <= rect.bottom + 5;
|
|
9579
|
-
}) || null);
|
|
9580
|
-
}
|
|
9581
|
-
function resolveBlockData(view, y, targetElement) {
|
|
9582
|
-
let blockEl = null;
|
|
9583
|
-
// 1. FAST PATH : Direct target
|
|
9584
|
-
// LEVEL 1: We look for the first parent element that is a direct child of the editor
|
|
9585
|
-
if (targetElement && !targetElement.classList.contains("ProseMirror")) {
|
|
9586
|
-
blockEl = targetElement.closest(".ProseMirror > *");
|
|
9587
|
-
}
|
|
9588
|
-
// 2. GUTTER PATH : Vertical scan
|
|
9589
|
-
if (!blockEl) {
|
|
9590
|
-
blockEl = findBlockElementAtY(view, y);
|
|
9591
|
-
}
|
|
9592
|
-
if (!blockEl) {
|
|
9593
|
-
return null;
|
|
9594
|
-
}
|
|
9595
|
-
// 3. PROSEMIRROR NODE RESOLUTION
|
|
9596
|
-
try {
|
|
9597
|
-
// We ask for the position at the beginning of this root block
|
|
9598
|
-
const pos = view.posAtDOM(blockEl, 0);
|
|
9599
|
-
const $pos = view.state.doc.resolve(pos);
|
|
9600
|
-
// CASE A : STANDARD BLOCKS (H1, P, Lists, Blockquotes...)
|
|
9601
|
-
// We make sure to take the node of depth 1 (Direct child of the Doc)
|
|
9602
|
-
if ($pos.depth >= 1) {
|
|
9603
|
-
const rootNode = $pos.node(1);
|
|
9604
|
-
const rootPos = $pos.before(1);
|
|
9605
|
-
const rootElement = getDomElement(view, rootPos);
|
|
9606
|
-
// Sécurité : on vérifie que l'élément DOM correspond bien
|
|
9607
|
-
if (rootNode && (rootElement || blockEl)) {
|
|
9608
|
-
return { node: rootNode, element: rootElement || blockEl, pos: rootPos };
|
|
9609
|
-
}
|
|
9610
|
-
}
|
|
9611
|
-
// CASE B : IMAGES / ATOMS (Depth 0)
|
|
9612
|
-
// Sometimes a root image is seen as Depth 0 by resolve()
|
|
9613
|
-
// We look at the node right after the position
|
|
9614
|
-
if ($pos.depth === 0) {
|
|
9615
|
-
const nodeAfter = $pos.nodeAfter;
|
|
9616
|
-
if (nodeAfter && (nodeAfter.isBlock || nodeAfter.isAtom)) {
|
|
9617
|
-
return { node: nodeAfter, element: blockEl, pos: pos };
|
|
9618
|
-
}
|
|
9619
|
-
}
|
|
9620
|
-
}
|
|
9621
|
-
catch (_e) {
|
|
9622
|
-
// console.warn(_e);
|
|
9623
|
-
}
|
|
9624
|
-
return null;
|
|
9625
|
-
}
|
|
9626
|
-
const AteBlockControlsExtension = Extension.create({
|
|
9627
|
-
name: "ateBlockControls",
|
|
9628
|
-
addOptions() {
|
|
9629
|
-
return { onHover: undefined };
|
|
9630
|
-
},
|
|
9631
|
-
addProseMirrorPlugins() {
|
|
9632
|
-
let lastHoveredElement = null;
|
|
9633
|
-
let lastHoveredPos = null;
|
|
9634
|
-
let rafId = null;
|
|
9635
|
-
let lastMouseX = 0;
|
|
9636
|
-
let lastMouseY = 0;
|
|
9637
|
-
const update = (view) => {
|
|
9638
|
-
if (!this.editor.isEditable) {
|
|
9639
|
-
return;
|
|
9640
|
-
}
|
|
9641
|
-
const editorRect = view.dom.getBoundingClientRect();
|
|
9642
|
-
const gutterWidth = 80;
|
|
9643
|
-
// Check Limites (Marge Gauche + Editeur)
|
|
9644
|
-
if (lastMouseY < editorRect.top ||
|
|
9645
|
-
lastMouseY > editorRect.bottom ||
|
|
9646
|
-
lastMouseX > editorRect.right ||
|
|
9647
|
-
lastMouseX < editorRect.left - gutterWidth) {
|
|
9648
|
-
if (lastHoveredElement) {
|
|
9649
|
-
reset();
|
|
9650
|
-
}
|
|
9651
|
-
return;
|
|
9652
|
-
}
|
|
9653
|
-
// STICKY ANTI-FLICKER
|
|
9654
|
-
// On garde le bloc tant qu'on est dans sa hauteur
|
|
9655
|
-
if (lastHoveredElement && lastHoveredElement.isConnected) {
|
|
9656
|
-
const rect = lastHoveredElement.getBoundingClientRect();
|
|
9657
|
-
if (lastMouseY >= rect.top && lastMouseY <= rect.bottom) {
|
|
9658
|
-
return;
|
|
9659
|
-
}
|
|
9660
|
-
}
|
|
9661
|
-
const target = document.elementFromPoint(lastMouseX, lastMouseY);
|
|
9662
|
-
const data = resolveBlockData(view, lastMouseY, target);
|
|
9663
|
-
if (data && (data.element !== lastHoveredElement || data.pos !== lastHoveredPos)) {
|
|
9664
|
-
lastHoveredElement = data.element;
|
|
9665
|
-
lastHoveredPos = data.pos;
|
|
9666
|
-
this.options.onHover?.(data);
|
|
9667
|
-
}
|
|
9668
|
-
else if (!data) {
|
|
9669
|
-
if (!lastHoveredElement || !lastHoveredElement.isConnected) {
|
|
9670
|
-
reset();
|
|
9671
|
-
}
|
|
9672
|
-
}
|
|
9673
|
-
};
|
|
9674
|
-
const reset = () => {
|
|
9675
|
-
this.options.onHover?.(null);
|
|
9676
|
-
lastHoveredElement = null;
|
|
9677
|
-
lastHoveredPos = null;
|
|
9678
|
-
};
|
|
9679
|
-
return [
|
|
9680
|
-
new Plugin({
|
|
9681
|
-
key: new PluginKey("ateBlockControls"),
|
|
9682
|
-
props: {
|
|
9683
|
-
handleDOMEvents: {
|
|
9684
|
-
mousemove: (view, event) => {
|
|
9685
|
-
lastMouseX = event.clientX;
|
|
9686
|
-
lastMouseY = event.clientY;
|
|
9687
|
-
if (rafId === null) {
|
|
9688
|
-
rafId = requestAnimationFrame(() => {
|
|
9689
|
-
update(view);
|
|
9690
|
-
rafId = null;
|
|
9691
|
-
});
|
|
9692
|
-
}
|
|
9693
|
-
return false;
|
|
9694
|
-
},
|
|
9695
|
-
mouseleave: () => {
|
|
9696
|
-
if (rafId) {
|
|
9697
|
-
cancelAnimationFrame(rafId);
|
|
9698
|
-
rafId = null;
|
|
9699
|
-
}
|
|
9700
|
-
reset();
|
|
9701
|
-
return false;
|
|
9702
|
-
},
|
|
9703
|
-
},
|
|
9704
|
-
},
|
|
9705
|
-
}),
|
|
9706
|
-
];
|
|
9707
|
-
},
|
|
9708
|
-
});
|
|
9709
|
-
|
|
9710
10019
|
function shallowArrayEqual(a, b) {
|
|
9711
10020
|
return a === b || (a.length === b.length && a.every((item, i) => item === b[i]));
|
|
9712
10021
|
}
|
|
@@ -9958,6 +10267,8 @@ class AngularTiptapEditorComponent {
|
|
|
9958
10267
|
this._wordCount = signal(0);
|
|
9959
10268
|
this._isDragOver = signal(false);
|
|
9960
10269
|
this._editorFullyInitialized = signal(false);
|
|
10270
|
+
/** Technical config the current editor was created with, see `technicalConfig()` */
|
|
10271
|
+
this.editorTechnicalConfig = null;
|
|
9961
10272
|
this._hoveredBlock = signal(null);
|
|
9962
10273
|
// Anti-echo: track last emitted HTML to prevent cursor reset on parent echo
|
|
9963
10274
|
this.lastEmittedHtml = null;
|
|
@@ -10246,22 +10557,19 @@ class AngularTiptapEditorComponent {
|
|
|
10246
10557
|
}, { allowSignalWrites: true });
|
|
10247
10558
|
// Effect to re-initialize editor when technical configuration changes
|
|
10248
10559
|
effect(() => {
|
|
10249
|
-
|
|
10250
|
-
this.finalTiptapExtensions();
|
|
10251
|
-
this.finalTiptapOptions();
|
|
10252
|
-
this.finalStarterKitOptions();
|
|
10253
|
-
this.finalAngularNodesConfig();
|
|
10254
|
-
this.finalBlockControls();
|
|
10560
|
+
const technicalConfig = this.technicalConfig();
|
|
10255
10561
|
untracked(() => {
|
|
10256
|
-
|
|
10257
|
-
|
|
10258
|
-
|
|
10259
|
-
|
|
10260
|
-
|
|
10261
|
-
|
|
10262
|
-
|
|
10263
|
-
}
|
|
10562
|
+
const currentEditor = this.editor();
|
|
10563
|
+
// Before the first initEditor (AfterViewInit) there is nothing to re-create: it reads the latest config.
|
|
10564
|
+
// Compare with the config the editor was created with, so a change is never missed, however soon it comes.
|
|
10565
|
+
if (!currentEditor ||
|
|
10566
|
+
!this.editorTechnicalConfig ||
|
|
10567
|
+
technicalConfig.every((value, index) => value === this.editorTechnicalConfig[index])) {
|
|
10568
|
+
return;
|
|
10264
10569
|
}
|
|
10570
|
+
currentEditor.destroy();
|
|
10571
|
+
this._editorFullyInitialized.set(false);
|
|
10572
|
+
this.initEditor();
|
|
10265
10573
|
});
|
|
10266
10574
|
}, { allowSignalWrites: true });
|
|
10267
10575
|
this.editorCommandsService.onImageUploaded = result => {
|
|
@@ -10290,7 +10598,21 @@ class AngularTiptapEditorComponent {
|
|
|
10290
10598
|
this.editorRegistry.unregister(id);
|
|
10291
10599
|
}
|
|
10292
10600
|
}
|
|
10601
|
+
/**
|
|
10602
|
+
* Settings that require re-creating the editor when they change.
|
|
10603
|
+
* Each computed compares by value, so a new reference means a real change.
|
|
10604
|
+
*/
|
|
10605
|
+
technicalConfig() {
|
|
10606
|
+
return [
|
|
10607
|
+
this.finalTiptapExtensions(),
|
|
10608
|
+
this.finalTiptapOptions(),
|
|
10609
|
+
this.finalStarterKitOptions(),
|
|
10610
|
+
this.finalAngularNodesConfig(),
|
|
10611
|
+
this.finalBlockControls(),
|
|
10612
|
+
];
|
|
10613
|
+
}
|
|
10293
10614
|
initEditor() {
|
|
10615
|
+
this.editorTechnicalConfig = this.technicalConfig();
|
|
10294
10616
|
this.editorCommandsService.srcResolver = this.finalImageSrcResolver() ?? null;
|
|
10295
10617
|
const starterKitOptions = this.finalStarterKitOptions();
|
|
10296
10618
|
const customExtensions = this.finalTiptapExtensions();
|
|
@@ -10528,11 +10850,8 @@ class AngularTiptapEditorComponent {
|
|
|
10528
10850
|
onCreate: ({ editor }) => {
|
|
10529
10851
|
this.editorCreated.emit(editor);
|
|
10530
10852
|
this.updateCharacterCount(editor);
|
|
10531
|
-
//
|
|
10532
|
-
|
|
10533
|
-
setTimeout(() => {
|
|
10534
|
-
this._editorFullyInitialized.set(true);
|
|
10535
|
-
}, 100);
|
|
10853
|
+
// Tiptap emits create once every extension and plugin is mounted: the menus can show
|
|
10854
|
+
this._editorFullyInitialized.set(true);
|
|
10536
10855
|
},
|
|
10537
10856
|
onFocus: ({ editor, event }) => {
|
|
10538
10857
|
const id = this.registeredId();
|