@atlaskit/editor-plugin-synced-block
Version:
SyncedBlock plugin for @atlaskit/editor-core
163 lines (156 loc) • 8.3 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.recomputeUnsyncTransaction = exports.recomputeDeleteTransaction = void 0;
var _model = require("@atlaskit/editor-prosemirror/model");
function _createForOfIteratorHelper(r, e) { var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (!t) { if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) { t && (r = t); var _n = 0, F = function F() {}; return { s: F, n: function n() { return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] }; }, e: function e(r) { throw r; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var o, a = !0, u = !1; return { s: function s() { t = t.call(r); }, n: function n() { var r = t.next(); return a = r.done, r; }, e: function e(r) { u = !0, o = r; }, f: function f() { try { a || null == t.return || t.return(); } finally { if (u) throw o; } } }; }
function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } }
function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; }
/**
* Whether `node` is the source block requested by `wanted`.
*
* Matching is done *per requested block* (rather than against global pools of
* every requested localId/resourceId) so a node cannot be matched by combining
* one block's `localId` with a different block's `resourceId`. We match on the
* requested block's `localId` first and only fall back to its `resourceId` when
* the `localId` has been regenerated (so it no longer matches the live node).
*/
var matchesRequestedBlock = function matchesRequestedBlock(node, wanted) {
var _ref = node.attrs,
localId = _ref.localId,
resourceId = _ref.resourceId;
if (wanted.localId && localId && wanted.localId === localId) {
return true;
}
return Boolean(wanted.resourceId && resourceId && wanted.resourceId === resourceId);
};
/**
* Locate the live positions of the source `bodiedSyncBlock` nodes identified by
* `syncBlockIds`, matching first on `localId` and falling back to `resourceId`.
*
* This walks the *current* document (`tr.doc`) rather than relying on positions
* captured when the delete was first triggered, so it is robust to intervening
* local edits and remote collab changes that occur while the confirmation modal
* is open.
*/
var findSourceBlocks = function findSourceBlocks(tr, isSourceBlock, syncBlockIds) {
var matches = [];
tr.doc.descendants(function (node, pos) {
if (!isSourceBlock(node)) {
// bodiedSyncBlock is always a top-level node, so there is no need to
// descend into other branches looking for one.
return false;
}
if (syncBlockIds.some(function (wanted) {
return matchesRequestedBlock(node, wanted);
})) {
matches.push({
node: node,
pos: pos
});
}
// Never recurse into a source block's body.
return false;
});
return matches;
};
/**
* Recompute a source `bodiedSyncBlock` delete from the live document.
*
* Instead of replaying a transaction that was stashed when the delete was first
* triggered (and then manually rebased against every intervening edit — the
* fragile pattern that produced "Invalid content for node bodiedSyncBlock: <>"
* and assorted position/open-depth errors), this finds the target node(s) by
* `localId`/`resourceId` in the current state and issues a fresh
* `tr.delete(pos, pos + nodeSize)` for each.
*
* Deletes are applied in reverse document order so earlier deletes do not
* invalidate the positions of later ones.
*
* Partial matches are handled gracefully: when only some of the requested
* blocks are still present (e.g. a remote collaborator removed the rest while
* the confirmation modal was open), the found ones are deleted and the missing
* ones are skipped. This is intentional — the backend deletion for every
* requested block has already been issued by the store manager, so the local
* transaction only needs to remove whatever is still in the live document.
*
* @returns the mutated transaction when at least one target node was found and
* deleted (this may be a partial delete if some targets were already gone),
* otherwise `undefined` when none of the targets exist any more — e.g. a remote
* collaborator already removed them all — so there is nothing to delete.
*/
var recomputeDeleteTransaction = exports.recomputeDeleteTransaction = function recomputeDeleteTransaction(tr, isSourceBlock, syncBlockIds) {
var matches = findSourceBlocks(tr, isSourceBlock, syncBlockIds);
if (matches.length === 0) {
return undefined;
}
// Delete in reverse document order so positions remain valid across deletes.
matches.sort(function (a, b) {
return b.pos - a.pos;
}).forEach(function (_ref2) {
var node = _ref2.node,
pos = _ref2.pos;
tr.delete(pos, pos + node.nodeSize);
});
return tr;
};
/**
* Recompute a source `bodiedSyncBlock` *unsync* from the live document.
*
* Unsync differs from delete: the sync wrapper must be removed while its content
* is preserved inline in the document. Instead of `tr.delete(pos, pos +
* nodeSize)` (which drops the content too — EDITOR-8230), this replaces each
* matched source block with its own content.
*
* The replacement uses a raw `tr.replace(pos, pos + nodeSize, new Slice(content,
* 0, 0))` — an explicit zero-open slice inserted verbatim — rather than
* `tr.replaceWith(...)`. `replaceWith` routes through ProseMirror's
* `replaceRange`, whose range-fitting heuristics can collapse or drop a trailing
* block (e.g. the block's final panel/empty paragraph) when the unwrapped
* content meets a document boundary, silently losing content (EDITOR-8230). A
* zero-open `replace` drops the wrapper's own open ends while keeping every child
* of the fragment as its own top-level node.
*
* Like `recomputeDeleteTransaction`, targets are located in the *current*
* document (`tr.doc`) so the operation is robust to intervening local edits and
* remote collab changes while the confirmation modal is open, and it handles
* partial matches gracefully (missing blocks are skipped).
*
* Replacements are applied in reverse document order so earlier replacements do
* not invalidate the positions of later ones.
*
* @returns the mutated transaction when at least one target node was found and
* unwrapped, otherwise `undefined` when none of the targets exist any more.
*/
var recomputeUnsyncTransaction = exports.recomputeUnsyncTransaction = function recomputeUnsyncTransaction(tr, isSourceBlock, syncBlockIds) {
var matches = findSourceBlocks(tr, isSourceBlock, syncBlockIds);
if (matches.length === 0) {
return undefined;
}
// Unwrap in reverse document order so positions remain valid across edits.
// Use a raw zero-open Slice replace (not `replaceWith`) so the block's content
// is inserted verbatim as top-level nodes without ProseMirror's replaceRange
// heuristics collapsing a trailing block (EDITOR-8230).
var orderedMatches = matches.sort(function (a, b) {
return b.pos - a.pos;
});
var _iterator = _createForOfIteratorHelper(orderedMatches),
_step;
try {
for (_iterator.s(); !(_step = _iterator.n()).done;) {
var _step$value = _step.value,
node = _step$value.node,
pos = _step$value.pos;
// This is ProseMirror's Transaction.replace (a document edit), not String.replace — the
// perf rule misfires on the method name, and there is nothing to hoist.
// eslint-disable-next-line @atlassian/perf-linting/no-expensive-split-replace
tr.replace(pos, pos + node.nodeSize, new _model.Slice(node.content, 0, 0));
}
} catch (err) {
_iterator.e(err);
} finally {
_iterator.f();
}
return tr;
};