import * as array from "@heydovetail/array"; import { fsm2 } from "@heydovetail/ui-components"; import { Plugin, Transaction } from "prosemirror-state"; import uuidv1 from "uuid/v1"; import { installReplaceTransactionMonkeyPatch } from "../replaceTransaction"; import { ClipboardRangeAttr, schema } from "../schema"; import { PluginDescriptor } from "../util/PluginDescriptor"; import { RangeCreateStep } from "./RangeCreateStep"; import { RangeDeleteStep } from "./RangeDeleteStep"; import { Range } from "./types"; import { RangeSetAttrsStep } from "./RangeSetAttrsStep"; import { Step } from "prosemirror-transform"; import { smoothScroll } from "../util"; interface PluginState { readonly type: "default"; /** * Ranges sorted by `range.from`. */ readonly sortedRanges: ReadonlyArray; } export const updateRangesFromSteps = (sortedRanges: ReadonlyArray, steps: Step[]): ReadonlyArray => { let newSortedRanges = sortedRanges; for (let i = 0; i < steps.length; i++) { const step = steps[i]; const stepMapping = step.getMap(); if (step instanceof RangeCreateStep) { // RangeSet can be used to update existing ranges, so remove any // corresponding existing range. newSortedRanges = array.omitOne(newSortedRanges, range => range.id === step.range.id); newSortedRanges = array.sortedInsert(newSortedRanges, step.range, array.sortComparatorAsc(range => range.from)); } else if (step instanceof RangeDeleteStep) { // RemoveRange removes range, so we simply find and omit it. newSortedRanges = array.omitOne(newSortedRanges, range => range.id === step.range.id); } else if (step instanceof RangeSetAttrsStep) { const existing = newSortedRanges.find(range => range.id === step.id); if (existing !== undefined) { newSortedRanges = array.omitOne(newSortedRanges, range => range.id === step.id); newSortedRanges = array.sortedInsert( newSortedRanges, { ...existing, attrs: step.curr } as typeof existing, array.sortComparatorAsc(range => range.from) ); } } else { // Finally for any other step type, simply map our ranges over it to // update the positions. newSortedRanges = newSortedRanges.map(range => ({ ...range, from: stepMapping.map(range.from, 1), to: stepMapping.map(range.to, -1) })); invariantRangesSorted(newSortedRanges); } } return newSortedRanges; }; export const transition = fsm2.createTransition()({ default: { tr: (prev, { tr }: { tr: Transaction }) => { return { type: "default", sortedRanges: updateRangesFromSteps(prev.sortedRanges, tr.steps) }; } } }); const { key, getPluginStateOrThrow } = new PluginDescriptor("RangePlugin"); export { PluginState, getPluginStateOrThrow }; /** * Track ranges in a document. * * This is kept as its own plugin (and not merged with `HighlightPlugin`) * primarily because it's anticipated that comments will use the same range * infrastructure as highlights, and so having ranges be managed by a single * dedicated plugin with its own transform steps avoids coupling to any * particular usage. */ export class RangePlugin extends Plugin { constructor(ranges: ReadonlyArray = [], scrollToRangeId?: string) { installReplaceTransactionMonkeyPatch(); super({ key, state: { init: (): PluginState => ({ type: "default", sortedRanges: array.sorted2(ranges, array.sortComparatorAsc(range => range.from)) }), apply: (tr, curr: PluginState): PluginState => transition(curr).tr({ tr }) }, replaceTransaction: (tr, oldState, newState) => { const { sortedRanges: newSortedRanges } = getPluginStateOrThrow(newState); const { sortedRanges: oldSortedRanges } = getPluginStateOrThrow(oldState); // Ranges that have all of their content deleted should themselves be // deleted. const deletedRanges: Range[] = []; for (const range of newSortedRanges) { const length = range.to - range.from; if (length <= 0) { deletedRanges.push(oldSortedRanges.find(r => r.id === range.id)!); } } // For ranges that are resized by having their from or to position moved // implicitly through having overlapping content deleted, in order to // support undo/redo we can't rely on the default mapping that the // plugin does, due to the non-inclusive way positions are mapped. // // If we relied only on the plugin state field mapping the following // problem would happen: // // 1. {range}ab{^}c{$}{/range} // 2. {range}ab{^}{/range} ← deleteSelection // 3. {range}ab{/range}{^}c{$} ← undo // // Instead we transform the transaction into the following: // // 1. {range}ab{^}c{$}{/range} // 2. ab{^}c{$} ← deleteRange(0, 3) // 3. ab{^} ← deleteSelection // 4. {range}ab{^}{/range} ← createRange(0, 2) // // This way when the transaction is undone, it's inverted into: // // 1. {range}ab{^}{/range} // 2. ab{^} ← deleteRange(0, 2) // 3. ab{^}c{$} ← insert // 4. {range}ab{^}c{$}{/range} ← createRange(0, 3) // const resizedRanges: Range[] = []; for (const map of tr.mapping.maps) { map.forEach((oldStart, oldEnd, newStart, newEnd) => { // Only special care is needed for ranges that have had the head or // tail moved. This is because the mapping that is performed as part // of the plugin state's apply is not inclusive, so undo does not // work correctly. const isDeletion = newEnd - newStart === 0; if (isDeletion) { for (const range of oldSortedRanges) { const deleted = deletedRanges.findIndex(r => r.id === range.id) !== -1; if (!deleted) { if ( // from shifted (range.from >= oldStart && range.from < oldEnd) || // to shifted (range.to > oldStart && range.to <= oldEnd) ) { resizedRanges.push(range); } } } } }); } const clipboardRanges: Range[] = []; tr.doc.descendants((node, pos, _) => { node.marks.forEach(m => { if (m.type.name === "cr") { clipboardRanges.push({ id: m.attrs[ClipboardRangeAttr.ID], type: m.attrs[ClipboardRangeAttr.TYPE], attrs: m.attrs[ClipboardRangeAttr.ATTRS], from: pos, to: pos + node.nodeSize }); } }); return true; }); if (deletedRanges.length > 0 || resizedRanges.length > 0 || clipboardRanges.length > 0) { const newTr = oldState.tr; for (const deletedRange of deletedRanges) { newTr.step(new RangeDeleteStep(deletedRange)); } for (const movedRange of resizedRanges) { newTr.step(new RangeDeleteStep(movedRange)); } for (const step of tr.steps) { newTr.step(step); } for (const movedRange of resizedRanges) { const newRange = newSortedRanges.find(r => r.id === movedRange.id)!; newTr.step(new RangeCreateStep(newRange)); } for (const newRange of clipboardRanges) { newTr.removeMark(newRange.from, newRange.to, schema.marks.cr); } for (const newRange of clipboardRanges) { newTr.step(new RangeCreateStep({ ...newRange, id: uuidv1() })); } if (tr.selectionSet) { newTr.setSelection(tr.selection); } if (tr.storedMarksSet) { newTr.setStoredMarks(tr.storedMarks!); } // It's necessary to copy transaction metadata, (see // https://github.com/ProseMirror/rfcs/pull/10#issuecomment-427756005) // tslint:disable-next-line:no-any const meta = ((tr as any) as { meta: {} }).meta; for (const key of Object.keys(meta)) { newTr.setMeta(key, tr.getMeta(key)); } return newTr; } return null; }, view: view => { const initialPluginState = getPluginStateOrThrow(view.state); if (scrollToRangeId !== undefined) { const scrollToRange = initialPluginState.sortedRanges.find(r => r.id === scrollToRangeId); if (scrollToRange !== undefined && window !== undefined) { setTimeout(() => { const pos = view.coordsAtPos(scrollToRange.from); smoothScroll(window.scrollX, pos.top - 80); }, 0); } } return {}; } }); } } /** * Verify that the ranges are still ordered by `.from`. They *should* * be, but this check will verify in production. This should all be * deleted at some point in the future after we're confident this * isn't needed. */ function invariantRangesSorted(ranges: ReadonlyArray): void { let violation = false; for (let i = 1; i < ranges.length; i++) { const prev = ranges[i - 1]; const curr = ranges[i - 1]; if (curr.from < prev.from) { violation = true; break; } } if (violation) { ranges = array.sorted2(ranges, array.sortComparatorAsc(range => range.from)); setTimeout(() => { throw new Error("Invariant violated: ranges out of sequence after tr mapping"); }, 0); } }