Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 | 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 67x 67x 67x 67x 67x 67x 66x 66x 66x 66x 25x 41x 41x 41x 41x 66x 41x 41x 41x 41x 64x 64x 64x 9x 72x 72x 72x 72x 72x 72x 72x 72x 72x 72x 375x 375x 375x 81x 375x 72x 375x 375x 375x 375x 157x 157x 218x 218x 67x 67x 67x 67x 67x 67x 67x 67x 67x 67x 67x 67x 67x 67x 67x 67x 82x 67x 151x 151x 71x 72x 72x 72x 72x 72x 72x 72x 72x 72x 72x 3x 3x 3x 88x 88x 88x 88x 88x 88x 88x 8x 8x 8x 8x 8x 8x 8x 233x 232x 232x 232x 232x 232x 232x 232x 232x 232x 144x 143x 143x 143x 143x 143x 143x 143x 143x 143x 143x 143x 143x 143x 143x 143x 144x 71x 71x 142x 71x 71x | import {Plugin, TextSelection} from 'prosemirror-state';
import type {EditorView} from 'prosemirror-view';
import {ReplaceStep} from 'prosemirror-transform';
import {history} from 'prosemirror-history';
import {Mark} from 'prosemirror-model';
import {FromPm} from './sync/FromPm';
import type {Fragment} from 'json-joy/lib/json-crdt-extensions/peritext/block/Fragment';
import {ToPmNode} from './sync/toPmNode';
import {applyPatch} from './sync/applyPatch';
import {pmPosToGap, pmPosToPoint, pointToPmPos} from './util';
import type {Range} from 'json-joy/lib/json-crdt-extensions/peritext/rga/Range';
import {SYNC_PLUGIN_KEY, TransactionOrigin} from './constants';
import {createPlugin as createPresencePlugin} from './presence/plugin';
import type {Peritext, PeritextApi} from 'json-joy/lib/json-crdt-extensions';
import type {ViewRange} from 'json-joy/lib/json-crdt-extensions/peritext/editor/types';
import type {PeritextRef, RichtextEditorFacade, PeritextOperation} from '@jsonjoy.com/collaborative-peritext';
import type {Node as PmNode} from 'prosemirror-model';
import type {Transaction} from 'prosemirror-state';
import type {PresenceManager} from '@jsonjoy.com/collaborative-presence';
import type {PresencePluginOpts} from './presence/plugin';
import type {SyncPluginTransactionMeta} from './sync/types';
/**
* Attempt to extract a single `PeritextOperation` from a single 1-step
* ProseMirror transaction. Returns `undefined` when the transaction contains
* non-trivial steps (anything other than plain-text insert / delete), in which
* case the caller should fall back to the full document merge path. Also,
* returns `undefined` if the transaction has more than one step.
*
* A step is considered "simple" when it is a `ReplaceStep` whose `slice` is
* either empty (pure deletion) or contains exactly one flat text node with no
* open depth (pure text insertion / replacement). This covers:
*
* - Typing a single character
* - Backspace / Delete
* - Pasting / replacing a selection with plain text
*/
const tryExtractPeritextOperation = (tr: Transaction, txt: Peritext, doc: PmNode): PeritextOperation | undefined => {
const steps = tr.steps;
Iif (steps.length !== 1) return;
const step = steps[0];
Iif (!(step instanceof ReplaceStep)) return;
const slice = step.slice;
if (!!slice.openStart || !!slice.openEnd) return;
const content = slice.content;
let insertedText = '';
const deleteLen = step.to - step.from;
if (content.childCount === 0) {
// Pure deletion — no inserted text. For now, we don't interpret
// multi-character deletes, as these can result in a complex block join.
Iif (deleteLen > 1) return;
} else if (content.childCount === 1) {
const child = content.firstChild!;
Iif (!child.isText) return;
insertedText = child.text ?? '';
} else Ereturn;
if (insertedText) {
// Bail out when inserting text at an inline-mark boundary. At mark edges
// ProseMirror decides whether to extend or not extend the mark to the new
// text (this can be different for different marks and even different for
// the same mark type, depending on how cursor was positioned), but the
// fast-path `PeritextOperation` tuple carries no mark info, so fall back
// to the full document merge to get correct annotations.
const $from = doc.resolve(step.from);
const marksBefore = $from.nodeBefore?.marks ?? Mark.none;
const marksAfter = $from.nodeAfter?.marks ?? Mark.none;
if (!Mark.sameSet(marksBefore, marksAfter)) return;
}
const gap = pmPosToGap(txt, doc, step.from);
Iif (gap < 0) return;
return [gap, deleteLen, insertedText];
};
export interface ProseMirrorFacadeOpts {
/**
* Whether to install the `prosemirror-history` undo/redo plugin.
*
* - `true` — always install the history plugin (even if one is already
* present in the editor state).
* - `false` — never install the history plugin.
* - `undefined` (default) — install the history plugin only when the editor
* state does not already contain one.
*/
history?: boolean;
/**
* Configuration for the collaborative presence plugin that renders remote
* cursors and selections. Pass an object with at least a manager field to
* enable the plugin, or `false` / `undefined` to disable it.
*
* When a {@link PresenceManager} instance is passed directly (not wrapped in
* an options object), it is used with default presence-plugin settings.
*/
presence?: PresenceManager | PresencePluginOpts | false;
}
export class ProseMirrorFacade implements RichtextEditorFacade {
_disposed = false;
_plugin: Plugin;
toPm: ToPmNode;
txOrig: TransactionOrigin = TransactionOrigin.UNKNOWN;
/**
* The single pending doc-changing transaction from plugin `apply()`, consumed
* in `update()`.
*/
_pendingTr: /** Attempt to process as a single `PeritextOperation` transaction. */
| Transaction
/** Multiple transactions in the same batch, give up on the fast path. */
| null
/** No pending transaction. */
| undefined = undefined;
onchange?: (change: PeritextOperation | void) => PeritextRef | void;
onselection?: () => void;
constructor(
protected readonly view: EditorView,
protected readonly peritext: PeritextRef,
protected readonly opts: ProseMirrorFacadeOpts = {},
) {
const self = this;
const state = view.state;
const plugin = (this._plugin = new Plugin({
key: SYNC_PLUGIN_KEY,
state: {
init() {
return {};
},
apply(transaction, value) {
const meta = transaction.getMeta(SYNC_PLUGIN_KEY) as SyncPluginTransactionMeta | undefined;
self.txOrig = meta?.orig || TransactionOrigin.UNKNOWN;
if (transaction.docChanged) {
// If this is the first doc-changing transaction, stash it.
// If a second arrives in the same batch, give up on the fast path.
self._pendingTr = self._pendingTr === undefined ? transaction : null;
}
return value;
},
},
view() {
return {
update(view, prevState) {
Iif (self._disposed) return;
const origin = self.txOrig;
self.txOrig = TransactionOrigin.UNKNOWN;
if (origin === TransactionOrigin.REMOTE) {
self._pendingTr = undefined;
return;
}
const docChanged = !prevState.doc.eq(view.state.doc);
if (docChanged) {
let simpleOperation: PeritextOperation | undefined;
SIMPLE_OPERATION: {
const pendingTransaction = self._pendingTr;
self._pendingTr = undefined;
Iif (!pendingTransaction) break SIMPLE_OPERATION;
const txt = self.peritext().txt;
simpleOperation = tryExtractPeritextOperation(pendingTransaction, txt, prevState.doc);
}
const ref = self.onchange?.(simpleOperation);
KEEP_CACHE_WARM: {
const peritext = ref?.();
Iif (!peritext) break KEEP_CACHE_WARM;
const txt = peritext.txt;
const peritextChildren = txt.blocks.root.children;
const length = peritextChildren.length;
const pmDoc = view.state.doc;
Iif (pmDoc.childCount !== length) break KEEP_CACHE_WARM;
const cache = self.toPm.cache;
for (let i = 0; i < length; i++) cache.set(peritextChildren[i].hash, pmDoc.child(i));
cache.gc();
}
} else {
const selectionChanged = !prevState.selection.eq(view.state.selection);
if (selectionChanged) self.onselection?.();
}
},
destroy() {
self._disposed = true;
},
};
},
}));
this.toPm = new ToPmNode(state.schema);
const {presence: presenceOpt, history: historyOpt} = this.opts;
let presencePlugin: Plugin | undefined;
Iif (presenceOpt) {
const presencePluginOpts: PresencePluginOpts =
typeof (presenceOpt as PresencePluginOpts).manager === 'object'
? {...(presenceOpt as PresencePluginOpts), peritext}
: {manager: presenceOpt as PresenceManager, peritext};
presencePlugin = createPresencePlugin(presencePluginOpts);
}
const hasHistory = state.plugins.some((p) => (p as any).key === 'history$');
const installHistory = historyOpt === true ? true : historyOpt === false ? false : !hasHistory;
const plugins: Plugin[] = installHistory ? [plugin, history()] : [plugin];
Iif (presencePlugin) plugins.push(presencePlugin);
const updatedPlugins = state.plugins.concat(plugins);
const newState = state.reconfigure({plugins: updatedPlugins});
view.updateState(newState);
}
get(): ViewRange {
Iif (this._disposed) return ['', 0, []];
const doc = this.view.state.doc;
return FromPm.convert(doc);
}
set(fragment: Fragment<string>): void {
Iif (this._disposed) return;
const pmNode = this.toPm.convert(fragment);
const view = this.view;
const state = view.state;
const {selection, tr} = state;
applyPatch(tr, state.doc, pmNode);
if (!tr.docChanged) return;
const newAnchor = tr.mapping.map(selection.anchor);
const newHead = tr.mapping.map(selection.head);
tr.setSelection(TextSelection.create(tr.doc, newAnchor, newHead));
const meta: SyncPluginTransactionMeta = {orig: TransactionOrigin.REMOTE};
tr.setMeta(SYNC_PLUGIN_KEY, meta);
tr.setMeta('addToHistory', false);
view.dispatch(tr);
}
/** Convert current ProseMirror selection to Peritext selection in CRDT-space. */
getSelection(peritext: PeritextApi): [range: Range<string>, startIsAnchor: boolean] | undefined {
if (this._disposed) return;
const view = this.view;
const selection = view.state.selection;
Iif (!selection) return;
const txt = peritext.txt;
const p1 = pmPosToPoint(txt, selection.$anchor);
const p2 = pmPosToPoint(txt, selection.$head);
const range = txt.rangeFromPoints(p1, p2);
const startIsAnchor = selection.anchor <= selection.head;
return [range, startIsAnchor];
}
/** Set ProseMirror selection from Peritext CRDT-space selection. */
setSelection(peritext: PeritextApi, range: Range<string>, startIsAnchor: boolean): void {
if (this._disposed) return;
const view = this.view;
const state = view.state;
const doc = state.doc;
const txt = peritext.txt;
const rootBlock = txt.blocks.root;
const anchorPoint = startIsAnchor ? range.start : range.end;
const headPoint = startIsAnchor ? range.end : range.start;
const anchor = pointToPmPos(rootBlock, anchorPoint, doc);
const head = pointToPmPos(rootBlock, headPoint, doc);
const newSelection = TextSelection.create(doc, anchor, head);
const tr = state.tr.setSelection(newSelection);
const meta: SyncPluginTransactionMeta = {orig: TransactionOrigin.REMOTE};
tr.setMeta(SYNC_PLUGIN_KEY, meta);
tr.setMeta('addToHistory', false);
view.dispatch(tr);
}
dispose(): void {
if (this._disposed) return;
this._disposed = true;
const state = this.view.state;
const plugins = state.plugins.filter((p) => p !== this._plugin);
const newState = state.reconfigure({plugins});
this.view.updateState(newState);
}
}
|