@atlaskit/editor-plugin-synced-block
Version:
SyncedBlock plugin for @atlaskit/editor-core
142 lines (136 loc) • 6.16 kB
JavaScript
import { Slice } from '@atlaskit/editor-prosemirror/model';
/**
* 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).
*/
const matchesRequestedBlock = (node, wanted) => {
const {
localId,
resourceId
} = node.attrs;
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.
*/
const findSourceBlocks = (tr, isSourceBlock, syncBlockIds) => {
const matches = [];
tr.doc.descendants((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(wanted => matchesRequestedBlock(node, wanted))) {
matches.push({
node,
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.
*/
export const recomputeDeleteTransaction = (tr, isSourceBlock, syncBlockIds) => {
const matches = findSourceBlocks(tr, isSourceBlock, syncBlockIds);
if (matches.length === 0) {
return undefined;
}
// Delete in reverse document order so positions remain valid across deletes.
matches.sort((a, b) => b.pos - a.pos).forEach(({
node,
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.
*/
export const recomputeUnsyncTransaction = (tr, isSourceBlock, syncBlockIds) => {
const 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).
const orderedMatches = matches.sort((a, b) => b.pos - a.pos);
for (const {
node,
pos
} of orderedMatches) {
// 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 Slice(node.content, 0, 0));
}
return tr;
};