@flogeez/angular-tiptap-editor 3.8.1 → 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 +17 -0
- package/esm2022/lib/components/editor/ate-block-controls.component.mjs +67 -18
- package/esm2022/lib/extensions/ate-block-controls.extension.mjs +222 -4
- package/fesm2022/flogeez-angular-tiptap-editor.mjs +436 -171
- package/fesm2022/flogeez-angular-tiptap-editor.mjs.map +1 -1
- package/lib/components/editor/ate-block-controls.component.d.ts +9 -0
- package/lib/extensions/ate-block-controls.extension.d.ts +10 -0
- package/package.json +1 -1
- package/src/lib/styles/index.css +13 -0
- package/styles/index.css +13 -0
|
@@ -8988,6 +8988,382 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
8988
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"] }]
|
|
8989
8989
|
}], ctorParameters: () => [] });
|
|
8990
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
|
+
}
|
|
8991
9367
|
/**
|
|
8992
9368
|
* Component providing Notion-like block controls (Plus button and Drag Handle).
|
|
8993
9369
|
*/
|
|
@@ -9002,23 +9378,37 @@ class AteBlockControlsComponent {
|
|
|
9002
9378
|
this.lastValidRect = null;
|
|
9003
9379
|
this.tippyInstance = null;
|
|
9004
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
|
+
};
|
|
9005
9388
|
effect(() => {
|
|
9006
9389
|
const data = this.hoveredData();
|
|
9007
9390
|
const interacting = this.isInteracting();
|
|
9008
9391
|
if (data) {
|
|
9009
9392
|
this.lastValidData = data;
|
|
9010
|
-
|
|
9393
|
+
if (data.element.isConnected) {
|
|
9394
|
+
this.lastValidRect = data.element.getBoundingClientRect();
|
|
9395
|
+
}
|
|
9011
9396
|
}
|
|
9012
9397
|
this.updateMenu(!!data || interacting);
|
|
9013
9398
|
});
|
|
9014
9399
|
}
|
|
9015
9400
|
ngAfterViewInit() {
|
|
9016
9401
|
this.initTippy();
|
|
9402
|
+
window.addEventListener("scroll", this.onScroll, { capture: true, passive: true });
|
|
9017
9403
|
}
|
|
9018
9404
|
ngOnDestroy() {
|
|
9405
|
+
window.removeEventListener("scroll", this.onScroll, { capture: true });
|
|
9019
9406
|
if (this.updateTimeout) {
|
|
9020
9407
|
clearTimeout(this.updateTimeout);
|
|
9021
9408
|
}
|
|
9409
|
+
if (this.glideFrame !== null) {
|
|
9410
|
+
cancelAnimationFrame(this.glideFrame);
|
|
9411
|
+
}
|
|
9022
9412
|
if (this.tippyInstance) {
|
|
9023
9413
|
this.tippyInstance.destroy();
|
|
9024
9414
|
this.tippyInstance = null;
|
|
@@ -9042,7 +9432,8 @@ class AteBlockControlsComponent {
|
|
|
9042
9432
|
zIndex: 99,
|
|
9043
9433
|
plugins: [sticky],
|
|
9044
9434
|
sticky: "reference",
|
|
9045
|
-
|
|
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,
|
|
9046
9437
|
getReferenceClientRect: () => this.getSelectionRect(),
|
|
9047
9438
|
popperOptions: {
|
|
9048
9439
|
modifiers: [
|
|
@@ -9071,8 +9462,33 @@ class AteBlockControlsComponent {
|
|
|
9071
9462
|
}
|
|
9072
9463
|
}, delay);
|
|
9073
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
|
+
}
|
|
9074
9486
|
showTippy() {
|
|
9075
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
|
+
}
|
|
9076
9492
|
this.tippyInstance.setProps({
|
|
9077
9493
|
getReferenceClientRect: () => this.getSelectionRect(),
|
|
9078
9494
|
});
|
|
@@ -9086,8 +9502,10 @@ class AteBlockControlsComponent {
|
|
|
9086
9502
|
}
|
|
9087
9503
|
getSelectionRect() {
|
|
9088
9504
|
const data = this.hoveredData();
|
|
9089
|
-
|
|
9090
|
-
|
|
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;
|
|
9091
9509
|
}
|
|
9092
9510
|
return this.lastValidRect || new DOMRect(-9999, -9999, 0, 0);
|
|
9093
9511
|
}
|
|
@@ -9112,7 +9530,7 @@ class AteBlockControlsComponent {
|
|
|
9112
9530
|
event.stopPropagation();
|
|
9113
9531
|
const ed = this.editor();
|
|
9114
9532
|
const data = this.hoveredData() || this.lastValidData;
|
|
9115
|
-
if (ed && data) {
|
|
9533
|
+
if (ed && data && !ateMultiBlockSelection(ed.state, data.pos)) {
|
|
9116
9534
|
// Force whole-node selection. This is crucial for atomic nodes like tables and images.
|
|
9117
9535
|
ed.commands.setNodeSelection(data.pos);
|
|
9118
9536
|
}
|
|
@@ -9151,32 +9569,33 @@ class AteBlockControlsComponent {
|
|
|
9151
9569
|
}
|
|
9152
9570
|
const { element, pos } = data;
|
|
9153
9571
|
const view = ed.view;
|
|
9154
|
-
// 1.
|
|
9572
|
+
// 1. Select the grabbed block, unless it belongs to a multi-block selection that is dragged as a whole
|
|
9155
9573
|
view.focus();
|
|
9156
|
-
ed.
|
|
9574
|
+
if (!ateMultiBlockSelection(ed.state, pos)) {
|
|
9575
|
+
ed.commands.setNodeSelection(pos);
|
|
9576
|
+
}
|
|
9157
9577
|
// 2. Browser Drag Image & metadata
|
|
9158
9578
|
event.dataTransfer.setDragImage(element, 0, 10);
|
|
9159
9579
|
event.dataTransfer.effectAllowed = "move";
|
|
9160
|
-
event.dataTransfer.setData(
|
|
9161
|
-
// 3.
|
|
9162
|
-
view.dragging = {
|
|
9163
|
-
slice: ed.state.selection.content(),
|
|
9164
|
-
move: true,
|
|
9165
|
-
};
|
|
9166
|
-
// 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
|
|
9167
9582
|
const pmEvent = new DragEvent("dragstart", {
|
|
9168
9583
|
dataTransfer: event.dataTransfer,
|
|
9169
9584
|
bubbles: true,
|
|
9170
9585
|
cancelable: true,
|
|
9171
9586
|
});
|
|
9172
9587
|
element.dispatchEvent(pmEvent);
|
|
9173
|
-
//
|
|
9588
|
+
// 4. Cleanup UI
|
|
9174
9589
|
setTimeout(() => {
|
|
9175
9590
|
this.hideTippy();
|
|
9176
9591
|
this.isInteracting.set(false);
|
|
9177
9592
|
}, 0);
|
|
9178
9593
|
}
|
|
9179
9594
|
onDragEnd() {
|
|
9595
|
+
const ed = this.editor();
|
|
9596
|
+
if (!ed.isDestroyed) {
|
|
9597
|
+
endAteBlockDrag(ed.view);
|
|
9598
|
+
}
|
|
9180
9599
|
this.isInteracting.set(false);
|
|
9181
9600
|
}
|
|
9182
9601
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AteBlockControlsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
@@ -9206,7 +9625,7 @@ class AteBlockControlsComponent {
|
|
|
9206
9625
|
<ate-icon name="blockDrag" />
|
|
9207
9626
|
</div>
|
|
9208
9627
|
</div>
|
|
9209
|
-
`, 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 }); }
|
|
9210
9629
|
}
|
|
9211
9630
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AteBlockControlsComponent, decorators: [{
|
|
9212
9631
|
type: Component,
|
|
@@ -9236,7 +9655,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
9236
9655
|
<ate-icon name="blockDrag" />
|
|
9237
9656
|
</div>
|
|
9238
9657
|
</div>
|
|
9239
|
-
`, 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"] }]
|
|
9240
9659
|
}], ctorParameters: () => [] });
|
|
9241
9660
|
|
|
9242
9661
|
/**
|
|
@@ -9597,160 +10016,6 @@ const AteLinkClickBehavior = Extension.create({
|
|
|
9597
10016
|
},
|
|
9598
10017
|
});
|
|
9599
10018
|
|
|
9600
|
-
// Helper to get the DOM element properly
|
|
9601
|
-
function getDomElement(view, pos) {
|
|
9602
|
-
const dom = view.nodeDOM(pos);
|
|
9603
|
-
if (!dom) {
|
|
9604
|
-
return null;
|
|
9605
|
-
}
|
|
9606
|
-
if (dom instanceof HTMLElement) {
|
|
9607
|
-
return dom;
|
|
9608
|
-
}
|
|
9609
|
-
return dom.parentElement;
|
|
9610
|
-
}
|
|
9611
|
-
// VERTICAL SCAN (For the empty margin)
|
|
9612
|
-
function findBlockElementAtY(view, y) {
|
|
9613
|
-
const editorDom = view.dom;
|
|
9614
|
-
const children = Array.from(editorDom.children);
|
|
9615
|
-
// We look for the direct child of the editor (Level 1) that intercepts the Y line
|
|
9616
|
-
return (children.find(child => {
|
|
9617
|
-
if (getComputedStyle(child).position === "absolute") {
|
|
9618
|
-
return false;
|
|
9619
|
-
}
|
|
9620
|
-
const rect = child.getBoundingClientRect();
|
|
9621
|
-
// Tolérance de 5px
|
|
9622
|
-
return y >= rect.top - 5 && y <= rect.bottom + 5;
|
|
9623
|
-
}) || null);
|
|
9624
|
-
}
|
|
9625
|
-
function resolveBlockData(view, y, targetElement) {
|
|
9626
|
-
let blockEl = null;
|
|
9627
|
-
// 1. FAST PATH : Direct target
|
|
9628
|
-
// LEVEL 1: We look for the first parent element that is a direct child of the editor
|
|
9629
|
-
if (targetElement && !targetElement.classList.contains("ProseMirror")) {
|
|
9630
|
-
blockEl = targetElement.closest(".ProseMirror > *");
|
|
9631
|
-
}
|
|
9632
|
-
// 2. GUTTER PATH : Vertical scan
|
|
9633
|
-
if (!blockEl) {
|
|
9634
|
-
blockEl = findBlockElementAtY(view, y);
|
|
9635
|
-
}
|
|
9636
|
-
if (!blockEl) {
|
|
9637
|
-
return null;
|
|
9638
|
-
}
|
|
9639
|
-
// 3. PROSEMIRROR NODE RESOLUTION
|
|
9640
|
-
try {
|
|
9641
|
-
// We ask for the position at the beginning of this root block
|
|
9642
|
-
const pos = view.posAtDOM(blockEl, 0);
|
|
9643
|
-
const $pos = view.state.doc.resolve(pos);
|
|
9644
|
-
// CASE A : STANDARD BLOCKS (H1, P, Lists, Blockquotes...)
|
|
9645
|
-
// We make sure to take the node of depth 1 (Direct child of the Doc)
|
|
9646
|
-
if ($pos.depth >= 1) {
|
|
9647
|
-
const rootNode = $pos.node(1);
|
|
9648
|
-
const rootPos = $pos.before(1);
|
|
9649
|
-
const rootElement = getDomElement(view, rootPos);
|
|
9650
|
-
// Sécurité : on vérifie que l'élément DOM correspond bien
|
|
9651
|
-
if (rootNode && (rootElement || blockEl)) {
|
|
9652
|
-
return { node: rootNode, element: rootElement || blockEl, pos: rootPos };
|
|
9653
|
-
}
|
|
9654
|
-
}
|
|
9655
|
-
// CASE B : IMAGES / ATOMS (Depth 0)
|
|
9656
|
-
// Sometimes a root image is seen as Depth 0 by resolve()
|
|
9657
|
-
// We look at the node right after the position
|
|
9658
|
-
if ($pos.depth === 0) {
|
|
9659
|
-
const nodeAfter = $pos.nodeAfter;
|
|
9660
|
-
if (nodeAfter && (nodeAfter.isBlock || nodeAfter.isAtom)) {
|
|
9661
|
-
return { node: nodeAfter, element: blockEl, pos: pos };
|
|
9662
|
-
}
|
|
9663
|
-
}
|
|
9664
|
-
}
|
|
9665
|
-
catch (_e) {
|
|
9666
|
-
// console.warn(_e);
|
|
9667
|
-
}
|
|
9668
|
-
return null;
|
|
9669
|
-
}
|
|
9670
|
-
const AteBlockControlsExtension = Extension.create({
|
|
9671
|
-
name: "ateBlockControls",
|
|
9672
|
-
addOptions() {
|
|
9673
|
-
return { onHover: undefined };
|
|
9674
|
-
},
|
|
9675
|
-
addProseMirrorPlugins() {
|
|
9676
|
-
let lastHoveredElement = null;
|
|
9677
|
-
let lastHoveredPos = null;
|
|
9678
|
-
let rafId = null;
|
|
9679
|
-
let lastMouseX = 0;
|
|
9680
|
-
let lastMouseY = 0;
|
|
9681
|
-
const update = (view) => {
|
|
9682
|
-
if (!this.editor.isEditable) {
|
|
9683
|
-
return;
|
|
9684
|
-
}
|
|
9685
|
-
const editorRect = view.dom.getBoundingClientRect();
|
|
9686
|
-
const gutterWidth = 80;
|
|
9687
|
-
// Check Limites (Marge Gauche + Editeur)
|
|
9688
|
-
if (lastMouseY < editorRect.top ||
|
|
9689
|
-
lastMouseY > editorRect.bottom ||
|
|
9690
|
-
lastMouseX > editorRect.right ||
|
|
9691
|
-
lastMouseX < editorRect.left - gutterWidth) {
|
|
9692
|
-
if (lastHoveredElement) {
|
|
9693
|
-
reset();
|
|
9694
|
-
}
|
|
9695
|
-
return;
|
|
9696
|
-
}
|
|
9697
|
-
// STICKY ANTI-FLICKER
|
|
9698
|
-
// On garde le bloc tant qu'on est dans sa hauteur
|
|
9699
|
-
if (lastHoveredElement && lastHoveredElement.isConnected) {
|
|
9700
|
-
const rect = lastHoveredElement.getBoundingClientRect();
|
|
9701
|
-
if (lastMouseY >= rect.top && lastMouseY <= rect.bottom) {
|
|
9702
|
-
return;
|
|
9703
|
-
}
|
|
9704
|
-
}
|
|
9705
|
-
const target = document.elementFromPoint(lastMouseX, lastMouseY);
|
|
9706
|
-
const data = resolveBlockData(view, lastMouseY, target);
|
|
9707
|
-
if (data && (data.element !== lastHoveredElement || data.pos !== lastHoveredPos)) {
|
|
9708
|
-
lastHoveredElement = data.element;
|
|
9709
|
-
lastHoveredPos = data.pos;
|
|
9710
|
-
this.options.onHover?.(data);
|
|
9711
|
-
}
|
|
9712
|
-
else if (!data) {
|
|
9713
|
-
if (!lastHoveredElement || !lastHoveredElement.isConnected) {
|
|
9714
|
-
reset();
|
|
9715
|
-
}
|
|
9716
|
-
}
|
|
9717
|
-
};
|
|
9718
|
-
const reset = () => {
|
|
9719
|
-
this.options.onHover?.(null);
|
|
9720
|
-
lastHoveredElement = null;
|
|
9721
|
-
lastHoveredPos = null;
|
|
9722
|
-
};
|
|
9723
|
-
return [
|
|
9724
|
-
new Plugin({
|
|
9725
|
-
key: new PluginKey("ateBlockControls"),
|
|
9726
|
-
props: {
|
|
9727
|
-
handleDOMEvents: {
|
|
9728
|
-
mousemove: (view, event) => {
|
|
9729
|
-
lastMouseX = event.clientX;
|
|
9730
|
-
lastMouseY = event.clientY;
|
|
9731
|
-
if (rafId === null) {
|
|
9732
|
-
rafId = requestAnimationFrame(() => {
|
|
9733
|
-
update(view);
|
|
9734
|
-
rafId = null;
|
|
9735
|
-
});
|
|
9736
|
-
}
|
|
9737
|
-
return false;
|
|
9738
|
-
},
|
|
9739
|
-
mouseleave: () => {
|
|
9740
|
-
if (rafId) {
|
|
9741
|
-
cancelAnimationFrame(rafId);
|
|
9742
|
-
rafId = null;
|
|
9743
|
-
}
|
|
9744
|
-
reset();
|
|
9745
|
-
return false;
|
|
9746
|
-
},
|
|
9747
|
-
},
|
|
9748
|
-
},
|
|
9749
|
-
}),
|
|
9750
|
-
];
|
|
9751
|
-
},
|
|
9752
|
-
});
|
|
9753
|
-
|
|
9754
10019
|
function shallowArrayEqual(a, b) {
|
|
9755
10020
|
return a === b || (a.length === b.length && a.every((item, i) => item === b[i]));
|
|
9756
10021
|
}
|