UNPKG

@atlaskit/editor-plugin-collab-edit

Version:

Collab Edit plugin for @atlaskit/editor-core

286 lines (278 loc) 12.4 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; import { getBrowserInfo } from '@atlaskit/editor-common/browser'; import { TELEPOINTER_DIM_CLASS, TELEPOINTER_PULSE_CLASS, TELEPOINTER_PULSE_DURING_TR_CLASS } from '@atlaskit/editor-common/collab'; import { Selection } from '@atlaskit/editor-prosemirror/state'; import { DecorationSet } from '@atlaskit/editor-prosemirror/view'; import { Participants } from '../participants'; import { createTelepointers, findPointers, getPositionOfTelepointer, isReplaceStep, hasExistingNudge } from '../utils'; import { ADD_AGENT_SHIMMER_META, buildAgentShimmerDecorations, HIGHLIGHT_AGENT_SHIMMER_META, reduceAgentShimmers, REMOVE_AGENT_SHIMMER_META } from './agent-shimmer-decorations'; /** * Returns position where it's possible to place a decoration. */ export const getValidPos = (tr, pos) => { const endOfDocPos = tr.doc.nodeSize - 2; if (pos <= endOfDocPos) { const resolvedPos = tr.doc.resolve(pos); const backwardSelection = Selection.findFrom(resolvedPos, -1, true); // if there's no correct cursor position before the `pos`, we try to find it after the `pos` const forwardSelection = Selection.findFrom(resolvedPos, 1, true); return backwardSelection ? backwardSelection.from : forwardSelection ? forwardSelection.from : pos; } return endOfDocPos; }; export class PluginState { get decorations() { return this.decorationSet; } get activeParticipants() { return this.participants; } get sessionId() { return this.sid; } constructor(decorations, participants, sessionId, collabInitalised = false, onError, nudgeAnimations = new Map(), agentShimmers = []) { // Active agent-edit shimmer ranges (pure data). Replaced with a fresh array of fresh // objects whenever it changes — never mutated in place. // eslint-disable-next-line no-console _defineProperty(this, "onError", error => console.error(error)); this.decorationSet = decorations; this.participants = participants; this.sid = sessionId; this.isReady = collabInitalised; this.onError = onError || this.onError; this.nudgeAnimations = nudgeAnimations; this.agentShimmers = agentShimmers; } getFullName(sessionId) { const participant = this.participants.get(sessionId); return participant ? participant.name : 'X'; } getInitial(sessionId) { return this.getFullName(sessionId).substring(0, 1).toUpperCase(); } getPresenceId(sessionId) { var _participant$presence; const participant = this.participants.get(sessionId); return (_participant$presence = participant === null || participant === void 0 ? void 0 : participant.presenceId) !== null && _participant$presence !== void 0 ? _participant$presence : sessionId; } apply(tr) { // Ignored via go/ees005 // eslint-disable-next-line prefer-const let { participants, sid, isReady } = this; const presenceData = tr.getMeta('presence'); const telepointerData = tr.getMeta('telepointer'); const nudgeTelepointerData = tr.getMeta('nudgeTelepointer'); const agentShimmerData = tr.getMeta(ADD_AGENT_SHIMMER_META); const removeAgentShimmerId = tr.getMeta(REMOVE_AGENT_SHIMMER_META); const highlightAgentShimmerId = tr.getMeta(HIGHLIGHT_AGENT_SHIMMER_META); const sessionIdData = tr.getMeta('sessionId'); let collabInitialised = tr.getMeta('collabInitialised'); if (typeof collabInitialised !== 'boolean') { collabInitialised = isReady; } if (sessionIdData) { sid = sessionIdData.sid; } let add = []; let remove = []; if (presenceData) { const { joined = [], left = [] } = presenceData; participants = participants.remove(left.map(i => i.sessionId)); participants = participants.add(joined); // Remove telepointers for users that left left.forEach(i => { const pointers = findPointers(this.getPresenceId(i.sessionId), this.decorationSet); if (pointers) { remove = remove.concat(pointers); } }); } if (telepointerData) { const { sessionId } = telepointerData; if (participants.get(sessionId) && sessionId !== sid) { const oldPointers = findPointers(this.getPresenceId(sessionId), this.decorationSet); if (oldPointers) { remove = remove.concat(oldPointers); } const endOfDocPos = tr.doc.nodeSize - 2; const anchor = telepointerData.selection.anchor; const head = telepointerData.selection.head; let rawFrom = anchor < head ? anchor : head; let rawTo = anchor >= head ? anchor : head; if (rawFrom > endOfDocPos) { rawFrom = endOfDocPos; } if (rawTo > endOfDocPos) { rawTo = endOfDocPos; } const isSelection = rawTo - rawFrom > 0; let from = 1; let to = 1; try { from = getValidPos(tr, isSelection ? Math.max(rawFrom, 0) : rawFrom); to = isSelection ? getValidPos(tr, rawTo) : from; } catch (err) { this.onError(err); } add = add.concat(createTelepointers(from, to, sessionId, isSelection, this.getInitial(sessionId), this.getPresenceId(sessionId), this.getFullName(sessionId), hasExistingNudge(sessionId, this.nudgeAnimations))); } } if (tr.docChanged) { // Adjust decoration positions to changes made by the transaction try { this.decorationSet = this.decorationSet.map(tr.mapping, tr.doc, { // Reapplies decorators those got removed by the state change onRemove: spec => { if (spec.pointer && spec.pointer.sessionId && spec.key === `telepointer-${spec.pointer.sessionId}`) { const step = tr.steps.filter(isReplaceStep)[0]; if (step) { const { sessionId, presenceId } = spec.pointer; const { slice: { content: { size } }, from // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any } = step; const pos = getValidPos(tr, size ? Math.min(from + size, tr.doc.nodeSize - 3) : Math.max(from, 1)); add = add.concat(createTelepointers(pos, pos, sessionId, false, this.getInitial(sessionId), presenceId, this.getFullName(sessionId), hasExistingNudge(sessionId, this.nudgeAnimations))); } } } }); } catch (err) { this.onError(err); } } const { selection } = tr; // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any this.decorationSet.find().forEach(deco => { var _deco$spec; // Never let telepointer dim/side logic touch agent-shimmer decorations. if ((_deco$spec = deco.spec) !== null && _deco$spec !== void 0 && _deco$spec.isAgentShimmer) { return; } if (deco.type.toDOM) { const hasTelepointerDimClass = deco.type.toDOM.classList.contains(TELEPOINTER_DIM_CLASS); const browser = getBrowserInfo(); if (deco.from === selection.from && deco.to === selection.to) { if (!hasTelepointerDimClass) { deco.type.toDOM.classList.add(TELEPOINTER_DIM_CLASS); } // Browser condition here to fix ED-14722 where telepointer // decorations with side -1 in Firefox causes backspace issues. // This is likely caused by contenteditable quirks in Firefox if (!browser.gecko) { deco.type.side = -1; } } else { if (hasTelepointerDimClass) { deco.type.toDOM.classList.remove(TELEPOINTER_DIM_CLASS); } deco.type.side = 0; } } }); if (nudgeTelepointerData) { const nudgeSessionId = nudgeTelepointerData === null || nudgeTelepointerData === void 0 ? void 0 : nudgeTelepointerData.sessionId; // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any this.decorationSet.find().forEach(deco => { var _deco$spec2, _deco$spec3, _deco$spec3$pointer, _deco$spec4; // Never let telepointer nudge logic touch agent-shimmer decorations. if ((_deco$spec2 = deco.spec) !== null && _deco$spec2 !== void 0 && _deco$spec2.isAgentShimmer) { return; } if (deco.type.toDOM && participants.get(nudgeSessionId) && ((_deco$spec3 = deco.spec) === null || _deco$spec3 === void 0 ? void 0 : (_deco$spec3$pointer = _deco$spec3.pointer) === null || _deco$spec3$pointer === void 0 ? void 0 : _deco$spec3$pointer.sessionId) === nudgeSessionId && ((_deco$spec4 = deco.spec) === null || _deco$spec4 === void 0 ? void 0 : _deco$spec4.key) === `telepointer-${nudgeSessionId}`) { // Restart animation by removing and re-adding the class deco.type.toDOM.classList.remove(TELEPOINTER_PULSE_DURING_TR_CLASS); deco.type.toDOM.classList.remove(TELEPOINTER_PULSE_CLASS); void deco.type.toDOM.offsetWidth; // Force reflow deco.type.toDOM.classList.add(TELEPOINTER_PULSE_CLASS); this.nudgeAnimations.set(nudgeSessionId, Date.now()); } }); } // Register / keep-aligned / remove the agent-edit purple highlight // decorations. Fully fault-isolated — it works on local copies and only merges into the shared // `add`/`remove` (and commits `this.agentShimmers`) after the whole block succeeds, so a fault // here can never corrupt telepointer decorations or the shared decoration set. Never mutates // shimmer entries in place: each change produces a fresh array of fresh objects. try { const { changed, next } = reduceAgentShimmers(this.agentShimmers, tr, agentShimmerData, removeAgentShimmerId, highlightAgentShimmerId); if (changed) { // Build into local arrays; only merge into the shared add/remove once the block succeeds. const agentRemove = this.decorationSet.find(undefined, undefined, spec => spec.isAgentShimmer); const agentAdd = buildAgentShimmerDecorations(tr, next, getValidPos, this.onError); // Commit only after the whole block succeeded. remove = remove.concat(agentRemove); add = add.concat(agentAdd); this.agentShimmers = next; } } catch (err) { this.onError(err); // Degrade to no shimmer without corrupting telepointer decorations: drop all agent // decorations and clear agent state, leaving the telepointer `add`/`remove` untouched. this.agentShimmers = []; try { remove = remove.concat(this.decorationSet.find(undefined, undefined, spec => spec.isAgentShimmer)); } catch { // Teardown must not re-throw. } } if (remove.length) { this.decorationSet = this.decorationSet.remove(remove); } if (add.length) { this.decorationSet = this.decorationSet.add(tr.doc, add); } // This piece needs to be after the decorationSet adjustments, // otherwise it's always one step behind where the cursor is if (telepointerData) { const { sessionId } = telepointerData; if (participants.get(sessionId)) { const positionForScroll = getPositionOfTelepointer(sessionId, this.decorationSet); if (positionForScroll) { participants = participants.updateCursorPos(sessionId, positionForScroll); } } } const nextState = new PluginState(this.decorationSet, participants, sid, collabInitialised, this.onError, this.nudgeAnimations, this.agentShimmers); return PluginState.eq(nextState, this) ? this : nextState; } static eq(a, b) { return a.participants === b.participants && a.sessionId === b.sessionId && a.isReady === b.isReady; } // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any static init(config) { const { doc, onError } = config; return new PluginState(DecorationSet.create(doc, []), new Participants(), undefined, undefined, onError); } }